diff --git a/apps/daemon/src/claude-diagnostics.ts b/apps/daemon/src/claude-diagnostics.ts index 7719f4a77..762289575 100644 --- a/apps/daemon/src/claude-diagnostics.ts +++ b/apps/daemon/src/claude-diagnostics.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { redactSecrets } from './redact.js'; export interface ClaudeCliDiagnosticInput { @@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput { stderrTail?: string | null; stdoutTail?: string | null; env?: Record | null; + resolvedBin?: string | null; } export interface ClaudeCliDiagnostic { @@ -51,6 +54,15 @@ function withContext( }; } +function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' { + if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude'; + const base = path + .basename(input.resolvedBin.trim().replace(/\\/g, '/')) + .replace(/\.(exe|cmd|bat)$/i, '') + .toLowerCase(); + return base === 'openclaude' ? 'openclaude' : 'claude'; +} + export function diagnoseClaudeCliFailure( input: ClaudeCliDiagnosticInput, ): ClaudeCliDiagnostic | null { @@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure( const normalized = text.toLowerCase(); const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null; const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null; + const runtime = selectedClaudeCompatibleRuntime(input); + const isOpenClaude = runtime === 'openclaude'; const customEndpointConnectionFailure = hasCustomBaseUrl && @@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure( ); } if (authFailure) { + if (isOpenClaude) { + return withContext( + 'OpenClaude could not authenticate with its configured endpoint.', + 'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.', + input, + ); + } const configHint = hasConfigDir ? 'The configured Claude config directory may contain stale or expired auth state.' : 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.'; @@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure( } if (!text.trim() && input.exitCode === 1) { + if (isOpenClaude) { + return withContext( + 'OpenClaude exited before producing diagnostics.', + 'Check the OpenClaude API key, endpoint, and local configuration, then retry.', + input, + ); + } const message = hasConfigDir ? 'Claude Code exited before producing diagnostics while using the configured Claude profile.' : 'Claude Code exited before producing diagnostics.'; diff --git a/apps/daemon/src/connectionTest.ts b/apps/daemon/src/connectionTest.ts index 95514ddd2..fe2bb957f 100644 --- a/apps/daemon/src/connectionTest.ts +++ b/apps/daemon/src/connectionTest.ts @@ -1862,6 +1862,8 @@ async function testAgentConnectionInternal( ...(def.env || {}), }, configuredAgentEnv, + undefined, + { resolvedBin: executableResolution.selectedPath }, ); const env = applyAgentLaunchEnv(baseEnv, executableResolution); const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env); @@ -2026,6 +2028,7 @@ async function testAgentConnectionInternal( stderrTail, stdoutTail: rawStdoutTail || buffered, env, + resolvedBin: executableResolution.selectedPath, }); if (claudeDiagnostic) { console.warn( diff --git a/apps/daemon/src/memory-llm.ts b/apps/daemon/src/memory-llm.ts index e0b570c61..49ebc7ae4 100644 --- a/apps/daemon/src/memory-llm.ts +++ b/apps/daemon/src/memory-llm.ts @@ -865,7 +865,13 @@ async function callLocalCli(provider, system, user, options) { } const env = applyAgentLaunchEnv( - spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv), + spawnEnvForAgent( + def.id, + { ...process.env, ...(def.env || {}) }, + configuredAgentEnv, + undefined, + { resolvedBin: launch.selectedPath }, + ), launch, ); const invocation = createCommandInvocation({ diff --git a/apps/daemon/src/runtimes/detection.ts b/apps/daemon/src/runtimes/detection.ts index 7a1779586..4ee50853e 100644 --- a/apps/daemon/src/runtimes/detection.ts +++ b/apps/daemon/src/runtimes/detection.ts @@ -151,6 +151,8 @@ async function probe( ...(def.env || {}), }, configuredEnv, + undefined, + { resolvedBin: launch.selectedPath }, ), launch, ); diff --git a/apps/daemon/src/runtimes/env.ts b/apps/daemon/src/runtimes/env.ts index ea218a52a..897f4f08b 100644 --- a/apps/daemon/src/runtimes/env.ts +++ b/apps/daemon/src/runtimes/env.ts @@ -15,6 +15,9 @@ import { } from '../sandbox-mode.js'; type RuntimeEnvMap = NodeJS.ProcessEnv | Record; +type SpawnEnvOptions = { + resolvedBin?: string | null; +}; const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule( path.dirname(fileURLToPath(import.meta.url)), @@ -51,6 +54,7 @@ export function spawnEnvForAgent( baseEnv: RuntimeEnvMap, configuredEnv: unknown = {}, systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(), + options: SpawnEnvOptions = {}, ): NodeJS.ProcessEnv { const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv); const env = mergeProxyAwareEnv( @@ -75,7 +79,9 @@ export function spawnEnvForAgent( return reapplySandboxRuntimeEnv(env, sandboxRuntime); } if (agentId === 'claude') { - stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']); + if (!isOpenClaudeExecutable(options.resolvedBin)) { + stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']); + } return reapplySandboxRuntimeEnv(env, sandboxRuntime); } if (agentId === 'codex') { @@ -88,6 +94,15 @@ export function spawnEnvForAgent( return reapplySandboxRuntimeEnv(env, sandboxRuntime); } +function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean { + if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false; + const base = path + .basename(resolvedBin.trim().replace(/\\/g, '/')) + .replace(/\.(exe|cmd|bat)$/i, '') + .toLowerCase(); + return base === 'openclaude'; +} + function sandboxRuntimeConfigForBaseEnv( baseEnv: RuntimeEnvMap, ): SandboxRuntimeConfig | null { diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 7a195e137..5f2cc90aa 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -11315,6 +11315,8 @@ export async function startServer({ ...(def.env || {}), }, configuredAgentEnv, + undefined, + { resolvedBin: agentLaunch.selectedPath }, ), agentLaunch, ) @@ -11697,6 +11699,8 @@ export async function startServer({ ...(def.env || {}), }, configuredAgentEnv, + undefined, + { resolvedBin: agentLaunch.selectedPath }, ); if (def.id === 'amr') { const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv); @@ -12650,6 +12654,7 @@ export async function startServer({ stderrTail: agentStderrTail, stdoutTail: agentStdoutTail, env: spawnedAgentEnv, + resolvedBin: agentLaunch.selectedPath, }); // A non-zero exit whose output reads as an auth / quota / upstream // problem (typical of Claude Code, codex, …) gets the specific code diff --git a/apps/daemon/tests/connection-test.test.ts b/apps/daemon/tests/connection-test.test.ts index cdcd5bf06..e99412550 100644 --- a/apps/daemon/tests/connection-test.test.ts +++ b/apps/daemon/tests/connection-test.test.ts @@ -86,6 +86,42 @@ async function withFakeAgent( } } +async function withOnlyFakeAgent( + binName: string, + script: string, + run: () => Promise, +): Promise { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-')); + const oldPath = process.env.PATH; + const oldAgentHome = process.env.OD_AGENT_HOME; + const oldClaudeBin = process.env.CLAUDE_BIN; + try { + if (process.platform === 'win32') { + const runner = path.join(dir, `${binName}-test-runner.cjs`); + await fsp.writeFile(runner, script); + await fsp.writeFile( + path.join(dir, `${binName}.cmd`), + `@echo off\r\nnode "${runner}" %*\r\n`, + ); + } else { + const bin = path.join(dir, binName); + await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`); + await fsp.chmod(bin, 0o755); + } + process.env.PATH = dir; + process.env.OD_AGENT_HOME = dir; + delete process.env.CLAUDE_BIN; + return await run(); + } finally { + process.env.PATH = oldPath; + if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME; + else process.env.OD_AGENT_HOME = oldAgentHome; + if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN; + else process.env.CLAUDE_BIN = oldClaudeBin; + await fsp.rm(dir, { recursive: true, force: true }); + } +} + async function withFakeCodex(script: string, run: () => Promise): Promise { return withFakeAgent('codex', script, run); } @@ -94,6 +130,10 @@ async function withFakeClaude(script: string, run: () => Promise): Promise return withFakeAgent('claude', script, run); } +async function withOnlyFakeOpenClaude(script: string, run: () => Promise): Promise { + return withOnlyFakeAgent('openclaude', script, run); +} + async function withFakeOpenCode(script: string, run: () => Promise): Promise { return withFakeAgent('opencode', script, run); } @@ -2199,6 +2239,58 @@ process.stdin.on('end', () => { ); }); + it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => { + const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`); + const previousKey = process.env.ANTHROPIC_API_KEY; + try { + process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test'; + await withOnlyFakeOpenClaude( + ` +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({ + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null, +})); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + try { + JSON.parse(input.trim()); + console.log(JSON.stringify({ + type: 'assistant', + message: { + id: 'msg_1', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + }, + })); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +}); +`, + async () => { + const result = await testAgentConnection({ agentId: 'claude' }); + + expect(result).toMatchObject({ + ok: true, + kind: 'success', + agentName: 'Claude Code', + }); + await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe( + JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }), + ); + expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i); + }, + ); + } finally { + if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = previousKey; + await fsp.rm(envFile, { force: true }); + } + }); + it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => { await withFakeClaude( `console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`, diff --git a/apps/daemon/tests/runtimes/env-and-detection.test.ts b/apps/daemon/tests/runtimes/env-and-detection.test.ts index 031a18eaa..a41a8ed97 100644 --- a/apps/daemon/tests/runtimes/env-and-detection.test.ts +++ b/apps/daemon/tests/runtimes/env-and-detection.test.ts @@ -957,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud assert.equal(env.PATH, '/usr/bin'); }); +test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => { + const env = spawnEnvForAgent( + 'claude', + { + ANTHROPIC_API_KEY: 'sk-openclaude', + PATH: '/usr/bin', + }, + {}, + {}, + { resolvedBin: '/tools/openclaude' }, + ); + + assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude'); + assert.equal(env.PATH, '/usr/bin'); +}); + test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => { for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) { const env = spawnEnvForAgent(agentId, {