From a81f430e414490af1538e913a35fc19bf02ca05a Mon Sep 17 00:00:00 2001 From: Randalix Date: Tue, 15 Sep 2026 10:25:45 +0200 Subject: [PATCH 01/14] feat(remote): wake a sleeping host from user input (Wake-on-LAN) A durable remote session survives SSH drops (COD-104/108), but nothing brought the HOST back: after the remote machine suspended, the local tmux pane's ssh child stalled silently and `send-keys` SUCCEEDS against it, so typed input vanished with no error anywhere. Add an optional per-host `wakeCommand` (Wake-on-LAN wrapper, e.g. whuff) that the input route runs when a wake-enabled host is unreachable: input is buffered, the host is woken, the pane is reattached, and the buffer is flushed in order. Detection is a throttled bare TCP probe on wake-enabled hosts only, and only REAL user input may wake a host - the auto-reconnect watcher and boot recovery deliberately cannot, or the host would be re-woken seconds after every suspend and could never stay asleep. --- src/remote-hosts.ts | 6 + src/remote-wake.ts | 443 ++++++++++++++++++++++++ src/types/session.ts | 14 + src/web/public/app.js | 2 + src/web/public/constants.js | 3 + src/web/public/panels-ui.js | 14 + src/web/routes/session-routes.ts | 50 ++- src/web/schemas.ts | 12 + src/web/sse-events.ts | 9 + test/mocks/mock-session.ts | 8 + test/remote-hosts.test.ts | 46 +++ test/remote-wake.test.ts | 305 ++++++++++++++++ test/routes/session-remote-wake.test.ts | 145 ++++++++ 13 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 src/remote-wake.ts create mode 100644 test/remote-wake.test.ts create mode 100644 test/routes/session-remote-wake.test.ts diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 37689ac2c..e57c150ec 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -549,6 +549,9 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi port: host.port, remotePath: remoteCase.remotePath, commands: host.commands, + // Wake-on-LAN command travels with the session so the input route can wake a + // sleeping host without a second config read (see remote-wake.ts). + wakeCommand: host.wakeCommand, // COD-105 — the COD-104 launch path creates the remote session, so we own it // (an explicit kill may propagate a remote kill-session). Discovered+attached // sessions go through `toAttachedSessionRemote` with `owned: false`. @@ -587,6 +590,9 @@ export function toAttachedSessionRemote( port: host.port, remotePath, commands: host.commands, + // An attached session can be woken exactly the same way — the identity of the + // creator does not change whether the host is asleep. + wakeCommand: host.wakeCommand, // Discovered + attached — another Codeman created it. Detach-not-kill. owned: false, remoteSessionName, diff --git a/src/remote-wake.ts b/src/remote-wake.ts new file mode 100644 index 000000000..21609de59 --- /dev/null +++ b/src/remote-wake.ts @@ -0,0 +1,443 @@ +/** + * @fileoverview Wake a SLEEPING remote host from user input (user-triggered Wake-on-LAN). + * + * A durable remote session survives SSH drops (COD-104) and auto-reconnects + * (COD-108), but nothing brings the HOST back: if the remote machine suspended, + * the local tmux pane's `ssh` child stalls silently. `tmux send-keys` then + * SUCCEEDS against a pane that will never deliver the bytes, so typed input is + * lost with no error anywhere — the failure this module exists to close. + * + * Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN): + * - ONLY real user input wakes a host. The auto-reconnect watcher and + * boot-recovery must never wake one, or a host would be re-woken ~45 s after + * each suspend and could never stay asleep (the "keepalive pings a sleeping + * host" failure already solved for a different consumer by + * `hufflepuff-mcp-lazy`). + * - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client, + * a few hundred bytes — below any meaningful activity threshold), throttled + * per session. No SSH keepalive is added to the launch command: keepalives + * would move bytes into an otherwise idle connection every interval, which is + * exactly the "an open pipe keeps the host awake" bug the remote-side idle + * detector was rewritten to avoid. + * - While a wake is in flight, input is BUFFERED and flushed in order once the + * pane is reattached, so the user's first characters after a long pause are + * not the ones that get eaten. + * + * The pure decisions and the IO are separated so the decision table can be + * unit-tested without tmux, ssh, or a real host. + * + * @module remote-wake + */ + +import { spawn } from 'node:child_process'; +import net from 'node:net'; + +/** Minimum spacing between two reachability probes for the same session. */ +export const REMOTE_WAKE_PROBE_MIN_INTERVAL_MS = 30_000; +/** TCP-connect timeout for a reachability probe (host awake ≈ a few ms). */ +export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500; +/** Poll spacing while waiting for a woken host to accept SSH again. */ +export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500; +/** Bounded wait for the host to come back after the wake command ran. */ +export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000; +/** The wake command itself must not hang the wake flow. */ +export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000; +/** + * Settle time between respawning the ssh pane and flushing buffered input: the + * respawned `ssh` needs a moment to run `tmux -L codeman-remote … -A` and attach, + * and bytes written into a still-connecting pane land in nothing. + */ +export const REMOTE_WAKE_ATTACH_SETTLE_MS = 1_500; +/** + * Cap on buffered input per session while a host is being woken. 4 KB is a lot + * of typing for a ~10 s wake; beyond it the OLDEST bytes are dropped (keeping the + * tail preserves what the user just typed, and a silently unbounded buffer would + * be a memory leak keyed on user input). + */ +export const REMOTE_WAKE_PENDING_MAX_BYTES = 4096; +/** Default SSH port used when the host config has no explicit `port`. */ +export const DEFAULT_SSH_PORT = 22; + +/** What the input path should do with a chunk of user input. Pure. */ +export type RemoteInputAction = 'deliver' | 'probe' | 'buffer'; + +/** + * The caller-facing outcome of {@link RemoteWakeRegistry.handleInput}: either the + * caller writes the bytes as usual, or the registry took ownership of them. + */ +export type RemoteInputOutcome = 'deliver' | 'buffered'; + +/** + * Decide what to do with an input chunk on an input route. Mirrors + * {@link RemoteWakeRegistry.handleInput} so the throttle table has exactly ONE + * definition and is unit-testable: + * + * - a wake already in flight → buffer (the flush owns delivery), + * - no wake command configured → deliver (feature off, today's behavior), + * - the last probe said "down" → buffer (no second probe; re-probing a known + * sleeping host on every keystroke would add seconds of latency per character), + * - never probed / throttle window elapsed → probe, + * - probed "up" inside the window → deliver. + * + * Pure — no clock, no IO. + */ +export function decideRemoteInputAction(args: { + hasWakeCommand: boolean; + waking: boolean; + probeAgeMs: number; + lastReachable?: boolean; + minProbeIntervalMs?: number; +}): RemoteInputAction { + if (args.waking) return 'buffer'; + if (!args.hasWakeCommand) return 'deliver'; + if (args.lastReachable === false) return 'buffer'; + const interval = args.minProbeIntervalMs ?? REMOTE_WAKE_PROBE_MIN_INTERVAL_MS; + if (args.probeAgeMs >= interval) return 'probe'; + return 'deliver'; +} + +/** + * Append `data` to the pending buffer, dropping the OLDEST bytes when the cap is + * exceeded. Returns the resulting buffer. Pure. + */ +export function appendBoundedPending( + pending: string[], + data: string, + maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES +): string[] { + const next = [...pending, data]; + let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + while (next.length > 1 && total > maxBytes) { + total -= Buffer.byteLength(next[0]); + next.shift(); + } + return next; +} + +/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */ +export interface WakeableRemote { + wakeCommand?: string; + hostId: string; + label: string; + host: string; + port?: number; +} + +/** + * The slice of `Session` the wake flow uses — an interface rather than the + * concrete class so the registry is testable without a tmux server. + */ +export interface WakeableSession { + readonly id: string; + readonly remote: WakeableRemote | undefined; + /** COD-108 reattach: respawns the local ssh pane, idempotently attaching the durable remote tmux. */ + reattachRemote(): Promise; + /** Write bytes to the session's pane. */ + writeViaMux(data: string): Promise; +} + +/** Injected IO so the registry holds no direct dependency on ssh/net/child_process in tests. */ +export interface RemoteWakeDeps { + /** Cheap reachability probe. Must resolve false (never throw) for a sleeping host. */ + probe(remote: WakeableRemote): Promise; + /** Run the host's wake command. Resolves false when it fails to run. */ + wake(command: string): Promise; + /** Poll until the woken host accepts connections again. */ + waitUntilReady(remote: WakeableRemote): Promise; + /** Sleep helper (injected for tests). */ + delay(ms: number): Promise; + /** Notify the COD-108 watcher so an exhausted backoff is reset. */ + noteReconnected?(sessionId: string, success: boolean): void; + /** SSE broadcast. */ + broadcast?( + event: 'remote:hostWaking' | 'remote:hostWakeFailed' | 'remote:sessionReconnected', + payload: Record + ): void; + /** Structured diagnostics. */ + log?(message: string): void; +} + +/** Per-session wake bookkeeping. */ +interface WakeState { + probedAt: number; + reachable?: boolean; + waking: Promise | null; + pending: string[]; +} + +/** + * Per-session wake state + single-flight wake flow. + * + * One instance per web server (module singleton in the routes file, like the + * signal-wait registry). State is keyed by session id and dropped with the + * session. + */ +export class RemoteWakeRegistry { + private readonly states = new Map(); + + constructor(private readonly deps: RemoteWakeDeps) {} + + /** Drop a session's state (session closed/killed). The pending buffer goes with it. */ + drop(sessionId: string): void { + this.states.delete(sessionId); + } + + /** Whether a wake is currently in flight (diagnostics/tests). */ + isWaking(sessionId: string): boolean { + return this.states.get(sessionId)?.waking != null; + } + + /** Buffered input bytes for a session (diagnostics/tests). */ + pendingBytes(sessionId: string): number { + const state = this.states.get(sessionId); + if (!state) return 0; + return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + } + + /** + * Decide + act for one input chunk. + * + * `'deliver'` means the caller writes it as usual (today's path, zero added + * cost). `'buffered'` means the registry took ownership of the bytes: it either + * queued them behind an in-flight wake or started a wake, and will flush them + * in order once the pane is reattached. + */ + async handleInput(session: WakeableSession, data: string): Promise { + const remote = session.remote; + const state = this._state(session.id); + const action = decideRemoteInputAction({ + hasWakeCommand: Boolean(remote?.wakeCommand), + waking: state.waking != null, + probeAgeMs: Date.now() - state.probedAt, + lastReachable: state.reachable, + }); + + if (action === 'deliver') return 'deliver'; + if (action === 'buffer') { + // A buffered decision with no wake in flight (the wake failed and the state + // was reset, or the very first input of a session in the throttle window) + // must still drive a wake, or the bytes would sit in the buffer forever. + if (state.waking == null && remote?.wakeCommand) { + this._enqueue(session.id, data); + void this.wake(session); + return 'buffered'; + } + this._enqueue(session.id, data); + return 'buffered'; + } + + // action === 'probe' — the throttle window elapsed, so one TCP connect is owed. + state.probedAt = Date.now(); + state.reachable = remote ? await this.deps.probe(remote) : true; + if (state.reachable) return 'deliver'; + + this._enqueue(session.id, data); + void this.wake(session); + return 'buffered'; + } + + /** + * Block until the host is reachable and the pane is reattached — the + * send-and-wait path, where the HTTP response stays open anyway and buffering + * would break the wait contract. + */ + async ensureAwake(session: WakeableSession): Promise { + const remote = session.remote; + if (!remote?.wakeCommand) return true; + const state = this._state(session.id); + if (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS) { + state.probedAt = Date.now(); + state.reachable = await this.deps.probe(remote); + } + if (state.reachable) return true; + return this.wake(session); + } + + /** + * Single-flight wake: probe-free (the caller already knows the host is down), + * run the wake command, poll for readiness, reattach the pane, flush the buffer. + */ + async wake(session: WakeableSession): Promise { + const remote = session.remote; + if (!remote?.wakeCommand) return true; + const state = this._state(session.id); + if (state.waking) return state.waking; + + state.waking = (async (): Promise => { + const id = session.id; + try { + this.deps.broadcast?.('remote:hostWaking', { sessionId: id, hostId: remote.hostId, label: remote.label }); + this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) for session ${id}`); + + const woke = await this.deps.wake(remote.wakeCommand as string); + if (!woke) this.deps.log?.(`[RemoteWake] wake command failed for ${remote.label}: ${remote.wakeCommand}`); + + const ready = await this.deps.waitUntilReady(remote); + if (!ready) { + this.deps.log?.(`[RemoteWake] ${remote.label} did not come back — input stays buffered`); + this.deps.broadcast?.('remote:hostWakeFailed', { sessionId: id, hostId: remote.hostId, label: remote.label }); + // Reset the probe state so the NEXT user input probes and retries + // instead of trusting a stale "down" verdict forever. + state.probedAt = 0; + state.reachable = undefined; + return false; + } + + state.reachable = true; + state.probedAt = Date.now(); + const reattached = await session.reattachRemote(); + if (!reattached) { + this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`); + return false; + } + // The reset also clears an EXHAUSTED COD-108 backoff, which otherwise + // never fires again for this session (see remote-reconnect.ts). + this.deps.noteReconnected?.(id, true); + this.deps.broadcast?.('remote:sessionReconnected', { sessionId: id }); + this.deps.log?.(`[RemoteWake] ${remote.label} reattached for session ${id}`); + + await this.deps.delay(REMOTE_WAKE_ATTACH_SETTLE_MS); + await this._flush(state, session); + return true; + } catch (err) { + this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`); + return false; + } finally { + state.waking = null; + } + })(); + + return state.waking; + } + + private _state(sessionId: string): WakeState { + let state = this.states.get(sessionId); + if (!state) { + state = { probedAt: 0, reachable: undefined, waking: null, pending: [] }; + this.states.set(sessionId, state); + } + return state; + } + + private _enqueue(sessionId: string, data: string): void { + const state = this._state(sessionId); + const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + state.pending = appendBoundedPending(state.pending, data); + const after = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + if (before + Buffer.byteLength(data) > after) { + this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`); + } + } + + private async _flush(state: WakeState, session: WakeableSession): Promise { + while (state.pending.length > 0) { + const chunk = state.pending[0]; + const ok = await session.writeViaMux(chunk).catch(() => false); + if (!ok) { + this.deps.log?.( + `[RemoteWake] flush failed for session ${session.id} — ${state.pending.length} chunk(s) retained` + ); + return; + } + state.pending.shift(); + } + } +} + +// ========== Default IO ========== + +/** + * Cheap reachability probe: a bare TCP connect to the SSH port. + * + * Deliberately NOT an `ssh … true` probe: that opens a full session (auth, + * remote log, process) every throttle window for a question a SYN already + * answers. Any byte count it does move is a few hundred bytes per probe, far + * below the remote idle detector's traffic threshold, so probing cannot keep a + * host awake. + */ +export function probeRemoteHostReachable( + remote: WakeableRemote, + timeoutMs = REMOTE_WAKE_PROBE_TIMEOUT_MS +): Promise { + const port = remote.port ?? DEFAULT_SSH_PORT; + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(value); + }; + const socket = net.connect({ host: remote.host, port }); + socket.setTimeout(timeoutMs, () => finish(false)); + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + }); +} + +/** + * Run a host's wake command (e.g. a Wake-on-LAN wrapper script). No shell — the + * value is a single executable path, so nothing in it can be interpreted. + * Resolves false on any failure (missing binary, non-zero exit, timeout) rather + * than throwing: a broken wake command must not break the input route. + */ +export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_COMMAND_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + resolve(value); + }; + let child: ReturnType; + try { + child = spawn(command, [], { stdio: 'ignore' }); + } catch { + finish(false); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(false); + }, timeoutMs); + child.once('error', () => { + clearTimeout(timer); + finish(false); + }); + child.once('exit', (code) => { + clearTimeout(timer); + finish(code === 0); + }); + }); +} + +/** Poll the host until it accepts connections again, or the bound is hit. */ +export async function waitUntilRemoteReady( + remote: WakeableRemote, + opts: { intervalMs?: number; timeoutMs?: number; probe?: (remote: WakeableRemote) => Promise } = {} +): Promise { + const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS; + const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS; + const probe = opts.probe ?? probeRemoteHostReachable; + const deadline = Date.now() + timeoutMs; + // Probe immediately: WoL from a warm S3 is fast (~7.5 s measured on this setup), + // and the first poll is what turns "just woke" into a sub-interval response. + for (;;) { + if (await probe(remote)) return true; + if (Date.now() + intervalMs > deadline) return false; + await delay(intervalMs); + } +} + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Production wiring: all IO defaults, overridable for tests. */ +export function createDefaultRemoteWakeDeps(overrides: Partial = {}): RemoteWakeDeps { + return { + probe: probeRemoteHostReachable, + wake: runRemoteWakeCommand, + waitUntilReady: (remote) => waitUntilRemoteReady(remote), + delay, + ...overrides, + }; +} diff --git a/src/types/session.ts b/src/types/session.ts index 1a82a0f3b..8460687bf 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -97,6 +97,15 @@ export interface RemoteHost extends RemoteSshOptions { username: string; port?: number; commands?: Partial>; + /** + * Optional Wake-on-LAN command that powers this host on from SLEEP (e.g. a + * wrapper script like `/home/joe/bin/whuff`). Absent = no wake support and + * today's behavior exactly. Executed WITHOUT a shell (a single executable + * path, never a command line), only from user input on a session whose host + * is unreachable — never from the auto-reconnect/boot-recovery path, which + * would re-wake a host seconds after each suspend. + */ + wakeCommand?: string; } export interface RemoteCase { @@ -137,6 +146,11 @@ export interface SessionRemote extends RemoteSshOptions { * session was created elsewhere. Only meaningful when `owned === false`. */ remoteSessionName?: string; + /** + * Wake-on-LAN command carried over from the host config (see `RemoteHost.wakeCommand`) + * so the input route can wake a sleeping host without re-reading the host list. + */ + wakeCommand?: string; } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index 4c6888997..27e5456d0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -219,6 +219,8 @@ const _SSE_HANDLER_MAP = [ // Remote auto-reconnect (COD-108) [SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'], [SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'], + [SSE_EVENTS.REMOTE_HOST_WAKING, '_onRemoteHostWaking'], + [SSE_EVENTS.REMOTE_HOST_WAKE_FAILED, '_onRemoteHostWakeFailed'], // Ralph [SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index be9780b04..8345a166f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1057,6 +1057,9 @@ const SSE_EVENTS = { REMOTE_SESSION_DROPPED: 'remote:sessionDropped', REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected', REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted', + // Wake-on-LAN from user input on a sleeping remote host + REMOTE_HOST_WAKING: 'remote:hostWaking', + REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed', // Ralph SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate', diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 80e6ce838..ef292640c 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -116,6 +116,20 @@ Object.assign(CodemanApp.prototype, { }, + // Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts). + _onRemoteHostWaking(data) { + const label = data && data.label ? data.label : 'Remote host'; + // Long enough to cover the wake + attach (~10s measured on a warm S3), and it + // is replaced by `remote:sessionReconnected` the moment the pane is back. + this.showToast(`Waking ${label} … input is queued`, 'info', { duration: 12000 }); + }, + + _onRemoteHostWakeFailed(data) { + const label = data && data.label ? data.label : 'Remote host'; + this.showToast(`${label} did not wake up — queued input is still held`, 'error', { duration: 15000 }); + }, + + // Bash tools _onBashToolStart(data) { this.handleBashToolStart(data.sessionId, data.tool); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index caad748b3..737bb7a88 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -67,6 +67,7 @@ import { type WaitSignal, type SignalWaitResult, } from '../session-wait-registry.js'; +import { RemoteWakeRegistry, createDefaultRemoteWakeDeps } from '../../remote-wake.js'; import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js'; import { autoConfigureRalph, @@ -816,8 +817,32 @@ export function resolveOmpConfigForCreate( export function registerSessionRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort, + /** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */ + options: { remoteWake?: RemoteWakeRegistry } = {} ): void { + // Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per + // route registration (= one web server) — the same shape as the process-wide + // `sessionWaits` singleton, but without the global. + // + // ⚠️ The ONLY caller that may wake a host is the input route below. The + // auto-reconnect watcher and boot recovery deliberately have no access to this + // registry: waking there would re-wake the host seconds after every suspend, so + // it could never stay asleep. + const remoteWake = + options.remoteWake ?? + new RemoteWakeRegistry( + createDefaultRemoteWakeDeps({ + noteReconnected: (sessionId, success) => { + // Duck-typed exactly like server.ts: TmuxManager owns the COD-108 backoff + // state, and the port interface does not expose it. + const mux = ctx.mux as unknown as { noteRemoteReconnect?: (id: string, ok: boolean) => void }; + mux.noteRemoteReconnect?.(sessionId, success); + }, + broadcast: (event, payload) => ctx.broadcast(event, payload), + log: (message) => console.log(message), + }) + ); // ═══════════════════════════════════════════════════════════════ // Auth // ═══════════════════════════════════════════════════════════════ @@ -1279,6 +1304,7 @@ export function registerSessionRoutes( } const session = findSessionOrFail(ctx, id, req); + remoteWake.drop(session.id); await ctx.cleanupSession(session.id, killMux, 'user_delete'); return {}; }); @@ -1555,6 +1581,28 @@ export function registerSessionRoutes( return {}; } + // Wake-on-LAN (remote-wake.ts): a wake-enabled remote host that suspended leaves + // the local ssh pane STALLED, and `send-keys` succeeds against it — the bytes + // would vanish with no error anywhere. Give the registry the chance to probe the + // host, wake it, reattach, and own delivery before we write into nothing. + // + // Costs nothing for non-wake hosts (the `wakeCommand` guard) or while the host is + // known reachable inside the probe throttle window; the probe itself is a bare + // TCP connect on wake-enabled hosts only, at most once per + // REMOTE_WAKE_PROBE_MIN_INTERVAL_MS. + if (!duplicate && session.remote?.wakeCommand) { + if (wantsWait) { + // Send-and-wait keeps the response open anyway, so blocking on the wake is + // simpler and more correct than buffering (buffering would break the wait). + await remoteWake.ensureAwake(session); + } else if ((await remoteWake.handleInput(session, inputStr)) === 'buffered') { + // The registry holds the bytes and flushes them in order once the pane is + // reattached. The client's ACK is this 200 — a tagged retry is deduped + // (`shouldApplyInput` above already consumed the seq), so nothing is lost. + return {}; + } + } + // Only a waiting request pays for the tmux probe: the browser's plain input path // (thousands of calls per session) must stay exec-free. const workerDead = wantsWait && workerIsDead(ctx.mux, session); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e7e8c28c8..76115274d 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -737,6 +737,18 @@ export const RemoteHostSchema = z.object({ .max(32) .optional(), commands: RemoteCommandOverridesSchema, + // Wake-on-LAN: a single executable path (no arguments, no shell) run to power a + // SLEEPING host back on, e.g. `/home/joe/bin/whuff`. Executed via spawn without + // a shell, so there is no shell layer to escape; the regexes are belt-and-braces + // (and the no-whitespace rule rejects an argument list before it can fail as a + // confusing ENOENT at wake time). See docs/remote-sessions.md §Wake-on-LAN. + wakeCommand: z + .string() + .min(1) + .max(4096) + .regex(/^\S+$/, 'Wake command must be a single executable path (no arguments)') + .regex(NO_SHELL_META, 'Invalid characters in wake command') + .optional(), }); export const RemoteCaseLinkSchema = z.object({ diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index b89c34252..4573f3ec2 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -184,6 +184,13 @@ export const RemoteSessionDropped = 'remote:sessionDropped' as const; export const RemoteSessionReconnected = 'remote:sessionReconnected' as const; /** Auto-reconnect gave up after the bounded backoff cap — manual reconnect needed. */ export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const; +/** + * User input arrived for a session whose host is unreachable, so a Wake-on-LAN + * command was started (see `remote-wake.ts`). Input sent meanwhile is buffered. + */ +export const RemoteHostWaking = 'remote:hostWaking' as const; +/** The host did not come back within the wake timeout — buffered input is still held. */ +export const RemoteHostWakeFailed = 'remote:hostWakeFailed' as const; // ─── Respawn ───────────────────────────────────────────────────────────────── @@ -535,6 +542,8 @@ export const SseEvent = { RemoteSessionDropped, RemoteSessionReconnected, RemoteReconnectExhausted, + RemoteHostWaking, + RemoteHostWakeFailed, // Respawn RespawnStarted, diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index e32b8e680..c07768130 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -96,6 +96,14 @@ export class MockSession extends EventEmitter { return true; } + /** + * Mirrors `Session.reattachRemote()` — the COD-108 transport re-establish that + * the wake-on-LAN flow calls once a sleeping host is back. Defaults to success; + * set `reattachRemote.mockResolvedValue(false)` to model a pane that could not + * be respawned. + */ + reattachRemote = vi.fn(async (): Promise => true); + /** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests * exercising the reliable-delivery path behave like production. */ private _appliedInputSeq = new Map(); diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts index 0934ab89c..6883cd65e 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -8,9 +8,11 @@ import { readRemoteHosts, remoteDisplayPath, remoteSshTarget, + toSessionRemote, writeRemoteCases, writeRemoteHosts, } from '../src/remote-hosts.js'; +import { RemoteHostSchema } from '../src/web/schemas.js'; describe('remote-hosts domain', () => { let dir: string | null = null; @@ -69,4 +71,48 @@ describe('remote-hosts domain', () => { 'aamer@box.local:/opt/work' ); }); + + it('carries the wake command from host config into the session', () => { + // The input route reads `session.remote.wakeCommand` — it must survive the host + // -> session mapping, or wake-on-LAN silently degrades to "no wake command". + const remote = toSessionRemote( + { + id: 'hufflepuff', + label: 'Hufflepuff', + host: '192.168.50.137', + username: 'j', + wakeCommand: '/home/joe/bin/whuff', + }, + { name: 'c', type: 'remote', hostId: 'hufflepuff', remotePath: '/home/j/work' } + ); + expect(remote.wakeCommand).toBe('/home/joe/bin/whuff'); + }); + + it('omits the wake command by default (feature off without a config entry)', () => { + const remote = toSessionRemote( + { id: 'h', label: 'H', host: '10.0.0.1', username: 'j' }, + { name: 'c', type: 'remote', hostId: 'h', remotePath: '/tmp' } + ); + expect(remote.wakeCommand).toBeUndefined(); + }); + + describe('RemoteHostSchema wakeCommand', () => { + const host = { id: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137', username: 'j' }; + + it('accepts an optional absolute executable path', () => { + expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/home/joe/bin/whuff' }).success).toBe(true); + expect(RemoteHostSchema.safeParse(host).success).toBe(true); + }); + + it('rejects an argument list (spawn runs the path without a shell)', () => { + // `spawn('/home/joe/bin/whuff --mac 00:11:22')` would fail as a confusing + // ENOENT at wake time — refuse it at config time instead. + expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/home/joe/bin/whuff --now' }).success).toBe(false); + }); + + it('rejects shell metacharacters as defence in depth', () => { + expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/sh$(id)' }).success).toBe(false); + expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false); + }); + }); }); diff --git a/test/remote-wake.test.ts b/test/remote-wake.test.ts new file mode 100644 index 000000000..f80831fcd --- /dev/null +++ b/test/remote-wake.test.ts @@ -0,0 +1,305 @@ +/** + * @fileoverview Wake-on-LAN from user input (see `src/remote-wake.ts`). + * + * Covers the two things that are easy to get wrong and expensive when wrong: + * 1. the decision/throttle table (probe at most once per window, never a probe + * burst per keystroke), + * 2. the guarantee that a wake is SINGLE-FLIGHT and that buffered input is + * flushed IN ORDER once the pane is reattached — plus that no reconnect or + * boot-recovery module can reach the wake flow at all (a wake there would + * re-wake the host seconds after every suspend, so it could never sleep). + * + * Pure logic + a fake session/deps: no tmux, no ssh, no real host. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, vi } from 'vitest'; +import { + RemoteWakeRegistry, + appendBoundedPending, + decideRemoteInputAction, + REMOTE_WAKE_PENDING_MAX_BYTES, + type RemoteWakeDeps, + type WakeableRemote, + type WakeableSession, +} from '../src/remote-wake.js'; + +// ========== Pure decisions ========== + +describe('decideRemoteInputAction', () => { + const base = { hasWakeCommand: true, waking: false, probeAgeMs: 0, lastReachable: undefined as boolean | undefined }; + + it('delivers unchanged when the host has no wake command (feature off)', () => { + expect(decideRemoteInputAction({ ...base, hasWakeCommand: false, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe( + 'deliver' + ); + }); + + it('buffers while a wake is already in flight, whatever the probe state says', () => { + expect(decideRemoteInputAction({ ...base, waking: true, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe('buffer'); + }); + + it('buffers without re-probing when the last probe said the host is down', () => { + // Re-probing per keystroke would add seconds of latency to every character. + expect(decideRemoteInputAction({ ...base, lastReachable: false, probeAgeMs: 1 })).toBe('buffer'); + }); + + it('delivers inside the throttle window when the host was reachable', () => { + expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 10 })).toBe('deliver'); + }); + + it('probes once the throttle window has elapsed', () => { + expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 30_001 })).toBe('probe'); + expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 29_999 })).toBe('deliver'); + }); + + it('probes on the very first input of a session (probeAgeMs 0 is only "never probed")', () => { + // probedAt is initialised to 0, so a fresh session's age is huge in real time. + expect(decideRemoteInputAction({ ...base, probeAgeMs: Date.now() })).toBe('probe'); + }); +}); + +describe('appendBoundedPending', () => { + it('keeps everything under the cap, in order', () => { + expect(appendBoundedPending(['a', 'b'], 'c')).toEqual(['a', 'b', 'c']); + }); + + it('drops the OLDEST chunk when the cap is exceeded, keeping the tail', () => { + const big = 'x'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES); + expect(appendBoundedPending([big], 'newest')).toEqual(['newest']); + }); + + it('never drops the just-typed chunk even when it alone exceeds the cap', () => { + const huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100); + expect(appendBoundedPending([], huge)).toEqual([huge]); + }); +}); + +// ========== Registry ========== + +const remote: WakeableRemote = { + hostId: 'hufflepuff', + label: 'Hufflepuff', + host: '192.168.50.137', + wakeCommand: '/home/joe/bin/whuff', +}; + +interface Harness { + registry: RemoteWakeRegistry; + session: WakeableSession; + probe: ReturnType; + wake: ReturnType; + waitUntilReady: ReturnType; + reattachRemote: ReturnType; + writeViaMux: ReturnType; + noteReconnected: ReturnType; + events: string[]; +} + +function harness(opts: { remote?: WakeableRemote; writesFail?: boolean } = {}): Harness { + const probe = vi.fn(async () => false); + const wake = vi.fn(async () => true); + const waitUntilReady = vi.fn(async () => true); + const reattachRemote = vi.fn(async () => true); + const writeViaMux = vi.fn(async () => !opts.writesFail); + const noteReconnected = vi.fn(); + const events: string[] = []; + + const deps: RemoteWakeDeps = { + probe, + wake, + waitUntilReady, + delay: async () => {}, + noteReconnected, + broadcast: (event) => events.push(event), + log: () => {}, + }; + + const session: WakeableSession = { + id: 'sess-1', + remote: opts.remote ?? remote, + reattachRemote, + writeViaMux, + }; + + return { + registry: new RemoteWakeRegistry(deps), + session, + probe, + wake, + waitUntilReady, + reattachRemote, + writeViaMux, + noteReconnected, + events, + }; +} + +describe('RemoteWakeRegistry', () => { + it('does nothing at all when the host has no wake command', async () => { + const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } }); + await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('deliver'); + expect(h.probe).not.toHaveBeenCalled(); + expect(h.wake).not.toHaveBeenCalled(); + }); + + it('delivers normally when the host is reachable, without waking', async () => { + const h = harness(); + h.probe.mockResolvedValue(true); + await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('deliver'); + expect(h.probe).toHaveBeenCalledTimes(1); + expect(h.wake).not.toHaveBeenCalled(); + }); + + it('skips the probe inside the throttle window once the host was reachable', async () => { + const h = harness(); + h.probe.mockResolvedValue(true); + await h.registry.handleInput(h.session, 'a'); + await h.registry.handleInput(h.session, 'b'); + await h.registry.handleInput(h.session, 'c'); + expect(h.probe).toHaveBeenCalledTimes(1); + expect(h.wake).not.toHaveBeenCalled(); + }); + + it('wakes an unreachable host once, then flushes buffered input in order after reattach', async () => { + const h = harness(); + h.probe.mockResolvedValue(false); + // Hold the wake open so the second input lands while it is genuinely in flight + // (with instantaneous mocks the whole wake chain can finish between two awaits). + let releaseWake: (() => void) | undefined; + h.waitUntilReady.mockImplementation( + () => + new Promise((resolve) => { + releaseWake = () => resolve(true); + }) + ); + + await expect(h.registry.handleInput(h.session, 'hal')).resolves.toBe('buffered'); + await expect(h.registry.handleInput(h.session, 'lo')).resolves.toBe('buffered'); + // Single-flight: the second input joins the in-flight wake, it does not start another. + expect(h.registry.isWaking('sess-1')).toBe(true); + expect(h.wake).toHaveBeenCalledTimes(1); + + releaseWake?.(); + await h.registry.wake(h.session); + + expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff'); + expect(h.reattachRemote).toHaveBeenCalledTimes(1); + expect(h.noteReconnected).toHaveBeenCalledWith('sess-1', true); + expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hal', 'lo']); + expect(h.registry.pendingBytes('sess-1')).toBe(0); + expect(h.events).toEqual(['remote:hostWaking', 'remote:sessionReconnected']); + }); + + it('keeps input buffered and reports failure when the host never comes back', async () => { + const h = harness(); + h.probe.mockResolvedValue(false); + h.waitUntilReady.mockResolvedValue(false); + + await h.registry.handleInput(h.session, 'hello'); + await h.registry.wake(h.session); + + expect(h.reattachRemote).not.toHaveBeenCalled(); + expect(h.writeViaMux).not.toHaveBeenCalled(); + expect(h.registry.pendingBytes('sess-1')).toBe(5); + expect(h.events).toContain('remote:hostWakeFailed'); + }); + + it('retries the wake on the next input after a failed wake (probe state reset)', async () => { + const h = harness(); + h.probe.mockResolvedValue(false); + h.waitUntilReady.mockResolvedValueOnce(false); + + await h.registry.handleInput(h.session, 'a'); + await h.registry.wake(h.session); + expect(h.wake).toHaveBeenCalledTimes(1); + + // Next keystroke must probe again (not trust the stale "down" verdict) and retry. + await h.registry.handleInput(h.session, 'b'); + await h.registry.wake(h.session); + expect(h.probe).toHaveBeenCalledTimes(2); + expect(h.wake).toHaveBeenCalledTimes(2); + expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['a', 'b']); + }); + + it('does not claim reconnected when the pane cannot be reattached', async () => { + const h = harness(); + h.probe.mockResolvedValue(false); + h.reattachRemote.mockResolvedValue(false); + + await h.registry.handleInput(h.session, 'a'); + await h.registry.wake(h.session); + + expect(h.noteReconnected).not.toHaveBeenCalled(); + expect(h.writeViaMux).not.toHaveBeenCalled(); + expect(h.events).not.toContain('remote:sessionReconnected'); + }); + + it('retains input that could not be written and reports nothing lost', async () => { + const h = harness({ writesFail: true }); + h.probe.mockResolvedValue(false); + + await h.registry.handleInput(h.session, 'abc'); + await h.registry.wake(h.session); + + expect(h.writeViaMux).toHaveBeenCalledTimes(1); + expect(h.registry.pendingBytes('sess-1')).toBe(3); + }); + + it('ensureAwake blocks only for the wait path and returns true without a wake command', async () => { + const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } }); + await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true); + expect(h.probe).not.toHaveBeenCalled(); + expect(h.wake).not.toHaveBeenCalled(); + }); + + it('ensureAwake wakes an unreachable host without buffering anything', async () => { + const h = harness(); + h.probe.mockResolvedValue(false); + await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true); + expect(h.wake).toHaveBeenCalledTimes(1); + expect(h.registry.pendingBytes('sess-1')).toBe(0); + }); + + it('drops buffered input with the session', async () => { + const h = harness(); + h.probe.mockResolvedValue(false); + await h.registry.handleInput(h.session, 'abc'); + h.registry.drop('sess-1'); + expect(h.registry.pendingBytes('sess-1')).toBe(0); + expect(h.registry.isWaking('sess-1')).toBe(false); + }); +}); + +// ========== Wiring guard ========== + +const SRC = fileURLToPath(new URL('../src', import.meta.url)); + +function walkTs(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + out.push(...walkTs(full)); + continue; + } + if (name.endsWith('.ts')) out.push(full); + } + return out; +} + +describe('wake wiring guard', () => { + it('only the input route may reach the wake registry', () => { + // The auto-reconnect watcher (tmux-manager.ts), the server's dropped-session + // handler and any boot-recovery path must NOT import remote-wake: waking there + // re-wakes the host seconds after each suspend. Asserted, not commented. + const allowed = new Set([join('web', 'routes', 'session-routes.ts')]); + const importers = walkTs(SRC) + .filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8'))) + .map((full) => relative(SRC, full)); + + expect(importers.sort()).toEqual([...allowed].sort()); + }); +}); diff --git a/test/routes/session-remote-wake.test.ts b/test/routes/session-remote-wake.test.ts new file mode 100644 index 000000000..039c6c539 --- /dev/null +++ b/test/routes/session-remote-wake.test.ts @@ -0,0 +1,145 @@ +/** + * @fileoverview Route tests for wake-on-LAN on `POST /api/sessions/:id/input`. + * + * The behavior that matters and cannot be tested at the registry level: a + * wake-enabled remote session whose host is asleep must return 200 WITHOUT + * writing into the stalled pane (the bytes would vanish), while every other + * session keeps the historical fire-and-forget path untouched. + * + * The registry is injected through `registerSessionRoutes`'s test seam so no real + * TCP connect, ssh, or WoL happens in CI. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fastifyCookie from '@fastify/cookie'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { createMockRouteContext } from '../mocks/index.js'; +import { sessionWaits } from '../../src/web/session-wait-registry.js'; +import { RemoteWakeRegistry, type RemoteWakeDeps } from '../../src/remote-wake.js'; +import type { SessionRemote } from '../../src/types.js'; + +const SESSION_ID = 'remote-wake-session'; +const URL = `/api/sessions/${SESSION_ID}/input`; + +afterEach(() => { + sessionWaits.cancelAll(SESSION_ID); + _resetPaneLivenessState(); +}); + +interface Harness { + app: FastifyInstance; + ctx: ReturnType; + registry: RemoteWakeRegistry; + probe: ReturnType; + wake: ReturnType; + events: string[]; + /** Let a held wake finish (see `holdWake`). */ + releaseWake: () => void; +} + +const remoteSession: SessionRemote = { + hostId: 'hufflepuff', + label: 'Hufflepuff', + host: '192.168.50.137', + username: 'j', + remotePath: '/home/j/codeman-pi-test', + wakeCommand: '/home/joe/bin/whuff', +}; + +async function harness(opts: { remote?: SessionRemote; hostUp?: boolean; holdWake?: boolean } = {}): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext({ sessionId: SESSION_ID }); + const session = ctx.sessions.get(SESSION_ID)!; + session.remote = opts.remote ?? remoteSession; + + const probe = vi.fn(async () => opts.hostUp ?? false); + const wake = vi.fn(async () => true); + const events: string[] = []; + // With instantaneous mocks the whole wake chain (wake -> wait -> reattach -> + // flush) can finish inside one `await`, so a test that wants to observe the + // in-flight state has to hold the readiness poll open. + let release: (() => void) | null = null; + const deps: RemoteWakeDeps = { + probe, + wake, + waitUntilReady: () => + opts.holdWake + ? new Promise((resolve) => { + release = () => resolve(true); + }) + : Promise.resolve(true), + delay: async () => {}, + noteReconnected: () => {}, + broadcast: (event) => events.push(event), + log: () => {}, + }; + const registry = new RemoteWakeRegistry(deps); + + registerSessionRoutes(app, ctx as never, { remoteWake: registry }); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx, registry, probe, wake, events, releaseWake: () => release?.() }; +} + +const send = (app: FastifyInstance, payload: Record) => + app.inject({ method: 'POST', url: URL, payload }); + +describe('POST /api/sessions/:id/input — wake-on-LAN', () => { + it('buffers input instead of writing into a sleeping host, then flushes after the wake', async () => { + const h = await harness({ hostUp: false, holdWake: true }); + const session = h.ctx.sessions.get(SESSION_ID)!; + + const res = await send(h.app, { input: 'hallo', useMux: true }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({}); + // Nothing reached the pane: writing now would be swallowed by the stalled ssh. + expect(session.writeBuffer).toEqual([]); + expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff'); + expect(h.registry.isWaking(SESSION_ID)).toBe(true); + + h.releaseWake(); + await h.registry.wake(session); + expect(session.writeBuffer).toEqual(['hallo']); + expect(session.reattachRemote).toHaveBeenCalled(); + }); + + it('keeps the historical fire-and-forget write when the host is reachable', async () => { + const h = await harness({ hostUp: true }); + const session = h.ctx.sessions.get(SESSION_ID)!; + + const res = await send(h.app, { input: 'hallo', useMux: true }); + + expect(res.json()).toEqual({}); + await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo'])); + expect(h.wake).not.toHaveBeenCalled(); + expect(session.reattachRemote).not.toHaveBeenCalled(); + }); + + it('never probes or wakes a session without a wake command', async () => { + const { wakeCommand, ...withoutWake } = remoteSession; + const h = await harness({ remote: withoutWake as SessionRemote }); + const session = h.ctx.sessions.get(SESSION_ID)!; + + await send(h.app, { input: 'hallo', useMux: true }); + + await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo'])); + expect(h.probe).not.toHaveBeenCalled(); + expect(h.wake).not.toHaveBeenCalled(); + }); + + it('wakes before writing on the send-and-wait path (no buffering, the response waits anyway)', async () => { + const h = await harness({ hostUp: false }); + const session = h.ctx.sessions.get(SESSION_ID)!; + + await send(h.app, { input: 'hallo', useMux: true, wait: 'idle', waitTimeout: 60 }); + + expect(h.wake).toHaveBeenCalledTimes(1); + // `ensureAwake` is awaited on this path, so the write happens inline and the + // waiter is registered against a live pane. + expect(session.writeBuffer).toEqual(['hallo']); + }); +}); From 0f3eea2fb581a5f51b5e76e18b6c168fd395f72d Mon Sep 17 00:00:00 2001 From: Randalix Date: Tue, 15 Sep 2026 10:35:58 +0200 Subject: [PATCH 02/14] fix(remote): refresh wake command from host config when restoring sessions A session's remote block is persisted at launch time and recovery uses that snapshot, so a wakeCommand added to remote-hosts.json afterwards never reached an already-running session - not even across a Codeman restart (observed: the live Hufflepuff session came back with no wakeCommand). Merge the host-level field in on restore, with the host config authoritative. --- src/remote-hosts.ts | 26 ++++++++++++++++++++ src/web/server.ts | 8 ++++++- test/remote-hosts.test.ts | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index e57c150ec..7eeb3b6c6 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -540,6 +540,32 @@ export function remoteDisplayPath( return `${remote.username}@${remote.host}:${path}`; } +/** + * Refresh HOST-level config on a RESTORED `SessionRemote`. + * + * A session's `remote` block is persisted at launch time (mux-sessions.json / + * state.json) and recovery uses that snapshot, so a field ADDED to the host config + * later never reaches an already-running session — not even across a Codeman + * restart. That is exactly how a `wakeCommand` added to `remote-hosts.json` would + * silently do nothing until the session is relaunched (which for an owned remote + * session means killing the remote tmux). + * + * Deliberately narrow: ONLY `wakeCommand` is taken from the host config, and the + * host is authoritative for it (removing it in the config turns the feature off + * again). The other host-level fields (`commands`, ssh options) stay as persisted + * so this cannot silently change how an existing pane connects. + */ +export function rehydrateRemoteHostFields( + remote: SessionRemote | undefined, + hostsById: ReadonlyMap +): SessionRemote | undefined { + if (!remote) return remote; + const host = hostsById.get(remote.hostId); + if (!host) return remote; + if (remote.wakeCommand === host.wakeCommand) return remote; + return { ...remote, wakeCommand: host.wakeCommand }; +} + export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote { return { hostId: host.id, diff --git a/src/web/server.ts b/src/web/server.ts index a863cb372..ffc13acdd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -41,6 +41,7 @@ import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; import { hostname as getHostname } from 'node:os'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; +import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js'; import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js'; import { GLYPH, palette } from '../cli-style.js'; import { getHookSecret } from '../config/hook-secret.js'; @@ -2867,6 +2868,9 @@ export class WebServer extends EventEmitter { // For each alive mux session, create a Session object if it doesn't exist const muxSessions = this.mux.getSessions(); + // Host-level config lives in remote-hosts.json, not in the persisted session + // snapshot, so refresh the fields that only exist there (see the helper). + const remoteHostsById = new Map((await readRemoteHosts(getDataDir())).map((host) => [host.id, host])); for (const muxSession of muxSessions) { if (!this.sessions.has(muxSession.sessionId)) { // Restore session settings from state.json (single source of truth) @@ -2943,7 +2947,9 @@ export class WebServer extends EventEmitter { // respawn rebuilds a LOCAL command, breaking the pane and silently // erasing `remote` from state.json on the next persist. mux-sessions.json // round-trips MuxSession.remote; state.json carries SessionState.remote. - remote: muxSession.remote ?? savedState?.remote, + // Host-level fields are refreshed from remote-hosts.json on top, or a + // field added to the host config after launch would never arrive. + remote: rehydrateRemoteHostFields(muxSession.remote ?? savedState?.remote, remoteHostsById), // Docker metadata round-trips the same way (mux-sessions.json carries // MuxSession.docker; state.json carries SessionState.docker), so recovery // rebuilds the `docker exec` launch instead of a broken local command. diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts index 6883cd65e..1395283cd 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -6,6 +6,7 @@ import { defaultRemoteCommandForMode, readRemoteCases, readRemoteHosts, + rehydrateRemoteHostFields, remoteDisplayPath, remoteSshTarget, toSessionRemote, @@ -115,4 +116,53 @@ describe('remote-hosts domain', () => { expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false); }); }); + + describe('rehydrateRemoteHostFields', () => { + const persisted = { + hostId: 'hufflepuff', + label: 'Hufflepuff', + host: '192.168.50.137', + username: 'j', + remotePath: '/home/j/work', + }; + const hosts = (wakeCommand?: string) => + new Map([ + [ + 'hufflepuff', + { + id: 'hufflepuff', + label: 'Hufflepuff', + host: '192.168.50.137', + username: 'j', + ...(wakeCommand ? { wakeCommand } : {}), + }, + ], + ]); + + it('adds a wake command that only exists in the host config', () => { + // The pre-existing-session case: the field was added to remote-hosts.json after + // this session was persisted, so recovery is the only place it can arrive. + expect(rehydrateRemoteHostFields(persisted, hosts('/home/joe/bin/whuff'))?.wakeCommand).toBe( + '/home/joe/bin/whuff' + ); + }); + + it('treats the host config as authoritative (removing it turns the feature off)', () => { + const remote = { ...persisted, wakeCommand: '/home/joe/bin/whuff' }; + expect(rehydrateRemoteHostFields(remote, hosts())?.wakeCommand).toBeUndefined(); + }); + + it('leaves the block untouched when the host is gone or the session is local', () => { + expect(rehydrateRemoteHostFields(persisted, new Map())).toBe(persisted); + expect(rehydrateRemoteHostFields(undefined, hosts('/x'))).toBeUndefined(); + }); + + it('keeps the other host-level fields as persisted', () => { + // Only wakeCommand is refreshed: silently re-pointing an existing pane's ssh + // options would be a behavior change nobody asked for. + const remote = { ...persisted, identityFile: '~/.ssh/pinned_key' }; + const rehydrated = rehydrateRemoteHostFields(remote, hosts('/home/joe/bin/whuff')); + expect(rehydrated?.identityFile).toBe('~/.ssh/pinned_key'); + }); + }); }); From 3f0bfde54aef74d099080a207286bc7c92ad218d Mon Sep 17 00:00:00 2001 From: Randalix Date: Tue, 15 Sep 2026 10:45:01 +0200 Subject: [PATCH 03/14] docs(remote): document the wake-on-LAN invariants; drop wake state on bulk delete Self-review pass: the input-ladder's two 'buffer' branches were the same three lines, and bulk delete left a session's (bounded, per-random-uuid) wake state behind. Documents the design where the code refers to it - remote-sessions.md section, the architecture invariant, and the CLAUDE.md key pattern. --- CLAUDE.md | 4 +- docs/architecture-invariants.md | 22 +++--- docs/remote-sessions.md | 122 ++++++++++++++++++++++--------- src/remote-wake.ts | 12 +-- src/web/routes/session-routes.ts | 1 + 5 files changed, 109 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ef88de48..2cacd33e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **AI** | `src/ai-checker-base.ts`, `ai-idle-checker.ts`, `ai-plan-checker.ts` | | | **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | | | **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts`, `intent-store.ts`, `tab-layout.ts` (pure model) + `-service` (sole mutation boundary) + `-persistence` + `-legacy-order` | | -| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns | +| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` + `remote-wake` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns | | **Web tabs** | `src/webview-store.ts`, `webview-capabilities.ts`, `src/web/webview-proxy.ts` (pure), `src/web/routes/webview-routes.ts` | Dashboard URLs as tabs; NOT a SessionMode | | **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` | | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | @@ -217,6 +217,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **It revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ **File reads in a remote case are the second ssh surface** (#415, `src/remote-files.ts`): they go through `buildSshConnectionArgs()` as well, a browser-supplied path is only ever a `shellescape`d token, an unreachable host answers 502 (never 404), the size cap uses the REMOTE size, and no remote file is ever copied onto the server's disk — which is why writes, office previews and thumbnails are deliberately unsupported over ssh (the `PUT` guard sits BEFORE the local path validation, or a same-named local directory such as an sshfs mount takes the write). The probe's symlink resolution FAILS CLOSED (a path it cannot canonicalize is a 404, never its own unresolved string: the directory-only fallback let a `notes.txt -> ~/.ssh/id_rsa` link pass containment), and ssh children are BOUNDED by `src/remote-ssh-limiter.ts` plus one batched probe per attachment-history listing, because terminal output in a remote session is written on the remote host and a prompt-injected agent can print hundreds of `codeman://attach` links. The ATTACHMENT routes (a clicked path outside the case dir) go through the same layer, and which host a record is read from follows the SESSION, never the path string. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md` +**Wake-on-LAN (`remote-wake.ts`)**: an optional `RemoteHost.wakeCommand` (single executable path, run without a shell) lets the INPUT route wake a sleeping host instead of writing into a stalled ssh pane. ⚠️ Only user input may wake: the auto-reconnect watcher, `handleRemoteSessionDropped` and boot recovery have no access to the registry (a wake there would re-wake the host seconds after every suspend), which `test/remote-wake.test.ts` asserts as a wiring guard. Detection is a throttled bare TCP probe — deliberately no `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. Input arriving during a wake is buffered and flushed in order after `reattachRemote()`; send-and-wait blocks instead. `wakeCommand` is re-read from `remote-hosts.json` on recovery, since the persisted `remote` snapshot never sees a field added later. + **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ A case may instead **ADOPT** a container the user already runs (`DockerCase.owned === false`, mirror of remote-SSH's `owned:false`): Codeman only `exec`s into it and never creates, starts, stops, restarts or removes it, so a missing or stopped container FAILS CLOSED with an actionable message instead of being fixed. Absent = owned, so existing cases are byte-identical. ⚠️ An ADOPTED container may back SEVERAL cases at different in-container directories (`classifyAdoptContainerConflict` in `docker-hosts.ts`: an exact twin on the same container AND directory is refused, an owned container still backs exactly one case, and a container another user adopted is refused), which is what the Add Case panel's "copy an existing case" picker relies on; the wire carries `CaseInfo.docker.owned` ONLY when false, so the picker tests `=== false`, never truthiness. The guarantee is enforced at four independent layers because it cannot be observed by using the feature: `buildDockerStopCommand`/`buildDockerRemoveCommand` throw during pure STRING CONSTRUCTION, `removeDockerContainer` refuses again, drift reports "none" (an adopted container carries no `codeman.confighash` label, so a real comparison would 409 the launch forever), and the boot reaper skips it. ⚠️ Two lifecycle touches the original design missed and that are easy to re-introduce: the full-image export `docker commit`s the container (refused for an adopted case) and the workspace export `docker pause`s it first (skipped — it freezes the owner's processes for the length of the tar). ⚠️ `owned` is applied AFTER `dockerConfigHash`, which takes an explicit field list, or every pre-existing case would trip the drift gate at once. ⚠️ Run modes for a container case come from the CONTAINER (`availableModes`, live-probed): gating the run menu on HOST CLIs (#201) is right for local sessions and wrong here, since a host with no `claude` may run a container that ships one. ⚠️ **A failed probe means opposite things per ownership** — for an ADOPTED case it is a fault worth reporting, for an OWNED one it is the NORMAL state before the first session (the launch chain creates the container), so treating it as a fault hid every agent mode on every freshly linked Docker case behind "start it yourself first". That is why `CaseInfo.docker.owned` is on the wire. ⚠️ Claude is launched WITHOUT `--dangerously-skip-permissions` when the container's exec user is root (Claude Code refuses the flag as root and the refusal is visible only inside the container); which flag to drop is a per-CLI fact, so it is the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is **admin-only in multi-user mode**, unlike `docker-link`: linking creates OUR container, whose one bind mount `isWorkingDirAllowed` has already confined, while an adopted container's mounts belong to its owner and one mounting `/` hands the adopter the host. The same reasoning admin-gates the container listing and the in-container directory browser; the preflight instead admits a non-admin for a container already linked to a case they own, because the run menu probes it for every docker case. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) **Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case ` reported "Case not found" on exactly the deployment the override exists for. ⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing. `Start-Codeman.sh` pre-creates both `CODEMAN_APPDATA_PATH` and `CODEMAN_CASES_PATH` on the host before `up`, which is what keeps the daemon from ever having to materialise either as root in the first place; the container ALSO starts as root (`cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against the base `cap_drop: ALL`; `test/docker-entrypoint.test.ts` pins that list) so `docker/entrypoint.sh` can correct a bind source that turns up root-owned anyway (a restored backup, a cleared directory, plain `docker compose up` run without the script) before dropping to `PUID:PGID` via `setpriv` — a directory owned by neither root nor `PUID:PGID` is never re-owned, since that ownership is not this container's to reassign; it is PROBED for writability as the runtime account (`setpriv ... test -w`, so ACLs, group-writable trees and CIFS/NFS mounts pass) and refused with a message naming path, owner and PUID:PGID if that fails. ⚠️ `KILL` is in that list for tini, not the entrypoint: `init: true` keeps tini as root while the server runs as PUID, and without CAP_KILL its SIGTERM forward fails and the server is SIGKILLed on every `compose down`/`restart` instead of flushing state. ⚠️ `/opt/codeman-cli` (the runtime-owned CLI prefix) is APPENDED to `PATH`, never prepended, and the entrypoint pins its own `PATH` to the system dirs: the root part of the start resolves `setpriv` by bare name, and a prefix ahead of `/usr/bin` let a planted `setpriv` run as uid 0 (measured). `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. ⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update below and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. `docs/docker-compose.md` + `docker/README.md` (user guides) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index eb9b772a7..95e4bdad8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1,6 +1,6 @@ # Architecture invariants -Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. +Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain _why_ each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. `CLAUDE.md` keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint. @@ -54,6 +54,8 @@ Model is NOT a session field: it is a composition entry in the profile's config ### Remote SSH cases +**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell) lets the input route wake a SLEEPING host instead of writing into a stalled ssh pane — `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from `POST /api/sessions/:id/input`: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery must never wake a host, or it would be re-woken seconds after each suspend and could never stay asleep (asserted by a wiring guard in `test/remote-wake.test.ts`, not just documented). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead), and `wakeCommand` is re-read from `remote-hosts.json` on session recovery because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. + **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id || claude --resume ` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: ` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`. ### Docker cases @@ -104,7 +106,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do ### Session lineage lines (tab → tab it spawned) -**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user *human* owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. +**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user _human_ owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. **Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart. @@ -164,7 +166,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `