Files
Codeman/test/custom-model-injection-apply.test.ts
T
Codeman maintainer 9b8f853c94 fix(custom-model): keep a session's own config dir conversation across an endpoint
A claude session on its own CLAUDE_CONFIG_DIR launched on an endpoint with
<isolated>/projects linked to the server's tree. The resume pin looks there, so
apply found no transcript and started a new conversation, and on clear the
endpoint's transcript stayed behind in the server's tree.

The link now goes to <own dir>/projects when the session set one, so the
conversation and what the endpoint run adds stay on the session's own account
across an apply, a clear and a restart. It goes through linkRealHomeEntry(),
which also creates a missing projects folder instead of leaving a dangling
link, in the own dir and in ~/.claude/projects on the default path. A relative
CLAUDE_CONFIG_DIR of the server gets no link, since joining it would create
projects under the server's working directory.

Rule #516: guardSessionConfigDirHome() now covers configDirVar, so all three
apply sites (route, quick-start, boot recovery) probe the own dir bounded before
the apply touches it, and a dir that does not answer falls back to the server's
tree. Under test, HOME is a temp dir and CLAUDE_CONFIG_DIR is stripped by
test/setup.ts, which a new test pins.

Measured on a real Codeman server with a fake claude: apply and re-apply resume
under the isolated dir with the transcript landing in the own dir, a clear
resumes on the own dir, the isolated dir goes while the transcript stays, and the
same holds across a server restart. The documented limits come out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 06:10:41 +01:00

412 lines
19 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,
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<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();
});
});