mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
fix(custom-model): unset injected env on clear, resume on restart, select the model for pi/omp/grok
Custom Model Endpoint Profiles (#393) let a session point its CLI at a custom OpenAI-compatible endpoint by injecting env vars or a config file and restarting the CLI in place. Review of the apply path found four things, two of them destructive. This lands all four plus the smaller items from the same review. 1. Clearing a selection did not clear it. The injected vars reach the CLI via `tmux setenv`, which persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so deleting the keys from the session's envOverrides relaunched the CLI still pointed at the old endpoint, and for the configDir kinds at a HOME/CODEX_HOME/GROK_HOME that had just been deleted. `Session.setCustomModel()` now reports the removed keys, queues them (`_pendingEnvUnsets`), and `RespawnPaneOptions.unsetEnvKeys` carries them into `applyEnvOverrides()`, which `setenv -u`s them before re-applying the live overrides, on the same path that already unsets the legacy CLAUDE_CODE_EFFORT_LEVEL. Verified on a private tmux socket that `setenv -u HOME` hands the next respawn the global HOME back. 2. Applying a model to a local claude session killed the pane. The relaunch was `claude --session-id <id>` and Claude refuses an id that already has a transcript, and unlike the dead-pane respawn this one kills a working pane first. `restartCli()` now pins the live conversation id as the resume id for that respawn when the CLI's launch declares a `fallback` chain, which renders the same `--resume <id> || --session-id <id>` shape the docker and remote pane commands use. Gated on the registry shape, not the CLI id: an entry whose resume id is minted by the CLI itself never declares that chain. 3. pi, omp and grok wrote their config file and then launched without the `--model` that selects it, so the file was ignored. The registry entry now declares `customModelInjection.launchModel` (`custom/{modelId}` for pi and omp, grok's `[model.codeman-custom]` block name), the builder renders it, and `_withCustomModelLaunchModel()` applies it onto the respawn options through `legacyConfigField`, leaving the stored <Mode>Config untouched so a clear falls back to the user's own model. A model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. 4. Remote (SSH) and Docker sessions reported `restarted: true` and changed nothing: their `restartCli()` reattaches the durable tmux rather than relaunching the agent, and the env lands on the local pane. Both are refused with a 400 until those paths are plumbed. Smaller items from the same review: - The selection survives a Codeman restart as the disk-only `__customModel` bookkeeping (endpoint, model, injected key NAMES, config dir, launch model; never the values, which carry the API key). Recovery re-derives the values from the endpoint store through the same apply path the route uses and keeps the bookkeeping even when the endpoint is gone, so a later clear still has keys to unset. - Discovery goes through `webviewFetch()`, so the RESOLVED address is judged by the same egress guard the web-tab proxy uses, and `baseUrl` reuses `webviewUrlSchema` (http(s) only, no embedded credentials, link-local and cloud-metadata addresses refused). undici's `fetch failed` wrapper is unwrapped so the user sees the ECONNREFUSED underneath. - `custom-model-hosts.json` is written 0600 via tmp+rename, the per-session config dir 0700/0600 (pi and omp embed the key literally), and that dir is removed with the session. - `PR.md` is gone from the repo root and the design doc moved to `docs/custom-model-endpoints-plan.md` with the LAN address and the personal name scrubbed; every reference follows. The guide's `authStyle` text matches the shipped schema (`bearer | api-key`, default `bearer`) and says that `customModelEndpointsEnabled` is read by nothing until the picker lands. - `config/tsconfig.scripts.json` typechecks `scripts/test-local-llm-harnesses.ts` (four real type errors fixed). It is not yet wired into `npm run typecheck` because that line differs on master; adding `&& tsc -p config/tsconfig.scripts.json` there is the one-line follow-up. Tests: `test/session-custom-model-restart.test.ts` drives a real Session and fails on the unfixed code for items 1 to 3; the route suite covers item 4 and the pattern refusal; `test/tmux-manager.test.ts` pins that the unsets run before the overrides and that a shell-metachar key never reaches tmux. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+92
-18
@@ -48,6 +48,8 @@ import {
|
||||
type OpenCodeConfig,
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type CustomModelBookkeeping,
|
||||
type CustomModelSelection,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
@@ -577,13 +579,19 @@ export class Session extends EventEmitter {
|
||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||
private _effort: EffortLevel | undefined;
|
||||
|
||||
// Custom Model Endpoint Profiles (deployment_plan.md). `envKeys` and `configDir` are
|
||||
// internal bookkeeping ONLY (never surfaced via toState()/customModel getter): they are
|
||||
// what setCustomModel() needs to undo a previous injection (remove exactly the env keys
|
||||
// it added, delete a previous isolated config dir) without guessing what it once wrote.
|
||||
private _customModel:
|
||||
| { endpointId: string; modelId: string; label?: string; envKeys: string[]; configDir?: string }
|
||||
| undefined;
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
|
||||
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
|
||||
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
|
||||
// previous injection (remove exactly the env keys it added, delete a previous isolated
|
||||
// config dir) without guessing what it once wrote. Persisted disk-only (`__customModel`).
|
||||
private _customModel: CustomModelBookkeeping | undefined;
|
||||
|
||||
// Env keys a retired custom-model selection injected that the NEXT respawn must
|
||||
// `tmux setenv -u`. Deleting a key from `_envOverrides` alone does nothing to the
|
||||
// tmux session, which keeps every `setenv` and hands it to `respawn-pane`, so the
|
||||
// relaunched CLI would come back still pointed at the old endpoint (measured, see
|
||||
// TmuxManager.applyEnvOverrides). Drained after a successful respawn.
|
||||
private _pendingEnvUnsets = new Set<string>();
|
||||
|
||||
// tmux history-limit (scrollback lines) allocated when this session's pane is created.
|
||||
private readonly _tmuxHistoryLimit: number;
|
||||
@@ -1246,38 +1254,63 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Model Endpoint Profiles (deployment_plan.md) — public-safe subset only
|
||||
// (never envKeys/configDir, which are internal bookkeeping for setCustomModel below).
|
||||
get customModel(): { endpointId: string; modelId: string; label?: string } | undefined {
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — public-safe
|
||||
// subset only (never envKeys/configDir/launchModel, the bookkeeping for setCustomModel).
|
||||
get customModel(): CustomModelSelection | undefined {
|
||||
if (!this._customModel) return undefined;
|
||||
const { endpointId, modelId, label } = this._customModel;
|
||||
return { endpointId, modelId, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
getCustomModelForPersist(): CustomModelBookkeeping | undefined {
|
||||
return this._customModel ? { ...this._customModel, envKeys: [...this._customModel.envKeys] } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* side-effect-free beyond mutating state, matching `setNice`/`setColor` above — this
|
||||
* class does no file IO, so it returns the PREVIOUS `configDir` (if any) for the
|
||||
* 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).
|
||||
*
|
||||
* Keys the previous selection injected that the new one does not re-set are queued
|
||||
* for `tmux setenv -u` on the next respawn (`_pendingEnvUnsets`, threaded through
|
||||
* `_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.
|
||||
*/
|
||||
setCustomModel(
|
||||
next: { endpointId: string; modelId: string; label?: string; envKeys: string[]; configDir?: string } | undefined,
|
||||
next: CustomModelBookkeeping | undefined,
|
||||
envOverrides?: Record<string, string>
|
||||
): string | undefined {
|
||||
): { removedEnvKeys: string[]; previousConfigDir: string | undefined } {
|
||||
const previousConfigDir = this._customModel?.configDir;
|
||||
const removedEnvKeys: string[] = [];
|
||||
if (this._customModel) {
|
||||
for (const key of this._customModel.envKeys) {
|
||||
if (this._envOverrides) delete this._envOverrides[key];
|
||||
removedEnvKeys.push(key);
|
||||
this._pendingEnvUnsets.add(key);
|
||||
}
|
||||
}
|
||||
this._customModel = next;
|
||||
this._customModel = next ? { ...next, envKeys: [...next.envKeys] } : 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
|
||||
// would set it right back anyway); keep the list to what actually goes away.
|
||||
for (const key of Object.keys(envOverrides)) this._pendingEnvUnsets.delete(key);
|
||||
}
|
||||
return previousConfigDir;
|
||||
return { removedEnvKeys, previousConfigDir };
|
||||
}
|
||||
|
||||
// Token tracking getters and setters
|
||||
@@ -1660,6 +1693,7 @@ export class Session extends EventEmitter {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
this._pendingEnvUnsets.clear();
|
||||
// Wait a moment for the respawned process to fully start
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
@@ -1756,7 +1790,7 @@ export class Session extends EventEmitter {
|
||||
/**
|
||||
* Kill and relaunch this session's CLI process IN PLACE — same pane, same tmux
|
||||
* session, fresh env/args from current state. Custom Model Endpoint Profiles
|
||||
* (deployment_plan.md) is the first caller: after `setCustomModel()` merges new
|
||||
* (docs/custom-model-endpoints-plan.md) is the first caller: after `setCustomModel()` merges new
|
||||
* env vars into `_envOverrides`, the running CLI process still has the OLD env
|
||||
* (inherited at its own process start, not live-reloaded), so switching a
|
||||
* session's model/endpoint requires this restart to actually take effect.
|
||||
@@ -1783,11 +1817,26 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
const options = this._buildRespawnPaneOptions();
|
||||
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
|
||||
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
|
||||
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
|
||||
// which turned an endpoint switch into a dead pane and a lost session. A launch that
|
||||
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
|
||||
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
|
||||
// already use, so pin the live conversation id for THIS respawn only. The registry
|
||||
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
|
||||
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
|
||||
// read from its own `<Mode>Config` rather than this top-level one anyway.
|
||||
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
|
||||
options.resumeSessionId = this._claudeSessionId ?? this.id;
|
||||
}
|
||||
const newPid = await mux.respawnPane(options);
|
||||
if (!newPid) {
|
||||
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
this._pendingEnvUnsets.clear();
|
||||
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
@@ -1799,7 +1848,7 @@ export class Session extends EventEmitter {
|
||||
* the spawn path.
|
||||
*/
|
||||
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
|
||||
return {
|
||||
const options: import('./mux-interface.js').RespawnPaneOptions = {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
@@ -1827,12 +1876,37 @@ export class Session extends EventEmitter {
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
};
|
||||
return this._withCustomModelLaunchModel(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
||||
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
||||
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
|
||||
* ...) or the top-level `model` for an entry that declares none — so this stays a
|
||||
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
|
||||
* `<Mode>Config` keeps whatever model the user chose at create, which is exactly what a
|
||||
* later clear must fall back to.
|
||||
*/
|
||||
private _withCustomModelLaunchModel(
|
||||
options: import('./mux-interface.js').RespawnPaneOptions
|
||||
): import('./mux-interface.js').RespawnPaneOptions {
|
||||
const launchModel = this._customModel?.launchModel;
|
||||
if (!launchModel) return options;
|
||||
const entry = getCli(this.mode);
|
||||
if (!entry) return options;
|
||||
const field = entry.launch.legacyConfigField;
|
||||
if (!field) return { ...options, model: launchModel };
|
||||
const bag = options as unknown as Record<string, unknown>;
|
||||
const existing = (bag[field] ?? {}) as Record<string, unknown>;
|
||||
return { ...options, [field]: { ...existing, model: launchModel } };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user