Files
Codeman/test/local-model-session-launch-guard.test.ts
T
Codeman maintainer 3c4bb91d6f 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>
2026-11-06 23:54:46 +01:00

295 lines
13 KiB
TypeScript

/**
* @fileoverview No coding agent is STARTED on the model Codeman runs on this Mac while that
* model cannot be started on: the launch guard below the routes.
*
* The two routes that put a session onto a custom model endpoint ask the install service
* before they write anything (test/routes/local-model-launch-guard.test.ts). That leaves
* every other way a new agent process comes to be in a session that already carries the
* selection: the attach that finds no pane, the respawn of a dead pane (a click on the tab, a
* respawn start, the recovery at boot), a restart in place, and the rebuild after a reboot.
* They all end in `Session`, so the guard sits there:
*
* - `Session.setLaunchGuard()` is asked right before a NEW agent process is started (a fresh
* pane, the respawn of a dead one, `restartCli()`), and never for an attach to an agent
* that runs: a session whose runtime was stopped can still be looked at and closed.
* - `WebServer.setupSessionListeners()` sets it for every session, and it asks
* `LocalModelService.launchRefusalMessage()` with the session's own selection.
* - The reboot-restore route asks the same question before it builds anything, so the entry
* is refused with its own reason and stays on offer.
*
* The Session cases drive a real `Session` against the in-memory tmux layer vitest
* substitutes. The wiring cases use the REAL install service over the real state file, with
* no runtime running, which is exactly the state that must refuse.
*
* Port: N/A (WebServer in test mode binds none; the routes go through app.inject).
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { dataPath } from '../src/config/instance.js';
import type { RebootRestoreEntry } from '../src/reboot-restore.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { httpStatusForErrorCode, type ApiErrorCode, type MuxSession, type SessionState } from '../src/types.js';
import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
import { registerRebootRestoreRoutes } from '../src/web/routes/reboot-restore-routes.js';
import { WebServer } from '../src/web/server.js';
import { createMockRouteContext, safeRmHomeTree } from './mocks/index.js';
const WORKSPACE = join(homedir(), 'codeman-cases', 'local-model-launch-guard');
const STATE = () => dataPath('local-model.json');
const NOT_RUNNING = 'The model on this Mac is not running. Start it first.';
const CHANGED = 'The model on this Mac changed since it was installed. Remove it and install it again.';
const SELECTION = { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b', envKeys: [] as string[] };
const sessions: Session[] = [];
function paneOf(mode: 'claude' | 'opencode' = 'claude'): MuxSession {
return {
sessionId: 'placeholder',
muxName: 'codeman-cafe0002',
pid: 1,
createdAt: Date.now(),
workingDir: WORKSPACE,
mode,
attached: false,
};
}
/** A session that already has a pane, alive or dead as the test says. */
function sessionWithPane(dead: boolean) {
mkdirSync(WORKSPACE, { recursive: true });
const mux = new TmuxManager();
const session = new Session({ workingDir: WORKSPACE, mode: 'claude', mux, useMux: true, muxSession: paneOf() });
sessions.push(session);
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
vi.spyOn(mux, 'isPaneDead').mockReturnValue(dead);
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
const create = vi.spyOn(mux, 'createSession');
return { session, mux, respawn, create };
}
/** The state file of an installed model whose connection is the managed record. No runtime runs. */
function writeInstalledState(): void {
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072, digest: 'abc123' },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
}
afterEach(() => {
for (const session of sessions.splice(0)) session.stop();
rmSync(STATE(), { force: true });
rebootRestoreRegistry.reset();
vi.restoreAllMocks();
});
afterAll(() => {
safeRmHomeTree(WORKSPACE);
});
describe('Session: the guard is asked before a new agent process, and only then', () => {
it('a restart in place that is refused rejects with the sentence, and the pane keeps what runs in it', async () => {
const { session, respawn } = sessionWithPane(false);
const guard = vi.fn(async () => CHANGED);
session.setLaunchGuard(guard);
await expect(session.restartCli()).rejects.toThrow(CHANGED);
expect(guard).toHaveBeenCalledTimes(1);
expect(respawn).not.toHaveBeenCalled();
});
it('a guard that has nothing to say lets the restart through', async () => {
const { session, respawn } = sessionWithPane(false);
session.setLaunchGuard(async () => undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn).toHaveBeenCalledTimes(1);
});
it('without a guard nothing changes', async () => {
const { session, respawn } = sessionWithPane(false);
expect(await session.restartCli()).toBe(true);
expect(respawn).toHaveBeenCalledTimes(1);
});
it('an attach to a pane whose agent runs is never asked: the session can still be looked at', async () => {
const { session, respawn, create } = sessionWithPane(false);
const guard = vi.fn(async () => NOT_RUNNING);
session.setLaunchGuard(guard);
await session.startInteractive();
expect(guard).not.toHaveBeenCalled();
expect(respawn).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
});
it('the respawn of a dead pane is asked first, and a refusal starts nothing', async () => {
const { session, respawn, create } = sessionWithPane(true);
const guard = vi.fn(async () => NOT_RUNNING);
session.setLaunchGuard(guard);
await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING);
expect(guard).toHaveBeenCalledTimes(1);
expect(respawn).not.toHaveBeenCalled();
// And no new pane is made in its place either.
expect(create).not.toHaveBeenCalled();
});
it('a session without a pane is asked before one is created', async () => {
mkdirSync(WORKSPACE, { recursive: true });
const mux = new TmuxManager();
const session = new Session({ workingDir: WORKSPACE, mode: 'claude', mux, useMux: true });
sessions.push(session);
const create = vi.spyOn(mux, 'createSession');
session.setLaunchGuard(async () => NOT_RUNNING);
await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING);
expect(create).not.toHaveBeenCalled();
// The guard taken away again: the pane is created.
session.setLaunchGuard(null);
create.mockResolvedValue(paneOf());
await session.startInteractive();
expect(create).toHaveBeenCalledTimes(1);
});
});
describe('WebServer: every session asks the install service about its own selection', () => {
interface ServerInternals {
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
}
let server: WebServer;
let internals: ServerInternals;
beforeAll(() => {
// Test mode: no port is opened and no CLI is launched.
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
});
afterAll(async () => {
await server.stop().catch(() => {});
});
async function wired(selection?: typeof SELECTION) {
const made = sessionWithPane(false);
if (selection) made.session.setCustomModel(selection, {});
await internals.setupSessionListeners(made.session);
return made;
}
it('a session on the managed endpoint is not restarted while the runtime is down', async () => {
writeInstalledState();
const { session, respawn } = await wired(SELECTION);
await expect(session.restartCli()).rejects.toThrow(NOT_RUNNING);
expect(respawn).not.toHaveBeenCalled();
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
it('nor is its dead pane respawned, which is what a click on the tab and the boot recovery do', async () => {
writeInstalledState();
const { session, mux, respawn } = await wired(SELECTION);
vi.spyOn(mux, 'isPaneDead').mockReturnValue(true);
await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING);
expect(respawn).not.toHaveBeenCalled();
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
it('only the model that was installed may be started on it', async () => {
writeInstalledState();
const { session } = await wired({ ...SELECTION, modelId: 'something-pulled-by-hand' });
await expect(session.restartCli()).rejects.toThrow(/Only Qwen3\.6 35B-A3B is installed on this Mac|not running/);
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
it('a session on another endpoint, and one without a selection, start as before', async () => {
writeInstalledState();
const other = await wired({ ...SELECTION, endpointId: 'office-gpu' });
expect(await other.session.restartCli()).toBe(true);
await internals.discardPartiallyBuiltSession(other.session.id).catch(() => {});
const plain = await wired();
expect(await plain.session.restartCli()).toBe(true);
await internals.discardPartiallyBuiltSession(plain.session.id).catch(() => {});
});
it('a selection made AFTER the listeners were wired is asked about too', async () => {
writeInstalledState();
const { session, respawn } = await wired();
session.setCustomModel(SELECTION, {});
await expect(session.restartCli()).rejects.toThrow(NOT_RUNNING);
expect(respawn).not.toHaveBeenCalled();
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
});
describe('POST /api/reboot-restore/restore: a session that ran on the managed endpoint', () => {
async function harness(ctx: ReturnType<typeof createMockRouteContext>): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerRebootRestoreRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return app;
}
/** An entry whose workspace exists, so only the model question can stop it. */
function entryOn(endpointId: string): RebootRestoreEntry {
mkdirSync(WORKSPACE, { recursive: true });
return {
sessionId: 'feedbeef00000001',
name: 'w1-app',
workingDir: WORKSPACE,
mode: 'claude',
resumeConversationId: 'conv-1',
state: {
id: 'feedbeef00000001',
pid: null,
status: 'idle',
workingDir: WORKSPACE,
currentTaskId: null,
createdAt: 1_760_000_000_000,
mode: 'claude',
__customModel: { endpointId, modelId: 'qwen3.6:35b-a3b', envKeys: [] },
} as unknown as SessionState,
};
}
it('is not rebuilt while the model cannot be started on, says why, and stays on offer', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeInstalledState();
rebootRestoreRegistry.set([entryOn('local-ollama')]);
const ctx = createMockRouteContext();
const app = await harness(ctx);
const body = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(body.restored).toEqual([]);
expect(body.skipped).toEqual([{ sessionId: 'feedbeef00000001', reason: 'local-model-not-ready' }]);
// Nothing was built: no session was added, so there is nothing to discard.
expect(ctx.addSession).not.toHaveBeenCalled();
// The model can be started and the click repeated.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['feedbeef00000001']);
await app.close();
});
});