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

Applying a custom model writes the endpoint's values over the keys it
injects, and clearing the model unset those keys. A session that had set
one of them itself lost that value: its own DSH_HOME, CODEX_HOME,
GROK_HOME or GEMINI_API_KEY was gone once the model was cleared, and so
was CLAUDE_CONFIG_DIR, which silently moved a Claude session onto the
default account. Switching endpoints did the same.

setCustomModel() now records what the session held under each injected
key and restores it on clear and on switch. Quick-start passes the
caller's values to the Session constructor so they are seen before the
selection lands.

The selection overwrites the only other copy, tmux's setenv, so the
session's own home directory has to survive a Codeman restart on its
own. __customModel therefore stores that one path, the single exception
to persisting keys only, and recovery runs it through
clampEnvOverridesForOwner() so a revoked grant cannot bring it back. Any
other own value, a credential included, stays in memory only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-05 14:25:49 +01:00
1 parent aa463d9d28
commit 10d43b993e
12 files changed
+335 -43

No files matched your search

+91 -5
View File
@@ -7,11 +7,10 @@
* 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 takes no session lookup on this path: the first apply's links are
* still in the generated directory, and one someone removed links from the server's
* `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s
* comment. A session's own home 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
@@ -99,4 +98,91 @@ 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('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();
});
});
});
@@ -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',
+104
View File
@@ -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,104 @@ 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('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'] });
});
});