fix: preserve OpenClaude fallback credentials (#3361)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 2s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 2s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped

This commit is contained in:
mehmet turac 2026-05-31 06:49:25 +03:00 committed by GitHub
parent d66a463d62
commit 8448b1105c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 169 additions and 2 deletions

View file

@ -1,3 +1,5 @@
import path from 'node:path';
import { redactSecrets } from './redact.js'; import { redactSecrets } from './redact.js';
export interface ClaudeCliDiagnosticInput { export interface ClaudeCliDiagnosticInput {
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
stderrTail?: string | null; stderrTail?: string | null;
stdoutTail?: string | null; stdoutTail?: string | null;
env?: Record<string, unknown> | null; env?: Record<string, unknown> | null;
resolvedBin?: string | null;
} }
export interface ClaudeCliDiagnostic { 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( export function diagnoseClaudeCliFailure(
input: ClaudeCliDiagnosticInput, input: ClaudeCliDiagnosticInput,
): ClaudeCliDiagnostic | null { ): ClaudeCliDiagnostic | null {
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
const normalized = text.toLowerCase(); const normalized = text.toLowerCase();
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null; const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null; const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
const runtime = selectedClaudeCompatibleRuntime(input);
const isOpenClaude = runtime === 'openclaude';
const customEndpointConnectionFailure = const customEndpointConnectionFailure =
hasCustomBaseUrl && hasCustomBaseUrl &&
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
); );
} }
if (authFailure) { 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 const configHint = hasConfigDir
? 'The configured Claude config directory may contain stale or expired auth state.' ? '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.'; : '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 (!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 const message = hasConfigDir
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.' ? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
: 'Claude Code exited before producing diagnostics.'; : 'Claude Code exited before producing diagnostics.';

View file

@ -1862,6 +1862,8 @@ async function testAgentConnectionInternal(
...(def.env || {}), ...(def.env || {}),
}, },
configuredAgentEnv, configuredAgentEnv,
undefined,
{ resolvedBin: executableResolution.selectedPath },
); );
const env = applyAgentLaunchEnv(baseEnv, executableResolution); const env = applyAgentLaunchEnv(baseEnv, executableResolution);
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env); const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
@ -2026,6 +2028,7 @@ async function testAgentConnectionInternal(
stderrTail, stderrTail,
stdoutTail: rawStdoutTail || buffered, stdoutTail: rawStdoutTail || buffered,
env, env,
resolvedBin: executableResolution.selectedPath,
}); });
if (claudeDiagnostic) { if (claudeDiagnostic) {
console.warn( console.warn(

View file

@ -865,7 +865,13 @@ async function callLocalCli(provider, system, user, options) {
} }
const env = applyAgentLaunchEnv( const env = applyAgentLaunchEnv(
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv), spawnEnvForAgent(
def.id,
{ ...process.env, ...(def.env || {}) },
configuredAgentEnv,
undefined,
{ resolvedBin: launch.selectedPath },
),
launch, launch,
); );
const invocation = createCommandInvocation({ const invocation = createCommandInvocation({

View file

@ -151,6 +151,8 @@ async function probe(
...(def.env || {}), ...(def.env || {}),
}, },
configuredEnv, configuredEnv,
undefined,
{ resolvedBin: launch.selectedPath },
), ),
launch, launch,
); );

View file

@ -15,6 +15,9 @@ import {
} from '../sandbox-mode.js'; } from '../sandbox-mode.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>; type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
type SpawnEnvOptions = {
resolvedBin?: string | null;
};
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule( const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
@ -51,6 +54,7 @@ export function spawnEnvForAgent(
baseEnv: RuntimeEnvMap, baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {}, configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(), systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
options: SpawnEnvOptions = {},
): NodeJS.ProcessEnv { ): NodeJS.ProcessEnv {
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv); const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
const env = mergeProxyAwareEnv( const env = mergeProxyAwareEnv(
@ -75,7 +79,9 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime); return reapplySandboxRuntimeEnv(env, sandboxRuntime);
} }
if (agentId === 'claude') { 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); return reapplySandboxRuntimeEnv(env, sandboxRuntime);
} }
if (agentId === 'codex') { if (agentId === 'codex') {
@ -88,6 +94,15 @@ export function spawnEnvForAgent(
return reapplySandboxRuntimeEnv(env, sandboxRuntime); 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( function sandboxRuntimeConfigForBaseEnv(
baseEnv: RuntimeEnvMap, baseEnv: RuntimeEnvMap,
): SandboxRuntimeConfig | null { ): SandboxRuntimeConfig | null {

View file

@ -11315,6 +11315,8 @@ export async function startServer({
...(def.env || {}), ...(def.env || {}),
}, },
configuredAgentEnv, configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
), ),
agentLaunch, agentLaunch,
) )
@ -11697,6 +11699,8 @@ export async function startServer({
...(def.env || {}), ...(def.env || {}),
}, },
configuredAgentEnv, configuredAgentEnv,
undefined,
{ resolvedBin: agentLaunch.selectedPath },
); );
if (def.id === 'amr') { if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv); const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
@ -12650,6 +12654,7 @@ export async function startServer({
stderrTail: agentStderrTail, stderrTail: agentStderrTail,
stdoutTail: agentStdoutTail, stdoutTail: agentStdoutTail,
env: spawnedAgentEnv, env: spawnedAgentEnv,
resolvedBin: agentLaunch.selectedPath,
}); });
// A non-zero exit whose output reads as an auth / quota / upstream // A non-zero exit whose output reads as an auth / quota / upstream
// problem (typical of Claude Code, codex, …) gets the specific code // problem (typical of Claude Code, codex, …) gets the specific code

View file

@ -86,6 +86,42 @@ async function withFakeAgent<T>(
} }
} }
async function withOnlyFakeAgent<T>(
binName: string,
script: string,
run: () => Promise<T>,
): Promise<T> {
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<T>(script: string, run: () => Promise<T>): Promise<T> { async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('codex', script, run); return withFakeAgent('codex', script, run);
} }
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
return withFakeAgent('claude', script, run); return withFakeAgent('claude', script, run);
} }
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
return withOnlyFakeAgent('openclaude', script, run);
}
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> { async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('opencode', script, run); 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 () => { it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
await withFakeClaude( await withFakeClaude(
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`, `console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,

View file

@ -957,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
assert.equal(env.PATH, '/usr/bin'); 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', () => { test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) { for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, { const env = spawnEnvForAgent(agentId, {