/** * @fileoverview Tests for the two custom-model IO-layer fixes on top of the pure builder * (docs/custom-model-endpoints-plan.md): * * 1. `contextLengthVar` — a discovered per-model context length reaches the actual * session env (CLAUDE_CODE_MAX_CONTEXT_TOKENS), so a CLI stops assuming a large * default window for an unrecognized custom model id and overflowing a much * smaller real one. * 2. `configDirVar` — an isolated, empty config directory is created and pointed at * (CLAUDE_CONFIG_DIR), so an injected API key never shares a directory with a * stored claude.ai OAuth session; `projects` is symlinked back into the real * config dir so the response viewer/subagent windows/Read My Mind keep working. * * Port: N/A (no server; filesystem-only, under a temp CODEMAN data dir from test/setup.ts). */ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getCli } from '../src/config/cli-registry/index.js'; import { applyCustomModelInjection, customModelConfigDir } from '../src/custom-model-injection-apply.js'; import type { CustomModelEndpoint } from '../src/custom-model-injection.js'; const endpoint: CustomModelEndpoint = { id: 'ep1', label: 'llama.cpp box', baseUrl: 'http://192.168.1.50:8080', apiKey: 'my-key', }; function entryOrThrow(id: string) { const entry = getCli(id); if (!entry) throw new Error(`missing CLI registry entry: ${id}`); return entry; } const sessionsToClean: string[] = []; afterEach(() => { for (const id of sessionsToClean.splice(0)) rmSync(customModelConfigDir(id), { recursive: true, force: true }); }); describe('applyCustomModelInjection: context length', () => { it('claude: passes a known context length through to CLAUDE_CODE_MAX_CONTEXT_TOKENS', () => { sessionsToClean.push('sess-ctx-1'); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', 'sess-ctx-1', 16384); expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('16384'); expect(applied?.envKeys).toContain('CLAUDE_CODE_MAX_CONTEXT_TOKENS'); }); it('claude: omits the var entirely when the context length is unknown', () => { sessionsToClean.push('sess-ctx-2'); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', 'sess-ctx-2'); expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); }); it('deepseek: passes the known context through its isolated model catalogue', () => { const sessionId = 'sess-deepseek-context'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768); expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY', 'DSH_HOME']); expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768'); }); }); describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => { it('claude: creates an isolated config dir (no real credential/config files) and points CLAUDE_CONFIG_DIR at it', () => { const sessionId = 'sess-cfgdir-1'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); const expectedDir = customModelConfigDir(sessionId); expect(applied?.envOverrides.CLAUDE_CONFIG_DIR).toBe(expectedDir); expect(applied?.configDir).toBe(expectedDir); expect(existsSync(expectedDir)).toBe(true); // 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.sort()).toEqual(['.claude.json', 'settings.json']); }); it('claude: symlinks (or junctions) projects back to the real config dir so the response viewer keeps working', () => { const sessionId = 'sess-cfgdir-2'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); const link = join(applied!.configDir!, 'projects'); // Best-effort: only assert the link exists if it was actually created (the real // ~/.claude/projects may not exist on a bare CI box, in which case linking is skipped). if (existsSync(join(homedir(), '.claude', 'projects'))) { expect(existsSync(link)).toBe(true); expect(lstatSync(link).isSymbolicLink() || lstatSync(link).isDirectory()).toBe(true); } }); it('claude: creates the missing projects folder under the temp HOME, never a real one', () => { // test/setup.ts points HOME at a temp dir and drops CLAUDE_CONFIG_DIR. Without both, the // apply below would create `projects` in the real `~/.claude` of whoever runs the suite. expect(homedir().startsWith(tmpdir())).toBe(true); expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined(); const sessionId = 'sess-cfgdir-default'; sessionsToClean.push(sessionId); const realProjects = join(homedir(), '.claude', 'projects'); rmSync(realProjects, { recursive: true, force: true }); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(realProjects); expect(statSync(realProjects).isDirectory()).toBe(true); }); it('claude: a relative CLAUDE_CONFIG_DIR of the server gets no link, nothing lands under its cwd', () => { const sessionId = 'sess-cfgdir-relative'; sessionsToClean.push(sessionId); vi.stubEnv('CLAUDE_CONFIG_DIR', 'relative-server-claude'); try { const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); expect(existsSync(join(applied!.configDir!, 'projects'))).toBe(false); expect(existsSync('relative-server-claude')).toBe(false); } finally { vi.unstubAllEnvs(); } }); it("claude: links projects from the session's own CLAUDE_CONFIG_DIR, creating it for a new account", () => { const sessionId = 'sess-cfgdir-own'; sessionsToClean.push(sessionId); const ownDir = join(homedir(), 'client-account'); const applied = applyCustomModelInjection( entryOrThrow('claude'), endpoint, 'qwen3', sessionId, undefined, undefined, (envVar) => (envVar === 'CLAUDE_CONFIG_DIR' ? ownDir : undefined) ); expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(ownDir, 'projects')); // A dangling directory link is not inert: the CLI's mkdir through it fails ENOENT. expect(statSync(join(ownDir, 'projects')).isDirectory()).toBe(true); rmSync(ownDir, { recursive: true, force: true }); }); it('claude: links projects from the real config dir when the own value is relative or the isolated dir', () => { for (const own of ['relative/claude', customModelConfigDir('sess-cfgdir-self')]) { sessionsToClean.push('sess-cfgdir-self'); const applied = applyCustomModelInjection( entryOrThrow('claude'), endpoint, 'qwen3', 'sess-cfgdir-self', undefined, undefined, () => own ); expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(homedir(), '.claude', 'projects')); rmSync(applied!.configDir!, { recursive: true, force: true }); } }); it('claude: re-applying to the same session is idempotent (boot-recovery re-apply)', () => { const sessionId = 'sess-cfgdir-3'; sessionsToClean.push(sessionId); const first = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); const second = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); expect(second?.configDir).toBe(first?.configDir); expect(existsSync(first!.configDir!)).toBe(true); }); it('pi: configDir-kind CLIs are unaffected — no configDirVar concept for them', () => { const sessionId = 'sess-cfgdir-pi'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3', sessionId); expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId)); }); it('deepseek: creates an isolated profile patch', () => { const sessionId = 'sess-cfgdir-deepseek'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId); expect(applied?.configDir).toBe(customModelConfigDir(sessionId)); expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true); }); }); describe('applyCustomModelInjection: apiKeyTrustFile (pre-approves the injected key)', () => { it('claude: seeds .claude.json so the "Detected a custom API key" prompt never fires', () => { const sessionId = 'sess-trust-1'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as { customApiKeyResponses: { approved: string[]; rejected: string[] }; }; expect(written.customApiKeyResponses.approved).toEqual(['my-key']); expect(written.customApiKeyResponses.rejected).toEqual([]); }); // ⚠ Claude Code stores and looks up only the LAST 20 CHARACTERS of a key // (`key.trim().slice(-20)`, applied on both write and read), so seeding the whole key // never matches for a REAL one and the launch stops at the interactive "Detected a // custom API key" prompt whose default is "No (recommended)". Every other test here // uses a key shorter than 20 characters, where slice(-20) is the whole string and the // bug is invisible, which is exactly how it survived review. it('claude: seeds a REAL-length key in the truncated form the CLI actually matches on', () => { const sessionId = 'sess-trust-long'; sessionsToClean.push(sessionId); const longKey = 'sk-or-v1-0123456789abcdef0123456789abcdef0123456789abcdef'; expect(longKey.length).toBeGreaterThan(20); const applied = applyCustomModelInjection( entryOrThrow('claude'), { ...endpoint, apiKey: longKey }, 'qwen3', sessionId ); const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as { customApiKeyResponses: { approved: string[] }; }; expect(written.customApiKeyResponses.approved).toEqual(['cdef0123456789abcdef']); expect(written.customApiKeyResponses.approved[0]).toHaveLength(20); // and the full credential is not written into this second file at all expect(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')).not.toContain(longKey); }); it('claude: falls back to the dummy key when the endpoint has none, and still seeds it', () => { const sessionId = 'sess-trust-2'; sessionsToClean.push(sessionId); const applied = applyCustomModelInjection( entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3', sessionId ); const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as { customApiKeyResponses: { approved: string[] }; }; expect(written.customApiKeyResponses.approved).toEqual(['local-dummy-key']); }); it('claude: merges onto fields the CLI itself already wrote into the same isolated dir, never overwrites them', () => { const sessionId = 'sess-trust-3'; sessionsToClean.push(sessionId); const configDir = customModelConfigDir(sessionId); mkdirSync(configDir, { recursive: true }); writeFileSync(join(configDir, '.claude.json'), JSON.stringify({ userID: 'abc123', numStartups: 3 })); const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as { userID: string; numStartups: number; customApiKeyResponses: { approved: string[] }; }; expect(written.userID).toBe('abc123'); expect(written.numStartups).toBe(3); expect(written.customApiKeyResponses.approved).toEqual(['my-key']); }); it('claude: a corrupt existing file is treated as absent rather than failing the apply', () => { const sessionId = 'sess-trust-4'; sessionsToClean.push(sessionId); const configDir = customModelConfigDir(sessionId); mkdirSync(configDir, { recursive: true }); writeFileSync(join(configDir, '.claude.json'), '{ not valid json'); expect(() => applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId)).not.toThrow(); const written = JSON.parse(readFileSync(join(configDir, '.claude.json'), 'utf8')) as { customApiKeyResponses: { approved: string[] }; }; expect(written.customApiKeyResponses.approved).toEqual(['my-key']); }); it('claude: re-approving the same key does not duplicate it in the approved list', () => { const sessionId = 'sess-trust-5'; sessionsToClean.push(sessionId); applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId); const second = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'llama3', sessionId); const written = JSON.parse(readFileSync(join(second!.configDir!, '.claude.json'), 'utf8')) as { customApiKeyResponses: { approved: string[] }; }; expect(written.customApiKeyResponses.approved).toEqual(['my-key']); }); it('opencode: has no apiKeyTrustFile declared (no configDirVar at all), nothing is seeded', () => { const sessionId = 'sess-trust-opencode'; const applied = applyCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', sessionId); expect(applied?.configDir).toBeUndefined(); expect(existsSync(customModelConfigDir(sessionId))).toBe(false); }); }); describe("applyCustomModelInjection: 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'; const applied = applyCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', sessionId); expect(applied?.configDir).toBeUndefined(); expect(applied?.envOverrides.OPENCODE_CONFIG_CONTENT).toBeTruthy(); }); it('antigravity: still undefined (unsupported)', () => { const applied = applyCustomModelInjection(entryOrThrow('antigravity'), endpoint, 'qwen3', 'sess-agy-1'); expect(applied).toBeUndefined(); }); });