mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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:
co-authored by
Claude Sonnet 5
parent
0e8b1981af
commit
97464bfa27
@@ -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 `<configDir>/.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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user