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
<isolatedConfigDir>/.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-16 13:08:07 +08:00
co-authored by Claude Sonnet 5
parent 0e8b1981af
commit 97464bfa27
9 changed files with 192 additions and 8 deletions
+11
View File
@@ -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
+7
View File
@@ -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: {
+11
View File
@@ -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 }
+43 -1
View File
@@ -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<string, unknown> = {};
try {
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
} 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 {
+8 -2
View File
@@ -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': {