Merge #767 fix(custom-model): give a session its own env values back when the selection goes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:01:14 +01:00
commit 9751f51bdc
17 files changed
+537 -62

No files matched your search

+121 -6
View File
@@ -7,18 +7,18 @@
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
* that clearing could never delete.
*
* Real-home resolution is NOT recovered from the session's env overrides any more:
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
* Real-home resolution reads the record's `ownEnv` on this path, never the session's
* overrides: the first apply's links are still in the generated directory, and an entry
* that is missing links from the own home, or with none from the server's `DSH_HOME` or
* the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment. The
* own home itself comes back on clear from `ownEnv`, the last block here.
*
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
* everything from the endpoint store and the Session it is handed), so it is driven off
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -99,4 +99,119 @@ describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', ()
expect(session.getCustomModelForPersist()).toEqual(saved);
});
describe("a session's own value across a restart", () => {
const OWN_HOME = '/home/u/my-dsh';
const ORIGINAL_MULTIUSER = process.env.CODEMAN_MULTIUSER;
afterEach(() => {
if (ORIGINAL_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL_MULTIUSER;
});
/** What a previous run left in state.json for a deepseek session on its own home. */
function savedByPreviousRun(): CustomModelBookkeeping {
const previous = new Session({ workingDir: '/tmp', mode: 'deepseek', envOverrides: { DSH_HOME: OWN_HOME } });
configs.push(previous.id);
previous.setCustomModel(
{
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
configDir: customModelConfigDir(previous.id),
homeEnvVar: 'DSH_HOME',
},
{ DSH_HOME: customModelConfigDir(previous.id), CODEMAN_CUSTOM_MODEL_API_KEY: 'k' }
);
return JSON.parse(JSON.stringify(previous.getCustomModelForPersist())) as CustomModelBookkeeping;
}
it('gets DSH_HOME back from state.json, which is the only copy left', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, savedByPreviousRun());
expect(session.envOverrideValue('DSH_HOME')).toBe(customModelConfigDir(session.id));
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
});
it('never fills an entry the own home lacks from the server home', async () => {
// The first apply skipped `settings.yaml`, since the own home has none. Recovery finds
// that gap and, with no lookup, used to fill it with the server's settings.
const serverHome = join(homedir(), 'codeman-recovery-server-dsh');
const ownHome = join(homedir(), 'codeman-recovery-own-dsh');
mkdirSync(join(ownHome, 'profiles'), { recursive: true });
mkdirSync(serverHome, { recursive: true });
writeFileSync(join(serverHome, 'settings.yaml'), 'server: true\n');
vi.stubEnv('DSH_HOME', serverHome);
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
homeEnvVar: 'DSH_HOME',
ownEnv: { DSH_HOME: ownHome },
});
const configDir = customModelConfigDir(session.id);
expect(readlinkSync(join(configDir, 'profiles'))).toBe(join(ownHome, 'profiles'));
expect(existsSync(join(configDir, 'settings.yaml'))).toBe(false);
rmSync(ownHome, { recursive: true, force: true });
rmSync(serverHome, { recursive: true, force: true });
});
it('keeps it when the endpoint is gone', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
await restoreCustomModel(session, savedByPreviousRun());
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
});
it('never reads what the previous run injected as the session own value', async () => {
// A record from before `ownEnv` existed. `__envOverrides` holds the isolated
// CLAUDE_CONFIG_DIR and the injected limit, which must go on clear, or the session
// stays pinned to a directory the clear just deleted.
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
envOverrides: { CLAUDE_CONFIG_DIR: '/isolated/old', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' },
});
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
});
session.setCustomModel(undefined);
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBeUndefined();
expect(session.envOverrideValue('CLAUDE_CODE_MAX_CONTEXT_TOKENS')).toBeUndefined();
});
it('does not give a revoked owner their DSH_HOME back from disk', async () => {
// DSH_HOME is a privileged key: the reboot-restore clamp strips `__envOverrides`, and
// this record must not be a way around it.
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const saved = savedByPreviousRun();
process.env.CODEMAN_MULTIUSER = '1';
const session = new Session({ workingDir: '/tmp', mode: 'deepseek', owner: 'nobody' });
configs.push(session.id);
await restoreCustomModel(session, saved);
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
});
});
});
@@ -14,8 +14,14 @@ vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
});
import { getCli } from '../src/config/cli-registry/registry.js';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
const probe = vi.mocked(probePathKind);
const deepseek = () => getCli('deepseek')!;
@@ -52,6 +58,22 @@ describe('guardSessionConfigDirHome', () => {
expect(warn).not.toHaveBeenCalled();
});
it('boot recovery probes the own home under the stall cap: it is a bulk pass, not one request', async () => {
probe.mockResolvedValue('unknown');
vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeCustomModelHosts(getDataDir(), [{ id: 'ep', label: 'Ep', baseUrl: 'http://box:8080' }]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
const saved: CustomModelBookkeeping = { endpointId: 'ep', modelId: 'q', envKeys: [] };
const proto = WebServer.prototype as unknown as {
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping, ownEnv: object) => Promise<unknown>;
};
await proto._rebuildCustomModelEnv(session, saved, { DSH_HOME: '/mnt/dead/dsh' });
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', {});
removeConfigDir(customModelConfigDir(session.id));
});
it('never probes a relative home: the apply refuses to join one', async () => {
const base = env({ DSH_HOME: 'relative/dsh' });
+2
View File
@@ -464,6 +464,8 @@ export class MockSession extends EventEmitter {
/** Mirrors Session.envOverrideValue; no override map here, so the custom-model
* real-home lookup defaults to "session says nothing" (overridable per test). */
envOverrideValue = vi.fn((_key: string): string | undefined => undefined);
/** Mirrors Session.ownEnvValue, same default. */
ownEnvValue = vi.fn((_key: string): string | undefined => undefined);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
@@ -92,6 +92,35 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
safeRmHomeTree(CASES_DIR);
});
it("learns the caller's own value under an injected key, so a clear can put it back", async () => {
const own = join(getDataDir(), 'client-claude');
const res = await quickStart({
caseName: 'cm-own-home',
mode: 'claude',
envOverrides: { CLAUDE_CONFIG_DIR: own },
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
const isolatedDir = customModelConfigDir(sessionId);
// It launches on the isolated directory, with the caller's value set aside rather
// than recorded as the injected one.
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(isolatedDir);
expect(session.getCustomModelForPersist()?.ownEnv).toEqual({ CLAUDE_CONFIG_DIR: own });
const clear = await app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/custom-model`,
payload: { clear: true },
});
expect(clear.statusCode).toBe(200);
expect(session.customModel).toBeUndefined();
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(own);
expect(existsSync(isolatedDir)).toBe(false);
});
it('launches a claude session already pointed at the endpoint — no restart at all', async () => {
const res = await quickStart({
caseName: 'cm-claude',
+22 -1
View File
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
import { existsSync, readFileSync, readlinkSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { webviewFetch } from '../../src/web/webview-egress.js';
@@ -97,6 +97,27 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(trustFile.customApiKeyResponses.approved).toEqual(['k']);
});
it("links a switch from the session's own home, never the injected one the map now holds", async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'deepseek';
const isolatedDir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
const ownHome = join(getDataDir(), 'own-dsh-home');
session.envOverrideValue.mockImplementation(() => isolatedDir);
session.ownEnvValue.mockImplementation((key: string) => (key === 'DSH_HOME' ? ownHome : undefined));
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
expect(readlinkSync(join(isolatedDir, 'sessions'))).toBe(join(ownHome, 'sessions'));
rmSync(isolatedDir, { recursive: true, force: true });
rmSync(ownHome, { recursive: true, force: true });
});
it('clears back to the native default', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
+130 -1
View File
@@ -1,7 +1,7 @@
/**
* @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 three ways the first cut broke a
* `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`,
@@ -16,6 +16,9 @@
* 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.
@@ -253,3 +256,129 @@ describe('Codex custom endpoint model overrides', () => {
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'] });
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* @fileoverview The env clamp's key list, read from the registry. A key stays clamped while
* its CLI is disabled: a session can outlive the switch (the reboot-restore route and the
* custom-model recovery both clamp for one that already exists), and neither the clear
* branch of the custom-model route nor `restartCli()` checks `enabled`.
*
* Port: N/A (the on-disk `clis.json` uses the per-file temp HOME from test/setup.ts).
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { reloadCliRegistry, registryFilePath } from '../src/config/cli-registry/registry.js';
import { clampEnvOverridesForOwner } from '../src/session-env-clamp.js';
describe('clampEnvOverridesForOwner: a disabled CLI', () => {
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ clis: { deepseek: { enabled: false } } }), { mode: 0o600 });
reloadCliRegistry();
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
});
it('still strips its privileged keys for a non-granted owner', async () => {
expect(await clampEnvOverridesForOwner('nobody', { DSH_HOME: '/own' })).toEqual({});
});
});