fix(custom-model): split the two confirmation questions, and seed the API key the way claude reads it

Two findings from the review of 5fc391a4, both fixed here rather than sent back.

**The API-key trust seed never matched a real key.** `seedApiKeyTrustFile()` wrote the
key verbatim into `customApiKeyResponses.approved`, but Claude Code stores and compares
only the last 20 characters (`key.trim().slice(-20)`, applied on both the write and the
lookup). For any real key the seed missed, so claude stopped at the interactive
"Detected a custom API key in your environment" prompt, whose default is
"No (recommended)": the launch hangs, or silently refuses the key this feature just
injected and falls through to an OAuth login the isolated config dir does not have. It
survived review because a keyless llama.cpp/llama-swap endpoint uses DEFAULT_API_KEY
('local-dummy-key', 15 chars), where slice(-20) returns the whole string and the seed
matches by accident, and every test used a key shorter than that. Now truncated through
`truncateApiKeyForTrustFile()`, with a test using a 57-character key that also asserts
the full credential never reaches that second file.

**One `confirmed` flag answered two different questions.** The context-floor warning
("this model's window is below what this CLI needs") and the swap-conflict warning
("loading this unloads the model another session is using") shared it, and the context
check runs first, so a user clicking "launch anyway" past the context warning silently
consented to evicting someone else's model. They are about different people, so an
answer to one is not consent to the other. Both routes now read `confirmedContext` and
`confirmedSwap` independently; the legacy `confirmed` still means both, because it
shipped in this feature's HTTP-API-only cut and an existing caller must keep working.
The frontend answers each question with its own flag and accumulates them, on the
one-shot path, the restart path and the batch carry-forward alike.

Also from the same review: the swap-confirm dialog no longer renders " are currently
using ..." when multi-user scoping leaves the affected-session list empty (the swap is
blocked regardless of ownership; only the NAMES are scoped), and the per-endpoint
llama-swap log tails are closed in `WebServer.stop()` instead of only by the idle sweep
whose interval that same teardown disposes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-19 12:32:50 +02:00
parent 3b55957d79
commit fe3bd0074c
9 changed files with 244 additions and 32 deletions
+21 -1
View File
@@ -93,6 +93,15 @@ function linkSharedProjectsDir(isolatedDir: string): void {
* 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.
*/
/**
* The form Claude Code actually stores an approved key in: the trimmed last 20
* characters. Mirrors the CLI's own `e.trim().slice(-20)`, which is applied on BOTH
* the write and the lookup, so anything else never matches.
*/
export function truncateApiKeyForTrustFile(apiKey: string): string {
return apiKey.trim().slice(-20);
}
function seedApiKeyTrustFile(
configDir: string,
trustFile: { relPath: string; shape: 'claude-api-key-responses' },
@@ -107,7 +116,18 @@ function seedApiKeyTrustFile(
}
const responses = (existing.customApiKeyResponses ?? {}) as { approved?: unknown; rejected?: unknown };
const approved = new Set(Array.isArray(responses.approved) ? (responses.approved as string[]) : []);
approved.add(apiKey);
// ⚠ Claude Code stores and compares only the LAST 20 CHARACTERS of a key, never the
// whole thing: its lookup is `approved.includes(key.trim().slice(-20))` (decompiled
// from the 2.1.278 bundle, and corroborated by real `~/.claude.json` files, whose
// customApiKeyResponses entries are all exactly 20 characters). Seeding the full key
// therefore never matches for a REAL key, and claude stops at the interactive
// "Detected a custom API key in your environment" prompt, whose default is
// "No (recommended)" — so the launch hangs or silently refuses the key this feature
// just injected. It went unnoticed because a keyless llama.cpp/llama-swap endpoint
// uses DEFAULT_API_KEY ('local-dummy-key', 15 chars), where slice(-20) is the whole
// string and the seed matches by accident. Truncating here also keeps a full
// third-party credential from being written into a second file on disk.
approved.add(truncateApiKeyForTrustFile(apiKey));
const rejected = Array.isArray(responses.rejected) ? responses.rejected : [];
existing.customApiKeyResponses = { approved: [...approved], rejected };
try {