fix(local-model): ask the launch guard before every start of an agent on the managed endpoint

Only the two session routes asked launchRefusalMessage(). The attach that
finds no pane, the respawn of a dead pane (a click on the tab, a respawn
start, the boot recovery), restartCli() and the reboot-restore rebuild
started an agent on the endpoint unasked. Session gains setLaunchGuard(),
asked right before a NEW agent process is started and never for an attach to
an agent that runs; WebServer.setupSessionListeners() sets it for every
session; a refusal is a LaunchRefusedError that never falls back to a direct
PTY. The reboot-restore route asks before it builds and skips the entry with
its own reason, left on offer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 23:54:46 +01:00
1 parent fa978008d2
commit 3c4bb91d6f
7 files changed
+380 -2

No files matched your search

+2
View File
@@ -134,6 +134,8 @@ export interface RebootRestoreRejection {
| 'workspace-forbidden'
| 'already-live'
| 'capacity-reached'
/** The session ran on the model Codeman runs on this Mac, and that model cannot be started on right now. */
| 'local-model-not-ready'
| 'rebuild-failed';
}
+48
View File
@@ -510,6 +510,18 @@ export class GuardedInputBusyError extends Error {
}
}
/**
* The session's launch guard refused the start of a new agent process
* (`Session.setLaunchGuard()`). Its message is the sentence for the user. A typed error, so a
* start that was refused is told apart from a tmux failure and never falls back to a direct PTY.
*/
export class LaunchRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'LaunchRefusedError';
}
}
/**
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
@@ -880,6 +892,12 @@ export class Session extends EventEmitter {
// config dir) without guessing what it once wrote. Persisted disk-only (`__customModel`).
private _customModel: CustomModelBookkeeping | undefined;
// Asked right before a NEW agent process is started for this session (a fresh pane, the
// respawn of a dead one, a restart in place), never for an attach to an agent that runs.
// Resolves with the sentence that refuses the start, or undefined. Set by the web layer
// (`WebServer.setupSessionListeners()`); this file does not know who asks or why.
private _launchGuard: (() => Promise<string | undefined>) | null = null;
// 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
@@ -2063,6 +2081,22 @@ export class Session extends EventEmitter {
}
}
/**
* Sets what is asked before a new agent process is started for this session (see
* `_launchGuard`). The first user is the model Codeman runs on this Mac: an agent is not
* started on it while its runtime is down or its model changed. `null` removes it.
*/
setLaunchGuard(guard: (() => Promise<string | undefined>) | null): void {
this._launchGuard = guard;
}
/** Throws the guard's sentence (`LaunchRefusedError`) when a new agent process must not be started now. */
private async _assertLaunchAllowed(): Promise<void> {
if (!this._launchGuard) return;
const refusal = await this._launchGuard();
if (refusal) throw new LaunchRefusedError(refusal);
}
// 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 {
@@ -2635,6 +2669,8 @@ export class Session extends EventEmitter {
let respawnedResumeId: string | undefined;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
// A new agent process: asked first (see `_launchGuard`). A refusal leaves the pane as it is.
await this._assertLaunchAllowed();
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
// `options.respawnPaneOptions` was built eagerly before this dead-pane
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
@@ -2689,6 +2725,8 @@ export class Session extends EventEmitter {
this._resumeSessionId = pinned;
}
}
// A new agent process: asked first (see `_launchGuard`).
await this._assertLaunchAllowed();
this._muxSession = await mux.createSession(options.createSessionOptions);
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
@@ -2808,6 +2846,9 @@ export class Session extends EventEmitter {
return false;
}
// A new agent process: asked first (see `_launchGuard`). A refusal rejects, and the pane
// keeps what runs in it.
await this._assertLaunchAllowed();
this._pinOmpRespawnId();
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
if (!newPid) {
@@ -3461,6 +3502,9 @@ export class Session extends EventEmitter {
this._armPaneSettle(true);
}
} catch (err) {
// A refused launch is an answer, not a tmux failure: it must not fall through to
// the direct PTY below, which would start the very process that was refused.
if (err instanceof LaunchRefusedError) throw err;
if (this._adopt) this._failAdoptedStart(err);
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
this._useMux = false;
@@ -3482,6 +3526,8 @@ export class Session extends EventEmitter {
throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`);
}
if (this._adopt) this._failAdoptedStart(new Error('tmux is not available'));
// A new agent process on this path too: asked first (see `_launchGuard`).
await this._assertLaunchAllowed();
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab.
@@ -4485,6 +4531,8 @@ export class Session extends EventEmitter {
}, 100);
}
} catch (err) {
// As in startInteractive(): a refused launch never falls back to a direct PTY.
if (err instanceof LaunchRefusedError) throw err;
if (this._adopt) this._failAdoptedStart(err);
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
this._useMux = false;
+2
View File
@@ -40,6 +40,8 @@ function rebootSkipReason(reason) {
return 'already open';
case 'capacity-reached':
return 'session limit reached';
case 'local-model-not-ready':
return 'the local model is not ready';
case 'rebuild-failed':
return 'the agent would not start';
default:
+16
View File
@@ -35,6 +35,7 @@ import {
isWorkingDirAllowedForUsername,
sessionCapacityMessage,
} from '../route-helpers.js';
import { getLocalModelService } from '../../local-model/install-job.js';
import { rebootRestoreRegistry } from '../reboot-restore-registry.js';
import { rejectAlreadyLive, type RebootRestoreEntry, type RebootRestoreRejection } from '../../reboot-restore.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
@@ -163,6 +164,21 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
failures.push({ sessionId: entry.sessionId, reason: 'workspace-forbidden' });
continue;
}
// A session that ran on the model Codeman runs on this Mac is rebuilt only while that
// model can be started on: its own runtime is up and its digest is the one stored at
// install time. Asked before anything is built, like the two session routes; the
// session's own launch guard would refuse the start too, but only after the session
// exists. Every other endpoint, and a session without one, is answered `undefined`.
const savedModel = (entry.state as { __customModel?: { endpointId?: string; modelId?: string } }).__customModel;
if (savedModel && typeof savedModel.endpointId === 'string' && typeof savedModel.modelId === 'string') {
const refusal = await getLocalModelService().launchRefusalMessage(savedModel.endpointId, savedModel.modelId);
if (refusal) {
console.warn(`[reboot-restore] ${entry.sessionId} is not rebuilt: ${refusal}`);
// Left on offer: the model can be started (or installed again) and the click repeated.
failures.push({ sessionId: entry.sessionId, reason: 'local-model-not-ready' });
continue;
}
}
try {
const saved = entry.state;
const claudeModeConfig = await ctx.getClaudeModeConfig();
+11
View File
@@ -2194,6 +2194,17 @@ export class WebServer extends EventEmitter {
// First keep-warm evaluation: publishes the effective opt-in for the Session Options
// readout even before any cache report arrives.
void this.keepWarmService.schedule(session.id);
// Before every start of a NEW agent process the session asks this (a fresh pane, the
// respawn of a dead one, a restart in place; never an attach to an agent that runs).
// The model Codeman runs on this Mac is the one endpoint with something to say: no agent
// is started on it while its runtime is down or its model is not the one that was
// installed. Every other session, and every other endpoint, is answered `undefined`.
// Set on every call, above the idempotency return: it holds no state of its own.
session.setLaunchGuard(async () => {
const selection = session.customModel;
if (!selection) return undefined;
return getLocalModelService().launchRefusalMessage(selection.endpointId, selection.modelId);
});
// Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit
// (removeSessionListenerRefs), so the re-attach routes (/interactive,
// /interactive-respawn, /shell) call this again to restore observability