From d3e01f21420f010f694a7500bab82704addc2232 Mon Sep 17 00:00:00 2001 From: qian Date: Mon, 15 Jun 2026 02:54:53 +0800 Subject: [PATCH] fix: tolerate Claude Code env flags during import --- .../main/imports/claude-code-config.test.ts | 14 +++++++++++ .../src/main/imports/claude-code-config.ts | 23 +++++++++++++------ apps/desktop/src/main/onboarding-ipc.test.ts | 23 +++++++++++++++++++ apps/desktop/src/main/onboarding/register.ts | 16 ++++++++++++- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/imports/claude-code-config.test.ts b/apps/desktop/src/main/imports/claude-code-config.test.ts index bdfe027b..8b56db7d 100644 --- a/apps/desktop/src/main/imports/claude-code-config.test.ts +++ b/apps/desktop/src/main/imports/claude-code-config.test.ts @@ -40,6 +40,20 @@ describe('parseClaudeCodeSettings', () => { expect(out.userType).toBe('has-api-key'); }); + it('ignores non-string Claude Code env flags that are unrelated to import', () => { + const json = JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-ant-test', + ANTHROPIC_BASE_URL: 'https://gateway.example.com', + CLAUDE_CODE_DISABLE_1M_CONTEXT: true, + }, + }); + const out = parseClaudeCodeSettings(json, { env: {} }); + expect(out.userType).toBe('has-api-key'); + expect(out.apiKey).toBe('sk-ant-test'); + expect(out.provider?.baseUrl).toBe('https://gateway.example.com'); + }); + it('attaches envKey: ANTHROPIC_AUTH_TOKEN as import metadata', () => { const json = JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'k' } }); const out = parseClaudeCodeSettings(json, { env: {} }); diff --git a/apps/desktop/src/main/imports/claude-code-config.ts b/apps/desktop/src/main/imports/claude-code-config.ts index 59718ecd..ff49c002 100644 --- a/apps/desktop/src/main/imports/claude-code-config.ts +++ b/apps/desktop/src/main/imports/claude-code-config.ts @@ -48,7 +48,7 @@ export interface ClaudeCodeImport { } type ClaudeCodeSettings = { - env?: Record; + env?: Record; apiKeyHelper?: string; }; @@ -64,6 +64,12 @@ export interface ParseClaudeCodeOptions { } const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']); +const IMPORTED_ENV_KEYS = new Set([ + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', +]); function baseUrlHost(url: string): string | null { try { @@ -108,9 +114,10 @@ function validateSettingsShape(parsed: Record): ParsedSettings const env = parsed['env']; if (env !== undefined) { if (!isRecord(env)) { - return { kind: 'error', warning: 'settings.env must be an object of string values' }; + return { kind: 'error', warning: 'settings.env must be an object' }; } for (const [key, value] of Object.entries(env)) { + if (!IMPORTED_ENV_KEYS.has(key)) continue; if (typeof value !== 'string') { return { kind: 'error', warning: `settings.env.${key} must be a string` }; } @@ -165,12 +172,14 @@ function buildUnusableImport( // that Electron inherits shell env only when launched from a terminal — // GUI launches on macOS will have a sparse process.env. function resolveApiKey( - settingsEnv: Record, + settingsEnv: Record, shellEnv: NodeJS.ProcessEnv, ): { apiKey: string | null; apiKeySource: 'settings-json' | 'shell-env' | 'none' } { - const settingsToken = settingsEnv['ANTHROPIC_AUTH_TOKEN'] ?? settingsEnv['ANTHROPIC_API_KEY']; - if (typeof settingsToken === 'string' && settingsToken.trim().length > 0) { - return { apiKey: settingsToken.trim(), apiKeySource: 'settings-json' }; + const settingsToken = + readOptionalEnvSetting(settingsEnv, 'ANTHROPIC_AUTH_TOKEN') ?? + readOptionalEnvSetting(settingsEnv, 'ANTHROPIC_API_KEY'); + if (settingsToken !== undefined) { + return { apiKey: settingsToken, apiKeySource: 'settings-json' }; } const shellToken = shellEnv['ANTHROPIC_AUTH_TOKEN'] ?? shellEnv['ANTHROPIC_API_KEY']; if (typeof shellToken === 'string' && shellToken.trim().length > 0) { @@ -179,7 +188,7 @@ function resolveApiKey( return { apiKey: null, apiKeySource: 'none' }; } -function readOptionalEnvSetting(env: Record, key: string): string | undefined { +function readOptionalEnvSetting(env: Record, key: string): string | undefined { const value = env[key]; if (typeof value !== 'string') return undefined; const trimmed = value.trim(); diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index 686e67c8..a3ca0175 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -112,6 +112,7 @@ vi.mock('./imports/codex-config', () => ({ })); vi.mock('./imports/claude-code-config', () => ({ + PARSE_REASON_NOT_JSON_OBJECT: '__parse_reason_not_json_object__', readClaudeCodeSettings: vi.fn(async () => null), })); @@ -920,6 +921,28 @@ describe('config:v1:import-codex-config empty env handling', () => { }); describe('config:v1:import-claude-code-config — user-type branching', () => { + it('throws CONFIG_PARSE_FAILED for malformed Claude Code settings', async () => { + await registerIpcForTest(); + const { readClaudeCodeSettings } = await import('./imports/claude-code-config'); + vi.mocked(readClaudeCodeSettings).mockResolvedValueOnce({ + provider: null, + apiKey: null, + apiKeySource: 'none', + userType: 'parse-error', + hasOAuthEvidence: false, + activeModel: null, + settingsPath: '/tmp/.claude/settings.json', + warnings: ['settings.env.ANTHROPIC_AUTH_TOKEN must be a string'], + }); + + const handler = handlers.get('config:v1:import-claude-code-config'); + expect(handler).toBeDefined(); + await expect(handler?.({} as unknown)).rejects.toMatchObject({ + code: 'CONFIG_PARSE_FAILED', + message: expect.stringContaining('ANTHROPIC_AUTH_TOKEN'), + }); + }); + it('throws CLAUDE_CODE_OAUTH_ONLY for oauth-only users without touching config', async () => { const { readClaudeCodeSettings } = await import('./imports/claude-code-config'); const { writeConfig } = await import('./config'); diff --git a/apps/desktop/src/main/onboarding/register.ts b/apps/desktop/src/main/onboarding/register.ts index c7e4b1cf..8041313b 100644 --- a/apps/desktop/src/main/onboarding/register.ts +++ b/apps/desktop/src/main/onboarding/register.ts @@ -6,7 +6,10 @@ import { type OnboardingState, } from '@open-codesign/shared'; import { ipcMain } from '../electron-runtime'; -import { readClaudeCodeSettings } from '../imports/claude-code-config'; +import { + PARSE_REASON_NOT_JSON_OBJECT, + readClaudeCodeSettings, +} from '../imports/claude-code-config'; import { readCodexConfig } from '../imports/codex-config'; import { readGeminiCliConfig } from '../imports/gemini-cli-config'; import { readOpencodeConfig } from '../imports/opencode-config'; @@ -234,6 +237,17 @@ export function registerOnboardingIpc(): void { // that case and shows the subscription-warning banner — a generic // "no config found" swallows the nuance. if (imported.provider === null && imported.userType !== 'oauth-only') { + if (imported.userType === 'parse-error') { + const rawReason = imported.warnings[0] ?? 'unknown reason'; + const reason = + rawReason === PARSE_REASON_NOT_JSON_OBJECT + ? 'top-level value is not a JSON object' + : rawReason; + throw new CodesignError( + `Claude Code settings at ${imported.settingsPath} could not be parsed: ${reason}`, + ERROR_CODES.CONFIG_PARSE_FAILED, + ); + } throw new CodesignError( 'No Claude Code settings found at ~/.claude/settings.json', ERROR_CODES.CONFIG_MISSING,