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

+10 -1
View File
@@ -301,6 +301,8 @@ export interface AppliedCustomModel {
envKeys: string[];
configDir?: string;
launchModel?: string;
/** The env key among `envKeys` that moves the CLI's home, see `CustomModelBookkeeping`. */
homeEnvVar?: string;
}
/**
@@ -360,9 +362,16 @@ export function applyCustomModelInjection(
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.configDirVar,
};
}
const configDir = customModelConfigDir(sessionId);
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
return {
envOverrides,
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.dirEnvVar,
};
}
+1 -1
View File
@@ -26,7 +26,7 @@
* re-resolves the grant at the moment it builds a session, for the same reason.
*
* @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes, web/server
*
* @module session-env-clamp
*/
+45 -8
View File
@@ -1994,21 +1994,36 @@ export class Session extends EventEmitter {
/**
* The full selection incl. bookkeeping, for state.json ONLY (`__customModel`, the
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no env values,
* so nothing secret lands on disk; recovery re-derives them from the endpoint store.
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no injected env
* values, so nothing secret lands on disk; recovery re-derives them from the endpoint store.
* Without this a Codeman restart left the pane on the custom endpoint (tmux keeps
* its `setenv`s) while `customModel` came back undefined, so the state was wrong and
* clearing had nothing to unset. Must NOT be included in any API-bound serializer.
*
* The one value it does carry is the session's own home directory (`ownEnv`, narrowed to
* `homeEnvVar`), a path like `configDir` beside it: the selection overwrote the tmux
* `setenv` that held it, so without this a clear after a restart would lose it. Any other
* entry of `ownEnv` may be a credential and stays in memory.
*/
getCustomModelForPersist(): CustomModelBookkeeping | undefined {
return this._customModel ? { ...this._customModel, envKeys: [...this._customModel.envKeys] } : undefined;
if (!this._customModel) return undefined;
const { ownEnv, ...selection } = this._customModel;
const { homeEnvVar } = selection;
const ownHome = homeEnvVar ? ownEnv?.[homeEnvVar] : undefined;
return {
...selection,
envKeys: [...selection.envKeys],
...(homeEnvVar && ownHome ? { ownEnv: { [homeEnvVar]: ownHome } } : {}),
};
}
/**
* Update this session's custom-model selection and merge the endpoint's injected env
* vars into `_envOverrides` — first UNDOING whatever the previous selection injected
* (removing exactly those env keys), so switching endpoints, or clearing back to the
* harness's native cloud default, never leaves a stale key behind. Synchronous and
* (removing exactly those env keys, and putting back a value the session had set under
* one itself), so switching endpoints, or clearing back to the harness's native cloud
* default, never leaves a stale key behind and never loses the session's own `DSH_HOME`
* or `CLAUDE_CONFIG_DIR`. Synchronous and
* side-effect-free beyond mutating state, matching `setNice`/`setColor` above — this
* class does no file IO, so it reports the PREVIOUS `configDir` (if any) for the
* caller to clean up on disk (custom-model-injection.ts's configDir kind).
@@ -2018,7 +2033,12 @@ export class Session extends EventEmitter {
* `_buildRespawnPaneOptions().unsetEnvKeys`): the tmux session inherits every
* `setenv` into `respawn-pane`, so dropping them from the map alone would relaunch
* the CLI still pointed at the old endpoint — and for the `configDir` kinds, at a
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted.
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted. A key put back stays out
* of that queue: `applyEnvOverrides()` writes its value over the injected one.
*
* `next.ownEnv` is for recovery, which states the session's own values itself: after a
* restart `_envOverrides` holds what the previous run injected (`CLAUDE_CONFIG_DIR` is
* persisted), and reading those as the session's own would pin it to a deleted directory.
*/
setCustomModel(
next: CustomModelBookkeeping | undefined,
@@ -2027,13 +2047,30 @@ export class Session extends EventEmitter {
const previousConfigDir = this._customModel?.configDir;
const removedEnvKeys: string[] = [];
if (this._customModel) {
const previousOwnEnv = this._customModel.ownEnv ?? {};
for (const key of this._customModel.envKeys) {
if (this._envOverrides) delete this._envOverrides[key];
removedEnvKeys.push(key);
this._pendingEnvUnsets.add(key);
const own = previousOwnEnv[key];
if (own) {
this._envOverrides = { ...(this._envOverrides ?? {}), [key]: own };
this._pendingEnvUnsets.delete(key);
} else {
this._pendingEnvUnsets.add(key);
}
}
}
this._customModel = next ? { ...next, envKeys: [...next.envKeys] } : undefined;
// Read after the undo, so a switch between endpoints carries the own values across. An
// empty value is skipped: `applyEnvOverrides()` never set one, so it has nothing to
// restore, and the injected key still has to be unset.
const ownEnv: Record<string, string> = { ...next?.ownEnv };
if (!next?.ownEnv) {
for (const key of Object.keys(envOverrides ?? {})) {
const own = this._envOverrides?.[key];
if (own) ownEnv[key] = own;
}
}
this._customModel = next ? { ...next, envKeys: [...next.envKeys], ownEnv } : undefined;
if (envOverrides && Object.keys(envOverrides).length > 0) {
this._envOverrides = { ...(this._envOverrides ?? {}), ...envOverrides };
// A key the new selection sets again does not need an unset (applyEnvOverrides
+13
View File
@@ -702,6 +702,19 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
configDir?: string;
/** Value forced onto the CLI's `model` launch param (pi/omp `custom/<id>`, grok's block name). */
launchModel?: string;
/**
* The variable that moves the CLI's home, when the recipe redirects one (`DSH_HOME`,
* `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME`). Its value is a path, which is what
* lets {@link CustomModelBookkeeping.ownEnv} keep that one entry on disk.
*/
homeEnvVar?: string;
/**
* What the session itself had set under keys the selection replaced, put back when the
* selection goes. In memory this can hold a credential (a session's own `GEMINI_API_KEY`),
* so `Session.getCustomModelForPersist()` writes `homeEnvVar`'s entry and nothing else:
* tmux keeps only the injected value, so state.json is the one copy left after a restart.
*/
ownEnv?: Record<string, string>;
}
/**
+14 -12
View File
@@ -1689,6 +1689,7 @@ export function registerSessionRoutes(
envKeys: applied.envKeys,
configDir: applied.configDir,
launchModel: applied.launchModel,
homeEnvVar: applied.homeEnvVar,
},
applied.envOverrides
);
@@ -4467,12 +4468,12 @@ export function registerSessionRoutes(
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
// a lighter version of them, since this is the same server-side authority reached a
// different way, not a separate, less-checked path.
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
// setCustomModel() bookkeeping must be given below. The Session constructor already
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
//
// Only the INJECTED keys (never the caller's envOverrides merged in): setCustomModel()
// merges these over what the Session constructor took, which is how it learns which of
// the caller's values the selection replaced. Handing the constructor the merged set
// would hide them, and giving setCustomModel() that set would put
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
let qsCustomModelLaunchModel: string | undefined;
let qsCustomModelSessionId: string | undefined;
@@ -4485,6 +4486,7 @@ export function registerSessionRoutes(
envKeys: string[];
configDir?: string;
launchModel?: string;
homeEnvVar?: string;
}
| undefined;
if (customModel) {
@@ -4588,7 +4590,6 @@ export function registerSessionRoutes(
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
}
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
qsCustomModelLaunchModel = cmApplied.launchModel;
qsCustomModelBookkeeping = {
@@ -4598,6 +4599,7 @@ export function registerSessionRoutes(
envKeys: cmApplied.envKeys,
configDir: cmApplied.configDir,
launchModel: cmApplied.launchModel,
homeEnvVar: cmApplied.homeEnvVar,
};
// Actually kick off llama-swap's load now — see the dedicated apply route's own
@@ -4630,7 +4632,7 @@ export function registerSessionRoutes(
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides,
envOverrides: qsGatedEnvOverrides,
effort,
advisorModel,
remote,
@@ -4659,10 +4661,10 @@ export function registerSessionRoutes(
const session = new Session(qsSessionOptions);
// Records the selection for session.customModel/getCustomModelForPersist() and future
// clear/switch calls — the actual env vars and launch-model config are already part of
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
// tmux IO of its own (see its own doc comment in session.ts).
// clear/switch calls, and merges the injected env, which the launch below reads along
// with the launch-model config set above (piConfig/grokConfig/ompConfig.model). Nothing
// has started yet, so this is never a restart: setCustomModel() is synchronous state,
// no tmux IO of its own (see its own doc comment in session.ts).
if (qsCustomModelBookkeeping) {
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
}
+13 -7
View File
@@ -104,6 +104,7 @@ import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator } from '../plan-orchestrator.js';
import { OrchestratorLoop } from '../orchestrator-loop.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { clampEnvOverridesForOwner } from '../session-env-clamp.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
@@ -1532,12 +1533,10 @@ export class WebServer extends EventEmitter {
}
try {
// Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that
// already fired once) and the session-env real-home lookup: the injected VALUES are
// never persisted, so after a real restart the session can no longer carry its
// allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first
// apply in-process. Real-home resolution therefore falls back to the SERVER's
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
// already fired once) and the session-env real-home lookup: the first apply already
// linked this directory from the session's own home, and `linkRealHomeEntry()` keeps
// what is there. Only a directory someone removed gets its links again, from the
// SERVER's `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
return applyCustomModelInjection(
entry,
endpoint,
@@ -1559,18 +1558,25 @@ export class WebServer extends EventEmitter {
* while the new recipe writes one, and clearing it later could then never delete the
* generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
* still carry `setenv`s from both, and a clear has to unset every one.
*
* `ownEnv` is always passed, empty for a record saved before it existed: `_envOverrides`
* holds what the previous run injected, and only this record says what was the session's
* own. It meets the owner's grant again, like every other env override rebuilt from disk.
*/
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
const applied = await this._rebuildCustomModelEnv(session, saved);
if (!applied) {
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
// is what a later clear needs.
session.setCustomModel(saved);
session.setCustomModel({ ...saved, ownEnv });
return;
}
session.setCustomModel(
{
...saved,
ownEnv,
homeEnvVar: applied.homeEnvVar ?? saved.homeEnvVar,
// Prefer the current recipe's dir; keep the saved one when the recipe writes none,
// so a clear can still delete a directory an older recipe created.
configDir: applied.configDir ?? saved.configDir,