Skip to content

Commit b0a0a4a

Browse files
committed
fix(desktop): split Claude Code import into OAuth / API-key / proxy branches
Users who logged into Claude Code via OAuth (Pro/Max subscription) hit a silent death path: clicking "Import from Claude Code" would set activeProvider to claude-code-imported but store no key, so toState() returned hasKey:false — yet the UI was already past onboarding and surfaced the generic "Onboarding is not complete" when the user tried to generate. Root causes: 1. parseClaudeCodeSettings only looked at settings.json env block, never at process.env or OAuth-evidence filesystem hints. 2. runImportClaudeCode unconditionally flipped activeProvider to the imported id even when no key was available. 3. No branching for the three practical Claude Code user types (OAuth subscription / API key / local proxy) — one banner, one import path, one outcome. Changes: - packages/shared/src/error-codes.ts New codes: CLAUDE_CODE_OAUTH_ONLY, PROVIDER_ACTIVE_MISSING_KEY. - apps/desktop/src/main/imports/claude-code-config.ts Rewrite parser around a ClaudeCodeUserType classification: has-api-key | oauth-only | local-proxy | remote-gateway | no-config Falls back to process.env ANTHROPIC_{AUTH_TOKEN,API_KEY} when settings.json env block is empty. Adds checkClaudeCodeOAuthEvidence() (filesystem-only probe for ~/.claude/.credentials.json or ~/Library/Application Support/Claude/). Provider entry now always carries envKey: 'ANTHROPIC_AUTH_TOKEN' for runtime fallback. - apps/desktop/src/main/onboarding-ipc.ts getApiKeyForProvider: if secret missing but entry.envKey is set, resolve from process.env before throwing. Rescues shell-env users who launch from terminal. runImportClaudeCode: oauth-only → throw CLAUDE_CODE_OAUTH_ONLY without touching config. no-key + non-oauth → create entry but preserve the existing activeProvider. This kills the death path. detect-external-configs: return richer meta (userType, baseUrl, hasApiKey, apiKeySource) so the renderer can render three banners. - apps/desktop/src/renderer/src/components/Settings.tsx Branch the claudeCode banner on userType: oauth-only → OAuthSubscriptionBanner with Console link + local proxy CTA + dismiss has-api-key → green import banner with source/baseUrl local-proxy → neutral banner explaining proxy setup remote-gateway → neutral banner, "Import (needs key)" action handleImportClaudeCode catches CLAUDE_CODE_OAUTH_ONLY and shows the info-tone toast instead of the generic "import failed" error. - apps/desktop/src/renderer/src/store.ts Replace the generic "Onboarding is not complete" with a specific "provider X has no key, open Settings" message when config.provider is set but hasKey:false. - packages/i18n/src/locales/{en,zh-CN}.json New strings for the three banner variants, the OAuth CTAs, and the providerMissingKey error. Tests: - 12 new parseClaudeCodeSettings cases covering every userType. - runImportClaudeCode tests for oauth-only throw, local-proxy no-activate, has-api-key activate. - getApiKeyForProvider envKey fallback tests. - Full suite: 414 desktop tests, all passing.
1 parent f9f78fd commit b0a0a4a

10 files changed

Lines changed: 741 additions & 58 deletions

File tree

apps/desktop/src/main/imports/claude-code-config.test.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,93 @@ describe('parseClaudeCodeSettings', () => {
1010
ANTHROPIC_AUTH_TOKEN: 'sk-ant-test',
1111
},
1212
});
13-
const out = parseClaudeCodeSettings(json);
13+
const out = parseClaudeCodeSettings(json, { env: {} });
1414
expect(out.provider?.id).toBe('claude-code-imported');
1515
expect(out.provider?.wire).toBe('anthropic');
1616
expect(out.provider?.baseUrl).toBe('https://gateway.example.com');
1717
expect(out.provider?.defaultModel).toBe('claude-opus-4-1');
1818
expect(out.apiKey).toBe('sk-ant-test');
19+
expect(out.apiKeySource).toBe('settings-json');
20+
expect(out.userType).toBe('has-api-key');
1921
});
2022

21-
it('accepts ANTHROPIC_API_KEY as a fallback to ANTHROPIC_AUTH_TOKEN', () => {
23+
it('accepts ANTHROPIC_API_KEY from settings.json as a fallback to ANTHROPIC_AUTH_TOKEN', () => {
2224
const json = JSON.stringify({ env: { ANTHROPIC_API_KEY: 'k' } });
23-
const out = parseClaudeCodeSettings(json);
25+
const out = parseClaudeCodeSettings(json, { env: {} });
2426
expect(out.apiKey).toBe('k');
27+
expect(out.apiKeySource).toBe('settings-json');
28+
expect(out.userType).toBe('has-api-key');
2529
});
2630

27-
it('warns when no key is present but still returns a provider', () => {
31+
it('falls back to shell env ANTHROPIC_AUTH_TOKEN when settings.json has no key', () => {
2832
const json = JSON.stringify({ env: {} });
29-
const out = parseClaudeCodeSettings(json);
30-
expect(out.provider).not.toBeNull();
33+
const out = parseClaudeCodeSettings(json, { env: { ANTHROPIC_AUTH_TOKEN: 'shell-key' } });
34+
expect(out.apiKey).toBe('shell-key');
35+
expect(out.apiKeySource).toBe('shell-env');
36+
expect(out.userType).toBe('has-api-key');
37+
});
38+
39+
it('attaches envKey: ANTHROPIC_AUTH_TOKEN on the provider for runtime fallback', () => {
40+
const json = JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'k' } });
41+
const out = parseClaudeCodeSettings(json, { env: {} });
42+
expect(out.provider?.envKey).toBe('ANTHROPIC_AUTH_TOKEN');
43+
});
44+
45+
it('classifies no-key + localhost baseUrl as local-proxy', () => {
46+
const json = JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://localhost:8082' } });
47+
const out = parseClaudeCodeSettings(json, { env: {} });
48+
expect(out.userType).toBe('local-proxy');
49+
expect(out.apiKey).toBeNull();
50+
expect(out.provider?.baseUrl).toBe('http://localhost:8082');
51+
});
52+
53+
it('classifies no-key + custom remote baseUrl as remote-gateway', () => {
54+
const json = JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.custom.example.com' } });
55+
const out = parseClaudeCodeSettings(json, { env: {} });
56+
expect(out.userType).toBe('remote-gateway');
57+
expect(out.apiKey).toBeNull();
58+
});
59+
60+
it('classifies no-key + OAuth evidence + default baseUrl as oauth-only and returns no provider', () => {
61+
const json = JSON.stringify({ env: {} });
62+
const out = parseClaudeCodeSettings(json, { env: {}, oauthEvidence: true });
63+
expect(out.userType).toBe('oauth-only');
64+
expect(out.provider).toBeNull();
65+
expect(out.hasOAuthEvidence).toBe(true);
66+
});
67+
68+
it('classifies no-key + no OAuth evidence + default baseUrl as no-config', () => {
69+
const json = JSON.stringify({ env: {} });
70+
const out = parseClaudeCodeSettings(json, { env: {}, oauthEvidence: false });
71+
expect(out.userType).toBe('no-config');
72+
expect(out.provider).toBeNull();
73+
});
74+
75+
it('surfaces apiKeyHelper presence as a warning without executing it', () => {
76+
const json = JSON.stringify({
77+
env: { ANTHROPIC_BASE_URL: 'http://localhost:8082' },
78+
apiKeyHelper: 'security find-generic-password -s anthropic -w',
79+
});
80+
const out = parseClaudeCodeSettings(json, { env: {} });
81+
expect(out.warnings.join(' ')).toMatch(/apiKeyHelper/);
3182
expect(out.apiKey).toBeNull();
32-
expect(out.warnings.join(' ')).toMatch(/manually/);
83+
});
84+
85+
it('ignores an empty ANTHROPIC_AUTH_TOKEN string and falls through to env', () => {
86+
const json = JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: ' ' } });
87+
const out = parseClaudeCodeSettings(json, { env: { ANTHROPIC_API_KEY: 'real' } });
88+
expect(out.apiKey).toBe('real');
89+
expect(out.apiKeySource).toBe('shell-env');
3390
});
3491

3592
it('returns a warning on non-JSON input', () => {
36-
const out = parseClaudeCodeSettings('{ bad json');
93+
const out = parseClaudeCodeSettings('{ bad json', { env: {} });
3794
expect(out.provider).toBeNull();
3895
expect(out.warnings[0]).toMatch(/not valid JSON/);
3996
});
97+
98+
it('promotes malformed JSON with OAuth evidence to oauth-only so the banner still fires', () => {
99+
const out = parseClaudeCodeSettings('{ bad json', { env: {}, oauthEvidence: true });
100+
expect(out.userType).toBe('oauth-only');
101+
});
40102
});
Lines changed: 188 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFile } from 'node:fs/promises';
1+
import { access, readFile } from 'node:fs/promises';
22
import { homedir } from 'node:os';
33
import { join } from 'node:path';
44
import type { ProviderEntry } from '@open-codesign/shared';
@@ -7,21 +7,84 @@ export function claudeCodeSettingsPath(home: string = homedir()): string {
77
return join(home, '.claude', 'settings.json');
88
}
99

10+
/**
11+
* Coarse classification of the Claude Code user we just scanned. Drives
12+
* which banner the Settings UI shows (subscription warning vs. one-click
13+
* import vs. manual-finish-required) and which import path we take.
14+
*
15+
* The app cannot reuse an OAuth subscription — Anthropic binds the token
16+
* to the Claude Code client, and refreshing it requires that client's
17+
* embedded OAuth ID. So `oauth-only` is a terminal "please get an API
18+
* key" state, not a failure we can code around.
19+
*/
20+
export type ClaudeCodeUserType =
21+
/** settings.json or shell env exposes a real ANTHROPIC_* key we can use. */
22+
| 'has-api-key'
23+
/** No key anywhere, and filesystem evidence suggests the user logs in via OAuth. */
24+
| 'oauth-only'
25+
/** baseUrl points at localhost — typical Claude Code Proxy / LiteLLM setup. */
26+
| 'local-proxy'
27+
/** baseUrl points at a non-anthropic remote endpoint, but no key was found. */
28+
| 'remote-gateway'
29+
/** No settings.json, no OAuth evidence — nothing to offer. */
30+
| 'no-config';
31+
1032
export interface ClaudeCodeImport {
1133
provider: ProviderEntry | null;
12-
/** The API key pulled from env (if ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY
13-
* was inlined in settings.json). */
1434
apiKey: string | null;
35+
apiKeySource: 'settings-json' | 'shell-env' | 'none';
36+
userType: ClaudeCodeUserType;
37+
hasOAuthEvidence: boolean;
1538
activeModel: string | null;
1639
warnings: string[];
1740
}
1841

1942
type ClaudeCodeSettings = {
2043
env?: Record<string, string>;
44+
apiKeyHelper?: string;
2145
};
2246

23-
export function parseClaudeCodeSettings(json: string): ClaudeCodeImport {
47+
export interface ParseClaudeCodeOptions {
48+
/** Defaults to `process.env`. Tests can inject a stub. */
49+
env?: NodeJS.ProcessEnv;
50+
/** Defaults to the result of `checkClaudeCodeOAuthEvidence()`. */
51+
oauthEvidence?: boolean;
52+
}
53+
54+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
55+
56+
function baseUrlHost(url: string): string | null {
57+
try {
58+
return new URL(url).hostname.toLowerCase();
59+
} catch {
60+
return null;
61+
}
62+
}
63+
64+
function classifyUserType(args: {
65+
apiKeySource: 'settings-json' | 'shell-env' | 'none';
66+
baseUrl: string;
67+
oauthEvidence: boolean;
68+
}): ClaudeCodeUserType {
69+
if (args.apiKeySource !== 'none') return 'has-api-key';
70+
const host = baseUrlHost(args.baseUrl);
71+
if (host !== null && LOCAL_HOSTS.has(host)) return 'local-proxy';
72+
if (args.oauthEvidence) return 'oauth-only';
73+
if (host !== null && host !== 'api.anthropic.com') return 'remote-gateway';
74+
// Default Anthropic endpoint, no key, no OAuth evidence — treat as no-config
75+
// rather than oauth-only so we don't nag users who happen to have a stray
76+
// settings.json without a key.
77+
return 'no-config';
78+
}
79+
80+
export function parseClaudeCodeSettings(
81+
json: string,
82+
options: ParseClaudeCodeOptions = {},
83+
): ClaudeCodeImport {
84+
const env = options.env ?? process.env;
85+
const oauthEvidence = options.oauthEvidence ?? false;
2486
const warnings: string[] = [];
87+
2588
let parsed: unknown;
2689
try {
2790
parsed = JSON.parse(json);
@@ -30,6 +93,9 @@ export function parseClaudeCodeSettings(json: string): ClaudeCodeImport {
3093
return {
3194
provider: null,
3295
apiKey: null,
96+
apiKeySource: 'none',
97+
userType: oauthEvidence ? 'oauth-only' : 'no-config',
98+
hasOAuthEvidence: oauthEvidence,
3399
activeModel: null,
34100
warnings: [`Claude Code settings.json is not valid JSON: ${msg}`],
35101
};
@@ -38,16 +104,72 @@ export function parseClaudeCodeSettings(json: string): ClaudeCodeImport {
38104
return {
39105
provider: null,
40106
apiKey: null,
107+
apiKeySource: 'none',
108+
userType: oauthEvidence ? 'oauth-only' : 'no-config',
109+
hasOAuthEvidence: oauthEvidence,
41110
activeModel: null,
42111
warnings: ['Claude Code settings.json has unexpected shape'],
43112
};
44113
}
45114

46115
const settings = parsed as ClaudeCodeSettings;
47-
const env = settings.env ?? {};
48-
const baseUrl = env['ANTHROPIC_BASE_URL'] ?? 'https://api.anthropic.com';
49-
const model = env['ANTHROPIC_MODEL'] ?? 'claude-sonnet-4-6';
50-
const apiKey = env['ANTHROPIC_AUTH_TOKEN'] ?? env['ANTHROPIC_API_KEY'] ?? null;
116+
const settingsEnv = settings.env ?? {};
117+
const baseUrl = settingsEnv['ANTHROPIC_BASE_URL'] ?? 'https://api.anthropic.com';
118+
const model = settingsEnv['ANTHROPIC_MODEL'] ?? 'claude-sonnet-4-6';
119+
120+
// Resolve the API key in priority order: settings.json first (explicit
121+
// per-project config), shell env second (user exported globally). Note
122+
// that Electron inherits shell env only when launched from a terminal —
123+
// GUI launches on macOS will have a sparse process.env.
124+
let apiKey: string | null = null;
125+
let apiKeySource: 'settings-json' | 'shell-env' | 'none' = 'none';
126+
const settingsToken = settingsEnv['ANTHROPIC_AUTH_TOKEN'] ?? settingsEnv['ANTHROPIC_API_KEY'];
127+
if (typeof settingsToken === 'string' && settingsToken.trim().length > 0) {
128+
apiKey = settingsToken.trim();
129+
apiKeySource = 'settings-json';
130+
} else {
131+
const shellToken = env['ANTHROPIC_AUTH_TOKEN'] ?? env['ANTHROPIC_API_KEY'];
132+
if (typeof shellToken === 'string' && shellToken.trim().length > 0) {
133+
apiKey = shellToken.trim();
134+
apiKeySource = 'shell-env';
135+
}
136+
}
137+
138+
if (apiKey === null && typeof settings.apiKeyHelper === 'string' && settings.apiKeyHelper.length > 0) {
139+
warnings.push(
140+
`Claude Code settings.json defines apiKeyHelper ("${settings.apiKeyHelper}"). ` +
141+
'Open CoDesign does not execute helper scripts — please paste a key manually, ' +
142+
'or export ANTHROPIC_API_KEY in your shell before launching from terminal.',
143+
);
144+
}
145+
146+
const userType = classifyUserType({ apiKeySource, baseUrl, oauthEvidence });
147+
148+
// For oauth-only users we deliberately return provider=null: there's no
149+
// config worth saving, and the caller treats provider===null as "nothing
150+
// to import" so Settings never seeds a zombie entry.
151+
if (userType === 'oauth-only') {
152+
return {
153+
provider: null,
154+
apiKey: null,
155+
apiKeySource: 'none',
156+
userType,
157+
hasOAuthEvidence: oauthEvidence,
158+
activeModel: null,
159+
warnings,
160+
};
161+
}
162+
if (userType === 'no-config') {
163+
return {
164+
provider: null,
165+
apiKey: null,
166+
apiKeySource: 'none',
167+
userType,
168+
hasOAuthEvidence: oauthEvidence,
169+
activeModel: null,
170+
warnings,
171+
};
172+
}
51173

52174
const provider: ProviderEntry = {
53175
id: 'claude-code-imported',
@@ -56,32 +178,84 @@ export function parseClaudeCodeSettings(json: string): ClaudeCodeImport {
56178
wire: 'anthropic',
57179
baseUrl,
58180
defaultModel: model,
181+
// Always attach the env key hint. Runtime `getApiKeyForProvider` uses
182+
// it as a last-resort fallback: if the stored secret gets wiped or the
183+
// user exports the token after import, we still resolve it without a
184+
// round-trip through onboarding.
185+
envKey: 'ANTHROPIC_AUTH_TOKEN',
59186
// Claude Code proxies commonly gate reasoning effort by plan — the
60187
// consumer-tier endpoint accepts only 'medium'. Seed this default so
61188
// imports just work; higher-tier users can raise it in Settings →
62189
// Providers → Reasoning depth.
63190
reasoningLevel: 'medium',
64191
};
65192

66-
if (apiKey === null) {
193+
if (apiKey === null && userType !== 'local-proxy' && userType !== 'remote-gateway') {
67194
warnings.push(
68-
'Claude Code settings.json did not inline ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY — you will need to paste the key manually.',
195+
'Claude Code settings.json did not inline ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY — ' +
196+
'paste the key manually, or export it in your shell and relaunch from terminal.',
69197
);
70198
}
71199

72-
return { provider, apiKey, activeModel: model, warnings };
200+
return {
201+
provider,
202+
apiKey,
203+
apiKeySource,
204+
userType,
205+
hasOAuthEvidence: oauthEvidence,
206+
activeModel: model,
207+
warnings,
208+
};
209+
}
210+
211+
/**
212+
* Best-effort probe for evidence that the user logs into Claude Code via
213+
* OAuth. We look at the filesystem only — `security find-generic-password`
214+
* would cover the macOS Keychain case but invoking shell from the main
215+
* process for classification feels heavier than the 5% extra accuracy buys.
216+
* False negatives (OAuth user with neither path present) fall through to
217+
* `no-config` and see no banner, which is safer than nagging.
218+
*/
219+
export async function checkClaudeCodeOAuthEvidence(home: string = homedir()): Promise<boolean> {
220+
const candidates = [
221+
join(home, '.claude', '.credentials.json'),
222+
join(home, 'Library', 'Application Support', 'Claude'),
223+
];
224+
for (const path of candidates) {
225+
try {
226+
await access(path);
227+
return true;
228+
} catch {
229+
/* not present, keep trying */
230+
}
231+
}
232+
return false;
73233
}
74234

75235
export async function readClaudeCodeSettings(
76236
home: string = homedir(),
77237
): Promise<ClaudeCodeImport | null> {
78238
const path = claudeCodeSettingsPath(home);
239+
const oauthEvidence = await checkClaudeCodeOAuthEvidence(home);
240+
79241
let raw: string;
80242
try {
81243
raw = await readFile(path, 'utf8');
82244
} catch (err) {
83-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
84-
throw err;
245+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
246+
// settings.json absent. If OAuth evidence is present, synthesize a
247+
// minimal ClaudeCodeImport so the Settings banner still offers the
248+
// subscription-user guidance — otherwise return null and stay silent.
249+
if (!oauthEvidence) return null;
250+
return {
251+
provider: null,
252+
apiKey: null,
253+
apiKeySource: 'none',
254+
userType: 'oauth-only',
255+
hasOAuthEvidence: true,
256+
activeModel: null,
257+
warnings: [],
258+
};
85259
}
86-
return parseClaudeCodeSettings(raw);
260+
return parseClaudeCodeSettings(raw, { oauthEvidence });
87261
}

0 commit comments

Comments
 (0)