From 97464bfa27d02463f9075ad3f372e4987c7c695f Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:08:07 +0800 Subject: [PATCH] fix(custom-model): pre-approve the injected API key in the isolated Claude config dir The CLAUDE_CONFIG_DIR isolation from the previous commit fixed the cosmetic auth warning but introduced a real regression: an otherwise-empty config directory has none of a real profile's prior custom-API-key approvals, so Claude Code stops at an interactive 'Detected a custom API key - use it?' prompt on every single launch. Confirmed live. With nobody at a TTY to answer, the prompt's own default ('No') silently refuses the very key this feature just injected, which looks like the endpoint being ignored. Adds apiKeyTrustFile to the env-kind customModelInjection capability shape ({relPath, shape: 'claude-api-key-responses'}), set on claude's entry to {relPath: '.claude.json', shape: 'claude-api-key-responses'}. The apply step merges customApiKeyResponses.approved: [apiKey] into /.claude.json - the exact field a real answered prompt itself writes to (confirmed against a real ~/.claude.json after answering by hand once), so this answers the prompt in advance rather than bypassing it. Merges onto whatever the CLI already wrote into that file on an earlier launch in the same isolated directory rather than overwriting it; a missing or corrupt file is treated as empty rather than failing the apply. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG --- docs/custom-model-endpoints.md | 18 +++++ docs/wiki/Custom-Model-Endpoints.md | 5 +- src/config/cli-registry/schema.ts | 11 +++ src/config/cli-registry/stock.ts | 7 ++ src/config/cli-registry/types.ts | 11 +++ src/custom-model-injection-apply.ts | 44 +++++++++++- src/custom-model-injection.ts | 10 ++- test/custom-model-injection-apply.test.ts | 87 +++++++++++++++++++++-- test/custom-model-injection.test.ts | 7 ++ 9 files changed, 192 insertions(+), 8 deletions(-) diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index d481cccb..37398806 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -188,6 +188,24 @@ registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:** the pre-existing blind-response-viewer side effect rather than failing the whole custom-model apply over it. +**That isolated directory needed one more fix to actually be usable +non-interactively.** An otherwise-empty `CLAUDE_CONFIG_DIR` has none of a +real profile's prior "Detected a custom API key — use it?" approvals, so +without more, Claude Code stops and asks that on *every single launch* — +confirmed live, and with nobody at a TTY to answer, its own default answer +("No") silently refuses the very key this feature just injected, which +looks like the endpoint being ignored entirely. `customModelInjection`'s +`apiKeyTrustFile` (`{ relPath: '.claude.json', shape: +'claude-api-key-responses' }` on claude's entry) pre-seeds that exact +approval: the apply step merges `customApiKeyResponses.approved: [apiKey]` +into `/.claude.json`, the same field a real answered prompt +itself writes to (confirmed against a real file after answering by hand +once) — this answers the prompt in advance rather than bypassing it. The +merge preserves whatever else the CLI already wrote into that file on an +earlier launch in the same isolated directory (`userID`, `numStartups`, +earlier approved keys), and a missing or corrupt file is treated as empty +rather than failing the apply. + Clear back to the harness's native cloud default with: ```bash diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 2127227a..6f90aa68 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -80,7 +80,10 @@ currently offer these entries. requests (the API key wins) but the CLI still prints a "both claude.ai and ANTHROPIC_API_KEY set" warning about it, which this avoids entirely. The isolated directory keeps a link back to your real session history so the response viewer and similar features - still work for that session. + still work for that session. That isolated directory starts with no prior approvals of its + own, so Codeman also pre-approves the injected key the same way answering Claude Code's own + "Detected a custom API key" prompt once would — without it, that prompt would otherwise + reappear on every single launch with nobody there to answer it. ## Which harnesses actually work diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 1b66e069..d4c9443a 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -347,6 +347,17 @@ const capabilitiesSchema = z // session. See the customModelInjection doc comment in cli-registry/types.ts. contextLengthVar: envName.optional(), configDirVar: envName.optional(), + // Relative path, WITHIN the isolated configDirVar directory, of a trust-dialog + // seed file the CLI itself owns the shape of — claude's `.claude.json` + // `customApiKeyResponses.approved` list, the same field an interactive "Detected + // a custom API key — use it?" prompt writes to on a real terminal. Only makes + // sense alongside configDirVar (an isolated, otherwise-empty directory has none + // of a real profile's prior approvals), and only implemented for the + // 'claude-api-key-responses' shape today — see custom-model-injection-apply.ts. + apiKeyTrustFile: z + .object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') }) + .strict() + .optional(), }) .strict(), z diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index f7d08bfc..73978f44 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -264,6 +264,13 @@ const CLAUDE: CliEntry = { // never shares a directory with a stored claude.ai OAuth login — see the doc comment on // customModelInjection in cli-registry/types.ts for the traded-off side effect. configDirVar: 'CLAUDE_CONFIG_DIR', + // ⚠️ Required alongside configDirVar, not optional in practice: verified live that an + // isolated, otherwise-empty config directory makes claude stop at an interactive + // "Detected a custom API key — use it?" prompt on EVERY launch, defaulting to "No" with + // no one at the TTY to answer — silently refusing the very key this feature injected. + // Pre-seeding this file's customApiKeyResponses.approved list (verified against a real + // ~/.claude.json after answering the prompt once by hand) answers it in advance instead. + apiKeyTrustFile: { relPath: '.claude.json', shape: 'claude-api-key-responses' }, }, }, overlays: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 3489354d..1b526955 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -512,6 +512,16 @@ export interface CliCapabilities { * that cosmetic warning for a documented side effect: a relocated config directory writes * transcripts outside `~/.claude/projects`, blinding the response viewer, subagent * windows, and Read My Mind for that session (see docs/wiki/Agent-CLIs.md). + * + * `apiKeyTrustFile` (env kind only, alongside configDirVar): an isolated config directory + * has none of a real profile's prior "detected a custom API key, use it?" approvals, so + * without this the CLI stops and asks interactively on every single launch — with no one + * at a TTY to answer, that's a hang, not a warning (confirmed live: claude's own default + * answer, "No", would silently refuse to use the very key this feature just injected). + * `relPath`/`shape` name the file (claude's `.claude.json`) and its + * `customApiKeyResponses.approved` field this pre-seeds — the exact field a real answered + * prompt itself writes to, so this isn't bypassing the check, just answering it the same + * way a one-off prior approval on a shared profile already would. */ customModelInjection: | { @@ -521,6 +531,7 @@ export interface CliCapabilities { modelVars: string[]; launchModel?: string; contextLengthVar?: string; + apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' }; configDirVar?: string; } | { kind: 'configContentEnv'; envVar: string; template: 'opencode-json'; launchModel?: string } diff --git a/src/custom-model-injection-apply.ts b/src/custom-model-injection-apply.ts index b9bbde3a..f5edea89 100644 --- a/src/custom-model-injection-apply.ts +++ b/src/custom-model-injection-apply.ts @@ -10,7 +10,7 @@ * cli-registry changes" requirement it was written against. */ -import { chmodSync, existsSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; import { homedir, platform } from 'node:os'; import { join, dirname } from 'node:path'; import { dataPath } from './config/instance.js'; @@ -79,6 +79,45 @@ function linkSharedProjectsDir(isolatedDir: string): void { } } +/** + * Pre-approves the injected API key in an isolated config directory's trust-dialog state + * (`customModelInjection.apiKeyTrustFile`), so an otherwise-empty directory doesn't make the + * CLI stop at an interactive "Detected a custom API key — use it?" prompt on every single + * launch. Confirmed live: with nobody at the TTY to answer, that prompt's own default + * ("No") silently refuses the very key this feature just injected — this isn't bypassing + * the check, it's answering it the same field a real answered prompt itself writes to + * (verified against a real `~/.claude.json` after answering by hand once). + * + * Merges rather than overwrites: the file may already carry fields the CLI itself wrote on + * an earlier launch in this same isolated directory (machineID, userID, other approved + * keys), and a corrupt or partially-written file (a crash mid-write) is treated as absent + * rather than failing the whole apply over a nice-to-have. + */ +function seedApiKeyTrustFile( + configDir: string, + trustFile: { relPath: string; shape: 'claude-api-key-responses' }, + apiKey: string +): void { + const filePath = join(configDir, trustFile.relPath); + let existing: Record = {}; + try { + existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record; + } catch { + existing = {}; + } + const responses = (existing.customApiKeyResponses ?? {}) as { approved?: unknown; rejected?: unknown }; + const approved = new Set(Array.isArray(responses.approved) ? (responses.approved as string[]) : []); + approved.add(apiKey); + const rejected = Array.isArray(responses.rejected) ? responses.rejected : []; + existing.customApiKeyResponses = { approved: [...approved], rejected }; + try { + writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 }); + chmodSync(filePath, 0o600); + } catch { + // best-effort only — the interactive prompt returns instead of a hard failure here + } +} + /** Best-effort recursive removal of a previously-written configDir. Never throws. */ export function removeConfigDir(dir: string | undefined): void { if (!dir) return; @@ -128,6 +167,9 @@ export function applyCustomModelInjection( configDir = customModelConfigDir(sessionId); mkdirSync(configDir, { recursive: true, mode: 0o700 }); linkSharedProjectsDir(configDir); + if (injection.apiKeyTrustFile && injection.apiKey) { + seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey); + } envOverrides = { ...envOverrides, [injection.configDirVar]: configDir }; } return { diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index 381bb51e..1a5ce782 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -52,6 +52,10 @@ export interface EnvInjection { * (`custom-model-injection-apply.ts`) creates it and adds it to `envOverrides`. */ configDirVar?: string; + /** See `customModelInjection.apiKeyTrustFile` — carried through so the IO wrapper can seed it. */ + apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' }; + /** The literal API key value this injection used, for `apiKeyTrustFile` to pre-approve. */ + apiKey?: string; } export interface ConfigDirInjection { @@ -115,8 +119,10 @@ export function buildCustomModelInjection( if (cap.contextLengthVar && contextLength !== undefined && Number.isFinite(contextLength)) { envOverrides[cap.contextLengthVar] = String(Math.trunc(contextLength)); } - const result = withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId); - return cap.configDirVar ? { ...result, configDirVar: cap.configDirVar } : result; + let result: EnvInjection = withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId); + if (cap.configDirVar) result = { ...result, configDirVar: cap.configDirVar }; + if (cap.apiKeyTrustFile) result = { ...result, apiKeyTrustFile: cap.apiKeyTrustFile, apiKey }; + return result; } case 'configContentEnv': { diff --git a/test/custom-model-injection-apply.test.ts b/test/custom-model-injection-apply.test.ts index c41fe80f..450b7eb0 100644 --- a/test/custom-model-injection-apply.test.ts +++ b/test/custom-model-injection-apply.test.ts @@ -13,7 +13,7 @@ * * Port: N/A (no server; filesystem-only, under a temp CODEMAN data dir from test/setup.ts). */ -import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs'; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -60,7 +60,7 @@ describe('applyCustomModelInjection: context length', () => { }); describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => { - it('claude: creates an isolated, empty config dir and points CLAUDE_CONFIG_DIR at it', () => { + it('claude: creates an isolated config dir (no real credential/config files) and points CLAUDE_CONFIG_DIR at it', () => { const sessionId = 'sess-cfgdir-1'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); @@ -68,9 +68,9 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => { expect(applied?.envOverrides.CLAUDE_CONFIG_DIR).toBe(expectedDir); expect(applied?.configDir).toBe(expectedDir); expect(existsSync(expectedDir)).toBe(true); - // No credential/config files written into it — isolation, not a real config copy. + // Only the trust-seed file and the projects link — no real OAuth credential/config. const entries = readdirSync(expectedDir).filter((name) => name !== 'projects'); - expect(entries).toEqual([]); + expect(entries).toEqual(['.claude.json']); }); it('claude: symlinks (or junctions) projects back to the real config dir so the response viewer keeps working', () => { @@ -110,6 +110,85 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => { }); }); +describe('applyCustomModelInjection: apiKeyTrustFile (pre-approves the injected key)', () => { + it('claude: seeds .claude.json so the "Detected a custom API key" prompt never fires', () => { + const sessionId = 'sess-trust-1'; + sessionsToClean.push(sessionId); + const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); + const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as { + customApiKeyResponses: { approved: string[]; rejected: string[] }; + }; + expect(written.customApiKeyResponses.approved).toEqual(['my-key']); + expect(written.customApiKeyResponses.rejected).toEqual([]); + }); + + it('claude: falls back to the dummy key when the endpoint has none, and still seeds it', () => { + const sessionId = 'sess-trust-2'; + sessionsToClean.push(sessionId); + const applied = applyCustomModelInjection( + entryOrThrow('claude'), + { ...endpoint, apiKey: undefined }, + 'qwen3', + sessionId + ); + const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as { + customApiKeyResponses: { approved: string[] }; + }; + expect(written.customApiKeyResponses.approved).toEqual(['local-dummy-key']); + }); + + it('claude: merges onto fields the CLI itself already wrote into the same isolated dir, never overwrites them', () => { + const sessionId = 'sess-trust-3'; + sessionsToClean.push(sessionId); + const configDir = customModelConfigDir(sessionId); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, '.claude.json'), JSON.stringify({ userID: 'abc123', numStartups: 3 })); + + const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); + + const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as { + userID: string; + numStartups: number; + customApiKeyResponses: { approved: string[] }; + }; + expect(written.userID).toBe('abc123'); + expect(written.numStartups).toBe(3); + expect(written.customApiKeyResponses.approved).toEqual(['my-key']); + }); + + it('claude: a corrupt existing file is treated as absent rather than failing the apply', () => { + const sessionId = 'sess-trust-4'; + sessionsToClean.push(sessionId); + const configDir = customModelConfigDir(sessionId); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, '.claude.json'), '{ not valid json'); + + expect(() => applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId)).not.toThrow(); + const written = JSON.parse(readFileSync(join(configDir, '.claude.json'), 'utf8')) as { + customApiKeyResponses: { approved: string[] }; + }; + expect(written.customApiKeyResponses.approved).toEqual(['my-key']); + }); + + it('claude: re-approving the same key does not duplicate it in the approved list', () => { + const sessionId = 'sess-trust-5'; + sessionsToClean.push(sessionId); + applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); + const second = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'llama3', sessionId); + const written = JSON.parse(readFileSync(join(second!.configDir!, '.claude.json'), 'utf8')) as { + customApiKeyResponses: { approved: string[] }; + }; + expect(written.customApiKeyResponses.approved).toEqual(['my-key']); + }); + + it('opencode: has no apiKeyTrustFile declared (no configDirVar at all), nothing is seeded', () => { + const sessionId = 'sess-trust-opencode'; + const applied = applyCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', sessionId); + expect(applied?.configDir).toBeUndefined(); + expect(existsSync(customModelConfigDir(sessionId))).toBe(false); + }); +}); + describe('applyCustomModelInjection: pre-existing behavior unaffected', () => { it('opencode: still returns a plain env-kind result with no configDir', () => { const sessionId = 'sess-opencode-1'; diff --git a/test/custom-model-injection.test.ts b/test/custom-model-injection.test.ts index 1989c0f1..3655c2fc 100644 --- a/test/custom-model-injection.test.ts +++ b/test/custom-model-injection.test.ts @@ -76,6 +76,13 @@ describe('buildCustomModelInjection', () => { expect(result.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); }); + it('claude: also declares apiKeyTrustFile, carrying the literal apiKey used', () => { + const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3'); + if (result.kind !== 'env') throw new Error('unreachable'); + expect(result.apiKeyTrustFile).toEqual({ relPath: '.claude.json', shape: 'claude-api-key-responses' }); + expect(result.apiKey).toBe('my-key'); + }); + it('claude: falls back to a dummy key when the endpoint has none', () => { const result = buildCustomModelInjection(entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3'); if (result.kind !== 'env') throw new Error('unreachable');