mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14: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.
|
||||
- **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).
|
||||
|
||||
@@ -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
|
||||
`<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
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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. */
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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<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', () => {
|
||||
it('opencode: still returns a plain env-kind result with no configDir', () => {
|
||||
const sessionId = 'sess-opencode-1';
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user