Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ Multi-provider support — see [Provider Configuration](docs/provider-configurat
# GitHub Copilot (use your subscription — no separate API key needed)
./dev.sh copilot

# OpenAI Codex OAuth (reuse ~/.codex/auth.json)
aceclaw models auth login --provider openai-codex
./dev.sh openai-codex
# Note: in openai-codex mode, AceClaw follows Codex backend rules
# (stream=true, store=false, no temperature/max_output_tokens).

# Ollama (local, offline)
./dev.sh ollama

Expand All @@ -120,7 +126,7 @@ Daemon (persistent JVM, separate process group)
├─ Context Compactor → 3-phase (prune → summarize → memory flush)
├─ Scheduler → persistent cron jobs, heartbeat runner
├─ Hook System → BOOT.md startup, command hooks
└─ LLM Client Factory → 7 providers, extended thinking, prompt caching
└─ LLM Client Factory → 8 providers, extended thinking, prompt caching
```

### Modules
Expand Down Expand Up @@ -188,7 +194,7 @@ DANGEROUS → always prompt, never remembered

- [x] Daemon-first architecture, streaming ReAct loop, 12 tools
- [x] Extended thinking, retry, prompt caching, context compaction
- [x] Multi-provider (7 providers), HMAC-signed memory, MCP integration
- [x] Multi-provider (8 providers), HMAC-signed memory, MCP integration
- [x] 8-tier memory hierarchy, hybrid search, daily journal, workspace isolation
- [x] Sub-agents: depth-1 delegation, filtered tool registries, task lifecycle
- [x] Self-learning: insight hierarchy, error/pattern detection, self-improvement engine
Expand Down
123 changes: 122 additions & 1 deletion aceclaw-cli/src/main/java/dev/aceclaw/cli/AceClawMain.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package dev.aceclaw.cli;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import dev.aceclaw.daemon.AceClawConfig;
import dev.aceclaw.daemon.AceClawDaemon;
import dev.aceclaw.llm.openai.CopilotDeviceAuth;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

/**
Expand All @@ -29,11 +32,13 @@
mixinStandardHelpOptions = true,
version = "aceclaw 0.1.0-SNAPSHOT",
description = "AI coding agent — Device as Agent",
subcommands = { AceClawMain.DaemonCommand.class }
subcommands = { AceClawMain.DaemonCommand.class, AceClawMain.ModelsCommand.class }
)
public final class AceClawMain implements Runnable {

static final String VERSION = "0.1.0-SNAPSHOT";
private static final Path CODEX_AUTH_FILE = Path.of(
System.getProperty("user.home"), ".codex", "auth.json");

@Override
public void run() {
Expand Down Expand Up @@ -141,6 +146,24 @@ public static void main(String[] args) {
System.exit(exitCode);
}

private static boolean hasCodexAccessToken() {
try {
if (!Files.exists(CODEX_AUTH_FILE)) {
return false;
}
var mapper = new ObjectMapper();
JsonNode root = mapper.readTree(Files.readString(CODEX_AUTH_FILE));
String accessToken = root.path("tokens").path("access_token").asText("");
if (!accessToken.isBlank()) {
return true;
}
String legacy = root.path("OPENAI_API_KEY").asText("");
return !legacy.isBlank();
} catch (Exception e) {
return false;
}
}

// -- Daemon subcommand group -----------------------------------------

/**
Expand All @@ -162,6 +185,104 @@ public void run() {
}
}

// -- Models subcommand group -----------------------------------------

@Command(
name = "models",
description = "Manage model providers and authentication",
subcommands = { ModelsAuthCommand.class }
)
static final class ModelsCommand implements Runnable {
@Override
public void run() {
new CommandLine(this).usage(System.out);
}
}

@Command(
name = "auth",
description = "Manage model provider authentication",
subcommands = { ModelsAuthLoginCommand.class }
)
static final class ModelsAuthCommand implements Runnable {
@Override
public void run() {
new CommandLine(this).usage(System.out);
}
}

@Command(
name = "login",
description = "Authenticate with a model provider (default: openai-codex)"
)
static final class ModelsAuthLoginCommand implements Runnable {
@Option(
names = "--provider",
description = "Provider to authenticate: ${COMPLETION-CANDIDATES}",
defaultValue = "openai-codex")
String provider;

@Override
public void run() {
String resolvedProvider = provider == null ? "openai-codex" : provider.trim().toLowerCase();
switch (resolvedProvider) {
case "openai-codex" -> loginOpenAiCodex();
case "copilot" -> loginCopilot();
default -> {
System.err.println("Unsupported auth provider: " + resolvedProvider);
System.err.println("Supported: openai-codex, copilot");
System.exit(1);
}
}
}

private static void loginOpenAiCodex() {
try {
if (hasCodexAccessToken()) {
System.out.println("OpenAI Codex OAuth token already available at ~/.codex/auth.json.");
return;
}

System.out.println("Starting Codex OAuth login...");
var process = new ProcessBuilder("codex", "auth", "login")
.inheritIO()
.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("'codex auth login' exited with code " + exitCode);
}

if (!hasCodexAccessToken()) {
throw new RuntimeException("Codex login completed but no access token found in ~/.codex/auth.json");
}
System.out.println("OpenAI Codex OAuth login successful.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("OpenAI Codex authentication interrupted.");
System.exit(1);
} catch (IOException e) {
System.err.println("Failed to start 'codex auth login'. Ensure Codex CLI is installed and in PATH.");
System.exit(1);
} catch (RuntimeException e) {
System.err.println("OpenAI Codex authentication failed: " + e.getMessage());
System.exit(1);
}
}

private static void loginCopilot() {
try {
if (CopilotDeviceAuth.loadCachedToken() != null) {
System.out.println("Copilot OAuth token already cached.");
return;
}
CopilotDeviceAuth.authenticate();
} catch (RuntimeException e) {
System.err.println("Copilot authentication failed: " + e.getMessage());
System.exit(1);
}
}
}

/**
* Starts the daemon in the foreground (blocking).
*/
Expand Down
45 changes: 42 additions & 3 deletions aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public final class AceClawConfig {

/** Claude CLI credentials directory. */
private static final Path CLAUDE_CLI_DIR = Path.of(System.getProperty("user.home"), ".claude");
/** Codex CLI credentials file for OpenAI Codex OAuth. */
private static final Path CODEX_AUTH_FILE = Path.of(System.getProperty("user.home"), ".codex", "auth.json");

private String provider;
private String baseUrl;
Expand Down Expand Up @@ -120,15 +122,17 @@ public static AceClawConfig load(Path projectPath) {
}

// 3. Determine which profile to apply:
// ACECLAW_PROFILE > ACECLAW_PROVIDER (if matching profile exists) > defaultProfile
// ACECLAW_PROFILE > ACECLAW_PROVIDER (if matching profile exists)
// > defaultProfile (only when ACECLAW_PROVIDER is not explicitly set)
var envProfile = System.getenv("ACECLAW_PROFILE");
var envProvider = System.getenv("ACECLAW_PROVIDER");
if (envProfile != null && !envProfile.isBlank()) {
config.applyProfile(envProfile);
} else if (envProvider != null && !envProvider.isBlank()
&& config.profiles != null && config.profiles.containsKey(envProvider.toLowerCase())) {
config.applyProfile(envProvider.toLowerCase());
} else if (config.defaultProfile != null && !config.defaultProfile.isBlank()) {
} else if ((envProvider == null || envProvider.isBlank())
&& config.defaultProfile != null && !config.defaultProfile.isBlank()) {
config.applyProfile(config.defaultProfile);
}

Expand Down Expand Up @@ -169,7 +173,13 @@ public static AceClawConfig load(Path projectPath) {
config.permissionMode = envPermMode.toLowerCase();
}

// 5. If apiKey is an OAuth token and no refresh token configured,
// 5. Provider-specific credential discovery fallback
if ((config.apiKey == null || config.apiKey.isBlank())
&& "openai-codex".equals(config.provider)) {
config.loadCodexAuthToken();
}

// 6. If apiKey is an OAuth token and no refresh token configured,
// try to load the refresh token from Claude CLI credentials
if (config.apiKey != null && config.apiKey.startsWith("sk-ant-oat")
&& config.refreshToken == null) {
Expand Down Expand Up @@ -439,6 +449,35 @@ private void loadClaudeCliCredentials() {
}
}

/**
* Attempts to load OpenAI Codex access token from Codex CLI credential file.
* Supports both modern {@code tokens.access_token} and legacy {@code OPENAI_API_KEY}.
*/
private void loadCodexAuthToken() {
if (!Files.isRegularFile(CODEX_AUTH_FILE)) {
return;
}
try {
var mapper = new ObjectMapper();
var tree = mapper.readTree(CODEX_AUTH_FILE.toFile());

String token = null;
var tokens = tree.path("tokens");
if (tokens.has("access_token")) {
token = tokens.get("access_token").asText(null);
}
if ((token == null || token.isBlank()) && tree.has("OPENAI_API_KEY")) {
token = tree.get("OPENAI_API_KEY").asText(null);
}
if (token != null && !token.isBlank()) {
this.apiKey = token;
log.info("Loaded OpenAI Codex access token from {}", CODEX_AUTH_FILE);
}
} catch (IOException e) {
log.debug("Could not read Codex auth file {}: {}", CODEX_AUTH_FILE, e.getMessage());
}
Comment on lines +456 to +478

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java | sed -n '450,485p'

Repository: xinhuagu/AceClaw

Length of output: 1604


🏁 Script executed:

# Search for other uses of ObjectMapper and readTree in the codebase
rg "readTree" --type java -B 2 -A 2

Repository: xinhuagu/AceClaw

Length of output: 50372


🌐 Web query:

Jackson ObjectMapper readTree null return behavior JsonNode

💡 Result:

ObjectMapper.readTree(...) (and ObjectCodec#readTree(JsonParser)) returns Java null only when there is no JSON content to read—i.e., the parser is at end-of-input / no more tokens. It does not return Java null for a JSON literal null; JSON null is represented as a non-null JsonNode where node.isNull() is true (a NullNode). [1][2]

This commonly shows up in custom deserializers when you do:

defaultDeserializer.deserialize(p, ctxt); // consumes all tokens for the value
JsonNode node = p.getCodec().readTree(p); // now at end => returns null

After the default deserializer has consumed the JSON, the parser has “no more content accessible”, so readTree(p) returns null. [3][4]

How to handle it (typical fixes):

  • Read the tree first, then deserialize from that tree by creating a new parser (so you don’t read the same JsonParser twice), e.g. via TreeTraversingParser. [4]
  • Or buffer the token stream with a TokenBuffer and create two parsers from it (one for default deserialization, one for tree access). [4]

Rule of thumb: if you need both “default POJO deserialization” and “tree access”, you must buffer (tree or token buffer) because a JsonParser is forward-only. [4]

Sources:
[1] Jackson ObjectMapper#readTree Javadoc (return null on no content; JSON null => JsonNode.isNull())
[2] ObjectMapper.readValue() returns null only for JSON literal null (Stack Overflow)
[3] ObjectMapper.readTree(JsonParser) implementation notes: returns null if no more content accessible
[4] “Use JsonParser again after deserialization” solution: read tree first / use TreeTraversingParser or TokenBuffer (Stack Overflow)


Add null check after readTree() to prevent NPE on empty or malformed auth.json.

If the auth file is empty, mapper.readTree() returns null, causing an uncaught NullPointerException at tree.path(). Additionally, check tree.isNull() to handle JSON null content gracefully.

Proposed fix
         try {
             var mapper = new ObjectMapper();
             var tree = mapper.readTree(CODEX_AUTH_FILE.toFile());
+            if (tree == null || tree.isNull()) {
+                log.warn("Codex auth file {} is empty or null JSON", CODEX_AUTH_FILE);
+                return;
+            }
 
             String token = null;
             var tokens = tree.path("tokens");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java` around
lines 456 - 478, In loadCodexAuthToken(), after calling
mapper.readTree(CODEX_AUTH_FILE.toFile()), check whether the returned JsonNode
(tree) is null or tree.isNull() and return early (or log a debug message) if so
to avoid NPE on the subsequent tree.path(...) calls; keep the rest of the token
extraction (tokens/access_token and OPENAI_API_KEY fallback) unchanged and only
proceed to set this.apiKey and log when a non-blank token is found.

}

private void mergeFromFile(Path configFile) {
if (!Files.isRegularFile(configFile)) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,12 @@ private void sendCancelledNotificationIfNeeded(CancellationToken token,
*/
private static String formatLlmError(dev.aceclaw.core.llm.LlmException e) {
int status = e.statusCode();
String message = e.getMessage();
String safeMessage = (message == null || message.isBlank()) ? "(no additional details)" : message;
if (status == 401 && message != null && message.contains("api.responses.write")) {
return "Authentication succeeded but token lacks required scope 'api.responses.write'. "
+ "Use a full OpenAI API key or provider=openai-codex with a valid Codex OAuth token.";
}
if (status == 401) {
return "Invalid API key. Please check your API key configuration in env vars or ~/.aceclaw/config.json.";
} else if (status == 429) {
Expand All @@ -499,11 +505,11 @@ private static String formatLlmError(dev.aceclaw.core.llm.LlmException e) {
} else if (status >= 500 && status < 600) {
return "The LLM service is temporarily unavailable (HTTP " + status + "). Please try again.";
} else if (status == 400) {
return "Bad request to LLM API: " + e.getMessage();
} else if (e.getMessage() != null && e.getMessage().contains("not-configured")) {
return "Bad request to LLM API: " + safeMessage;
} else if (message != null && message.contains("not-configured")) {
return "API key not configured. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY) or add apiKey to ~/.aceclaw/config.json.";
} else {
return "LLM error: " + e.getMessage();
return "LLM error: " + safeMessage;
}
}

Expand Down
22 changes: 21 additions & 1 deletion aceclaw-llm/src/main/java/dev/aceclaw/llm/LlmClientFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import dev.aceclaw.llm.anthropic.AnthropicClient;
import dev.aceclaw.llm.openai.CopilotRoutingClient;
import dev.aceclaw.llm.openai.CopilotTokenProvider;
import dev.aceclaw.llm.openai.OpenAiCodexTokenProvider;
import dev.aceclaw.llm.openai.OpenAICompatClient;
import dev.aceclaw.llm.openai.OpenAIRoutingClient;
import dev.aceclaw.llm.openai.OpenAIResponsesClient;
Expand All @@ -26,6 +27,7 @@ public final class LlmClientFactory {
/** Default base URLs for known providers (without trailing slash). */
private static final Map<String, String> DEFAULT_BASE_URLS = Map.of(
"openai", "https://api.openai.com",
"openai-codex", "https://chatgpt.com/backend-api/codex",
"groq", "https://api.groq.com/openai",
"together", "https://api.together.xyz",
"mistral", "https://api.mistral.ai",
Expand All @@ -36,6 +38,7 @@ public final class LlmClientFactory {
/** Default model identifiers for known providers. */
private static final Map<String, String> DEFAULT_MODELS = Map.of(
"openai", "gpt-4o",
"openai-codex", "gpt-5-codex",
"groq", "llama-3.3-70b-versatile",
"together", "meta-llama/Llama-3.3-70B-Instruct-Turbo",
"mistral", "mistral-large-latest",
Expand All @@ -46,6 +49,7 @@ public final class LlmClientFactory {
/** Providers that support image input. */
private static final Map<String, ProviderCapabilities> PROVIDER_CAPABILITIES = Map.of(
"openai", ProviderCapabilities.OPENAI,
"openai-codex", ProviderCapabilities.CODEX,
"groq", ProviderCapabilities.OPENAI_COMPAT,
"together", ProviderCapabilities.OPENAI_COMPAT,
"mistral", ProviderCapabilities.OPENAI_COMPAT,
Expand Down Expand Up @@ -92,6 +96,7 @@ public static LlmClient create(String provider, String apiKey,
case "anthropic" -> createAnthropicClient(apiKey, refreshToken, baseUrl);
case "copilot" -> createCopilotClient(apiKey, baseUrl, model);
case "openai" -> createOpenAiClient(apiKey, baseUrl, model);
case "openai-codex" -> createOpenAiCodexClient(apiKey, baseUrl, model);
case "groq", "together", "mistral", "ollama" -> {
String resolvedBaseUrl = baseUrl != null ? baseUrl : DEFAULT_BASE_URLS.get(provider);
String resolvedModel = model != null ? model : DEFAULT_MODELS.getOrDefault(provider, "gpt-4o");
Expand All @@ -101,7 +106,7 @@ public static LlmClient create(String provider, String apiKey,
}
default -> throw new IllegalArgumentException(
"Unknown provider: " + provider
+ ". Supported: anthropic, openai, groq, together, mistral, copilot, ollama");
+ ". Supported: anthropic, openai, openai-codex, groq, together, mistral, copilot, ollama");
};
}

Expand Down Expand Up @@ -150,6 +155,21 @@ private static LlmClient createOpenAiClient(String apiKey, String baseUrl, Strin
return new OpenAIRoutingClient(chatClient, responsesClient, resolvedModel);
}

private static LlmClient createOpenAiCodexClient(String apiKey, String baseUrl, String model) {
var tokenProvider = new OpenAiCodexTokenProvider(apiKey);
String resolvedBaseUrl = baseUrl != null ? baseUrl : DEFAULT_BASE_URLS.get("openai-codex");
String resolvedModel = model != null ? model : DEFAULT_MODELS.getOrDefault("openai-codex", "gpt-5-codex");

var chatClient = new OpenAICompatClient(
tokenProvider, resolvedBaseUrl, "/v1/chat/completions",
"openai-codex", resolvedModel, ProviderCapabilities.OPENAI, Map.of());
var responsesClient = new OpenAIResponsesClient(
tokenProvider, resolvedBaseUrl, "/v1/responses",
"openai-codex", resolvedModel, ProviderCapabilities.CODEX, Map.of());

return new OpenAIRoutingClient(chatClient, responsesClient, resolvedModel, "openai-codex");
}
Comment on lines +158 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for backend-api/codex references and related endpoint usage
rg -n "backend-api/codex" --type java -C 3
rg -n "codex" -i --type java | grep -E "endpoint|path|url|/v1" | head -20

Repository: xinhuagu/AceClaw

Length of output: 945


🌐 Web query:

OpenAI Codex backend API endpoint paths documentation

💡 Result:

Codex doesn’t have a separate “Codex API” with unique paths; Codex clients (e.g., Codex CLI) call the Responses API. The relevant documented endpoint paths are:

# OpenAI API (API-key auth)
POST https://api.openai.com/v1/responses

This is the standard endpoint Codex CLI uses when authenticated with an API key. [1]

# ChatGPT login (Codex CLI / ChatGPT-backed)
POST https://chatgpt.com/backend-api/codex/responses

This is the ChatGPT “backend-api” path used by Codex CLI when you log in with ChatGPT (it’s referenced in OpenAI’s Codex agent loop write-up, not as a general public API reference). [1]

Additional related API path docs (model pages list supported endpoints) include:

/v1/chat/completions
/v1/realtime
/v1/assistants
/v1/batch

(plus /v1/responses above). [2]

Sources: OpenAI “Unrolling the Codex agent loop” (Jan 23, 2026) [1]; OpenAI model docs showing supported endpoints for Codex-capable models [2].


Fix incorrect endpoint paths for ChatGPT backend-api Codex client.

The base URL https://chatgpt.com/backend-api/codex does not use /v1/ prefixed paths. According to OpenAI documentation, the ChatGPT backend-api Codex endpoint is POST https://chatgpt.com/backend-api/codex/responses, not /v1/responses. Similarly, the chat completions path should be adjusted to match the backend-api structure (likely /chat/completions without the /v1/ prefix). Update the endpoint paths in createOpenAiCodexClient() to remove the /v1/ prefix when appending to the backend-api base URL.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-llm/src/main/java/dev/aceclaw/llm/LlmClientFactory.java` around lines
158 - 171, The createOpenAiCodexClient function is using "/v1/chat/completions"
and "/v1/responses" which are incorrect for the ChatGPT backend-api Codex base
URL; update the endpoint paths passed to OpenAICompatClient and
OpenAIResponsesClient to remove the "/v1/" prefix (use "/chat/completions" and
"/responses") so the calls constructed against the resolvedBaseUrl (e.g.,
DEFAULT_BASE_URLS.get("openai-codex") like
https://chatgpt.com/backend-api/codex) match the backend-api structure used by
OpenAIRoutingClient/OpenAICompatClient/OpenAIResponsesClient.


private static LlmClient createAnthropicClient(String apiKey, String refreshToken, String baseUrl) {
if (apiKey != null && apiKey.startsWith("sk-ant-oat") && refreshToken != null) {
if (baseUrl != null) {
Expand Down
Loading