mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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
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:
parent
d66a463d62
commit
8448b1105c
8 changed files with 169 additions and 2 deletions
|
|
@ -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.';
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ async function probe(
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredEnv,
|
configuredEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: launch.selectedPath },
|
||||||
),
|
),
|
||||||
launch,
|
launch,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);`,
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue