mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat(custom-model): skip Claude Code's first-run wizard on custom-model launches
A fresh, isolated CLAUDE_CONFIG_DIR (used to keep an injected API key away from a stored claude.ai OAuth login) looks like a brand-new Claude Code profile to the CLI, so it replays its ENTIRE first-run sequence on every single launch: the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with --dangerously-skip-permissions) a one-time bypass-permissions warning — confirmed live, none of which a real, already-onboarded profile shows again. - New registry-declared env-kind field `skipFirstRunPrompts` (alongside apiKeyTrustFile, which it reuses) — claude's entry only, carried through buildCustomModelInjection (pure) into applyCustomModelInjection (IO). - seedFirstRunOnboardingState(): merges hasCompletedOnboarding: true and this session's own projects[workingDir].hasTrustDialogAccepted: true into the same <configDir>/.claude.json the API-key trust file already writes to — other projects and other fields on this session's own entry are left untouched. - seedSkipBypassPermissionsPrompt(): merges skipDangerousModePermissionPrompt: true into <configDir>/settings.json, a separate file, same corrupt-tolerant merge behavior. - applyCustomModelInjection() gains an optional workingDir parameter, threaded from session.workingDir (dedicated apply route) / resolvedCasePath (quick-start route) — boot recovery omits it (a dialog already answered once needs no re-seed on the same, persisted isolated directory). Tests added at the pure-builder, IO-wrapper (including merge-preserves- other-fields and corrupt-file-tolerance cases), and existing directory- listing assertions updated for the new settings.json file. Typecheck/ lint/format clean; full suite shows no new regressions (baseline pre-existing Windows-environment failures unchanged, 8 more passing tests than before — the ones added here). 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
2c89359d42
commit
211b872335
@@ -13,6 +13,10 @@ Everything below was found and fixed against a **real llama-swap server**, not j
|
|||||||
- **Context floor too small for Claude Code to even start.** Fixing the overflow above surfaced a second, unfixable-by-injection failure: Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on their own (confirmed live via an `in:0 out:0` failure on the very first message), which can exceed a small model's entire real context before any conversation history exists to trim — no `CLAUDE_CODE_MAX_CONTEXT_TOKENS` value fixes that, since it only governs when history gets compacted. Applying such a model now returns a warning (gated on the CLI registry declaring a `contextLengthVar`, so it's a no-op for every other harness) instead of launching straight into a guaranteed first-message failure, and the Run-menu picker shows it as an in-app dialog naming the model, its discovered context and the ~40K safe floor, with the actual fix spelled out: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on auto-fit, which optimizes for the biggest model that fits rather than the biggest context. "Launch anyway" is still one click away.
|
- **Context floor too small for Claude Code to even start.** Fixing the overflow above surfaced a second, unfixable-by-injection failure: Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on their own (confirmed live via an `in:0 out:0` failure on the very first message), which can exceed a small model's entire real context before any conversation history exists to trim — no `CLAUDE_CODE_MAX_CONTEXT_TOKENS` value fixes that, since it only governs when history gets compacted. Applying such a model now returns a warning (gated on the CLI registry declaring a `contextLengthVar`, so it's a no-op for every other harness) instead of launching straight into a guaranteed first-message failure, and the Run-menu picker shows it as an in-app dialog naming the model, its discovered context and the ~40K safe floor, with the actual fix spelled out: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on auto-fit, which optimizes for the biggest model that fits rather than the biggest context. "Launch anyway" is still one click away.
|
||||||
- **The real root cause of "it still says opus, not my model."** llama.cpp runs exactly one model at a time; llama-swap unloads and reloads it on demand, which can take anywhere from a few seconds to well over a minute — long enough that a session mid-swap is indistinguishable from one that never left the native backend. Applying a selection now checks llama-swap's own `GET /running` first (feature-detected; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is never checked); if switching would unload a model **another live session is actively using**, the apply is refused with a warning naming that session instead of silently switching, and a confirmation retry proceeds anyway. Either way, a sticky "loading model…" toast now covers the actual swap window until llama-swap reports the target model ready, so a prompt sent mid-swap reads as "loading," never as silence or an answer from whatever was loaded a moment before.
|
- **The real root cause of "it still says opus, not my model."** llama.cpp runs exactly one model at a time; llama-swap unloads and reloads it on demand, which can take anywhere from a few seconds to well over a minute — long enough that a session mid-swap is indistinguishable from one that never left the native backend. Applying a selection now checks llama-swap's own `GET /running` first (feature-detected; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is never checked); if switching would unload a model **another live session is actively using**, the apply is refused with a warning naming that session instead of silently switching, and a confirmation retry proceeds anyway. Either way, a sticky "loading model…" toast now covers the actual swap window until llama-swap reports the target model ready, so a prompt sent mid-swap reads as "loading," never as silence or an answer from whatever was loaded a moment before.
|
||||||
|
|
||||||
|
- **Claude's whole first-run sequence, on every single launch.** A fresh, otherwise-empty `CLAUDE_CONFIG_DIR` isn't just missing the API-key approval above — Claude Code treats it as a brand-new profile and replays the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running bypassed) a one-time permissions-bypass warning, every time, confirmed live. None of that shows up again for a real, already-onboarded profile. `customModelInjection`'s new `skipFirstRunPrompts` (claude's entry only) pre-seeds that same "already been through this" state — `hasCompletedOnboarding` and this session's own project trust into the same `.claude.json` the API-key approval merges into, `skipDangerousModePermissionPrompt` into `settings.json` — so a custom-model launch reaches the conversation exactly as fast as a native cloud one, with nobody there to click through a wizard.
|
||||||
|
|
||||||
|
Two more, from actually clicking through the swap-confirm and context-warning dialogs live: their z-index sat under the centred status banner, so a dialog could render fully hidden behind "Claude started — switching to llama-swap…"; and their Cancel/confirm buttons stacked instead of sitting side by side (`.btn-toolbar`'s own `display: flex` needs a row-layout parent it never had). Both dialogs now clear the banner and lay their buttons out centred, side by side.
|
||||||
|
|
||||||
Remote (SSH) and Docker sessions are refused for now (400) — their restart reattaches the durable remote/in-container tmux rather than relaunching the agent.
|
Remote (SSH) and Docker sessions are refused for now (400) — their restart reattaches the durable remote/in-container tmux rather than relaunching the agent.
|
||||||
|
|
||||||
**One more, from watching it launch live: opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP now launch directly on the endpoint, with no restart at all.** Picking one of these seven from the Run-menu picker used to launch natively first, wait for it to settle, then restart it in place with the endpoint applied — a deliberate two-step design, but visibly a native boot immediately followed by a second one, worst on a CLI whose TUI fully reinitializes on a restart (confirmed live on Codex). `POST /api/quick-start` now accepts a `customModel` field and computes the same injection _before_ the session exists, launching straight onto the endpoint the first time — no visible relaunch, and it also runs the same llama-swap conflict check (warns before unloading a model another live session is using) at create time. Claude still uses the original launch-then-restart path for now (its own `--resume`-based restart is far less jarring, and `runClaude()`'s multi-tab and docker-config-drift-retry logic make folding it into the one-shot path separate work).
|
**One more, from watching it launch live: opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP now launch directly on the endpoint, with no restart at all.** Picking one of these seven from the Run-menu picker used to launch natively first, wait for it to settle, then restart it in place with the endpoint applied — a deliberate two-step design, but visibly a native boot immediately followed by a second one, worst on a CLI whose TUI fully reinitializes on a restart (confirmed live on Codex). `POST /api/quick-start` now accepts a `customModel` field and computes the same injection _before_ the session exists, launching straight onto the endpoint the first time — no visible relaunch, and it also runs the same llama-swap conflict check (warns before unloading a model another live session is using) at create time. Claude still uses the original launch-then-restart path for now (its own `--resume`-based restart is far less jarring, and `runClaude()`'s multi-tab and docker-config-drift-retry logic make folding it into the one-shot path separate work).
|
||||||
|
|||||||
@@ -285,6 +285,27 @@ earlier launch in the same isolated directory (`userID`, `numStartups`,
|
|||||||
earlier approved keys), and a missing or corrupt file is treated as empty
|
earlier approved keys), and a missing or corrupt file is treated as empty
|
||||||
rather than failing the apply.
|
rather than failing the apply.
|
||||||
|
|
||||||
|
**A fresh `CLAUDE_CONFIG_DIR` isn't just missing that one approval — Claude
|
||||||
|
Code treats it as a brand-new profile and replays its ENTIRE first-run
|
||||||
|
sequence on every launch: the theme picker, the security-notes screen, the
|
||||||
|
per-project "trust this folder?" dialog, and (running with
|
||||||
|
`--dangerously-skip-permissions`) a one-time warning about bypassing
|
||||||
|
permissions.** Confirmed live: none of these show up again for a real,
|
||||||
|
already-onboarded profile, but every custom-model session gets a fresh,
|
||||||
|
otherwise-empty isolated directory, so it saw all four every single time.
|
||||||
|
`customModelInjection`'s `skipFirstRunPrompts` (`true` on claude's entry,
|
||||||
|
requires `apiKeyTrustFile` since it reuses the same file) pre-seeds the
|
||||||
|
state a real profile accumulates from answering all of that once:
|
||||||
|
`hasCompletedOnboarding: true` and the launching session's own
|
||||||
|
`projects[workingDir].hasTrustDialogAccepted: true` go into the same
|
||||||
|
`<configDir>/.claude.json` the API-key approval above already merges into
|
||||||
|
(other projects, and other fields on this session's own project entry, are
|
||||||
|
left untouched), and `skipDangerousModePermissionPrompt: true` goes into
|
||||||
|
`<configDir>/settings.json` — a different file, merged the same
|
||||||
|
corrupt-tolerant way. `workingDir` is used exactly as the session was
|
||||||
|
launched with as its cwd, never realpath'd or slash-normalized, since
|
||||||
|
that's the literal string Claude Code itself uses as the project key.
|
||||||
|
|
||||||
**llama-swap gets two more fixes on top of the context-length/config-dir
|
**llama-swap gets two more fixes on top of the context-length/config-dir
|
||||||
ones above, both from watching a real switch live.** llama.cpp only ever
|
ones above, both from watching a real switch live.** llama.cpp only ever
|
||||||
runs one model at a time; llama-swap swaps the backing process on demand,
|
runs one model at a time; llama-swap swaps the backing process on demand,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ error telling you to check the llama-swap server's own logs, and **the session t
|
|||||||
for is closed automatically** — a console left open and pointed at a model that never
|
for is closed automatically** — a console left open and pointed at a model that never
|
||||||
finished loading would just be confusing to leave sitting there.
|
finished loading would just be confusing to leave sitting there.
|
||||||
|
|
||||||
**Claude Code specifically gets two extra fixes applied automatically:**
|
**Claude Code specifically gets three extra fixes applied automatically:**
|
||||||
|
|
||||||
- Its discovered context length (see above) is passed through as
|
- Its discovered context length (see above) is passed through as
|
||||||
`CLAUDE_CODE_MAX_CONTEXT_TOKENS`, so it doesn't send a full-size prompt against a much
|
`CLAUDE_CODE_MAX_CONTEXT_TOKENS`, so it doesn't send a full-size prompt against a much
|
||||||
@@ -103,6 +103,14 @@ finished loading would just be confusing to leave sitting there.
|
|||||||
own, so Codeman also pre-approves the injected key the same way answering Claude Code's own
|
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
|
"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.
|
reappear on every single launch with nobody there to answer it.
|
||||||
|
- **That same fresh isolated directory also looks like a brand-new Claude Code profile**, so
|
||||||
|
without this fix it replayed the WHOLE first-run sequence every single launch: the theme
|
||||||
|
picker, the security-notes screen, the "trust this folder?" dialog, and a one-time warning
|
||||||
|
about running with permissions bypassed — none of which a real, already-used profile shows
|
||||||
|
again. Codeman now pre-seeds that same "already been through this once" state (onboarding
|
||||||
|
completed, this session's own project marked trusted, the bypass-permissions warning
|
||||||
|
acknowledged) so a custom-model launch reaches the actual conversation exactly as fast as a
|
||||||
|
native cloud one does, instead of stopping at a wizard with nobody there to click through it.
|
||||||
|
|
||||||
**If a model's real context is too small for Claude Code to even get started, you get a
|
**If a model's real context is too small for Claude Code to even get started, you get a
|
||||||
warning instead of a confusing failure.** Claude Code's own system prompt and tools take up
|
warning instead of a confusing failure.** Claude Code's own system prompt and tools take up
|
||||||
|
|||||||
@@ -358,6 +358,12 @@ const capabilitiesSchema = z
|
|||||||
.object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') })
|
.object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') })
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
// An isolated config directory replays the CLI's whole first-run sequence (theme
|
||||||
|
// picker, security notes, per-project trust dialog, bypass-permissions warning)
|
||||||
|
// on every launch, same root cause as apiKeyTrustFile above — this reuses that
|
||||||
|
// same file to pre-seed the state a real, already-onboarded profile carries. See
|
||||||
|
// the customModelInjection doc comment in cli-registry/types.ts.
|
||||||
|
skipFirstRunPrompts: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -271,6 +271,14 @@ const CLAUDE: CliEntry = {
|
|||||||
// Pre-seeding this file's customApiKeyResponses.approved list (verified against a real
|
// 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.
|
// ~/.claude.json after answering the prompt once by hand) answers it in advance instead.
|
||||||
apiKeyTrustFile: { relPath: '.claude.json', shape: 'claude-api-key-responses' },
|
apiKeyTrustFile: { relPath: '.claude.json', shape: 'claude-api-key-responses' },
|
||||||
|
// ⚠️ Same isolated-directory root cause, one step further: verified live that on top
|
||||||
|
// of the API-key prompt above, a fresh CLAUDE_CONFIG_DIR also replays claude's ENTIRE
|
||||||
|
// first-run sequence on every launch — the theme picker, the security-notes screen,
|
||||||
|
// the per-project "trust this folder?" dialog, and (running with
|
||||||
|
// --dangerously-skip-permissions) a one-time bypass-permissions warning — none of
|
||||||
|
// which a real, already-onboarded profile shows again. Pre-seeds that same
|
||||||
|
// already-onboarded state instead of leaving a human to click through it.
|
||||||
|
skipFirstRunPrompts: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overlays: {
|
overlays: {
|
||||||
|
|||||||
@@ -522,6 +522,19 @@ export interface CliCapabilities {
|
|||||||
* `customApiKeyResponses.approved` field this pre-seeds — the exact field a real answered
|
* `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
|
* 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.
|
* way a one-off prior approval on a shared profile already would.
|
||||||
|
*
|
||||||
|
* `skipFirstRunPrompts` (env kind only, alongside apiKeyTrustFile): an isolated config
|
||||||
|
* directory is not just missing API-key approvals — it is a brand-new profile as far as
|
||||||
|
* the CLI is concerned, so it also replays its ENTIRE first-run sequence on every launch:
|
||||||
|
* the theme picker, the security-notes screen, the per-project "trust this folder?"
|
||||||
|
* dialog, and (running with a bypass-permissions flag) a one-time warning about it —
|
||||||
|
* confirmed live, none of which a real, long-used profile ever shows again. `true`
|
||||||
|
* pre-seeds the same state a real profile accumulates from having answered all of that
|
||||||
|
* once: `hasCompletedOnboarding` and the launching session's own project entry in the
|
||||||
|
* `apiKeyTrustFile` (claude's `.claude.json`), plus `skipDangerousModePermissionPrompt`
|
||||||
|
* in claude's `settings.json` — see `seedFirstRunState`/`seedSkipBypassPermissionsPrompt`
|
||||||
|
* in custom-model-injection-apply.ts. Requires `apiKeyTrustFile` to be set too, since it
|
||||||
|
* reuses that file.
|
||||||
*/
|
*/
|
||||||
customModelInjection:
|
customModelInjection:
|
||||||
| {
|
| {
|
||||||
@@ -533,6 +546,7 @@ export interface CliCapabilities {
|
|||||||
contextLengthVar?: string;
|
contextLengthVar?: string;
|
||||||
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
|
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
|
||||||
configDirVar?: string;
|
configDirVar?: string;
|
||||||
|
skipFirstRunPrompts?: boolean;
|
||||||
}
|
}
|
||||||
| { kind: 'configContentEnv'; envVar: string; template: 'opencode-json'; launchModel?: string }
|
| { kind: 'configContentEnv'; envVar: string; template: 'opencode-json'; launchModel?: string }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -118,6 +118,79 @@ function seedApiKeyTrustFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-seeds the two remaining pieces of "already been onboarded" state a fresh
|
||||||
|
* `CLAUDE_CONFIG_DIR` has none of (`customModelInjection.skipFirstRunPrompts`, alongside
|
||||||
|
* apiKeyTrustFile): claude replays its whole first-run sequence — the theme picker, the
|
||||||
|
* security-notes screen, and (per-project) the "trust this folder?" dialog — against ANY
|
||||||
|
* config directory that has never completed it, confirmed live against a genuinely fresh
|
||||||
|
* isolated directory. `hasCompletedOnboarding` skips the theme/security-notes screens
|
||||||
|
* outright; `projects[workingDir].hasTrustDialogAccepted` answers the trust dialog for
|
||||||
|
* THIS session's own working directory the same way a real profile's own prior approval
|
||||||
|
* would — other projects in the file are left alone, and `workingDir` is used verbatim
|
||||||
|
* (never realpath'd or slash-normalized) since that's the literal string claude itself
|
||||||
|
* uses as the project key, being whatever string the session was actually launched with
|
||||||
|
* as its cwd.
|
||||||
|
*
|
||||||
|
* Same merge-not-overwrite and corrupt-file-tolerant behavior as `seedApiKeyTrustFile`
|
||||||
|
* (same file, so a second sequential read-modify-write here is deliberate rather than
|
||||||
|
* folding both into one pass — keeps each seed independently testable and optional).
|
||||||
|
*/
|
||||||
|
function seedFirstRunOnboardingState(
|
||||||
|
configDir: string,
|
||||||
|
trustFile: { relPath: string; shape: 'claude-api-key-responses' },
|
||||||
|
workingDir: 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 = {};
|
||||||
|
}
|
||||||
|
existing.hasCompletedOnboarding = true;
|
||||||
|
const projects =
|
||||||
|
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
|
||||||
|
? (existing.projects as Record<string, Record<string, unknown>>)
|
||||||
|
: {};
|
||||||
|
const existingProject = projects[workingDir] && typeof projects[workingDir] === 'object' ? projects[workingDir] : {};
|
||||||
|
projects[workingDir] = { ...existingProject, hasTrustDialogAccepted: true };
|
||||||
|
existing.projects = projects;
|
||||||
|
try {
|
||||||
|
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||||
|
chmodSync(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort only — the interactive dialogs return instead of a hard failure here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-seeds the "skip the bypass-permissions warning" setting (`customModelInjection.
|
||||||
|
* skipFirstRunPrompts`, alongside apiKeyTrustFile) into an isolated config directory's
|
||||||
|
* `settings.json` — a real, already-onboarded profile answers claude's one-time warning
|
||||||
|
* about running with a bypass-permissions flag once and never sees it again, but every
|
||||||
|
* custom-model session launches with a fresh, otherwise-empty CLAUDE_CONFIG_DIR that
|
||||||
|
* carries none of that (confirmed live). A different file from apiKeyTrustFile's
|
||||||
|
* `.claude.json` — this is claude's own global `settings.json`, not project-keyed —
|
||||||
|
* so it gets its own merge-not-overwrite read-modify-write.
|
||||||
|
*/
|
||||||
|
function seedSkipBypassPermissionsPrompt(configDir: string): void {
|
||||||
|
const filePath = join(configDir, 'settings.json');
|
||||||
|
let existing: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
existing = {};
|
||||||
|
}
|
||||||
|
existing.skipDangerousModePermissionPrompt = true;
|
||||||
|
try {
|
||||||
|
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||||
|
chmodSync(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort only — the interactive warning returns instead of a hard failure here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Best-effort recursive removal of a previously-written configDir. Never throws. */
|
/** Best-effort recursive removal of a previously-written configDir. Never throws. */
|
||||||
export function removeConfigDir(dir: string | undefined): void {
|
export function removeConfigDir(dir: string | undefined): void {
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
@@ -151,7 +224,13 @@ export function applyCustomModelInjection(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
/** Discovered context-window size for `modelId`, if known — see `contextLengthVar`. */
|
/** Discovered context-window size for `modelId`, if known — see `contextLengthVar`. */
|
||||||
contextLength?: number
|
contextLength?: number,
|
||||||
|
/**
|
||||||
|
* The session's own working directory — only used for `skipFirstRunPrompts`'s per-project
|
||||||
|
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
|
||||||
|
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
|
||||||
|
*/
|
||||||
|
workingDir?: string
|
||||||
): AppliedCustomModel | undefined {
|
): AppliedCustomModel | undefined {
|
||||||
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
|
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
|
||||||
if (injection.kind === 'unsupported') return undefined;
|
if (injection.kind === 'unsupported') return undefined;
|
||||||
@@ -170,6 +249,10 @@ export function applyCustomModelInjection(
|
|||||||
if (injection.apiKeyTrustFile && injection.apiKey) {
|
if (injection.apiKeyTrustFile && injection.apiKey) {
|
||||||
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
|
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
|
||||||
}
|
}
|
||||||
|
if (injection.skipFirstRunPrompts && injection.apiKeyTrustFile) {
|
||||||
|
if (workingDir) seedFirstRunOnboardingState(configDir, injection.apiKeyTrustFile, workingDir);
|
||||||
|
seedSkipBypassPermissionsPrompt(configDir);
|
||||||
|
}
|
||||||
envOverrides = { ...envOverrides, [injection.configDirVar]: configDir };
|
envOverrides = { ...envOverrides, [injection.configDirVar]: configDir };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface EnvInjection {
|
|||||||
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
|
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
|
||||||
/** The literal API key value this injection used, for `apiKeyTrustFile` to pre-approve. */
|
/** The literal API key value this injection used, for `apiKeyTrustFile` to pre-approve. */
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
/** See `customModelInjection.skipFirstRunPrompts` — carried through so the IO wrapper can seed it. */
|
||||||
|
skipFirstRunPrompts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigDirInjection {
|
export interface ConfigDirInjection {
|
||||||
@@ -122,6 +124,7 @@ export function buildCustomModelInjection(
|
|||||||
let result: EnvInjection = withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId);
|
let result: EnvInjection = withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId);
|
||||||
if (cap.configDirVar) result = { ...result, configDirVar: cap.configDirVar };
|
if (cap.configDirVar) result = { ...result, configDirVar: cap.configDirVar };
|
||||||
if (cap.apiKeyTrustFile) result = { ...result, apiKeyTrustFile: cap.apiKeyTrustFile, apiKey };
|
if (cap.apiKeyTrustFile) result = { ...result, apiKeyTrustFile: cap.apiKeyTrustFile, apiKey };
|
||||||
|
if (cap.skipFirstRunPrompts) result = { ...result, skipFirstRunPrompts: true };
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1272,7 +1272,14 @@ export function registerSessionRoutes(
|
|||||||
// fails its pattern rather than quoting it, which would silently launch the CLI on its
|
// fails its pattern rather than quoting it, which would silently launch the CLI on its
|
||||||
// own default provider again, so refuse an id the pattern cannot carry up front.
|
// own default provider again, so refuse an id the pattern cannot carry up front.
|
||||||
const modelSpec = entry.launch.params.model;
|
const modelSpec = entry.launch.params.model;
|
||||||
const applied = applyCustomModelInjection(entry, endpoint, body.modelId, session.id, contextLength);
|
const applied = applyCustomModelInjection(
|
||||||
|
entry,
|
||||||
|
endpoint,
|
||||||
|
body.modelId,
|
||||||
|
session.id,
|
||||||
|
contextLength,
|
||||||
|
session.workingDir
|
||||||
|
);
|
||||||
if (!applied) {
|
if (!applied) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||||
}
|
}
|
||||||
@@ -3643,7 +3650,8 @@ export function registerSessionRoutes(
|
|||||||
cmEndpoint,
|
cmEndpoint,
|
||||||
customModel.modelId,
|
customModel.modelId,
|
||||||
qsCustomModelSessionId,
|
qsCustomModelSessionId,
|
||||||
cmContextLength
|
cmContextLength,
|
||||||
|
resolvedCasePath
|
||||||
);
|
);
|
||||||
if (!cmApplied) {
|
if (!cmApplied) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
|
|||||||
expect(applied?.envOverrides.CLAUDE_CONFIG_DIR).toBe(expectedDir);
|
expect(applied?.envOverrides.CLAUDE_CONFIG_DIR).toBe(expectedDir);
|
||||||
expect(applied?.configDir).toBe(expectedDir);
|
expect(applied?.configDir).toBe(expectedDir);
|
||||||
expect(existsSync(expectedDir)).toBe(true);
|
expect(existsSync(expectedDir)).toBe(true);
|
||||||
// Only the trust-seed file and the projects link — no real OAuth credential/config.
|
// The trust-seed file, the skipFirstRunPrompts settings.json, and the projects link —
|
||||||
|
// no real OAuth credential/config.
|
||||||
const entries = readdirSync(expectedDir).filter((name) => name !== 'projects');
|
const entries = readdirSync(expectedDir).filter((name) => name !== 'projects');
|
||||||
expect(entries).toEqual(['.claude.json']);
|
expect(entries.sort()).toEqual(['.claude.json', 'settings.json']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('claude: symlinks (or junctions) projects back to the real config dir so the response viewer keeps working', () => {
|
it('claude: symlinks (or junctions) projects back to the real config dir so the response viewer keeps working', () => {
|
||||||
@@ -189,6 +190,104 @@ describe('applyCustomModelInjection: apiKeyTrustFile (pre-approves the injected
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("applyCustomModelInjection: skipFirstRunPrompts (an isolated dir replays claude's whole first-run sequence)", () => {
|
||||||
|
it("claude: seeds hasCompletedOnboarding and this session's own project trust into .claude.json", () => {
|
||||||
|
const sessionId = 'sess-firstrun-1';
|
||||||
|
sessionsToClean.push(sessionId);
|
||||||
|
const applied = applyCustomModelInjection(
|
||||||
|
entryOrThrow('claude'),
|
||||||
|
endpoint,
|
||||||
|
'qwen3',
|
||||||
|
sessionId,
|
||||||
|
undefined,
|
||||||
|
'/home/user/myproject'
|
||||||
|
);
|
||||||
|
const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as {
|
||||||
|
hasCompletedOnboarding: boolean;
|
||||||
|
projects: Record<string, { hasTrustDialogAccepted: boolean }>;
|
||||||
|
};
|
||||||
|
expect(written.hasCompletedOnboarding).toBe(true);
|
||||||
|
expect(written.projects['/home/user/myproject'].hasTrustDialogAccepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: seeds skipDangerousModePermissionPrompt into settings.json', () => {
|
||||||
|
const sessionId = 'sess-firstrun-2';
|
||||||
|
sessionsToClean.push(sessionId);
|
||||||
|
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
|
||||||
|
const written = JSON.parse(readFileSync(join(applied!.configDir!, 'settings.json'), 'utf8')) as {
|
||||||
|
skipDangerousModePermissionPrompt: boolean;
|
||||||
|
};
|
||||||
|
expect(written.skipDangerousModePermissionPrompt).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: with no workingDir given (boot recovery), hasCompletedOnboarding/settings still seed, but no project entry is added', () => {
|
||||||
|
const sessionId = 'sess-firstrun-3';
|
||||||
|
sessionsToClean.push(sessionId);
|
||||||
|
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
|
||||||
|
const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as {
|
||||||
|
hasCompletedOnboarding: boolean;
|
||||||
|
projects?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(written.hasCompletedOnboarding).toBeUndefined();
|
||||||
|
expect(written.projects).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claude: merges onto an existing project entry's other fields rather than overwriting them", () => {
|
||||||
|
const sessionId = 'sess-firstrun-4';
|
||||||
|
sessionsToClean.push(sessionId);
|
||||||
|
const configDir = customModelConfigDir(sessionId);
|
||||||
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(configDir, '.claude.json'),
|
||||||
|
JSON.stringify({ projects: { '/home/user/myproject': { allowedTools: ['Bash'] } } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const applied = applyCustomModelInjection(
|
||||||
|
entryOrThrow('claude'),
|
||||||
|
endpoint,
|
||||||
|
'qwen3',
|
||||||
|
sessionId,
|
||||||
|
undefined,
|
||||||
|
'/home/user/myproject'
|
||||||
|
);
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as {
|
||||||
|
projects: Record<string, { allowedTools: string[]; hasTrustDialogAccepted: boolean }>;
|
||||||
|
};
|
||||||
|
expect(written.projects['/home/user/myproject'].allowedTools).toEqual(['Bash']);
|
||||||
|
expect(written.projects['/home/user/myproject'].hasTrustDialogAccepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: a corrupt existing settings.json is treated as absent rather than failing the apply', () => {
|
||||||
|
const sessionId = 'sess-firstrun-5';
|
||||||
|
sessionsToClean.push(sessionId);
|
||||||
|
const configDir = customModelConfigDir(sessionId);
|
||||||
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
writeFileSync(join(configDir, 'settings.json'), '{ not valid json');
|
||||||
|
|
||||||
|
expect(() => applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId)).not.toThrow();
|
||||||
|
const written = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) as {
|
||||||
|
skipDangerousModePermissionPrompt: boolean;
|
||||||
|
};
|
||||||
|
expect(written.skipDangerousModePermissionPrompt).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi: has no skipFirstRunPrompts concept (no apiKeyTrustFile either) — nothing beyond its own config file', () => {
|
||||||
|
const sessionId = 'sess-firstrun-pi';
|
||||||
|
sessionsToClean.push(sessionId);
|
||||||
|
const applied = applyCustomModelInjection(
|
||||||
|
entryOrThrow('pi'),
|
||||||
|
endpoint,
|
||||||
|
'qwen3',
|
||||||
|
sessionId,
|
||||||
|
undefined,
|
||||||
|
'/home/user/myproject'
|
||||||
|
);
|
||||||
|
const entries = readdirSync(applied!.configDir!);
|
||||||
|
expect(entries).not.toContain('settings.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('applyCustomModelInjection: pre-existing behavior unaffected', () => {
|
describe('applyCustomModelInjection: pre-existing behavior unaffected', () => {
|
||||||
it('opencode: still returns a plain env-kind result with no configDir', () => {
|
it('opencode: still returns a plain env-kind result with no configDir', () => {
|
||||||
const sessionId = 'sess-opencode-1';
|
const sessionId = 'sess-opencode-1';
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ describe('buildCustomModelInjection', () => {
|
|||||||
expect(result.apiKey).toBe('my-key');
|
expect(result.apiKey).toBe('my-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('claude: also declares skipFirstRunPrompts on the env-kind result', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3');
|
||||||
|
if (result.kind !== 'env') throw new Error('unreachable');
|
||||||
|
expect(result.skipFirstRunPrompts).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: has no skipFirstRunPrompts (no apiKeyTrustFile/configDirVar concept for it either)', () => {
|
||||||
|
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3');
|
||||||
|
if (result.kind !== 'env') throw new Error('unreachable');
|
||||||
|
expect(result.skipFirstRunPrompts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('claude: falls back to a dummy key when the endpoint has none', () => {
|
it('claude: falls back to a dummy key when the endpoint has none', () => {
|
||||||
const result = buildCustomModelInjection(entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3');
|
const result = buildCustomModelInjection(entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3');
|
||||||
if (result.kind !== 'env') throw new Error('unreachable');
|
if (result.kind !== 'env') throw new Error('unreachable');
|
||||||
|
|||||||
Reference in New Issue
Block a user