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

+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,