feat(remote): wake a sleeping host when a session is created or attached

Pressing Run on a remote case whose host was asleep failed with
`could not verify tmux on remote host 192.168.50.137: …` — an ssh error that
blames tmux for a machine that is merely suspended. The only wake paths were
typed input on an established session and the banner's Wake button, so OPENING a
session (the moment the user actually decides to use that host) had none.

`RemoteWakeRegistry.ensureHostAwake()` reuses the existing probe/wake/readiness
machinery for a host that has no session yet, and is wired into the two
user-initiated create paths: `POST /api/quick-start` for a remote case (before
the tmux prereq probe, which is what surfaced the misleading error) and
`POST /api/sessions` with `attachRemoteSession`. A host without a wake target is
not even probed, so its behavior and latency are byte-identical. The wake is
blocking — the caller gets the session or an error — but bounded by
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS (40 s) instead of the 90 s session default,
because the dashboard sits behind a reverse proxy whose default
`proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while
the session was still being created. The budget has to cover the whole request
(40 s wake + 1.5 s probe + the tmux probe's own 15 s = 56.5 s worst case), which
is why it is 40 s and not 45. A timeout now says the host did not come back, and
an unreachable host without a wake target says so instead of pointing at tmux.

The wiring is deliberately in the HTTP ROUTE, never in the shared session
service: `cron-service.ts` builds sessions there with nobody waiting on the
answer, and a wake on that path would power the host on for every schedule —
the timer-driven re-wake invariant #1 exists to prevent. Both halves are asserted
(importers of `remote-wake`, and `ensureHostAwake` having exactly one caller
file), so a future caller has to come through the guard test. A rejection from
the wake IO is caught too: a broken target must fail the wake, not the route.

`remote:hostWaking`/`remote:hostWakeFailed` now carry `forNewSession` for the
session-less case, where "input is queued" would be untrue; the toast then reads
"the session starts when it is back".

Live wake numbers are unchanged (this reuses the measured ~12 s S3 path); the
route behavior is covered by new tests in session-routes.test.ts with an injected
registry, so no test opens a real socket or ssh.
This commit is contained in:
Randalix
2026-09-15 22:37:37 +02:00
parent 8dfc965d13
commit d0a5a583cd
7 changed files with 518 additions and 40 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ Model is NOT a session field: it is a composition entry in the profile's config
### Remote SSH cases ### Remote SSH cases
**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — 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` and that explicit wake route: 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). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). 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 the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. **Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — 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 an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). 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, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). 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 <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — 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 <id> || claude --resume <id>` 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: <ssh line>` 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`. **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 <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — 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 <id> || claude --resume <id>` 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: <ssh line>` 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`.
+25 -6
View File
@@ -368,6 +368,20 @@ it is unreachable it wakes it, polls until the host answers, reattaches the pane
agent conversation is not restarted), and flushes the input that arrived meanwhile. agent conversation is not restarted), and flushes the input that arrived meanwhile.
Implementation: `src/remote-wake.ts`. Implementation: `src/remote-wake.ts`.
The same wake path also serves **opening** a session, which is where a sleeping host used to
be a dead end: pressing Run on a remote case (`POST /api/quick-start`) or Attach on a
discovered remote tmux session (`POST /api/sessions` + `attachRemoteSession`) probes the host
first, and on a sleeping one wakes it, waits for SSH and only then runs the tmux prereq probe.
Without that the run failed with `could not verify tmux on remote host …` — an ssh error that
blames tmux for a machine that is merely suspended. The wait is **blocking** (the caller gets
the session or the error) but bounded by `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS` (40 s) rather
than the 90 s session default, because the dashboard sits behind a reverse proxy whose default
`proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while the session was
still being created. The budget covers the whole request, not just the wait (40 s wake + 1.5 s
probe + the tmux prereq probe's own 15 s timeout = 56.5 s worst case). A host with no wake target is not even probed on this path, so nothing
changes for it, and `remote:hostWaking` is broadcast without a `sessionId` (the toast then reads
"the session starts when it is back" — there is no session yet, and no input queued behind it).
Two wake paths, `wakeCommand` first because it is the explicit override: Two wake paths, `wakeCommand` first because it is the explicit override:
- **`wakeMac`** — Codeman builds the magic packet itself (`buildMagicPacket`, six `0xFF` - **`wakeMac`** — Codeman builds the magic packet itself (`buildMagicPacket`, six `0xFF`
@@ -387,12 +401,17 @@ banner comes from `GET /api/sessions/:id/reachability`, polled for the active re
The invariants worth keeping: The invariants worth keeping:
- **Only real user input or an explicit wake request may wake a host.** The COD-108 watcher, - **Only an EXPLICIT request may wake a host:** user input on an established session, the wake
the server's dropped-session handler and boot recovery have no access to the wake registry — button, or the user's own session create/attach request (`ensureHostAwake`). Everything that
a wake there would re-wake the host seconds after every suspend, so it could never stay runs on a TIMER must never wake one — the COD-108 watcher, the server's dropped-session
asleep (the same failure `hufflepuff-mcp-lazy` exists to prevent for MCP keepalives). A handler, boot recovery and session discovery have no access to the wake registry, and neither
reachability check never wakes: it is a question, not an action. Both are enforced by tests has the shared session service, because `cron-service.ts` builds sessions there with nobody
in `test/remote-wake.test.ts` and `test/routes/session-remote-wake.test.ts`, not comments. waiting on the answer; a wake on such a path would re-wake the host seconds after every
suspend, so it could never stay asleep (the same failure `hufflepuff-mcp-lazy` exists to
prevent for MCP keepalives). A reachability check, a discovery listing and the tmux prereq
probe never wake: they are questions, not actions. All of it is enforced by tests in
`test/remote-wake.test.ts` (two wiring guards, one of them asserting `ensureHostAwake` has
exactly one caller file) and `test/routes/session-remote-wake.test.ts`, not by comments.
- **Detection is a bare TCP connect** to the SSH port (then the configured `port`, else 22), - **Detection is a bare TCP connect** to the SSH port (then the configured `port`, else 22),
throttled per session, and only for wake-enabled hosts. No `ServerAliveInterval` is added to throttled per session, and only for wake-enabled hosts. No `ServerAliveInterval` is added to
the launch command: keepalives push bytes into an otherwise idle connection every interval, the launch command: keepalives push bytes into an otherwise idle connection every interval,
+156 -29
View File
@@ -8,11 +8,16 @@
* lost with no error anywhere — the failure this module exists to close. * lost with no error anywhere — the failure this module exists to close.
* *
* Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN): * Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN):
* - ONLY real user input wakes a host. The auto-reconnect watcher and * - An EXPLICIT request wakes a host, and nothing else: user input on an
* boot-recovery must never wake one, or a host would be re-woken ~45 s after * established session (`handleInput`), the wake button (`ensureAwake`), or the
* each suspend and could never stay asleep (the "keepalive pings a sleeping * user's own session create/attach request (`ensureHostAwake`, wired in the HTTP
* host" failure already solved for a different consumer by * routes). Everything that runs on a TIMER — the auto-reconnect watcher, boot
* `hufflepuff-mcp-lazy`). * recovery, the reachability probe, session discovery — 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`). The create path is deliberately wired in
* `session-routes.ts` and NOT in the shared session service, because
* `cron-service.ts` builds sessions there without a user waiting on the answer.
* - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client, * - 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 * a few hundred bytes — below any meaningful activity threshold), throttled
* per session. No SSH keepalive is added to the launch command: keepalives * per session. No SSH keepalive is added to the launch command: keepalives
@@ -41,6 +46,17 @@ export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500;
export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500; export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500;
/** Bounded wait for the host to come back after the wake command ran. */ /** Bounded wait for the host to come back after the wake command ran. */
export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000; export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000;
/**
* Budget for a wake that an HTTP REQUEST is waiting on (session create/attach).
* Deliberately shorter than {@link REMOTE_WAKE_READY_TIMEOUT_MS}: the dashboard is
* served through a reverse proxy whose default `proxy_read_timeout` is 60 s, so a
* 90 s wait would be cut off AT THE PROXY while the session was still being built —
* the browser reports a failure for a session that exists. The budget has to cover
* the WHOLE request, not just the wait: 40 s here + the 1.5 s reachability probe +
* the tmux prereq probe's own 15 s timeout = 56.5 s worst case, still under 60 s.
* A warm S3 resume measures ~12 s, so 40 s is >3× the observed wake.
*/
export const REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS = 40_000;
/** The wake command itself must not hang the wake flow. */ /** The wake command itself must not hang the wake flow. */
export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000; export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000;
/** /**
@@ -224,7 +240,7 @@ export interface RemoteWakeDeps {
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */ /** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
wake(target: NonNullable<WakeTarget>): Promise<boolean>; wake(target: NonNullable<WakeTarget>): Promise<boolean>;
/** Poll until the woken host accepts connections again. */ /** Poll until the woken host accepts connections again. */
waitUntilReady(remote: WakeableRemote): Promise<boolean>; waitUntilReady(remote: WakeableRemote, opts?: { timeoutMs?: number }): Promise<boolean>;
/** Sleep helper (injected for tests). */ /** Sleep helper (injected for tests). */
delay(ms: number): Promise<void>; delay(ms: number): Promise<void>;
/** Notify the COD-108 watcher so an exhausted backoff is reset. */ /** Notify the COD-108 watcher so an exhausted backoff is reset. */
@@ -264,6 +280,25 @@ export function wakeConfigured(remote: WakeableRemote | undefined): WakeConfigur
return target.kind; return target.kind;
} }
/**
* Outcome of waking a host for a caller that has NO session yet (the create/attach
* routes). A union rather than a boolean because the three cases need different
* handling: `'no-target'` must leave the caller's behavior byte-identical (no probe,
* no extra latency for a host without WoL), and only `'failed'` is an error that
* deserves its own message instead of the caller's usual one.
*/
export type HostWakeOutcome = 'no-target' | 'ready' | 'failed';
/**
* State key for a host-scoped wake. Prefixed so it can never collide with a session
* id, and keyed on the HOST rather than the case: two cases on one host share a
* single in-flight wake and one probe verdict. Such an entry is tiny (no input
* buffer) and bounded by the number of configured hosts, so it is never dropped.
*/
function hostWakeKey(hostId: string): string {
return `host:${hostId}`;
}
/** Per-session wake bookkeeping. */ /** Per-session wake bookkeeping. */
interface WakeState { interface WakeState {
probedAt: number; probedAt: number;
@@ -391,6 +426,67 @@ export class RemoteWakeRegistry {
return this.wake(session); return this.wake(session);
} }
/**
* Host-scoped reachability, for a caller that has no session yet (create/attach).
* Shares the per-HOST probe state with {@link ensureHostAwake}, so the probe the
* wake flow just paid for also answers "was that ssh failure really a sleeping
* machine?". Never wakes anything — it is a question, not an action.
*/
async checkHostReachable(remote: WakeableRemote, opts: { force?: boolean; ttlMs?: number } = {}): Promise<boolean> {
const state = this._state(hostWakeKey(remote.hostId));
const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
if (Date.now() - state.probedAt >= ttl) {
state.probedAt = Date.now();
state.reachable = await this.deps.probe(remote);
}
return state.reachable === true;
}
/**
* Wake a host for a REQUEST that is waiting on it — the session create/attach
* routes, where there is no session to reattach and no input to buffer yet.
*
* `'no-target'` returns without probing, so a host without WoL config costs
* nothing and behaves exactly as before. Single-flight per host, so a double click
* (or two cases on the same host) sends one packet and shares one readiness poll.
*/
async ensureHostAwake(remote: WakeableRemote, opts: { timeoutMs?: number } = {}): Promise<HostWakeOutcome> {
if (!resolveWakeTarget(remote)) return 'no-target';
const state = this._state(hostWakeKey(remote.hostId));
if (state.waking) return (await state.waking) ? 'ready' : 'failed';
state.probedAt = Date.now();
state.reachable = await this.deps.probe(remote);
if (state.reachable) return 'ready';
this.deps.log?.(`[RemoteWake] ${remote.label} (${remote.host}) is unreachable — waking it for a new session`);
return (await this.wakeHost(remote, opts)) ? 'ready' : 'failed';
}
/**
* Single-flight wake for a host with no session (see {@link ensureHostAwake}).
* A write into the same `waking` slot the session flow uses, so the two can never
* run two readiness polls against one host from the same key space.
*/
private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number }): Promise<boolean> {
const state = this._state(hostWakeKey(remote.hostId));
if (state.waking) return state.waking;
state.waking = (async (): Promise<boolean> => {
try {
return await this._wakeAndWait(remote, state, { timeoutMs: opts.timeoutMs, forNewSession: true });
} catch (err) {
// Injected IO is documented not to throw, but a rejected promise here would
// surface as an unhandled rejection AND take the route down with it (the
// session path catches for exactly this reason). A broken wake target must
// fail the wake, never the create route beyond its own error response.
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
return false;
} finally {
state.waking = null;
}
})();
return state.waking;
}
/** /**
* Single-flight wake: probe-free (the caller already knows the host is down), * 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. * run the wake command, poll for readiness, reattach the pane, flush the buffer.
@@ -405,29 +501,9 @@ export class RemoteWakeRegistry {
state.waking = (async (): Promise<boolean> => { state.waking = (async (): Promise<boolean> => {
const id = session.id; const id = session.id;
try { try {
this.deps.broadcast?.('remote:hostWaking', { sessionId: id, hostId: remote.hostId, label: remote.label }); const ready = await this._wakeAndWait(remote, state, { sessionId: id });
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} for session ${id}`); if (!ready) return false;
const woke = await this.deps.wake(target);
if (!woke) {
this.deps.log?.(
`[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}`
);
}
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(); const reattached = await session.reattachRemote();
if (!reattached) { if (!reattached) {
this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`); this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`);
@@ -453,6 +529,57 @@ export class RemoteWakeRegistry {
return state.waking; return state.waking;
} }
/**
* Broadcast + run the wake target + wait for SSH. Shared by the session flow (which
* then reattaches and flushes the buffer) and the create/attach flow (which has no
* pane yet). On failure the probe state is reset so the NEXT attempt probes and
* retries instead of trusting a stale "down" verdict forever.
*/
private async _wakeAndWait(
remote: WakeableRemote,
state: WakeState,
opts: { sessionId?: string; timeoutMs?: number; forNewSession?: boolean } = {}
): Promise<boolean> {
const target = resolveWakeTarget(remote);
if (!target) return true;
const forWhat = opts.sessionId ? `for session ${opts.sessionId}` : 'for a new session';
// No `sessionId` for a create-path wake: the toast handler is then the only one
// that acts (a banner for a session that does not exist yet would have no target),
// which is exactly the `forNewSession` distinction the UI renders.
this.deps.broadcast?.('remote:hostWaking', {
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
hostId: remote.hostId,
label: remote.label,
});
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`);
const woke = await this.deps.wake(target);
if (!woke) {
this.deps.log?.(
`[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}`
);
}
const ready = await this.deps.waitUntilReady(remote, { timeoutMs: opts.timeoutMs });
if (!ready) {
this.deps.log?.(
`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`
);
this.deps.broadcast?.('remote:hostWakeFailed', {
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
hostId: remote.hostId,
label: remote.label,
});
state.probedAt = 0;
state.reachable = undefined;
return false;
}
state.reachable = true;
state.probedAt = Date.now();
return true;
}
private _state(sessionId: string): WakeState { private _state(sessionId: string): WakeState {
let state = this.states.get(sessionId); let state = this.states.get(sessionId);
if (!state) { if (!state) {
@@ -675,7 +802,7 @@ export function createDefaultRemoteWakeDeps(overrides: Partial<RemoteWakeDeps> =
return { return {
probe: probeRemoteHostReachable, probe: probeRemoteHostReachable,
wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)), wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)),
waitUntilReady: (remote) => waitUntilRemoteReady(remote), waitUntilReady: (remote, opts) => waitUntilRemoteReady(remote, opts),
delay, delay,
...overrides, ...overrides,
}; };
+16 -2
View File
@@ -326,9 +326,16 @@ Object.assign(CodemanApp.prototype, {
*/ */
_onRemoteHostWaking(data) { _onRemoteHostWaking(data) {
const label = data && data.label ? data.label : 'Remote host'; const label = data && data.label ? data.label : 'Remote host';
// A create-path wake (the user pressed Run / Attach) has no session yet, so
// nothing is queued behind it — the wording has to say what actually happens.
const forNewSession = Boolean(data && data.forNewSession);
// Long enough to cover the wake + attach (~10s measured on a warm S3), and it // 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. // is replaced by `remote:sessionReconnected` the moment the pane is back.
this.showToast(`Waking ${label} … input is queued`, 'info', { duration: 12000 }); this.showToast(
forNewSession ? `Waking ${label} … the session starts when it is back` : `Waking ${label} … input is queued`,
'info',
{ duration: 12000 }
);
const state = this._hostWake; const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return; if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = true; state.waking = true;
@@ -340,7 +347,14 @@ Object.assign(CodemanApp.prototype, {
/** SSE `remote:hostWakeFailed` — the host did not come back in time. */ /** SSE `remote:hostWakeFailed` — the host did not come back in time. */
_onRemoteHostWakeFailed(data) { _onRemoteHostWakeFailed(data) {
const label = data && data.label ? data.label : 'Remote host'; const label = data && data.label ? data.label : 'Remote host';
this.showToast(`${label} did not wake up — queued input is still held`, 'error', { duration: 15000 }); const forNewSession = Boolean(data && data.forNewSession);
this.showToast(
forNewSession
? `${label} did not wake up — no session was started`
: `${label} did not wake up — queued input is still held`,
'error',
{ duration: 15000 }
);
const state = this._hostWake; const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return; if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = false; state.waking = false;
+64 -1
View File
@@ -28,6 +28,7 @@ import {
type GrokConfig, type GrokConfig,
type DeepSeekConfig, type DeepSeekConfig,
type OmpConfig, type OmpConfig,
type RemoteHost,
} from '../../types.js'; } from '../../types.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
@@ -67,7 +68,12 @@ import {
type WaitSignal, type WaitSignal,
type SignalWaitResult, type SignalWaitResult,
} from '../session-wait-registry.js'; } from '../session-wait-registry.js';
import { RemoteWakeRegistry, createDefaultRemoteWakeDeps } from '../../remote-wake.js'; import {
RemoteWakeRegistry,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
createDefaultRemoteWakeDeps,
type WakeableRemote,
} from '../../remote-wake.js';
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js'; import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
import { import {
autoConfigureRalph, autoConfigureRalph,
@@ -816,6 +822,22 @@ export function resolveOmpConfigForCreate(
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig; return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
} }
/**
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
* bearing: the registry keys its per-host wake state on `hostId`.
*/
function wakeableHost(host: RemoteHost): WakeableRemote {
return {
hostId: host.id,
label: host.label,
host: host.host,
port: host.port,
wakeMac: host.wakeMac,
wakeCommand: host.wakeCommand,
};
}
export function registerSessionRoutes( export function registerSessionRoutes(
app: FastifyInstance, app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
@@ -928,6 +950,19 @@ export function registerSessionRoutes(
const { hostId, remoteSessionName } = body.attachRemoteSession; const { hostId, remoteSessionName } = body.attachRemoteSession;
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId); const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
// An explicit wake request is the only thing that may wake a host, and the user
// pressing Attach IS one (see quick-start for the same gate, and
// `remote-wake.ts` for what must never call this). Without it a sleeping host
// answers with an ssh failure that blames anything but the machine being asleep.
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
});
if (hostWake === 'failed') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`${host.label} did not come back after a wake-on-LAN request — nothing was attached`
);
}
workingDir = `${host.username}@${host.host}:${remoteSessionName}`; workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir); remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
} }
@@ -3272,11 +3307,39 @@ export function registerSessionRoutes(
); );
} }
// The user pressing "Run" on a case whose host is asleep IS an explicit wake
// request (docs/remote-sessions.md §Wake-on-LAN), and the tmux probe below would
// otherwise fail with "could not verify tmux on remote host …" — an ssh failure
// that blames tmux for a machine that is merely suspended. Wired HERE, in the HTTP
// route, and deliberately NOT in the shared session service: `cron-service.ts`
// builds sessions through the service, and a wake down there would re-wake the
// host on every schedule (the failure invariant #1 exists to prevent).
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
});
if (hostWake === 'failed') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`${host.label} did not come back after a wake-on-LAN request — the session was not started`
);
}
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote // tmux is a hard prerequisite on the remote host (the agent runs inside a remote
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux // tmux server so it survives ssh drops). Probe before spawning so a missing tmux
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane. // surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
const tmuxCheck = await checkRemoteTmuxAvailable(host); const tmuxCheck = await checkRemoteTmuxAvailable(host);
if (!tmuxCheck.ok) { if (!tmuxCheck.ok) {
// An unreachable host and a host without tmux fail the same way over ssh, so the
// probe's own message would send the user hunting for a tmux install. Ask the
// registry (which just probed, when it woke the host) which of the two it is.
if (!(await remoteWake.checkHostReachable(wakeableHost(host)))) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
hostWake === 'no-target'
? `${host.label} (${host.host}) is not reachable, and this host has no wake-on-LAN target — configure a MAC address or a wake command first`
: `${host.label} (${host.host}) is not reachable`
);
}
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux'); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
} }
+109
View File
@@ -26,6 +26,7 @@ import {
sendWakePackets, sendWakePackets,
wakeConfigured, wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES, REMOTE_WAKE_PENDING_MAX_BYTES,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
type RemoteWakeDeps, type RemoteWakeDeps,
type WakeableRemote, type WakeableRemote,
type WakeableSession, type WakeableSession,
@@ -439,6 +440,98 @@ describe('RemoteWakeRegistry', () => {
}); });
}); });
// ========== Host-scoped wake (session create/attach) ==========
describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it', () => {
const hostRemote: WakeableRemote = {
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
};
it('does not even probe a host without a wake target (byte-identical to no feature)', async () => {
const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } });
await expect(h.registry.ensureHostAwake(h.session.remote!)).resolves.toBe('no-target');
expect(h.probe).not.toHaveBeenCalled();
expect(h.wake).not.toHaveBeenCalled();
});
it('reports ready without waking when the host already answers', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(true);
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('ready');
expect(h.wake).not.toHaveBeenCalled();
});
it('wakes a sleeping host and waits with the caller’s budget, not the 90 s default', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
await expect(
h.registry.ensureHostAwake(hostRemote, { timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
).resolves.toBe('ready');
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
// The budget has to reach the readiness poll: the reverse proxy cuts a request at
// 60 s, so a create-path wake must not inherit the 90 s session default.
expect(h.waitUntilReady).toHaveBeenCalledWith(hostRemote, {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
});
expect(h.events).toContain('remote:hostWaking');
});
it('reports failed when the host never comes back, and probes again on the next attempt', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
h.waitUntilReady.mockResolvedValue(false);
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
expect(h.events).toContain('remote:hostWakeFailed');
// The failure resets the probe verdict, so a second Run probes instead of
// trusting a stale "down" forever.
h.waitUntilReady.mockResolvedValue(true);
h.probe.mockClear();
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('ready');
expect(h.probe).toHaveBeenCalled();
});
it('single-flights two concurrent create-path wakes for the same host', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
let release: (value: boolean) => void = () => {};
h.waitUntilReady.mockImplementation(() => new Promise<boolean>((resolve) => (release = resolve)));
const first = h.registry.ensureHostAwake(hostRemote);
const second = h.registry.ensureHostAwake(hostRemote);
await vi.waitFor(() => expect(h.wake).toHaveBeenCalledTimes(1));
release(true);
await expect(Promise.all([first, second])).resolves.toEqual(['ready', 'ready']);
// One magic packet for a double click, not two.
expect(h.wake).toHaveBeenCalledTimes(1);
});
it('checkHostReachable is a question, never an action', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
await expect(h.registry.checkHostReachable(hostRemote)).resolves.toBe(false);
expect(h.wake).not.toHaveBeenCalled();
});
it('reports failed instead of rejecting when the wake IO itself throws', async () => {
// A create route must answer with its own error, not a 500 from an unexpected
// rejection — the session flow catches for the same reason.
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
h.wake.mockRejectedValue(new Error('udp socket exploded'));
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
});
});
// ========== Wiring guard ========== // ========== Wiring guard ==========
const SRC = fileURLToPath(new URL('../src', import.meta.url)); const SRC = fileURLToPath(new URL('../src', import.meta.url));
@@ -468,4 +561,20 @@ describe('wake wiring guard', () => {
expect(importers.sort()).toEqual([...allowed].sort()); expect(importers.sort()).toEqual([...allowed].sort());
}); });
it('wakes a host for a create/attach request ONLY from the HTTP route', () => {
// The create-path wake (`ensureHostAwake`) is a USER request, so it belongs to the
// HTTP route. `cron-service.ts` builds sessions through the shared service with
// nobody waiting on the answer, so a wake down there would power the host on for
// every schedule — the failure invariant #1 exists to prevent. Asserted across the
// source tree, so a future caller has to come through this test.
// `remote-wake.ts` names itself: that is the definition, not a caller, and the
// import guard above already pins the file to the route.
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), 'remote-wake.ts']);
const callers = walkTs(SRC)
.filter((full) => /ensureHostAwake\s*\(/.test(readFileSync(full, 'utf-8')))
.map((full) => relative(SRC, full));
expect(callers.sort()).toEqual([...allowed].sort());
});
}); });
+147 -1
View File
@@ -55,6 +55,7 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
}); });
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js'; import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
interface LocalHarness { interface LocalHarness {
@@ -62,6 +63,15 @@ interface LocalHarness {
ctx: MockRouteContext; ctx: MockRouteContext;
} }
// Wake-on-LAN seam: the production registry opens a real TCP connection to the host
// and can run a real wake command, so every route registered here gets a fake one
// (the same seam `test/routes/session-remote-wake.test.ts` uses). Default: the host
// answers, so nothing ever wakes.
const wakeProbe = vi.fn(async () => true);
const wakeCommandRun = vi.fn(async () => true);
const wakeWaitUntilReady = vi.fn(async () => true);
let wakeRegistry: RemoteWakeRegistry;
/** /**
* Build a Fastify instance that mirrors production's uniform-envelope behavior * Build a Fastify instance that mirrors production's uniform-envelope behavior
* (server.ts preSerialization hook) so the test wire format matches the contract: * (server.ts preSerialization hook) so the test wire format matches the contract:
@@ -108,7 +118,17 @@ describe('session-routes', () => {
let harness: LocalHarness; let harness: LocalHarness;
beforeEach(async () => { beforeEach(async () => {
harness = await createEnvelopeHarness(registerSessionRoutes); wakeProbe.mockReset().mockResolvedValue(true);
wakeCommandRun.mockReset().mockResolvedValue(true);
wakeWaitUntilReady.mockReset().mockResolvedValue(true);
wakeRegistry = new RemoteWakeRegistry({
probe: wakeProbe,
wake: wakeCommandRun,
waitUntilReady: wakeWaitUntilReady,
delay: async () => {},
log: () => {},
});
harness = await createEnvelopeHarness((app, ctx) => registerSessionRoutes(app, ctx, { remoteWake: wakeRegistry }));
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe // Reset remote store so tests start with empty hosts/cases and a passing tmux probe
remoteStore.hosts = []; remoteStore.hosts = [];
remoteStore.cases = []; remoteStore.cases = [];
@@ -1897,6 +1917,132 @@ describe('session-routes', () => {
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED }); expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
}); });
describe('remote create/attach wakes a sleeping host (Wake-on-LAN)', () => {
const host = (extra: Record<string, unknown> = {}) => ({
id: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
username: 'j',
wakeMac: '04:d9:f5:80:c6:58',
...extra,
});
const remoteCase = { name: 'hufflepuff-work', type: 'remote', hostId: 'hufflepuff', remotePath: '/home/j/work' };
const quickStart = () =>
harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'hufflepuff-work', mode: 'shell' },
});
it('wakes the host before the tmux probe when the user runs a remote case', async () => {
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
try {
remoteStore.hosts = [host()];
remoteStore.cases = [remoteCase];
wakeProbe.mockResolvedValue(false); // asleep
const res = await quickStart();
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
expect(wakeCommandRun).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
// The request budget, not the 90 s session default: the reverse proxy would
// cut the request at 60 s while the session was still being built.
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
});
} finally {
startShell.mockRestore();
}
});
it('does not wake a host that answers, and never probes a host without a wake target', async () => {
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
try {
remoteStore.hosts = [host()];
remoteStore.cases = [remoteCase];
// The fake probe answers `true` by default — a reachable host.
expect((await quickStart()).statusCode).toBe(200);
expect(wakeCommandRun).not.toHaveBeenCalled();
// No wake target at all: not even a probe, so hosts without WoL keep the
// exact behavior (and latency) they had before this feature.
wakeProbe.mockClear();
remoteStore.hosts = [host({ wakeMac: undefined })];
expect((await quickStart()).statusCode).toBe(200);
expect(wakeProbe).not.toHaveBeenCalled();
expect(wakeCommandRun).not.toHaveBeenCalled();
} finally {
startShell.mockRestore();
}
});
it('refuses the run when the host never comes back, and starts no session', async () => {
remoteStore.hosts = [host()];
remoteStore.cases = [remoteCase];
wakeProbe.mockResolvedValue(false);
wakeWaitUntilReady.mockResolvedValue(false);
const sessionsBefore = harness.ctx.sessions.size;
const res = await quickStart();
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
expect(JSON.parse(res.body).error).toMatch(/did not come back after a wake-on-LAN request/);
// No half-created session: the failure is the answer, not a dead tab.
expect(harness.ctx.sessions.size).toBe(sessionsBefore);
});
it('blames the sleeping host, not tmux, when the host has no wake target', async () => {
remoteStore.hosts = [host({ wakeMac: undefined })];
remoteStore.cases = [remoteCase];
remoteStore.tmuxCheck = {
ok: false,
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
};
wakeProbe.mockResolvedValue(false);
const res = await quickStart();
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
expect(JSON.parse(res.body).error).toMatch(/has no wake-on-LAN target/);
});
it('keeps the tmux error when the host is up but tmux is really missing', async () => {
remoteStore.hosts = [host()];
remoteStore.cases = [remoteCase];
remoteStore.tmuxCheck = {
ok: false,
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
};
// Probe answers `true`: the ssh failure is genuinely about tmux.
const res = await quickStart();
expect(JSON.parse(res.body).error).toMatch(/needs tmux installed/);
});
it('wakes the host when attaching to a discovered remote session', async () => {
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
try {
remoteStore.hosts = [host()];
wakeProbe.mockResolvedValue(false);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
});
expect(res.statusCode).toBe(200);
expect(wakeCommandRun).toHaveBeenCalledTimes(1);
} finally {
startInteractive.mockRestore();
startShell.mockRestore();
}
});
});
it('does not run local codex availability check for a remote codex case', async () => { it('does not run local codex availability check for a remote codex case', async () => {
// A remote codex case must NOT be blocked by the LOCAL codex availability gate // A remote codex case must NOT be blocked by the LOCAL codex availability gate
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck. // (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.