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';
|
||||
|
||||
export interface ClaudeCliDiagnosticInput {
|
||||
|
|
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
|
|||
stderrTail?: string | null;
|
||||
stdoutTail?: string | null;
|
||||
env?: Record<string, unknown> | 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.';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ async function probe(
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredEnv,
|
||||
undefined,
|
||||
{ resolvedBin: launch.selectedPath },
|
||||
),
|
||||
launch,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
} from '../sandbox-mode.js';
|
||||
|
||||
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
return withFakeAgent('codex', script, run);
|
||||
}
|
||||
|
|
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
|
|||
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> {
|
||||
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);`,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue