Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/desktop/src/main/imports/claude-code-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} });
Expand Down
23 changes: 16 additions & 7 deletions apps/desktop/src/main/imports/claude-code-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface ClaudeCodeImport {
}

type ClaudeCodeSettings = {
env?: Record<string, string>;
env?: Record<string, unknown>;
apiKeyHelper?: string;
};

Expand All @@ -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 {
Expand Down Expand Up @@ -108,9 +114,10 @@ function validateSettingsShape(parsed: Record<string, unknown>): 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` };
}
Expand Down Expand Up @@ -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<string, string>,
settingsEnv: Record<string, unknown>,
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) {
Expand All @@ -179,7 +188,7 @@ function resolveApiKey(
return { apiKey: null, apiKeySource: 'none' };
}

function readOptionalEnvSetting(env: Record<string, string>, key: string): string | undefined {
function readOptionalEnvSetting(env: Record<string, unknown>, key: string): string | undefined {
const value = env[key];
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/main/onboarding-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));

Expand Down Expand Up @@ -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');
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/main/onboarding/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Loading