mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Two findings from the review of 5fc391a4, both fixed here rather than sent back.
**The API-key trust seed never matched a real key.** `seedApiKeyTrustFile()` wrote the
key verbatim into `customApiKeyResponses.approved`, but Claude Code stores and compares
only the last 20 characters (`key.trim().slice(-20)`, applied on both the write and the
lookup). For any real key the seed missed, so claude stopped at the interactive
"Detected a custom API key in your environment" prompt, whose default is
"No (recommended)": the launch hangs, or silently refuses the key this feature just
injected and falls through to an OAuth login the isolated config dir does not have. It
survived review because a keyless llama.cpp/llama-swap endpoint uses DEFAULT_API_KEY
('local-dummy-key', 15 chars), where slice(-20) returns the whole string and the seed
matches by accident, and every test used a key shorter than that. Now truncated through
`truncateApiKeyForTrustFile()`, with a test using a 57-character key that also asserts
the full credential never reaches that second file.
**One `confirmed` flag answered two different questions.** The context-floor warning
("this model's window is below what this CLI needs") and the swap-conflict warning
("loading this unloads the model another session is using") shared it, and the context
check runs first, so a user clicking "launch anyway" past the context warning silently
consented to evicting someone else's model. They are about different people, so an
answer to one is not consent to the other. Both routes now read `confirmedContext` and
`confirmedSwap` independently; the legacy `confirmed` still means both, because it
shipped in this feature's HTTP-API-only cut and an existing caller must keep working.
The frontend answers each question with its own flag and accumulates them, on the
one-shot path, the restart path and the batch carry-forward alike.
Also from the same review: the swap-confirm dialog no longer renders " are currently
using ..." when multi-user scoping leaves the affected-session list empty (the swap is
blocked regardless of ownership; only the NAMES are scoped), and the per-endpoint
llama-swap log tails are closed in `WebServer.stop()` instead of only by the idle sweep
whose interval that same teardown disposes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
332 lines
16 KiB
TypeScript
332 lines
16 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([]);
|
|
});
|
|
|
|
// ⚠ 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<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();
|
|
});
|
|
});
|