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

+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,
};
}
+4 -3
View File
@@ -26,13 +26,13 @@
* 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
*/
import { canUsernameRunPrivilegedCommands } from './user-store.js';
import { enabledClis } from './config/cli-registry/registry.js';
import { listClis } from './config/cli-registry/registry.js';
/**
* Env-var keys a non-granted owner must not be able to set, because each one
@@ -68,7 +68,8 @@ import { enabledClis } from './config/cli-registry/registry.js';
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
*/
export function ownerClampedEnvKeys(): string[] {
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
// Every CLI, enabled or not: a session can outlive the switch that disabled its CLI.
return listClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
}
/**
+59 -9
View File
@@ -29,7 +29,7 @@
*/
import { EventEmitter } from 'node:events';
import { basename } from 'node:path';
import { basename, isAbsolute } from 'node:path';
import { realpath } from 'node:fs/promises';
import { execSync, execFileSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid';
@@ -1372,6 +1372,17 @@ export class Session extends EventEmitter {
return value && value.trim() ? value.trim() : undefined;
}
/**
* What the SESSION itself set for `key`, same single-key rule. `envOverrideValue()` answers
* with whatever the map holds, which under a selection is the injected value: a re-apply
* resolving the CLI's real home must read the session's own home, or none.
*/
ownEnvValue(key: string): string | undefined {
const selection = this._customModel;
const value = selection?.envKeys.includes(key) ? selection.ownEnv?.[key] : this._envOverrides?.[key];
return value?.trim() || undefined;
}
/** Owning username in multi-user mode, else undefined. */
get owner(): string | undefined {
return this._owner;
@@ -1992,21 +2003,38 @@ 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, and so does a `homeEnvVar`
* value that is not an absolute path: the registry types that name as any env var, so a
* `clis.json` override can point it at a credential.
*/
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 && isAbsolute(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).
@@ -2016,7 +2044,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,
@@ -2025,13 +2058,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
+14
View File
@@ -702,6 +702,20 @@ 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, when it is an absolute
* path, and nothing else: tmux keeps only the injected value, so state.json is the one copy
* left after a restart.
*/
ownEnv?: Record<string, string>;
}
/**
+21 -17
View File
@@ -202,7 +202,7 @@ import {
} from '../../docker-hosts.js';
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
import { LRUMap } from '../../utils/lru-map.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import { describeUnknownPath, probePathKind, type PathProbeOptions } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
@@ -244,18 +244,20 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
* Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on
* `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that
* one env var, so linking falls through to the server's own environment and the unresponsive
* path is never touched. Any other entry's lookup passes through untouched.
* path is never touched. Any other entry's lookup passes through untouched. Boot recovery
* passes `probeOptions` of `{}`: it walks every saved session, so it keeps the bulk cap.
*/
export async function guardSessionConfigDirHome(
entry: Pick<CliEntry, 'capabilities'>,
baseLookup: (envVar: string) => string | undefined
baseLookup: (envVar: string) => string | undefined,
probeOptions: PathProbeOptions = { pastCap: true }
): Promise<(envVar: string) => string | undefined> {
const recipe = entry.capabilities.customModelInjection;
if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup;
const dirEnvVar = recipe.dirEnvVar;
const supplied = baseLookup(dirEnvVar)?.trim();
if (!supplied || !isAbsolute(supplied)) return baseLookup;
if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') {
if ((await probePathKind(supplied, probeOptions)) === 'unknown') {
console.warn(
`[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead`
);
@@ -1675,7 +1677,7 @@ export function registerSessionRoutes(
session.id,
contextLength,
session.workingDir,
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
await guardSessionConfigDirHome(entry, (envVar) => session.ownEnvValue(envVar))
);
if (!applied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
@@ -1689,6 +1691,7 @@ export function registerSessionRoutes(
envKeys: applied.envKeys,
configDir: applied.configDir,
launchModel: applied.launchModel,
homeEnvVar: applied.homeEnvVar,
},
applied.envOverrides
);
@@ -4467,12 +4470,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 +4488,7 @@ export function registerSessionRoutes(
envKeys: string[];
configDir?: string;
launchModel?: string;
homeEnvVar?: string;
}
| undefined;
if (customModel) {
@@ -4588,7 +4592,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 +4601,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 +4634,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 +4663,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);
}
+25 -12
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,
@@ -135,6 +136,7 @@ import { fetchWorkspaceOnce } from '../git-fetch.js';
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
import { probePathKind } from '../utils/bounded-path-probe.js';
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
import { guardSessionConfigDirHome } from './routes/session-routes.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { EditHistorySync } from './edit-history-sync.js';
@@ -1519,7 +1521,8 @@ export class WebServer extends EventEmitter {
*/
private async _rebuildCustomModelEnv(
session: Session,
saved: CustomModelBookkeeping
saved: CustomModelBookkeeping,
ownEnv: Record<string, string> = {}
): Promise<AppliedCustomModel | undefined> {
const entry = getCli(session.mode);
if (!entry) return undefined;
@@ -1531,19 +1534,21 @@ export class WebServer extends EventEmitter {
return undefined;
}
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
// Boot recovery omits `workingDir` (no reason to re-answer a trust dialog that already
// fired once). The real-home lookup reads the record's own values, since the session's
// overrides hold what the previous run injected. `linkRealHomeEntry()` keeps what is
// there, so this only decides an entry that is missing, which an own home skips when it
// does not hold one. With no own home, or one that does not answer (probed under the
// bulk cap, this walks every saved session), a missing entry links from the SERVER's
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
return applyCustomModelInjection(
entry,
endpoint,
saved.modelId,
session.id,
resolveCustomModelContextLength(endpoint, saved.modelId)
resolveCustomModelContextLength(endpoint, saved.modelId),
undefined,
await guardSessionConfigDirHome(entry, (envVar) => ownEnv[envVar], {})
);
} catch (err) {
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
@@ -1559,18 +1564,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 applied = await this._rebuildCustomModelEnv(session, saved);
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
const applied = await this._rebuildCustomModelEnv(session, saved, ownEnv);
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,
@@ -1590,8 +1602,9 @@ export class WebServer extends EventEmitter {
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
// separate from the sanitized public attachmentHistory in toState().
const attachmentHistory = session.getAttachmentHistoryForPersist();
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir,
// launch model; never the values) so recovery can restore and later clear it.
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir, launch
// model, the session's own home path; never an injected value or any other own value)
// so recovery can restore and later clear it.
const customModel = session.getCustomModelForPersist();
const state = {
...base,