mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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
304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
/**
|
|
* @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, rmSync, writeFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it } 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: has no contextLengthVar declared, so a passed-in length is a no-op', () => {
|
|
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384);
|
|
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
|
|
});
|
|
});
|
|
|
|
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: 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: no configDirVar declared, so no config dir is created at all', () => {
|
|
const sessionId = 'sess-cfgdir-deepseek';
|
|
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId);
|
|
expect(applied?.configDir).toBeUndefined();
|
|
expect(existsSync(customModelConfigDir(sessionId))).toBe(false);
|
|
});
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
|
|
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<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';
|
|
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();
|
|
});
|
|
});
|