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');