mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
getCustomModelForPersist() trusted homeEnvVar to name a path, but the registry types it as any env name, so a clis.json override could write a session's own credential to state.json. Only an absolute path is written now. A re-apply resolved the CLI's real home from what the session's map held, which under a selection is the injected directory, and recovery resolved none at all. An entry the own home lacks (settings.yaml) was then linked from the server's home on an endpoint switch and on recovery. The route now reads the new Session.ownEnvValue(), and recovery reads the clamped record through the same bounded guard, under the bulk cap since it walks every saved session. Also corrects the comment that said only a removed directory gets its links again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
17 KiB
TypeScript
385 lines
17 KiB
TypeScript
/**
|
|
* @fileoverview Custom Model Endpoint Profiles, the Session half of applying and
|
|
* clearing a selection against a live pane (`Session.setCustomModel()` +
|
|
* `Session.restartCli()`), pinned against the four ways the first cut broke a
|
|
* working session:
|
|
*
|
|
* 1. Clearing did not clear. The injected vars reach the CLI via `tmux setenv`,
|
|
* which persists at the tmux-session level and is inherited by `respawn-pane`,
|
|
* so removing them from `_envOverrides` relaunched the CLI still pointed at the
|
|
* endpoint (and, for the configDir kinds, at a `HOME`/`CODEX_HOME` that had just
|
|
* been deleted). The retired keys must ride `RespawnPaneOptions.unsetEnvKeys`.
|
|
* 2. Applying to a local claude session killed the pane: the relaunch was
|
|
* `claude --session-id <id>` and Claude refuses an id that already has a
|
|
* transcript, so it needs the `--resume <id> || --session-id <id>` shape the
|
|
* docker and remote pane commands use, i.e. a pinned resume id. The pin is
|
|
* gated on that transcript existing, so these tests write one.
|
|
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
|
* that selects it, so the file was ignored.
|
|
* 4. A value the session set itself under an injected key was overwritten, then deleted
|
|
* with the injected one on clear: an own `DSH_HOME` fell back to the server's tree and
|
|
* an own `CLAUDE_CONFIG_DIR` silently moved the session to the default Claude account.
|
|
*
|
|
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
|
|
* spying on `respawnPane` to read the options the relaunch would get.
|
|
* Port: N/A.
|
|
*/
|
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { Session } from '../src/session.js';
|
|
import { getCli } from '../src/config/cli-registry/index.js';
|
|
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
|
|
import { TmuxManager } from '../src/tmux-manager.js';
|
|
import type { MuxSession, SessionMode } from '../src/types.js';
|
|
|
|
const workingDir = join(homedir(), 'codeman-cases', 'custom-model-restart');
|
|
const projectsDir = join(homedir(), '.claude', 'projects', '-custom-model-restart');
|
|
const sessions: Session[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const s of sessions.splice(0)) s.stop();
|
|
rmSync(workingDir, { recursive: true, force: true });
|
|
// Only the directory these tests create. `setup.ts` gives each test file a
|
|
// temp HOME, but a wider sweep here would delete a real `~/.claude` the day
|
|
// that stops being true.
|
|
rmSync(projectsDir, { recursive: true, force: true });
|
|
});
|
|
|
|
/**
|
|
* Write the transcript Claude would have written for a conversation. A pane
|
|
* `restartCli()` relaunches is a WORKING one, so its conversation has a
|
|
* transcript on disk; that is both what makes the bare `--session-id` collide
|
|
* and what the pin is now gated on.
|
|
*/
|
|
function giveTranscript(conversationId: string): void {
|
|
mkdirSync(projectsDir, { recursive: true });
|
|
writeFileSync(join(projectsDir, `${conversationId}.jsonl`), '{"type":"user"}\n');
|
|
}
|
|
|
|
function liveSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
|
|
mkdirSync(workingDir, { recursive: true });
|
|
const mux = new TmuxManager();
|
|
const muxSession: MuxSession = {
|
|
sessionId: 'placeholder',
|
|
muxName: 'codeman-cafe0001',
|
|
pid: 1,
|
|
createdAt: Date.now(),
|
|
workingDir,
|
|
mode,
|
|
attached: false,
|
|
};
|
|
const session = new Session({ workingDir, mode, mux, useMux: true, muxSession, ...extra });
|
|
sessions.push(session);
|
|
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
|
|
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
|
|
return { session, respawn };
|
|
}
|
|
|
|
const CLAUDE_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_DEFAULT_SONNET_MODEL'];
|
|
const claudeEnv = {
|
|
ANTHROPIC_BASE_URL: 'http://box:8080',
|
|
ANTHROPIC_API_KEY: 'k',
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'q',
|
|
};
|
|
|
|
describe('clearing a selection unsets what it injected', () => {
|
|
it('queues the retired keys for setenv -u on the next respawn and drops them from the overrides', async () => {
|
|
const { session, respawn } = liveSession('claude', { envOverrides: { CLAUDE_CODE_KEEP: '1' } });
|
|
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
|
|
|
const result = session.setCustomModel(undefined);
|
|
expect(result.removedEnvKeys).toEqual(CLAUDE_KEYS);
|
|
expect(session.customModel).toBeUndefined();
|
|
|
|
expect(await session.restartCli()).toBe(true);
|
|
const options = respawn.mock.calls[0][0];
|
|
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
|
|
expect(options.envOverrides).toEqual({ CLAUDE_CODE_KEEP: '1' });
|
|
|
|
// Drained once the respawn succeeded: the next relaunch has nothing to unset.
|
|
respawn.mockClear();
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].unsetEnvKeys).toBeUndefined();
|
|
});
|
|
|
|
it('switching endpoints re-sets the shared keys instead of unsetting them', async () => {
|
|
const { session, respawn } = liveSession('claude');
|
|
session.setCustomModel({ endpointId: 'a', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
|
const next = { ...claudeEnv, ANTHROPIC_BASE_URL: 'http://other:8080' };
|
|
session.setCustomModel({ endpointId: 'b', modelId: 'q', envKeys: CLAUDE_KEYS }, next);
|
|
|
|
await session.restartCli();
|
|
const options = respawn.mock.calls[0][0];
|
|
expect(options.unsetEnvKeys).toBeUndefined();
|
|
expect(options.envOverrides?.ANTHROPIC_BASE_URL).toBe('http://other:8080');
|
|
});
|
|
|
|
it('reports the previous config dir so the caller can delete it, and never leaks bookkeeping on the wire', () => {
|
|
const { session } = liveSession('pi');
|
|
session.setCustomModel(
|
|
{ endpointId: 'ep', modelId: 'q', envKeys: ['HOME'], configDir: '/tmp/cfg-1', launchModel: 'custom/q' },
|
|
{ HOME: '/tmp/cfg-1' }
|
|
);
|
|
expect(session.customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
|
|
expect(session.toState().customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
|
|
expect(session.getCustomModelForPersist()).toEqual({
|
|
endpointId: 'ep',
|
|
modelId: 'q',
|
|
label: undefined,
|
|
envKeys: ['HOME'],
|
|
configDir: '/tmp/cfg-1',
|
|
launchModel: 'custom/q',
|
|
});
|
|
expect(session.setCustomModel(undefined).previousConfigDir).toBe('/tmp/cfg-1');
|
|
});
|
|
});
|
|
|
|
describe('restartCli() must not kill a working pane', () => {
|
|
it('claude: pins the live conversation id so the relaunch renders --resume <id> || --session-id <id>', async () => {
|
|
const { session, respawn } = liveSession('claude');
|
|
giveTranscript(session.id);
|
|
await session.restartCli();
|
|
const options = respawn.mock.calls[0][0];
|
|
expect(options.resumeSessionId).toBe(session.claudeSessionId);
|
|
expect(options.resumeSessionId).toBe(session.id);
|
|
// The pin is per-respawn: nothing about the session's own resume id changed.
|
|
expect(session.toState().resumeSessionId).toBeUndefined();
|
|
});
|
|
|
|
it('claude: pins nothing when the pane has no transcript, because nothing can collide', async () => {
|
|
// A pane that was launched and never prompted. `--session-id <this.id>` is
|
|
// accepted on an id no transcript holds, so pinning would buy nothing and
|
|
// cost two things: claude prints "No conversation found" into a pane with
|
|
// no history, and `wrapWithNice()` prefixes only the first branch of the
|
|
// rendered `a || b`, so the branch that actually runs loses its priority
|
|
// for the life of the session.
|
|
const { session, respawn } = liveSession('claude');
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
|
|
});
|
|
|
|
it('claude: an explicit resume id from a resume-from-history launch wins over the pin', async () => {
|
|
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
|
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
|
giveTranscript(RESUMED);
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
|
|
});
|
|
|
|
it('claude: a launch seed survives the pin walk even with its transcript gone', async () => {
|
|
// The walk only ever ADDS a pin. The seed is what the session was created
|
|
// with and every respawn has always carried it, so a transcript deleted
|
|
// under a running session leaves the relaunch on
|
|
// `--resume <seed> || --session-id <this.id>` — the resume fails and the
|
|
// fallback runs, which is safe precisely because nothing is on disk to
|
|
// collide with.
|
|
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
|
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
|
|
});
|
|
|
|
it('pi: no top-level resume pin, its resume id is minted by the CLI and lives in piConfig', async () => {
|
|
const { session, respawn } = liveSession('pi');
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('launchModel reaches the CLI through its own launch param', () => {
|
|
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
|
|
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
|
|
session.setCustomModel(
|
|
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
|
|
{ HOME: '/tmp/cfg' }
|
|
);
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'custom/qwen3', thinking: 'low' });
|
|
// The user's own choice survives underneath, which is what a clear falls back to.
|
|
expect(session.toState().piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
|
|
|
|
session.setCustomModel(undefined);
|
|
respawn.mockClear();
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
|
|
});
|
|
|
|
it('grok: the block name lands in grokConfig.model', async () => {
|
|
const { session, respawn } = liveSession('grok');
|
|
session.setCustomModel(
|
|
{
|
|
endpointId: 'ep',
|
|
modelId: 'qwen3',
|
|
envKeys: ['GROK_HOME'],
|
|
configDir: '/tmp/cfg',
|
|
launchModel: 'codeman-custom',
|
|
},
|
|
{ GROK_HOME: '/tmp/cfg' }
|
|
);
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].grokConfig).toEqual({ model: 'codeman-custom' });
|
|
});
|
|
|
|
it('claude: a selection without a launchModel leaves the model param untouched', async () => {
|
|
const { session, respawn } = liveSession('claude');
|
|
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].model).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Codex custom endpoint model overrides', () => {
|
|
it('selects the local model over a native launch default and restores the default on clear', async () => {
|
|
const entry = getCli('codex');
|
|
if (!entry) throw new Error('Missing Codex entry');
|
|
const injection = buildCustomModelInjection(
|
|
entry,
|
|
{ id: 'ep', label: 'Local', baseUrl: 'http://box:8080' },
|
|
'qwen3'
|
|
);
|
|
if (injection.kind !== 'configDir') throw new Error('Expected isolated Codex config');
|
|
const { session, respawn } = liveSession('codex', {
|
|
codexConfig: { model: 'native-model', reasoningEffort: 'high' },
|
|
});
|
|
session.setCustomModel(
|
|
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], launchModel: injection.launchModel },
|
|
{ CODEX_HOME: '/isolated/codex' }
|
|
);
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'qwen3', reasoningEffort: 'high' });
|
|
session.setCustomModel(undefined);
|
|
respawn.mockClear();
|
|
await session.restartCli();
|
|
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' });
|
|
});
|
|
});
|
|
|
|
describe('a value the session set itself comes back with the selection gone', () => {
|
|
const DSH_SELECTION = {
|
|
endpointId: 'ep',
|
|
modelId: 'q',
|
|
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
|
configDir: '/isolated/dsh',
|
|
homeEnvVar: 'DSH_HOME',
|
|
};
|
|
const DSH_ENV = { DSH_HOME: '/isolated/dsh', CODEMAN_CUSTOM_MODEL_API_KEY: 'k' };
|
|
|
|
it('puts an own DSH_HOME back on clear and sets it rather than unsetting it', async () => {
|
|
const { session, respawn } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
|
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
|
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
|
|
|
session.setCustomModel(undefined);
|
|
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
|
expect(session.deepSeekHomeOverride).toBe('/home/u/my-dsh');
|
|
|
|
await session.restartCli();
|
|
const options = respawn.mock.calls[0][0];
|
|
expect(options.envOverrides).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
|
expect(options.unsetEnvKeys).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
|
|
});
|
|
|
|
it('carries it across a switch between endpoints', () => {
|
|
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
|
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
|
session.setCustomModel({ ...DSH_SELECTION, endpointId: 'other' }, DSH_ENV);
|
|
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
|
|
|
session.setCustomModel(undefined);
|
|
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
|
});
|
|
|
|
it('keeps a claude session on its own account, the #255 override', async () => {
|
|
const { session, respawn } = liveSession('claude', {
|
|
envOverrides: { CLAUDE_CONFIG_DIR: '/home/u/.claude-client', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000' },
|
|
});
|
|
session.setCustomModel(
|
|
{
|
|
endpointId: 'ep',
|
|
modelId: 'q',
|
|
envKeys: [...CLAUDE_KEYS, 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
|
configDir: '/isolated/claude',
|
|
homeEnvVar: 'CLAUDE_CONFIG_DIR',
|
|
},
|
|
{ ...claudeEnv, CLAUDE_CONFIG_DIR: '/isolated/claude', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' }
|
|
);
|
|
|
|
session.setCustomModel(undefined);
|
|
await session.restartCli();
|
|
const options = respawn.mock.calls[0][0];
|
|
expect(options.envOverrides).toEqual({
|
|
CLAUDE_CONFIG_DIR: '/home/u/.claude-client',
|
|
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000',
|
|
});
|
|
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
|
|
});
|
|
|
|
it('still unsets a key whose own value was empty, since tmux never held that value', () => {
|
|
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '' } });
|
|
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
|
|
|
session.setCustomModel(undefined);
|
|
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
|
});
|
|
|
|
it('writes the own home to state.json and no other own value', () => {
|
|
const { session } = liveSession('gemini', {
|
|
envOverrides: { GEMINI_API_KEY: 'own-secret', DSH_HOME: '/home/u/my-dsh' },
|
|
});
|
|
session.setCustomModel(
|
|
{
|
|
endpointId: 'ep',
|
|
modelId: 'q',
|
|
envKeys: ['GEMINI_API_KEY', 'DSH_HOME'],
|
|
homeEnvVar: 'DSH_HOME',
|
|
},
|
|
{ GEMINI_API_KEY: 'endpoint-key', DSH_HOME: '/isolated/dsh' }
|
|
);
|
|
|
|
const persisted = session.getCustomModelForPersist();
|
|
expect(persisted?.ownEnv).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
|
expect(JSON.stringify(persisted)).not.toContain('own-secret');
|
|
// The secret is still put back: it just never leaves memory.
|
|
session.setCustomModel(undefined);
|
|
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
|
|
});
|
|
|
|
it('keeps a home variable holding anything but an absolute path off disk', () => {
|
|
// The registry types `configDirVar`/`dirEnvVar` as plain env names, so a clis.json
|
|
// override can name a credential as the home variable. Only a path may reach disk.
|
|
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
|
|
session.setCustomModel(
|
|
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'], homeEnvVar: 'GEMINI_API_KEY' },
|
|
{ GEMINI_API_KEY: 'endpoint-key' }
|
|
);
|
|
|
|
expect(JSON.stringify(session.getCustomModelForPersist())).not.toContain('own-secret');
|
|
session.setCustomModel(undefined);
|
|
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
|
|
});
|
|
|
|
it('reads the own value while a selection holds the key, never the injected one', () => {
|
|
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: ' /home/u/my-dsh ' } });
|
|
expect(session.ownEnvValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
|
|
|
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
|
expect(session.ownEnvValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
|
// An injected key the session never set has no own value, whatever the map holds now.
|
|
expect(session.envOverrideValue('CODEMAN_CUSTOM_MODEL_API_KEY')).toBe('k');
|
|
expect(session.ownEnvValue('CODEMAN_CUSTOM_MODEL_API_KEY')).toBeUndefined();
|
|
});
|
|
|
|
it('persists nothing extra for a recipe with no home variable', () => {
|
|
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
|
|
session.setCustomModel(
|
|
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] },
|
|
{ GEMINI_API_KEY: 'endpoint-key' }
|
|
);
|
|
|
|
expect(session.getCustomModelForPersist()).toEqual({ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] });
|
|
});
|
|
});
|