From 211b87233523c0e700efe9041355dec6fa46e153 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:21:36 +0800 Subject: [PATCH] feat(custom-model): skip Claude Code's first-run wizard on custom-model launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.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 /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 Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG --- .changeset/run-menu-custom-model-picker.md | 4 + docs/custom-model-endpoints.md | 21 +++++ docs/wiki/Custom-Model-Endpoints.md | 10 +- src/config/cli-registry/schema.ts | 6 ++ src/config/cli-registry/stock.ts | 8 ++ src/config/cli-registry/types.ts | 14 +++ src/custom-model-injection-apply.ts | 85 ++++++++++++++++- src/custom-model-injection.ts | 3 + src/web/routes/session-routes.ts | 12 ++- test/custom-model-injection-apply.test.ts | 103 ++++++++++++++++++++- test/custom-model-injection.test.ts | 12 +++ 11 files changed, 272 insertions(+), 6 deletions(-) diff --git a/.changeset/run-menu-custom-model-picker.md b/.changeset/run-menu-custom-model-picker.md index 315a9f24..f3962a7e 100644 --- a/.changeset/run-menu-custom-model-picker.md +++ b/.changeset/run-menu-custom-model-picker.md @@ -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. - **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. **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). diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index 3f7b1571..d2b21101 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -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 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 +`/.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 +`/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 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, diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 1b681582..47727119 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -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 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 `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 "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. +- **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 warning instead of a confusing failure.** Claude Code's own system prompt and tools take up diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index d4c9443a..2bd75da6 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -358,6 +358,12 @@ const capabilitiesSchema = z .object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') }) .strict() .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(), z diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 73978f44..37d8f37e 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -271,6 +271,14 @@ const CLAUDE: CliEntry = { // 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' }, + // ⚠️ 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: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 1b526955..8351febf 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -522,6 +522,19 @@ export interface CliCapabilities { * `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. + * + * `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: | { @@ -533,6 +546,7 @@ export interface CliCapabilities { contextLengthVar?: string; apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' }; configDirVar?: string; + skipFirstRunPrompts?: boolean; } | { 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 f5edea89..51ca6fbc 100644 --- a/src/custom-model-injection-apply.ts +++ b/src/custom-model-injection-apply.ts @@ -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 = {}; + try { + existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record; + } catch { + existing = {}; + } + existing.hasCompletedOnboarding = true; + const projects = + existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects) + ? (existing.projects as Record>) + : {}; + 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 = {}; + try { + existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record; + } 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. */ export function removeConfigDir(dir: string | undefined): void { if (!dir) return; @@ -151,7 +224,13 @@ export function applyCustomModelInjection( modelId: string, sessionId: string, /** 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 { const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength); if (injection.kind === 'unsupported') return undefined; @@ -170,6 +249,10 @@ export function applyCustomModelInjection( if (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 }; } return { diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index 1a5ce782..0d927866 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -56,6 +56,8 @@ export interface EnvInjection { apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' }; /** The literal API key value this injection used, for `apiKeyTrustFile` to pre-approve. */ apiKey?: string; + /** See `customModelInjection.skipFirstRunPrompts` — carried through so the IO wrapper can seed it. */ + skipFirstRunPrompts?: boolean; } export interface ConfigDirInjection { @@ -122,6 +124,7 @@ export function buildCustomModelInjection( 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 }; + if (cap.skipFirstRunPrompts) result = { ...result, skipFirstRunPrompts: true }; return result; } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index ecc24f82..f47eebfc 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1272,7 +1272,14 @@ export function registerSessionRoutes( // 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. 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) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`); } @@ -3643,7 +3650,8 @@ export function registerSessionRoutes( cmEndpoint, customModel.modelId, qsCustomModelSessionId, - cmContextLength + cmContextLength, + resolvedCasePath ); if (!cmApplied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`); diff --git a/test/custom-model-injection-apply.test.ts b/test/custom-model-injection-apply.test.ts index 450b7eb0..dfc8e793 100644 --- a/test/custom-model-injection-apply.test.ts +++ b/test/custom-model-injection-apply.test.ts @@ -68,9 +68,10 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => { expect(applied?.envOverrides.CLAUDE_CONFIG_DIR).toBe(expectedDir); expect(applied?.configDir).toBe(expectedDir); 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'); - 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', () => { @@ -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; + }; + 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; + }; + 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; + }; + 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', () => { 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 3655c2fc..886a2e35 100644 --- a/test/custom-model-injection.test.ts +++ b/test/custom-model-injection.test.ts @@ -83,6 +83,18 @@ describe('buildCustomModelInjection', () => { 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', () => { const result = buildCustomModelInjection(entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3'); if (result.kind !== 'env') throw new Error('unreachable');