Merge pull request #439

feat(remote): wake a sleeping host (Wake-on-LAN) from input, banner and native magic packet
This commit is contained in:
Ark0N
2026-09-19 12:18:18 +02:00
committed by GitHub
26 changed files with 4021 additions and 14 deletions
+6 -4
View File
@@ -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` (IO: `dgram`/`net`/`child_process`), `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-<id>`, 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.wakeMac` (Codeman builds the magic packet itself) or `RemoteHost.wakeCommand` (single executable path, run without a shell, takes precedence) lets the INPUT route, `POST /api/sessions/:id/wake`, and the user's own create/attach request (`POST /api/quick-start`, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`) wake a sleeping host instead of writing into a stalled ssh pane. ⚠️ An explicit request — input, the wake button, or the user pressing Run/Attach — and NOTHING else may wake: the auto-reconnect watcher, `handleRemoteSessionDropped`, boot recovery and `cron-service.ts` have no access to the registry (a wake there would re-wake the host seconds after every suspend, and the create wake is wired in the route rather than the shared session service for exactly that reason), which `test/remote-wake.test.ts` asserts as two wiring guards — the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. `GET /api/sessions/:id/reachability` merely probes and never wakes. 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. ⚠️ A host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option is reachability-UNKNOWN (`isProbeable()`): the probe connects to `host:port`, which such a host does not answer even while ssh works, so the registry never buffers for it, never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false` — the banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids). Input arriving during a wake is buffered (a chunk over 4 KB is dropped whole, never delivered as a fragment; the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell) and flushed in order after `reattachRemote()` with `fromUser` — a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later; send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything — the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The wake fields are re-read from `remote-hosts.json` on recovery and, throttled+cached via `RemoteWakeDeps.resolveRemote`, for a LIVE session, since the persisted `remote` snapshot never sees a field added later. UI: the amber `#hostWakeBanner` (`host-wake-ui.js`) with Wake / "Configure WoL" → `#wakeConfigModal`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
**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 <name>` 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)
@@ -300,7 +302,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
@@ -379,11 +381,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
158 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 158 = 158, no drift either direction). ⚠️ `hook:agent_working` is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events.
160 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 160 = 160, no drift either direction). ⚠️ `hook:agent_working` is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events.
### API Routes
~233 handlers across 27 route files in `src/web/routes/`: system (56), sessions (34), cases (34), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), custom-model (5), reboot-restore (3), me (2), teams (2), tab-layout (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~235 handlers across 27 route files in `src/web/routes/`: system (56), sessions (37), cases (34), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), custom-model (5), reboot-restore (3), me (2), teams (2), tab-layout (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+10
View File
@@ -324,6 +324,16 @@ from the session's current state rather than requiring a new transition: the
original turn may be long over. It comes back as
`"delivered": false, "duplicate": true`.
**Wake-on-LAN hosts** (`docs/remote-sessions.md` §Wake-on-LAN): when the session's
remote host has a wake target and is asleep, the non-wait form answers `200` with
`{"buffered": true}` — the bytes are held and flushed after the host is back — or
`{"buffered": true, "dropped": true}` for a chunk over the 4 KB wake buffer, which
is gone (never delivered as a fragment). Both fields are additive to the historical
bare `{}`. With `wait`, the route blocks on the wake instead and answers
`422 OPERATION_FAILED` ("did not come back after a wake-on-LAN request — nothing was
sent") when the host never returns, rather than writing into the stalled pane and
reporting `delivered:true` plus a timeout.
### Response
All three nest the wait result under `data.wait`, so one client helper works against
+2
View File
@@ -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.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; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), 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`.
### Docker cases
+175
View File
@@ -352,6 +352,177 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo
`fs`, and a local session never opens an ssh connection — including for attachment
records, which are keyed to the session that registered them.
## Wake-on-LAN from user input
A durable remote session survives an SSH drop (COD-104/108), but nothing brought the
HOST back. When the remote machine suspended, the local pane's `ssh` child **stalled**
rather than exited: `tmux send-keys` SUCCEEDS against a stalled pane, so typed input
vanished with no error anywhere, and without a keepalive the pane could look alive for
the OS TCP timeout. The only recovery was waiting for the reconnect watcher, which
gave up after ~13 minutes and, once exhausted, never retried.
An **optional** `wakeMac` (one or more MAC addresses, comma-separated) or `wakeCommand` on a
remote host closes that: on user input, `POST /api/sessions/:id/input` probes the host, and if
it is unreachable it wakes it, polls until the host answers, reattaches the pane
(`Session.reattachRemote()`, which idempotently attaches the still-running remote tmux — the
agent conversation is not restarted), and flushes the input that arrived meanwhile.
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:
- **`wakeMac`** — Codeman builds the magic packet itself (`buildMagicPacket`, six `0xFF`
bytes then the MAC repeated 16×; the shape is asserted byte-for-byte) and broadcasts it
over UDP port 9 (`sendWakePackets`). This is the normal case: no external script, and one
MAC list per host instead of one per consumer.
- **`wakeCommand`** — a single executable path, run WITHOUT a shell. For hosts that need a
router/another machine to send the packet.
**UI**: a banner (`#hostWakeBanner`, `host-wake-ui.js`) appears while the ACTIVE remote
session's host is unreachable — amber, since the Codeman session is healthy and only the
machine is asleep. With a wake target the action is **Wake** (`POST /api/sessions/:id/wake`);
with none it is **Configure WoL** and opens `#wakeConfigModal`, a small form for that host's
`wakeMac`/`wakeCommand` that saves with `PUT /api/remote-hosts/:id` (in multi-user mode that
GET is admin-only, so a non-admin is told the setting is admin-only instead of "host not
found"). Reachability for the banner comes from `GET /api/sessions/:id/reachability`: once
when the remote tab is activated (a user action), and every 30 s while the tab is visible
**only for a host with a wake target** — each poll is a TCP connect to the host, and a timer
that connects to a host Codeman could not wake anyway is exactly the timer-driven traffic
the keepalive rule below rejects (it cannot wake a host, but it can keep an activity-based
suspend timer from firing). A host the probe cannot reach (see the next section) is never
polled. ⚠️ The button is pressed from the SAME
dashboard as Run/Attach, so it holds its request open under the same proxy and uses the same
40 s budget — and it **queues nothing**: browser keystrokes travel over the WebSocket, which
deliberately does not pass through the registry (that is the hot path this feature keeps its
hands off), so the banner says "waiting for the host to come back" for the button and only
claims "input is queued" when the HTTP input path actually buffered bytes
(`queuedInput` on the two SSE events).
**Hosts behind a jump host or SOCKS proxy are reachability-UNKNOWN.** The probe is a bare
TCP connect to `host:port`, and a host reached through `jumpHost`, `socksProxy` or a
`ProxyCommand`/`ProxyJump` in `extraSshOptions` does not answer that even while ssh works —
the direct address may not route at all (the cloudflared case). Acting on the resulting
"unreachable" verdict was wrong three times over: a permanent banner over a healthy session,
a create-path error that replaced a genuine "needs tmux" with "not reachable", and — with a
wake target configured — every HTTP input buffered for the life of the session, because the
readiness poll could never succeed. `isProbeable()` (`remote-wake.ts`) decides from the
proxy fields, which travel on `WakeableRemote`; for such a host the registry delivers input
unchanged, `GET …/reachability` answers `reachable: null, probeable: false` (unknown is not
`false`, and only a proven `false` raises the banner), the create/attach path is not gated
(`ensureHostAwake` → `'unprobeable'`, handled like `'no-target'`), and the quick-start
"not reachable" message is reserved for a **proven** unreachable host (`=== false`). A wake
target can still be fired for it through `POST /api/sessions/:id/wake`, blind: the packet or
command goes out and the response says only whether it did — no readiness poll, no reattach
(the COD-108 watcher owns the pane once ssh works again), no "waking" toast.
The invariants worth keeping:
- **Authorization comes before the wake.** In multi-user mode the attach path
(`POST /api/sessions` + `attachRemoteSession`) answers `403` to a non-admin BEFORE the
host is looked up or probed: remote hosts are admin-only infrastructure everywhere else
(the list is `[]` for a non-admin, write and discovery routes are `adminOnly`), and the
wake spawns the host's `wakeCommand` or broadcasts a packet — a gate that came after the
wake handed an unprivileged account a way to run that executable for any configured
`hostId`, hold the request for the wake budget, and only then be refused for the
workingDir. The quick-start path resolves its remote case through `canAccessOwned`
first. Pinned in `test/routes/session-remote-wake.test.ts` (wake spy stays empty).
- **The caller is told what happened to its bytes.** The non-wait input route answers
`{buffered:true}` when the registry took the chunk and `{buffered:true, dropped:true}`
when it was over the cap and is gone; the send-and-wait route answers `OPERATION_FAILED`
when the host never comes back, like the create and attach paths, instead of writing
into the stalled pane and reporting `delivered:true` plus a timeout. Flushed chunks are
written with `fromUser`, so a first prompt that was buffered through a wake can still
name the tab.
- **Only an EXPLICIT request may wake a host:** user input on an established session, the wake
button, or the user's own session create/attach request (`ensureHostAwake`). Everything that
runs on a TIMER must never wake one — the COD-108 watcher, the server's dropped-session
handler, boot recovery and session discovery have no access to the wake registry, and neither
has the shared session service, because `cron-service.ts` builds sessions there with nobody
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 pins the importers — the route module and
`server.ts`, which holds the registry for its LIFETIME only, `drop()` on session cleanup and
`stop()` on shutdown — and one asserts `server.ts` calls nothing but those two, while
`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),
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,
which is exactly what a byte-threshold idle detector must not count as activity. A probe is
~200 bytes per 30 s, orders of magnitude below any such threshold, and the SYN alone cannot
wake a host.
- **Input is buffered while a wake is in flight** (`REMOTE_WAKE_PENDING_MAX_BYTES`,
oldest whole chunks dropped, bounded so user input cannot grow memory) and flushed in
order after the reattach, with a settle delay so bytes cannot land in a still-connecting
pane. ⚠️ A chunk LARGER than the cap (one big paste is one `input` value) is dropped
**outright**, never trimmed: it was never typed character by character, so its tail is not
"what the user just typed" but a fragment of a command they never sent — the drop is logged
instead. ⚠️ Only the HTTP input route reaches the registry; the **WebSocket keystroke path
is deliberately NOT wake-aware**, so typing into a sleeping host sends nothing and queues
nothing (the banner's Wake button is the recovery for that case, which is why it must not
promise queued input). The **send-and-wait** path blocks on the wake instead — its response
is open anyway, and buffering would break the wait contract. ⚠️ A flush write that FAILS
drops the whole remaining buffer (logged) rather than retaining it: the wake still resolves
and marks the host reachable, so the next input takes the deliver path while a retained
chunk would wait for the NEXT wake — replayed hours later, after everything typed since,
possibly ending in a carriage return. Same policy as the oversized paste.
- **The command runs without a shell** (`spawn(path, [], { stdio: 'ignore' })` — `shell`
defaults to `false`), the schema
requires a single executable path (no arguments, no `$`/backtick), and `wakeMac` is a
structural hex-pair allowlist. A broken or missing wake target fails the wake, never the
input route.
- **`wakeMac`/`wakeCommand` are host-level config, refreshed on recovery AND live**
(`rehydrateRemoteHostFields` in `src/remote-hosts.ts` plus `RemoteWakeDeps.resolveRemote`).
A session's `remote` block is persisted at launch time, so a field added to
`remote-hosts.json` later would otherwise never reach an already-running session — not even
across a Codeman restart, and certainly not right after saving the banner's config dialog.
Recovery rehydration covers restarts, the (throttled, cache-backed) resolver covers the live
session; the host config is authoritative for both (removing the field disables the feature
again). Other host-level fields deliberately stay as persisted, so neither path can
silently re-point an existing pane's SSH options.
- **UI/SSE**: `remote:hostWaking` and `remote:hostWakeFailed` (plus the reused
`remote:sessionReconnected`) drive the banner and toasts, all from `host-wake-ui.js` —
its handlers are the ONLY definitions, since a second one in another mixin would be
silently shadowed by script order. Both carry `queuedInput`, which is true only when the
server actually holds bytes for that session — the wording keys off that, not off "a wake
is running", so the button path never claims input is queued. In multi-user mode the
whole `remote:` family is **session-scoped** (`deriveSseHint`, `server.ts`): an event with
a `sessionId` reaches that session's owner, and the create/attach wake — which has no
session yet — carries the requesting `username` instead (`ensureHostAwake({ requestedBy })`),
since its payload names a `hostId`/`label` that `GET /api/remote-hosts` withholds from
non-admins. With neither, it reaches admins only.
- **No real IO under vitest.** `probeRemoteHostReachable`, `runRemoteWakeCommand` and the
default UDP socket of `sendWakePackets` throw under `VITEST` (as `remote-files.ts` does),
so a test that reaches the defaults fails loudly instead of connecting, spawning or
broadcasting from CI. Every consumer injects its IO (`RemoteWakeDeps`, the socket
factory); `createDefaultRemoteWakeDeps({ probe })` also polls readiness with THAT probe,
which is the leak the guard found.
Tests: `test/remote-wake.test.ts` (decision/throttle table, single-flight registry,
buffering + flush order, MAC parsing/magic packet, live host-config resolution, the proxied
host, SSE payload routing, the vitest IO guard, and the wiring guard),
`test/routes/session-remote-wake.test.ts` (the input route buffers instead of writing into a
sleeping host — and writes straight into a proxied one —, the reachability route never wakes
and reports a proxied host as unknown, and the wake route reports the no-target case the UI
turns into "configure WoL"), `test/sse-routing-remote.test.ts` (multi-user routing of the
`remote:` family) and `test/host-wake-banner.test.ts` (banner visibility and when the poller
may connect).
## API
Routes are registered in `src/web/routes/case-routes.ts`:
@@ -365,6 +536,10 @@ Routes are registered in `src/web/routes/case-routes.ts`:
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
`RemoteHost` accepts the optional `wakeMac` (magic packet, sent by Codeman) and `wakeCommand`
(single executable path, run without a shell, takes precedence) — see **Wake-on-LAN from user
input** above.
Attaching to a discovered session is a **session-create** path, not a host route:
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
(schema in `schemas.ts`; `remoteSessionName` must match `^codeman-[a-zA-Z0-9._-]+$`),
+34
View File
@@ -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`/`wakeMac` are taken from the host config,
* and the host is authoritative for them (removing one in the config turns that
* wake path 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<T extends { hostId: string; wakeCommand?: string; wakeMac?: string }>(
remote: T | undefined,
hostsById: ReadonlyMap<string, RemoteHost>
): T | undefined {
if (!remote) return remote;
const host = hostsById.get(remote.hostId);
if (!host) return remote;
if (remote.wakeCommand === host.wakeCommand && remote.wakeMac === host.wakeMac) return remote;
return { ...remote, wakeCommand: host.wakeCommand, wakeMac: host.wakeMac };
}
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
return {
hostId: host.id,
@@ -549,6 +575,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
port: host.port,
remotePath: remoteCase.remotePath,
commands: host.commands,
// Wake-on-LAN command/MAC travel with the session so the input route can wake a
// sleeping host without a second config read (see remote-wake.ts).
wakeCommand: host.wakeCommand,
wakeMac: host.wakeMac,
// 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 +617,10 @@ 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,
wakeMac: host.wakeMac,
// Discovered + attached — another Codeman created it. Detach-not-kill.
owned: false,
remoteSessionName,
+1016
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -115,6 +115,25 @@ export interface RemoteHost extends RemoteSshOptions {
username: string;
port?: number;
commands?: Partial<Record<RemoteCommandMode, string>>;
/**
* Optional Wake-on-LAN MAC address(es), comma-separated (e.g.
* `04:d9:f5:80:c6:58`). Codeman sends the magic packet itself (UDP port 9
* broadcast), so the common case needs no external script. A SLEEPING host's
* port-22 probe still fails, which is what triggers the wake — this only
* controls HOW the host is woken.
*/
wakeMac?: string;
/**
* Optional Wake-on-LAN command that powers this host on from SLEEP (e.g. a
* wrapper script like `/home/joe/bin/whuff`). TAKES PRECEDENCE over `wakeMac`
* (an explicit override for hosts that need a router/other-host wake). 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 or an
* explicit wake request 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 {
@@ -155,6 +174,13 @@ 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;
/** Wake-on-LAN MAC address(es) from the host config (see `RemoteHost.wakeMac`). */
wakeMac?: string;
}
/**
+8 -1
View File
@@ -219,7 +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'],
[SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'],
@@ -1824,6 +1825,9 @@ class CodemanApp {
_onInit(data) {
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
this.handleInit(data);
// Start the remote-host reachability poller even if no session switch follows
// (a page loaded with the remote tab already active) — see host-wake-ui.js.
this._ensureHostWakePoller?.();
}
_onSessionCreated(data) {
@@ -6190,6 +6194,9 @@ class CodemanApp {
// bar (issue #262). Also disarms a one-shot Ctrl left over from the tab we
// just left, so it can never fire against the session we just opened.
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
// Remote-host reachability banner: only meaningful for a remote session, so this
// also clears it when the newly active tab is local.
this.refreshHostWakeBanner?.(sessionId);
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
// the async buffer load. Without this, the offset is 0 during the
+3
View File
@@ -1148,6 +1148,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',
+440
View File
@@ -0,0 +1,440 @@
/**
* @fileoverview Remote-host wake-on-LAN: the "host unreachable" banner + its config dialog.
*
* A sleeping remote host does not fail loudly. The local tmux pane runs `ssh`, and when
* the machine suspends, that ssh child stalls: `tmux send-keys` still SUCCEEDS, so typed
* input disappears with no error and the pane looks alive. The server side
* (`src/remote-wake.ts`) buffers input and wakes the host when the user types; this
* module makes the state VISIBLE and gives it a button, which is what turns "why is
* nothing happening" into one click.
*
* Behavior:
* - Asks `GET /api/sessions/:id/reachability` for the ACTIVE remote session only:
* once when the tab is activated (a user action), and every `POLL_MS` while the tab
* is visible ONLY for a host with a wake target. The timer is the one thing here that
* is not user-driven, and each poll is a TCP connect to the host — the same
* timer-driven traffic invariant #2 rejects keepalives for: it cannot wake a host,
* but it can keep an activity-based suspend timer from firing. So a host Codeman
* could not wake anyway is never polled on a timer. A host behind a jump host or
* SOCKS proxy (`probeable: false`) is never polled at all: the probe cannot reach
* it, so its answer would only ever be a false "asleep". The endpoint shares the
* server's probe cache with the input path, so opening the tab also primes the
* wake path.
* - Unreachable + a configured wake target → "Wake" button → `POST /api/sessions/:id/wake`
* (which wakes, waits, reattaches the pane and flushes buffered input).
* - Unreachable + NO wake target → "Configure WoL" → `#wakeConfigModal`, a small form
* for this host's MAC/command that saves via `PUT /api/remote-hosts/:id`. The server
* re-resolves host config while the session is live, so saving takes effect without
* restarting the session.
* - SSE (`remote:hostWaking`, `remote:hostWakeFailed`, `remote:sessionReconnected`)
* keeps the banner in sync while a wake is running.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
* @dependency constants.js (SSE_EVENTS — the remote:hostWaking / remote:hostWakeFailed names)
* @loadorder 12.2 — loaded after session-ui.js, before webview-tabs.js
*/
const HOST_WAKE_POLL_MS = 30_000;
Object.assign(CodemanApp.prototype, {
/** Per-tab banner state (single active session at a time). */
_hostWake: null,
/** The page-wide poller interval (created once, see `_ensureHostWakePoller`). */
_hostWakeTimer: null,
/** Fresh state for a session we just switched to. */
_hostWakeState() {
return {
sessionId: null,
/** Last reachability answer, or null before the first poll. */
reachable: null,
/** 'command' | 'mac' | 'none' — what the banner action should do. */
wakeConfigured: 'none',
host: '',
label: '',
/**
* False for a host the server's probe cannot reach (behind a jump host or SOCKS
* proxy): its reachability is unknown, so there is no banner and no polling.
*/
probeable: true,
/** True between clicking Wake and the answer coming back. */
waking: false,
/**
* True only when the server is actually holding bytes for this session (the typing
* path buffers them). Browser keystrokes go over the WebSocket, which never passes
* through the wake registry — so the Wake BUTTON must not claim input is queued.
*/
queuedInput: false,
/** Set when the last wake attempt or poll failed. */
error: '',
};
},
/**
* Entry point from the session switcher — called for every active session, remote or
* not, so it must be cheap and must clear the banner for local sessions.
*
* ⚠️ The POLLER is page-wide and independent of this call on purpose: a session
* switch is not the only way the active tab changes (boot restore, a page loaded with
* the tab already active, and `selectSession`'s own early return for the tab you are
* already on), and the banner must not depend on any single one of those paths
* running — that is exactly how it could silently never appear.
*/
refreshHostWakeBanner(sessionId) {
this._ensureHostWakePoller();
const state = this._hostWake;
if (state && state.sessionId && state.sessionId !== sessionId) this._hostWake = null;
this._hostWakeTick();
},
/** Create the page-wide poller once (interval + a visibility wake-up). */
_ensureHostWakePoller() {
if (this._hostWakeTimer) return;
this._hostWakeTimer = setInterval(() => this._hostWakeTick({ periodic: true }), HOST_WAKE_POLL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this._hostWakeTick({ periodic: true });
});
},
/**
* One poller tick: resolve the ACTIVE session, reset the banner when it changed, and
* ask the server. No-op while the page is hidden (a background tab must not poll).
*
* `periodic` marks the timer (and the visibility wake-up) as opposed to a tab
* activation: a periodic tick polls only a host with a wake target, see the module
* comment. The activation poll is what still offers "Configure WoL" for a sleeping
* host that has none — one connect, on a user action.
*/
_hostWakeTick({ periodic = false } = {}) {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
const sessionId = this.activeSessionId;
const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null;
if (!sessionId || !session || !session.remote) {
// Render unconditionally: `refreshHostWakeBanner` clears `_hostWake` BEFORE
// calling this tick, so a guard here would skip the repaint and leave the
// banner up on every chat (the clear and the repaint must not be coupled to
// whoever cleared the state). Idempotent — with a null state it just hides.
this._hostWake = null;
this._renderHostWakeBanner();
return;
}
let state = this._hostWake;
let fresh = false;
if (!state || state.sessionId !== sessionId) {
fresh = true;
state = this._hostWake = this._hostWakeState();
state.sessionId = sessionId;
state.host = session.remote.host || '';
state.label = session.remote.label || 'Remote host';
// Text from the session payload first (instant, no round trip), corrected by the
// poll — a session whose wake config was added after launch only knows it after
// the server resolves host config. The kind matters: the payload can say WHICH
// path is configured, so a command-only host is not mislabelled 'mac' until the
// first poll lands.
state.wakeConfigured = session.remote.wakeMac ? 'mac' : session.remote.wakeCommand ? 'command' : 'none';
// Known from the payload already: a proxied host is not probeable (the server
// says so too, on every answer), so not even the activation poll is worth a
// round trip whose verdict could only be a wrong "asleep".
state.probeable = !(session.remote.jumpHost || session.remote.socksProxy);
this._renderHostWakeBanner();
}
if (!state.probeable) return;
if (periodic && !fresh && state.wakeConfigured === 'none') return;
this._pollHostReachability();
},
/** One reachability check for the active remote session. */
async _pollHostReachability(force = false) {
const state = this._hostWake;
if (!state || !state.sessionId) return;
const sessionId = state.sessionId;
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/reachability${force ? '?force=1' : ''}`);
const data = await res.json();
if (!data.success) return;
// The tab may have changed while this was in flight.
if (this._hostWake !== state || state.sessionId !== sessionId) return;
// `reachable` is `null` (unknown, not unreachable) for a host the probe cannot
// reach — only a PROVEN `false` may raise the banner.
state.reachable = data.data.reachable !== false;
if (data.data.probeable === false) state.probeable = false;
state.wakeConfigured = data.data.wakeConfigured || 'none';
if (data.data.host) state.host = data.data.host;
if (data.data.label) state.label = data.data.label;
if (state.reachable) {
state.waking = false;
state.error = '';
}
this._renderHostWakeBanner();
} catch {
/* A failed poll is not a state change: leave the banner as it was. */
}
},
/** Draw the banner from `_hostWake`. */
_renderHostWakeBanner() {
const state = this._hostWake;
const banner = this.$('hostWakeBanner');
const text = this.$('hostWakeBannerText');
const detail = this.$('hostWakeBannerDetail');
const action = this.$('hostWakeBannerAction');
if (!banner || !text || !action) return;
const visible = Boolean(state && state.sessionId && state.reachable === false);
banner.hidden = !visible;
if (!visible) return;
const hasTarget = state.wakeConfigured !== 'none';
const target = state.label || state.host || 'Remote host';
if (state.waking) {
text.textContent = `Waking ${target} …`;
} else if (state.error) {
text.textContent = `${target} did not wake up`;
} else {
text.textContent = `${target} is not reachable`;
}
if (detail) {
detail.textContent = state.waking
? state.queuedInput
? 'input is queued until it is back'
: 'waiting for the host to come back'
: hasTarget
? `ssh ${state.host}`
: 'no wake-on-LAN configured';
}
// After a FAILED wake the only useful next step is fixing the target (wrong MAC,
// host moved NIC, command gone) — otherwise a configured-but-broken host would be
// stuck behind a button that keeps failing with no way to edit it.
const offerConfig = !hasTarget || Boolean(state.error);
action.textContent = state.waking ? 'Waking …' : offerConfig ? 'Configure WoL' : 'Wake';
action.disabled = state.waking;
},
/** Banner button: wake the host, or open the setup dialog when nothing is configured. */
hostWakeAction() {
const state = this._hostWake;
if (!state || !state.sessionId || state.waking) return;
if (state.wakeConfigured === 'none' || state.error) {
this.openWakeConfigDialog();
return;
}
this.wakeRemoteHost();
},
/** POST the manual wake for the active session and follow the result. */
async wakeRemoteHost() {
const state = this._hostWake;
if (!state || !state.sessionId) return;
const sessionId = state.sessionId;
state.waking = true;
// The button path holds nothing: whatever the user typed went into the stalled pane
// over the WebSocket and is gone. Saying otherwise is a promise the next keystroke
// disproves.
state.queuedInput = false;
state.error = '';
this._renderHostWakeBanner();
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/wake`, { method: 'POST' });
const data = await res.json();
if (this._hostWake !== state || state.sessionId !== sessionId) return;
state.waking = false;
if (!data.success) {
// The ROUTE is the authority on whether a target is configured, so ask it again
// (`/reachability` reports `wakeConfigured`) rather than pattern-matching the
// error message: the message is prose, and the code is generic (`INVALID_INPUT`
// covers "Not a remote session" too).
state.error = data.error || 'Wake failed';
this._renderHostWakeBanner();
await this._pollHostReachability(true);
return;
}
state.reachable = data.data.reachable !== false;
state.wakeConfigured = data.data.wakeConfigured || state.wakeConfigured;
if (state.reachable) {
this.showToast(`${state.label || 'Remote host'} is awake`, 'success');
} else {
state.error = 'timeout';
}
this._renderHostWakeBanner();
} catch (err) {
if (this._hostWake !== state) return;
state.waking = false;
state.error = err && err.message ? err.message : 'Wake failed';
this._renderHostWakeBanner();
}
},
/**
* Why the host could not be read. In multi-user mode `GET /api/remote-hosts` returns
* `[]` to a non-admin, so "Remote host not found" would blame a config the user simply
* is not allowed to see — the save is admin-only, and that is what it should say.
*/
_wakeConfigUnavailableMessage() {
const me = window.__codemanUser || {};
return me.multiUser && me.role !== 'admin' ? 'Wake-on-LAN configuration is admin-only' : 'Remote host not found';
},
/** Open the small WoL dialog for the banner's host, pre-filled from the host config. */
async openWakeConfigDialog() {
const state = this._hostWake;
const session = state && state.sessionId && this.sessions ? this.sessions.get(state.sessionId) : null;
if (!session || !session.remote) return;
const hostId = session.remote.hostId;
const label = this.$('wakeConfigHostLabel');
const mac = this.$('wakeConfigMac');
const command = this.$('wakeConfigCommand');
const status = this.$('wakeConfigStatus');
if (!mac || !command) return;
mac.value = session.remote.wakeMac || '';
command.value = session.remote.wakeCommand || '';
if (label) label.textContent = session.remote.label || hostId;
if (status) status.textContent = '';
this._wakeConfigHostId = hostId;
const modal = this.$('wakeConfigModal');
if (modal) modal.classList.add('active');
// Read the saved host so the dialog shows what is actually persisted (the session
// payload may predate a change made in another tab).
try {
const res = await fetch('/api/remote-hosts');
const data = await res.json();
const hosts = data.success ? data.data : [];
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
if (host && this._wakeConfigHostId === hostId) {
mac.value = host.wakeMac || '';
command.value = host.wakeCommand || '';
} else if (!host && this._wakeConfigHostId === hostId && status) {
// Say it up front rather than only when Save fails.
status.textContent = this._wakeConfigUnavailableMessage();
}
} catch {
/* The form is already usable from the session payload. */
}
},
closeWakeConfigDialog() {
const modal = this.$('wakeConfigModal');
if (modal) modal.classList.remove('active');
this._wakeConfigHostId = null;
},
/** Save MAC/command for the host, then re-check whether the session can wake now. */
async saveWakeConfig() {
const hostId = this._wakeConfigHostId;
const mac = this.$('wakeConfigMac');
const command = this.$('wakeConfigCommand');
const status = this.$('wakeConfigStatus');
const save = this.$('wakeConfigSave');
if (!hostId || !mac || !command) return;
const macValue = mac.value.trim();
const commandValue = command.value.trim();
if (
macValue &&
!/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/.test(macValue)
) {
if (status) status.textContent = 'MAC must look like 04:d9:f5:80:c6:58 (comma-separated for several).';
return;
}
if (commandValue && /\s/.test(commandValue)) {
if (status) status.textContent = 'The wake command must be a single executable path (no arguments).';
return;
}
if (save) save.disabled = true;
if (status) status.textContent = 'Saving …';
try {
const listRes = await fetch('/api/remote-hosts');
const listData = await listRes.json();
const hosts = listData.success ? listData.data : [];
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
if (!host) throw new Error(this._wakeConfigUnavailableMessage());
// PUT takes the whole host (schema-validated), so send back everything we know and
// only replace the wake fields. `undefined` drops the key entirely.
const payload = {
...host,
wakeMac: macValue || undefined,
wakeCommand: commandValue || undefined,
};
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Save failed');
this.showToast('Wake settings saved', 'success');
this.closeWakeConfigDialog();
// The server re-resolves host config for live sessions, so the banner can offer
// the wake right away — probe fresh instead of waiting out the poll interval.
await this._pollHostReachability(true);
} catch (err) {
if (status) status.textContent = err && err.message ? err.message : 'Save failed';
} finally {
if (save) save.disabled = false;
}
},
/**
* SSE `remote:hostWaking` — a wake is running (ours or one started by typing).
*
* ⚠️ The ONLY definition of this handler: `panels-ui.js` must not define it too.
* Both mix into `Codeman.prototype` and this file loads later, so a second copy
* would be silently shadowed (the guard in `sse-dispatch-table.test.ts` sees that a
* handler exists, not that two modules claim the same name). The toast is
* deliberately UNCONDITIONAL — a wake can start for a background session (input on
* a non-active tab) where there is no banner to update.
*/
_onRemoteHostWaking(data) {
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);
// Only the typing path buffers bytes; the wake button and the send-and-wait path
// hold none, and a browser keystroke never reaches the registry at all.
const queuedInput = Boolean(data && data.queuedInput);
// 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(
forNewSession
? `Waking ${label} … the session starts when it is back`
: queuedInput
? `Waking ${label} … input is queued`
: `Waking ${label} … waiting for it to come back`,
'info',
{ duration: 12000 }
);
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = true;
state.queuedInput = queuedInput;
state.error = '';
if (data.label) state.label = data.label;
this._renderHostWakeBanner();
},
/** SSE `remote:hostWakeFailed` — the host did not come back in time. */
_onRemoteHostWakeFailed(data) {
const label = data && data.label ? data.label : 'Remote host';
const forNewSession = Boolean(data && data.forNewSession);
const queuedInput = Boolean(data && data.queuedInput);
this.showToast(
forNewSession
? `${label} did not wake up — no session was started`
: queuedInput
? `${label} did not wake up — queued input is still held`
: `${label} did not wake up`,
'error',
{ duration: 15000 }
);
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = false;
state.queuedInput = queuedInput;
state.error = 'timeout';
state.reachable = false;
this._renderHostWakeBanner();
},
});
+53
View File
@@ -213,6 +213,18 @@
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
</div>
<!-- Remote-host unreachable: the machine SLEEPS, the local ssh pane stalls
silently (send-keys succeeds against it, so typed input would vanish) and
Codeman can wake it. Amber, not red: the session is fine, the host is
asleep. Without a configured wake target the action becomes "Configure
WoL" and opens the small config dialog. -->
<div class="offline-banner host-wake-banner" id="hostWakeBanner" role="status" hidden>
<span class="offline-banner-dot" aria-hidden="true"></span>
<span class="offline-banner-text" id="hostWakeBannerText">Remote host is unreachable</span>
<span class="offline-banner-detail" id="hostWakeBannerDetail"></span>
<button class="offline-banner-retry" id="hostWakeBannerAction" onclick="app.hostWakeAction()">Wake</button>
</div>
<!-- Reboot-restore offer: shown when the server found sessions a host reboot
killed and is asking whether to rebuild them. Populated by
reboot-restore-ui.js; nothing is created until the user clicks. -->
@@ -2878,6 +2890,11 @@
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
</div>
<div class="form-row">
<label>Wake-on-LAN MAC</label>
<input type="text" id="remoteHostWakeMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. Comma-separated for several NICs. Codeman sends the magic packet itself so a sleeping host can be woken from the session banner.</span>
</div>
<div class="form-row">
<label>Codex Command Override</label>
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
@@ -2886,6 +2903,11 @@
<details class="advanced-options">
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced SSH</span></summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Wake Command</label>
<input type="text" id="remoteHostWakeCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Optional override for the MAC above (takes precedence). A single executable path, run without a shell — use it when the host needs a router/other machine to send the packet.</span>
</div>
<div class="form-row">
<label>Identity File</label>
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
@@ -3481,6 +3503,36 @@
text is set via value/textContent only: predictor output derives from
observable (injectable) content, and the explicit click here is the
security boundary (nothing is ever auto-sent). -->
<!-- Wake-on-LAN setup for a remote host whose session cannot be woken yet. Kept
deliberately small (host is fixed, only the wake fields are editable) so it can
be opened from the banner with one click. Persists via PUT /api/remote-hosts/:id. -->
<div class="modal" id="wakeConfigModal">
<div class="modal-backdrop" onclick="app.closeWakeConfigDialog()"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Wake-on-LAN &middot; <span id="wakeConfigHostLabel"></span></h3>
<button class="modal-close" onclick="app.closeWakeConfigDialog()" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<label>MAC address(es)</label>
<input type="text" id="wakeConfigMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Comma-separated for several NICs. Codeman sends the magic packet itself (UDP port 9, broadcast).</span>
</div>
<div class="form-row">
<label>Wake command (optional)</label>
<input type="text" id="wakeConfigCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Takes precedence over the MAC. A single executable path, run without a shell.</span>
</div>
<div class="form-hint" id="wakeConfigStatus"></div>
</div>
<div class="modal-footer">
<button class="btn-toolbar" onclick="app.closeWakeConfigDialog()">Cancel</button>
<button class="btn-toolbar btn-primary" id="wakeConfigSave" onclick="app.saveWakeConfig()">Save</button>
</div>
</div>
</div>
<div class="modal" id="readMyMindModal">
<div class="modal-backdrop" onclick="app.closeReadMyMind()"></div>
<div class="modal-content readmymind-modal">
@@ -3556,6 +3608,7 @@
<script defer src="reboot-restore-ui.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="host-wake-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
+13
View File
@@ -92,6 +92,9 @@ Object.assign(CodemanApp.prototype, {
_onRemoteSessionReconnected(data) {
const id = this.getShortId(data.sessionId);
this.showToast(`Remote session ${id} reconnected`, 'success');
// A successful reattach (the wake flow's own, or the watcher's) means the host is
// back: drop the "unreachable" banner without waiting out the poll interval.
if (this.activeSessionId === data.sessionId) this._pollHostReachability?.(true);
},
_onRemoteReconnectExhausted(data) {
@@ -116,6 +119,16 @@ Object.assign(CodemanApp.prototype, {
},
// Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts).
// ⚠️ The `remote:hostWaking` / `remote:hostWakeFailed` HANDLERS live in
// `host-wake-ui.js`, which owns the banner state. They are NOT redefined here:
// both files mix into `CodemanApp.prototype` and `host-wake-ui.js` is loaded
// later, so a second definition would silently shadow the banner update (and the
// toast would never fire — the exact silent no-op `sse-dispatch-table.test.ts`
// exists to prevent, which cannot see shadowing). The toasts are shown from the
// host-wake-ui handlers instead.
// Bash tools
_onBashToolStart(data) {
this.handleBashToolStart(data.sessionId, data.tool);
+14
View File
@@ -2512,6 +2512,10 @@ Object.assign(CodemanApp.prototype, {
'remoteHostSocksProxy',
'remoteHostJumpHost',
'remoteHostExtraSshOptions',
// Wake-on-LAN: they belong to the HOST being configured, so leaving them filled in
// would carry one host's MAC/command onto the next host this form saves.
'remoteHostWakeMac',
'remoteHostWakeCommand',
];
remoteFields.forEach(id => {
const el = document.getElementById(id);
@@ -3109,6 +3113,9 @@ Object.assign(CodemanApp.prototype, {
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
// Wake-on-LAN: keep in sync with `_readRemoteHostFromForm` (the Discover path).
const wakeMac = document.getElementById('remoteHostWakeMac').value.trim();
const wakeCommand = document.getElementById('remoteHostWakeCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
@@ -3146,6 +3153,8 @@ Object.assign(CodemanApp.prototype, {
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(wakeMac ? { wakeMac } : {}),
...(wakeCommand ? { wakeCommand } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
const hostRes = await fetch('/api/remote-hosts', {
@@ -3606,6 +3615,9 @@ Object.assign(CodemanApp.prototype, {
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
// Wake-on-LAN: keep in sync with `linkRemoteCase`'s inline payload.
const wakeMac = document.getElementById('remoteHostWakeMac').value.trim();
const wakeCommand = document.getElementById('remoteHostWakeCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
@@ -3625,6 +3637,8 @@ Object.assign(CodemanApp.prototype, {
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(wakeMac ? { wakeMac } : {}),
...(wakeCommand ? { wakeCommand } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
},
+12
View File
@@ -15389,6 +15389,18 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: rgba(255, 255, 255, 0.24);
}
/* Remote-host unreachable (host asleep, can be woken). Reuses the offline-banner
layout and children; amber instead of red because the Codeman session itself is
perfectly healthy — only the machine is asleep. */
.host-wake-banner {
background: linear-gradient(90deg, #b45309, #92400e);
}
.host-wake-banner .offline-banner-retry:disabled {
opacity: 0.6;
cursor: default;
}
/* Above the mobile fixed header (1200) and modals (1300): this is a blocking
"nothing works right now" state, and it only appears before any session
state has loaded, so there is no modal underneath to bury. Stays below the
+228 -2
View File
@@ -28,6 +28,7 @@ import {
type GrokConfig,
type DeepSeekConfig,
type OmpConfig,
type RemoteHost,
} from '../../types.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
@@ -68,6 +69,13 @@ import {
type WaitSignal,
type SignalWaitResult,
} from '../session-wait-registry.js';
import {
RemoteWakeRegistry,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
createDefaultRemoteWakeDeps,
isProbeable,
type WakeableRemote,
} from '../../remote-wake.js';
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
import {
autoConfigureRalph,
@@ -135,6 +143,7 @@ import {
checkRemoteTmuxAvailable,
readRemoteCases,
readRemoteHosts,
rehydrateRemoteHostFields,
toAttachedSessionRemote,
toSessionRemote,
} from '../../remote-hosts.js';
@@ -750,10 +759,65 @@ export function resolveOmpConfigForCreate(
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`. The proxy fields
* travel too: they are what tells the registry its probe cannot reach this host.
*/
function wakeableHost(host: RemoteHost): WakeableRemote {
return {
hostId: host.id,
label: host.label,
host: host.host,
port: host.port,
wakeMac: host.wakeMac,
wakeCommand: host.wakeCommand,
jumpHost: host.jumpHost,
socksProxy: host.socksProxy,
extraSshOptions: host.extraSshOptions,
};
}
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
): void {
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 } = {}
): RemoteWakeRegistry {
// 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),
// The session's `remote` block is a launch-time snapshot, so a wake target
// configured later (banner's config dialog, or a hand-edited remote-hosts.json)
// is resolved here — throttled by the registry, and the host config is
// authoritative in BOTH directions (removing the field turns the feature off
// for a live session too).
resolveRemote: async (session) => {
const remote = session.remote;
if (!remote) return undefined;
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
return rehydrateRemoteHostFields(remote, new Map(hosts.map((host) => [host.id, host])));
},
})
);
// ═══════════════════════════════════════════════════════════════
// Auth
// ═══════════════════════════════════════════════════════════════
@@ -825,9 +889,33 @@ export function registerSessionRoutes(
// creation (owned durable sessions) is handled by the dedicated case-create
// endpoint below, which #145 consolidated remote-host resolution into.
if (body.attachRemoteSession) {
// Remote hosts are admin-only infrastructure everywhere else (the list answers
// `[]` to a non-admin; write and discovery routes are `adminOnly`), and the wake
// below spawns the host's `wakeCommand` or broadcasts a packet. So the gate comes
// FIRST — before the host is even looked up — or an unprivileged account could
// invoke that executable for any configured `hostId` and only then be told the
// workingDir was outside its workspace (reproduced upstream: wake spy fired, 403).
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Remote hosts are admin-only in multi-user mode');
}
const { hostId, remoteSessionName } = body.attachRemoteSession;
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
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,
// No session yet, so the wake events name their requester (multi-user routing).
requestedBy: ownerFor(req),
});
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}`;
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
}
@@ -1215,6 +1303,8 @@ export function registerSessionRoutes(
}
const session = findSessionOrFail(ctx, id, req);
// Wake state is dropped by `cleanupSession` itself (server.ts), on EVERY cleanup
// path — not here: the scheduled-run and admin paths clean up without this route.
await ctx.cleanupSession(session.id, killMux, 'user_delete');
return {};
});
@@ -1450,6 +1540,67 @@ export function registerSessionRoutes(
// Terminal I/O (input, resize, buffer)
// ═══════════════════════════════════════════════════════════════
// ========== Wake-on-LAN: state + manual trigger ==========
//
// Both routes are session-scoped (not host-scoped) because the wake flow needs the
// SESSION: a woken host whose pane is not reattached is still a dead terminal, and an
// exhausted COD-108 backoff never retries on its own. The probe in `/reachability` is
// the same cheap TCP connect the input path uses and it NEVER wakes a host — the UI
// decides that, with the button.
app.get('/api/sessions/:id/reachability', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const remote = session.remote;
if (!remote) {
return { success: true, data: { reachable: true, probeable: true, wakeConfigured: 'none' as const } };
}
const force = (req.query as { force?: string })?.force === '1';
// `reachable: null` + `probeable: false` for a host behind a jump host / SOCKS proxy:
// the probe cannot reach it, so the UI shows no banner and stops polling.
const reachable = await remoteWake.checkReachable(session, { force });
return {
success: true,
data: {
reachable,
probeable: isProbeable(remote),
wakeConfigured: await remoteWake.wakeConfigured(session),
host: remote.host,
label: remote.label,
},
};
});
app.post('/api/sessions/:id/wake', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
if (!session.remote) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not a remote session');
}
// The UI uses this to route to the host config dialog instead of a dead button.
if (!(await remoteWake.hasWakeTarget(session))) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'No wake-on-LAN target configured for this host (set a MAC address or a wake command)'
);
}
// The button is pressed from the SAME dashboard the create/attach paths are, under
// the same reverse proxy — so it holds the request open the same way and needs the
// same request budget, not the 90 s session default (see remote-wake.ts).
const woke = await remoteWake.ensureAwake(session, {
force: true,
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
});
return {
success: true,
data: {
woke,
reachable: await remoteWake.checkReachable(session),
wakeConfigured: await remoteWake.wakeConfigured(session),
},
};
});
// ========== Send Input ==========
app.post('/api/sessions/:id/input', async (req, reply) => {
@@ -1491,6 +1642,42 @@ 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 && (await remoteWake.hasWakeTarget(session))) {
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).
// A host that never comes back is an error here, as on the create/attach
// paths: writing into the stalled pane would answer `delivered:true` plus a
// timeout, which is the combination the API docs send callers to the wrong
// recovery for.
if (!(await remoteWake.ensureAwake(session))) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`${session.remote?.label ?? 'the remote host'} did not come back after a wake-on-LAN request — nothing was sent`
);
}
} else {
const outcome = await remoteWake.handleInput(session, inputStr);
// 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.
// `buffered` is additive to the historical bare `{}`; `dropped` says the chunk
// was over the wake buffer's cap and is GONE (a 200 with no field could not
// tell delivered from buffered from dropped).
if (outcome === 'buffered') return { buffered: true };
if (outcome === 'dropped') return { buffered: true, dropped: true };
}
}
// 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);
@@ -3125,11 +3312,44 @@ 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,
// No session yet, so the wake events name their requester (multi-user routing).
requestedBy: ownerFor(req),
});
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 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.
const tmuxCheck = await checkRemoteTmuxAvailable(host);
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.
// `=== false` on purpose: a proxied host answers `null` (the probe cannot reach
// it), and an unknown verdict must not replace the real ssh error with
// "not reachable" over a host that is fine.
if ((await remoteWake.checkHostReachable(wakeableHost(host))) === false) {
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');
}
@@ -4688,4 +4908,10 @@ export function registerSessionRoutes(
return { path: filepath, filename };
});
// Returned so the server can own the registry's LIFETIME (drop state when a session is
// cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE
// code stays here: `test/remote-wake.test.ts` pins that `server.ts` calls nothing but
// `drop`/`stop` on this handle, so no timer path can reach a wake through it.
return remoteWake;
}
+24
View File
@@ -737,6 +737,30 @@ 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(),
// Wake-on-LAN MAC address(es), comma-separated. Structural: only hex pairs with
// `:`/`-` separators, so nothing here can be a shell token even by accident (the
// value never reaches a shell — Codeman builds the magic packet itself).
wakeMac: z
.string()
.min(11)
.max(128)
.regex(
/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/,
'Wake MAC must be one or more MAC addresses, comma-separated'
)
.optional(),
});
export const RemoteCaseLinkSchema = z.object({
+37 -3
View File
@@ -43,6 +43,8 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
import { GLYPH, palette } from '../cli-style.js';
import { getHookSecret } from '../config/hook-secret.js';
@@ -270,6 +272,14 @@ export class WebServer extends EventEmitter {
private scheduledRuns: Map<string, ScheduledRun> = new Map();
/** Cron service (assigned in setupRoutes). */
private cronService!: CronService;
/**
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
* re-wake a host on every timer tick (the invariant `remote-wake.ts` documents), so
* the wiring guard in `test/remote-wake.test.ts` pins that this file calls nothing
* but `drop`/`stop` on it.
*/
private remoteWake: RemoteWakeRegistry | null = null;
private sse: SseStreamManager;
private store = getStore();
private tabLayouts!: TabLayoutService;
@@ -1070,7 +1080,9 @@ export class WebServer extends EventEmitter {
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
registerSessionRoutes(this.app, ctx);
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx);
@@ -1461,6 +1473,11 @@ export class WebServer extends EventEmitter {
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
// Wake state goes with the session on EVERY cleanup path (delete routes, the cron
// and admin paths, scheduled-run teardown, error paths) — that is why it lives here
// rather than in the two delete routes, where it left an entry behind, including up
// to 4 KB of the user's buffered keystrokes.
this.remoteWake?.drop(sessionId);
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
@@ -2332,11 +2349,19 @@ export class WebServer extends EventEmitter {
'scheduled:',
'team:',
'case:',
'remote:',
];
if (SESSION_PREFIXES.some((p) => event.startsWith(p))) {
const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string } };
const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string }; username?: string };
const sessionId = d.sessionId ?? d.id ?? d.session?.id;
const owner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
// `remote:hostWaking` / `remote:hostWakeFailed` for a create/attach wake have no
// session yet (nothing exists until the host is up), so the registry names the
// requesting user instead; the payload carries `hostId`/`label`, which non-admins
// are not shown elsewhere. No session and no requester: admins only (fail closed).
if (!sessionId && event.startsWith('remote:') && d.username) {
return { username: d.username, sessionScoped: true };
}
return { owner, sessionScoped: true };
}
// #20/#38: clipboard:write writes into the receiver's OS clipboard — route it to
@@ -3116,6 +3141,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)
@@ -3193,7 +3221,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.
@@ -3587,6 +3617,10 @@ export class WebServer extends EventEmitter {
// response), so without this a 10-minute wait holds shutdown open.
sessionWaits.cancelEverything();
approvalInbox.stop();
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
// and `app.close()` (the last line of this method) does not abort in-flight requests —
// so without this a restart during a wake waits out the readiness poll.
this.remoteWake?.stop();
this.lastRecordedTokens.clear();
+16 -3
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 158 event constants organized by category:
* 160 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -14,7 +14,7 @@
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
* - **Tasks** (4): created, completed, failed, updated
* - **Mux** (4): created, killed, died, statsUpdated
* - **Remote auto-reconnect** (3): sessionDropped, sessionReconnected, reconnectExhausted
* - **Remote auto-reconnect / wake** (5): sessionDropped, sessionReconnected, reconnectExhausted, hostWaking, hostWakeFailed
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
* - **Workflow runs** (3): run_discovered, run_updated, run_removed (ultracode / Workflow tool)
@@ -176,7 +176,9 @@ export const MuxDied = 'mux:died' as const;
/** tmux session stats refreshed. */
export const MuxStatsUpdated = 'mux:statsUpdated' as const;
// ─── Remote auto-reconnect (COD-108) ─────────────────────────────────────────
// ─── Remote auto-reconnect (COD-108) + wake-on-LAN ───────────────────────────
// Session-scoped in multi-user mode (`deriveSseHint`): routed to the session's owner,
// or — for a wake with no session yet — to the requesting `username` in the payload.
/** A remote session's local ssh pane died; an auto-reconnect attempt is starting. */
export const RemoteSessionDropped = 'remote:sessionDropped' as const;
@@ -184,6 +186,15 @@ 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.
* Payload: `sessionId` (session wake) or `forNewSession: true` + `username`
* (create/attach wake), `hostId`, `label`, `queuedInput`.
*/
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 +546,8 @@ export const SseEvent = {
RemoteSessionDropped,
RemoteSessionReconnected,
RemoteReconnectExhausted,
RemoteHostWaking,
RemoteHostWakeFailed,
// Respawn
RespawnStarted,
+184
View File
@@ -0,0 +1,184 @@
// Port: none (pure frontend module in a node VM with a fake DOM — no browser, no server).
//
// The remote-host wake banner (src/web/public/host-wake-ui.js) is a SINGLE global
// element that is shown only for the active remote session. The regression this
// guards: `refreshHostWakeBanner` clears `_hostWake` when the tab switches, but
// `_hostWakeTick`'s clear branch only re-rendered when IT was the one clearing —
// so switching from an unreachable remote session to a LOCAL one left the banner
// visible ("Hufflepuff is not reachable") on every chat until a full reload.
//
// The bug is a pure ordering problem between two methods, so it can be reproduced
// here without a browser: render the remote state, switch to a local session, and
// assert the banner is hidden again.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const REMOTE_ID = 'remote-session-0001';
const LOCAL_ID = 'local-session-0001';
type El = { hidden: boolean; textContent: string; disabled: boolean; classList: { add(): void; remove(): void } };
function fakeElement(): El {
return { hidden: false, textContent: '', disabled: false, classList: { add() {}, remove() {} } };
}
const PROXIED_ID = 'remote-session-proxied';
const NOWOL_ID = 'remote-session-nowol';
/** Load `host-wake-ui.js` with the minimal DOM it touches, and return a wired app. */
function loadWakeApp() {
const fetches: string[] = [];
const elements = new Map<string, El>([
['hostWakeBanner', fakeElement()],
['hostWakeBannerText', fakeElement()],
['hostWakeBannerDetail', fakeElement()],
['hostWakeBannerAction', fakeElement()],
]);
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
console,
setInterval: () => 1,
clearInterval: () => {},
fetch: (url: string) => {
fetches.push(url);
return Promise.resolve({ json: () => Promise.resolve({ success: false }) });
},
document: {
visibilityState: 'visible',
getElementById: (id: string) => elements.get(id) ?? null,
addEventListener: () => {},
},
window: {},
});
vm.runInContext(readFileSync(resolve(PUBLIC, 'host-wake-ui.js'), 'utf8'), context, { filename: 'host-wake-ui.js' });
const app = new (CodemanApp as new () => Record<string, unknown>)();
app.$ = (id: string) => elements.get(id) ?? null;
app.activeSessionId = REMOTE_ID;
app.sessions = new Map<string, { remote?: Record<string, unknown> }>([
[
REMOTE_ID,
{ remote: { hostId: 'hufflepuff', host: '192.168.50.137', label: 'Hufflepuff', wakeMac: '04:d9:f5:80:c6:58' } },
],
[
PROXIED_ID,
{
remote: {
hostId: 'bastioned',
host: '10.20.0.5',
label: 'Behind bastion',
jumpHost: 'bastion',
wakeMac: '04:d9:f5:80:c6:58',
},
},
],
[NOWOL_ID, { remote: { hostId: 'plain', host: '10.0.0.9', label: 'Plain' } }],
[LOCAL_ID, {}],
]);
return {
app,
fetches,
banner: elements.get('hostWakeBanner') as El,
text: elements.get('hostWakeBannerText') as El,
};
}
describe('host wake banner visibility', () => {
it('hides the banner when switching from an unreachable remote session to a local one', () => {
const { app, banner, text } = loadWakeApp();
// The banner is up for the active, unreachable remote session.
app._hostWake = {
sessionId: REMOTE_ID,
reachable: false,
wakeConfigured: 'mac',
host: '192.168.50.137',
label: 'Hufflepuff',
waking: false,
error: '',
};
(app._renderHostWakeBanner as () => void)();
expect(banner.hidden).toBe(false);
expect(text.textContent).toBe('Hufflepuff is not reachable');
// Switch to a LOCAL session. `refreshHostWakeBanner` clears the state, and the
// tick that follows must still repaint the (now empty) banner as hidden.
app.activeSessionId = LOCAL_ID;
(app.refreshHostWakeBanner as (id: string) => void)(LOCAL_ID);
expect(app._hostWake).toBeNull();
expect(banner.hidden).toBe(true);
});
it('keeps the banner hidden on a later poller tick once the state is cleared', () => {
const { app, banner } = loadWakeApp();
app.activeSessionId = LOCAL_ID;
app._hostWake = null;
// A page-wide tick on a local session must be idempotent and leave it hidden.
(app._hostWakeTick as () => void)();
expect(banner.hidden).toBe(true);
});
it('shows the banner only while the active session is remote and unreachable', () => {
const { app, banner } = loadWakeApp();
app._hostWake = {
sessionId: REMOTE_ID,
reachable: false,
wakeConfigured: 'mac',
host: '192.168.50.137',
label: 'Hufflepuff',
waking: false,
error: '',
};
(app._renderHostWakeBanner as () => void)();
expect(banner.hidden).toBe(false);
// Reachable again → hidden, state intact (the banner must not leak across the
// reachable/unreachable transition either).
app._hostWake.reachable = true;
(app._renderHostWakeBanner as () => void)();
expect(banner.hidden).toBe(true);
});
});
describe('host wake banner polling', () => {
// Each poll is a TCP connect to the host from the server. The timer is the one
// trigger that is not a user action, so it must not fire for a host Codeman could
// not wake anyway (it cannot wake it, but it can keep an activity-based suspend timer
// from firing), and a proxied host is never polled: the probe cannot reach it.
const tick = (app: Record<string, unknown>, periodic: boolean) =>
(app._hostWakeTick as (o: { periodic: boolean }) => void)({ periodic });
it('polls a wake-configured host on activation and on the timer', () => {
const { app, fetches } = loadWakeApp();
app.activeSessionId = REMOTE_ID;
tick(app, false);
tick(app, true);
tick(app, true);
expect(fetches).toHaveLength(3);
expect(fetches[0]).toContain(`/api/sessions/${REMOTE_ID}/reachability`);
});
it('polls a host without a wake target once on activation, never on the timer', () => {
const { app, fetches } = loadWakeApp();
app.activeSessionId = NOWOL_ID;
tick(app, false);
tick(app, true);
tick(app, true);
expect(fetches).toHaveLength(1);
});
it('never polls a host behind a jump host or SOCKS proxy', () => {
const { app, fetches } = loadWakeApp();
app.activeSessionId = PROXIED_ID;
tick(app, false);
tick(app, true);
expect(fetches).toHaveLength(0);
expect((app._hostWake as { probeable: boolean }).probeable).toBe(false);
});
});
+8
View File
@@ -99,6 +99,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<boolean> => true);
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
* exercising the reliable-delivery path behave like production. */
private _appliedInputSeq = new Map<string, number>();
+114
View File
@@ -6,11 +6,14 @@ import {
defaultRemoteCommandForMode,
readRemoteCases,
readRemoteHosts,
rehydrateRemoteHostFields,
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 +72,115 @@ 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);
});
it('accepts one or more MAC addresses and rejects anything else', () => {
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58' }).success).toBe(true);
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04-d9-f5-80-c6-58, 1C:61:B4:20:58:EB' }).success).toBe(
true
);
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6' }).success).toBe(false);
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58; rm -rf /' }).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('refreshes a MAC that only exists in the host config', () => {
const withMac = new Map(
hosts()
.entries()
.map(([id, host]) => [id, { ...host, wakeMac: '04:d9:f5:80:c6:58' }] as const)
);
expect(rehydrateRemoteHostFields(persisted, withMac)?.wakeMac).toBe('04:d9:f5:80:c6:58');
});
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');
});
});
});
+925
View File
@@ -0,0 +1,925 @@
/**
* @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,
buildMagicPacket,
createDefaultRemoteWakeDeps,
decideRemoteInputAction,
isProbeable,
parseMacList,
probeRemoteHostReachable,
resolveWakeTarget,
runRemoteWakeCommand,
sendWakePackets,
waitUntilRemoteReady,
wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
type RemoteWakeDeps,
type WakeableRemote,
type WakeableSession,
} from '../src/remote-wake.js';
// ========== Pure decisions ==========
describe('decideRemoteInputAction', () => {
const base = { hasWakeTarget: 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, hasWakeTarget: 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('drops an oversized chunk outright instead of delivering a fragment of it', () => {
// One large paste is one input value and was never typed character by character, so its
// tail is not "what the user just typed" — writing it into the pane would run a partial
// command (with the paste's trailing carriage return, if it had one).
const huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100);
expect(appendBoundedPending([], huge)).toEqual([]);
// The bytes already queued are left alone, not replaced by the fragment.
expect(appendBoundedPending(['typed'], huge)).toEqual(['typed']);
});
it('measures the cap in UTF-8 bytes, so a multi-byte paste is dropped too', () => {
const cap = 10;
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes > cap
expect(appendBoundedPending([], value, cap)).toEqual([]);
});
});
describe('MAC parsing + magic packet', () => {
it('parses one or more MACs with either separator', () => {
expect(parseMacList('04:d9:f5:80:c6:58')).toEqual([[4, 217, 245, 128, 198, 88]]);
expect(parseMacList('04-d9-f5-80-c6-58, 1c:61:b4:20:58:eb')).toEqual([
[4, 217, 245, 128, 198, 88],
[28, 97, 180, 32, 88, 235],
]);
});
it('is all-or-nothing so a typo cannot half-arm a host', () => {
expect(parseMacList('04:d9:f5:80:c6')).toBeNull();
expect(parseMacList('04:d9:f5:80:c6:58, nonsense')).toBeNull();
expect(parseMacList('')).toBeNull();
expect(
parseMacList('04:d9:f5:80:c6:58,1c:61:b4:20:58:eb,aa:bb:cc:dd:ee:ff,11:22:33:44:55:66,99:88:77:66:55:44')
).toBeNull();
});
it('builds the documented magic packet byte-for-byte', () => {
// 6 x 0xFF then the MAC repeated 16 times — a packet off by one byte simply never
// wakes anything, so the shape is pinned rather than described.
const mac = [4, 217, 245, 128, 198, 88];
const packet = buildMagicPacket(mac);
expect(packet.length).toBe(6 + 16 * 6);
expect([...packet.subarray(0, 6)]).toEqual([255, 255, 255, 255, 255, 255]);
for (let repeat = 0; repeat < 16; repeat++) {
expect([...packet.subarray(6 + repeat * 6, 12 + repeat * 6)]).toEqual(mac);
}
});
it('binds BEFORE enabling broadcast — the order that silently kills the packet on Linux', async () => {
// `setBroadcast()` on an unbound socket throws EBADF on Linux and the follow-up
// send dies with EACCES, so the magic packet never leaves the machine (verified
// against a real sleeping host). The order is asserted, not described.
const calls: string[] = [];
const sent: { packet: Buffer; port: number; address: string }[] = [];
const packets = await sendWakePackets(
[
[4, 217, 245, 128, 198, 88],
[28, 97, 180, 32, 88, 235],
],
9,
() => ({
bind: (cb: () => void) => {
calls.push('bind');
cb();
},
setBroadcast: () => calls.push('setBroadcast'),
send: (packet: Buffer, port: number, address: string, cb: (err?: Error | null) => void) => {
calls.push('send');
sent.push({ packet, port, address });
cb(null);
},
close: () => calls.push('close'),
once: () => undefined,
})
);
expect(packets).toBe(true);
expect(calls[0]).toBe('bind');
expect(calls[1]).toBe('setBroadcast');
// One 102-byte magic packet per MAC, to the broadcast address on port 9.
expect(sent).toHaveLength(2);
expect(sent.every((s) => s.packet.length === 102 && s.port === 9 && s.address === '255.255.255.255')).toBe(true);
});
it('reports failure when the platform refuses to broadcast', async () => {
const ok = await sendWakePackets([[4, 217, 245, 128, 198, 88]], 9, () => ({
bind: (cb: () => void) => cb(),
setBroadcast: () => {
throw new Error('EBADF');
},
send: () => undefined,
close: () => undefined,
once: () => undefined,
}));
expect(ok).toBe(false);
});
it('resolves the wake target with the command as the explicit override', () => {
const mac = '04:d9:f5:80:c6:58';
expect(resolveWakeTarget(undefined)).toBeNull();
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1' })).toBeNull();
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac })).toEqual({
kind: 'mac',
macs: [[4, 217, 245, 128, 198, 88]],
});
expect(
resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac, wakeCommand: '/bin/wake' })
).toEqual({ kind: 'command', command: '/bin/wake' });
// A malformed MAC (hand-written config) must not arm a broken wake.
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: 'nope' })).toBeNull();
});
it('reports which wake path the UI should offer', () => {
expect(wakeConfigured(undefined)).toBe('none');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x' })).toBe('none');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeMac: '04:d9:f5:80:c6:58' })).toBe('mac');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeCommand: '/bin/wake' })).toBe('command');
});
});
// ========== 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<typeof vi.fn>;
wake: ReturnType<typeof vi.fn>;
waitUntilReady: ReturnType<typeof vi.fn>;
reattachRemote: ReturnType<typeof vi.fn>;
writeViaMux: ReturnType<typeof vi.fn>;
noteReconnected: ReturnType<typeof vi.fn>;
events: string[];
payloads: Array<{ event: string; payload: Record<string, unknown> }>;
}
function harness(
opts: { remote?: WakeableRemote; writesFail?: boolean; resolveRemote?: RemoteWakeDeps['resolveRemote'] } = {}
): 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 payloads: Array<{ event: string; payload: Record<string, unknown> }> = [];
const deps: RemoteWakeDeps = {
probe,
wake,
waitUntilReady,
delay: async () => {},
noteReconnected,
broadcast: (event, payload) => {
events.push(event);
payloads.push({ event, payload });
},
log: () => {},
...(opts.resolveRemote ? { resolveRemote: opts.resolveRemote } : {}),
};
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,
payloads,
};
}
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<boolean>((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({ kind: 'command', command: '/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('reports an oversized chunk as dropped, and flushes as user input so the tab can be named', async () => {
const h = harness();
h.probe.mockResolvedValue(false);
let release: (() => void) | undefined;
h.waitUntilReady.mockImplementation(() => new Promise<boolean>((resolve) => (release = () => resolve(true))));
await expect(h.registry.handleInput(h.session, 'ok')).resolves.toBe('buffered');
// Over the cap: never enters the buffer, and the caller is told — a bare 200 could
// not distinguish delivered from buffered from gone.
await expect(h.registry.handleInput(h.session, 'x'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 1))).resolves.toBe(
'dropped'
);
expect(h.registry.pendingBytes('sess-1')).toBe(2);
release?.();
await h.registry.wake(h.session);
// `fromUser`: a first prompt that was buffered through a wake may still name the tab.
expect(h.writeViaMux).toHaveBeenCalledWith('ok', { fromUser: true });
});
it('drops the buffer when a flush write fails, so nothing is replayed by a later wake', async () => {
// Retaining the chunk was the earlier behaviour, and it was worse: the wake still
// resolves and marks the host reachable, so the next input takes the deliver path
// while the retained chunk waits for the NEXT wake — replayed hours later, after
// everything typed since. Same policy as the oversized paste: dropped, logged.
const h = harness({ writesFail: true });
h.probe.mockResolvedValue(false);
await h.registry.handleInput(h.session, 'abc');
await h.registry.handleInput(h.session, 'def');
await h.registry.wake(h.session);
expect(h.writeViaMux).toHaveBeenCalledTimes(1);
expect(h.registry.pendingBytes('sess-1')).toBe(0);
// And the recovered host takes the deliver path from here, with nothing behind it.
h.probe.mockClear();
await expect(h.registry.handleInput(h.session, 'g')).resolves.toBe('deliver');
expect(h.registry.pendingBytes('sess-1')).toBe(0);
});
it('flushes the chunk it is writing out of the buffer first, so a concurrent enqueue cannot drop a different one', async () => {
// Input arriving DURING the flush is enqueued (`waking` is still set), and the cap
// then drops the OLDEST chunk — the one already on its way to the pane. Shifting the
// buffer after the write removed the NEXT chunk instead, so the drop-oldest
// bookkeeping lost a chunk that was never written.
const h = harness();
h.probe.mockResolvedValue(false);
let release: (() => void) | undefined;
h.waitUntilReady.mockImplementation(
() =>
new Promise<boolean>((resolve) => {
release = () => resolve(true);
})
);
const big = 'a'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES - 10);
await h.registry.handleInput(h.session, big);
await h.registry.handleInput(h.session, 'bbbbbbbbbb'); // fills the cap exactly
// The third chunk arrives while the FIRST write is in flight, which is what pushes
// the buffer over the cap mid-flush.
h.writeViaMux.mockImplementationOnce(async () => {
await h.registry.handleInput(h.session, 'c');
return true;
});
release?.();
await h.registry.wake(h.session);
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual([big, 'bbbbbbbbbb', 'c']);
expect(h.registry.pendingBytes('sess-1')).toBe(0);
});
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('wakes a MAC-configured host by magic packet, with no external command', async () => {
const h = harness({
remote: { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' },
});
h.probe.mockResolvedValue(false);
await expect(h.registry.handleInput(h.session, 'hi')).resolves.toBe('buffered');
await h.registry.wake(h.session);
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hi']);
});
it('resolves host config for a session that predates it, so a saved MAC works live', async () => {
// The persisted `remote` snapshot is taken at launch: without this the banner's
// config dialog would only take effect after restarting the session.
const resolveRemote = vi.fn(async () => ({
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
}));
const h = harness({
remote: { hostId: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137' },
resolveRemote,
});
h.probe.mockResolvedValue(false);
expect(await h.registry.hasWakeTarget(h.session)).toBe(true);
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('buffered');
await h.registry.wake(h.session);
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
expect(resolveRemote).toHaveBeenCalledTimes(1);
// Cached: the next keystroke must not re-read the host config.
await h.registry.hasWakeTarget(h.session);
expect(resolveRemote).toHaveBeenCalledTimes(1);
});
it('consults the resolver on the TTL even when the session has a target', async () => {
// The host config is authoritative in BOTH directions: a target removed in the config
// (or the dialog) must turn the feature off for a live session, which it cannot do if
// the session's own snapshot short-circuits the lookup.
const resolveRemote = vi.fn(async () => ({
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
}));
const h = harness({
remote: {
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
},
resolveRemote,
});
expect(await h.registry.hasWakeTarget(h.session)).toBe(false);
expect(await h.registry.wakeConfigured(h.session)).toBe('none');
// ... and with the feature off there is nothing to buffer for.
expect(await h.registry.handleInput(h.session, 'x')).toBe('deliver');
// Cached for the TTL — not one host-config read per keystroke.
expect(resolveRemote).toHaveBeenCalledTimes(1);
await h.registry.hasWakeTarget(h.session);
expect(resolveRemote).toHaveBeenCalledTimes(1);
});
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);
});
it('never keys the state map on a local session', async () => {
// `hasWakeTarget` runs on EVERY input chunk (it is the route's gate), so allocating
// state before the `!session.remote` return would put an entry — and later a pending
// buffer — in the map for every local session the user types in.
const h = harness();
const local: WakeableSession = {
id: 'local-1',
remote: undefined,
reattachRemote: h.reattachRemote,
writeViaMux: h.writeViaMux,
};
expect(await h.registry.hasWakeTarget(local)).toBe(false);
expect(await h.registry.wakeConfigured(local)).toBe('none');
expect(h.registry.stateCount()).toBe(0);
expect(h.probe).not.toHaveBeenCalled();
});
it('ensureAwake hands the caller’s budget to the readiness poll (the wake button’s case)', async () => {
// The button is pressed from the same dashboard as Run/Attach, so it must not inherit
// the 90 s session default and get cut off by the proxy's 60 s read timeout.
const h = harness();
h.probe.mockResolvedValue(false);
await expect(
h.registry.ensureAwake(h.session, { force: true, timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
).resolves.toBe(true);
expect(h.waitUntilReady).toHaveBeenCalledWith(remote, {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
signal: expect.any(AbortSignal),
});
});
});
// ========== Host-scoped wake (session create/attach) ==========
describe('isProbeable', () => {
const base: WakeableRemote = { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' };
it('is true for a host reached directly', () => {
expect(isProbeable(base)).toBe(true);
expect(isProbeable({ ...base, extraSshOptions: ['ServerAliveCountMax=3', 'StrictHostKeyChecking=no'] })).toBe(true);
});
it('is false behind a jump host, a SOCKS proxy, or a ProxyCommand/ProxyJump option', () => {
expect(isProbeable({ ...base, jumpHost: 'bastion.example' })).toBe(false);
expect(isProbeable({ ...base, socksProxy: '127.0.0.1:1080' })).toBe(false);
expect(isProbeable({ ...base, extraSshOptions: ['ProxyCommand=cloudflared access ssh --hostname %h'] })).toBe(
false
);
expect(isProbeable({ ...base, extraSshOptions: ['proxyjump=bastion'] })).toBe(false);
});
});
describe('RemoteWakeRegistry — a proxied host is reachability-unknown', () => {
// The bare TCP probe connects to `host:port`, which a jump-host/SOCKS host does not
// answer even while ssh works. Acting on that verdict buffered input for the life of
// the session (the readiness poll could never succeed), showed a permanent banner and
// hid the real ssh error behind "not reachable". Unknown is not asleep.
const proxied: WakeableRemote = {
hostId: 'behind-bastion',
label: 'Behind bastion',
host: '10.20.0.5',
jumpHost: 'bastion.example',
wakeCommand: '/usr/local/bin/wake-behind-bastion',
};
it('delivers every input without probing, buffering or waking', async () => {
const h = harness({ remote: proxied });
await expect(h.registry.handleInput(h.session, 'ls\r')).resolves.toBe('deliver');
await expect(h.registry.handleInput(h.session, 'pwd\r')).resolves.toBe('deliver');
expect(h.probe).not.toHaveBeenCalled();
expect(h.wake).not.toHaveBeenCalled();
expect(h.registry.pendingBytes('sess-1')).toBe(0);
});
it('answers null (unknown), never false, so the UI has no banner to raise', async () => {
const h = harness({ remote: proxied });
await expect(h.registry.checkReachable(h.session, { force: true })).resolves.toBeNull();
await expect(h.registry.checkHostReachable(proxied, { force: true })).resolves.toBeNull();
expect(h.probe).not.toHaveBeenCalled();
});
it('does not gate a create/attach request on it (unprobeable, like no-target)', async () => {
const h = harness({ remote: proxied });
await expect(h.registry.ensureHostAwake(proxied)).resolves.toBe('unprobeable');
expect(h.probe).not.toHaveBeenCalled();
expect(h.wake).not.toHaveBeenCalled();
});
it('lets the send-and-wait path through, and fires the manual wake blind', async () => {
const h = harness({ remote: proxied });
await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true);
expect(h.wake).not.toHaveBeenCalled();
// The button: the user asked, so the target goes out — but nothing can verify the
// host came back, so there is no readiness poll, no reattach and no "waking" toast
// promising a wait that does not happen.
await expect(h.registry.ensureAwake(h.session, { force: true })).resolves.toBe(true);
expect(h.wake).toHaveBeenCalledTimes(1);
expect(h.waitUntilReady).not.toHaveBeenCalled();
expect(h.reattachRemote).not.toHaveBeenCalled();
expect(h.events).toEqual([]);
h.wake.mockResolvedValueOnce(false);
await expect(h.registry.ensureAwake(h.session, { force: true })).resolves.toBe(false);
// A wake IO that throws is a failed wake, not a rejected route — and the public
// `wake()` takes the same blind path, so nobody can poll readiness through a proxy.
h.wake.mockRejectedValueOnce(new Error('udp socket exploded'));
await expect(h.registry.wake(h.session)).resolves.toBe(false);
expect(h.waitUntilReady).not.toHaveBeenCalled();
});
});
describe('RemoteWakeRegistry — SSE payload routing', () => {
const hostRemote: WakeableRemote = {
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
};
it('a session wake names its session, so the server routes it to the owner', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
await h.registry.handleInput(h.session, 'x');
await h.registry.wake(h.session);
const waking = h.payloads.find((p) => p.event === 'remote:hostWaking')!;
expect(waking.payload).toMatchObject({ sessionId: 'sess-1', hostId: 'hufflepuff', label: 'Hufflepuff' });
expect(waking.payload).not.toHaveProperty('username');
});
it('a create/attach wake has no session, so it names the requesting user instead', async () => {
// Without it the server can only fail closed (admins only) — the requester would
// never see their own wake. The payload carries `hostId`/`label`, which non-admins
// are not shown elsewhere, so it must not go global either.
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
h.waitUntilReady.mockResolvedValue(false);
await expect(h.registry.ensureHostAwake(hostRemote, { requestedBy: 'alice' })).resolves.toBe('failed');
const [waking, failed] = ['remote:hostWaking', 'remote:hostWakeFailed'].map(
(event) => h.payloads.find((p) => p.event === event)!.payload
);
expect(waking).toMatchObject({ forNewSession: true, username: 'alice' });
expect(failed).toMatchObject({ forNewSession: true, username: 'alice' });
expect(waking).not.toHaveProperty('sessionId');
});
it('omits the requester when the route did not name one (single-user mode)', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
await h.registry.ensureHostAwake(hostRemote);
expect(h.payloads.find((p) => p.event === 'remote:hostWaking')!.payload).not.toHaveProperty('username');
});
});
describe('real IO is refused under vitest', () => {
// Every consumer injects its IO (RemoteWakeDeps, the socket factory). The guard is
// what makes that seam mandatory: a test that reaches the defaults fails loudly here
// instead of opening a TCP connection, spawning a process or broadcasting UDP from CI.
const target: WakeableRemote = { hostId: 'h', label: 'H', host: '127.0.0.1', port: 1 };
it('the TCP probe', () => {
expect(() => probeRemoteHostReachable(target)).toThrow(/disabled under test/);
});
it('the wake command', () => {
expect(() => runRemoteWakeCommand('/bin/true')).toThrow(/disabled under test/);
});
it('the UDP broadcast — only with the DEFAULT socket, an injected one still works', async () => {
await expect(sendWakePackets([[1, 2, 3, 4, 5, 6]])).rejects.toThrow(/disabled under test/);
});
it('the readiness poll, which probes by default', async () => {
await expect(waitUntilRemoteReady(target, { timeoutMs: 10, intervalMs: 1 })).rejects.toThrow(/disabled under test/);
});
it('the default deps poll readiness with the INJECTED probe, never the real one', async () => {
// `createDefaultRemoteWakeDeps({ probe })` used to override `probe` alone while
// `waitUntilReady` kept the module default — so a shutdown test polled a production
// address until the guard above made it fail instead of connecting.
const probe = vi.fn(async () => true);
const deps = createDefaultRemoteWakeDeps({ probe });
await expect(deps.waitUntilReady(target, { timeoutMs: 10 })).resolves.toBe(true);
expect(probe).toHaveBeenCalledWith(target);
});
});
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,
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
signal: expect.any(AbortSignal),
});
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');
});
it('stop() resolves an in-flight wake as failed, so shutdown cannot wait it out', async () => {
// `WebServer.stop()` ends with `app.close()`, which does not abort in-flight requests:
// without this the shutdown sits out the whole readiness poll. Real `waitUntilReady`
// (abortable sleep) with fake probe/wake, which is the shape of a restart mid-wake.
const registry = new RemoteWakeRegistry(
createDefaultRemoteWakeDeps({ probe: async () => false, wake: async () => true, log: () => {} })
);
const pending = registry.ensureHostAwake(hostRemote, { timeoutMs: 60_000 });
await vi.waitFor(() => expect(registry.isWaking('host:hufflepuff')).toBe(true));
registry.stop();
await expect(pending).resolves.toBe('failed');
// ... and nothing new starts afterwards.
await expect(registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
});
});
describe('waitUntilRemoteReady', () => {
const remote: WakeableRemote = { hostId: 'h', label: 'H', host: '10.0.0.9' };
it('ends on abort instead of waiting out the current interval', async () => {
const controller = new AbortController();
const started = Date.now();
const pending = waitUntilRemoteReady(remote, {
intervalMs: 1_000,
timeoutMs: 60_000,
probe: async () => false,
signal: controller.signal,
});
setTimeout(() => controller.abort(), 10);
await expect(pending).resolves.toBe(false);
expect(Date.now() - started).toBeLessThan(1_000);
});
it('returns false immediately when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const probe = vi.fn(async () => true);
await expect(waitUntilRemoteReady(remote, { probe, signal: controller.signal })).resolves.toBe(false);
expect(probe).not.toHaveBeenCalled();
});
});
// ========== 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 route module and the server may import remote-wake', () => {
// The auto-reconnect watcher (tmux-manager.ts), the server's dropped-session
// handler and any boot-recovery path must NOT be able to WAKE a host: waking there
// re-wakes the host seconds after each suspend. `web/server.ts` is allowed to hold
// the registry for its LIFETIME only (`drop()` on session cleanup, `stop()` on
// shutdown) — the test below pins that it never calls a waking method, which is the
// property this import list is an approximation of.
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), join('web', 'server.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());
});
it('the server only ever calls drop/stop on the registry — never a waking method', () => {
// `server.ts` holds the registry because `cleanupSession` and `stop()` need it, and
// those run on timers and shutdown paths. Any wake-capable call from this file is the
// exact failure invariant #1 exists to prevent, so it is asserted here rather than
// left to the import check above (which the field's type alone would satisfy).
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
for (const method of [
'wake',
'ensureAwake',
'ensureHostAwake',
'handleInput',
'checkReachable',
'checkHostReachable',
]) {
expect(server).not.toContain(`remoteWake.${method}(`);
expect(server).not.toContain(`remoteWake?.${method}(`);
}
expect(server).toContain('remoteWake?.drop(');
expect(server).toContain('remoteWake?.stop(');
});
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());
});
});
+390
View File
@@ -0,0 +1,390 @@
/**
* @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 { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getDataDir } from '../../src/config/instance.js';
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 { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.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';
/**
* Mirror production's envelope + status mapping (as inbox-routes.test.ts does): a
* returned `createErrorResponse` carries its 4xx, a plain object is wrapped in
* `{success:true, data}`. Without it every error would read as a 200.
*/
function installEnvelope(app: FastifyInstance): void {
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
}
const URL = `/api/sessions/${SESSION_ID}/input`;
afterEach(() => {
sessionWaits.cancelAll(SESSION_ID);
_resetPaneLivenessState();
});
interface Harness {
app: FastifyInstance;
ctx: ReturnType<typeof createMockRouteContext>;
registry: RemoteWakeRegistry;
probe: ReturnType<typeof vi.fn>;
wake: ReturnType<typeof vi.fn>;
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;
/** Stands in for the auth middleware (multi-user mode); absent = synthetic admin. */
authUser?: { username: string; role: 'admin' | 'user' };
} = {}
): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
if (opts.authUser) {
const authUser = opts.authUser;
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
}
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<boolean>((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 });
installEnvelope(app);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, registry, probe, wake, events, releaseWake: () => release?.() };
}
const send = (app: FastifyInstance, payload: Record<string, unknown>) =>
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({ success: true, data: { buffered: true } });
// Nothing reached the pane: writing now would be swallowed by the stalled ssh.
expect(session.writeBuffer).toEqual([]);
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/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('flushes several inputs typed during a wake IN ORDER (the browser posts one per keystroke)', async () => {
// The concurrency surface that only exists in production: xterm's onData posts each
// keystroke as its OWN request, so a wake collects N concurrent buffer writes and must
// replay them in order. Route-level, so it is covered on every run instead of only in a
// hand-driven browser session.
const h = await harness({ hostUp: false, holdWake: true });
const session = h.ctx.sessions.get(SESSION_ID)!;
for (const chunk of ['h', 'a', 'llo']) {
const res = await send(h.app, { input: chunk, useMux: true });
expect(res.statusCode).toBe(200);
}
// Nothing written while the host is asleep/dead — that is the whole point.
expect(session.writeBuffer).toEqual([]);
h.releaseWake();
await h.registry.wake(session);
expect(session.writeBuffer).toEqual(['h', 'a', 'llo']);
});
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({ success: true, data: {} }); // the historical bare answer, untouched
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('writes straight into a proxied host with a wake target: no probe, no buffer, no wake', async () => {
// With a target configured, the old verdict buffered EVERY input for the life of
// the session: the readiness poll can never succeed through a proxy, so nothing was
// ever flushed (three inputs, nothing written, buffer non-empty — reproduced upstream).
const h = await harness({ remote: { ...remoteSession, socksProxy: '127.0.0.1:1080' }, hostUp: false });
const session = h.ctx.sessions.get(SESSION_ID)!;
for (const input of ['a', 'b', 'c']) expect((await send(h.app, { input, useMux: true })).statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['a', 'b', 'c']);
expect(h.probe).not.toHaveBeenCalled();
expect(h.wake).not.toHaveBeenCalled();
expect(h.registry.pendingBytes(SESSION_ID)).toBe(0);
});
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']);
});
});
describe('GET /api/sessions/:id/reachability', () => {
const get = (app: FastifyInstance, url: string) => app.inject({ method: 'GET', url });
it('reports the probe result and how the host can be woken', async () => {
const up = await harness({ hostUp: true });
const upBody = (await get(up.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
expect(upBody.data.reachable).toBe(true);
expect(upBody.data.wakeConfigured).toBe('command');
expect(upBody.data.label).toBe('Hufflepuff');
const down = await harness({ hostUp: false });
const downBody = (await get(down.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
expect(downBody.data.reachable).toBe(false);
// A reachability check is a QUESTION, never an action: the host stays asleep.
expect(down.wake).not.toHaveBeenCalled();
});
it('says nothing can wake a host without a configured target', async () => {
const { wakeCommand, ...withoutWake } = remoteSession;
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
expect(body.data.reachable).toBe(false);
expect(body.data.wakeConfigured).toBe('none');
});
it('reports a proxied host as unknown, not unreachable, and never probes it', async () => {
// A jump-host / SOCKS host does not answer the bare TCP probe even while ssh works;
// `reachable:false` here drew a permanent banner over a healthy session.
const h = await harness({ remote: { ...remoteSession, jumpHost: 'bastion.example' }, hostUp: false });
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
expect(body.data.reachable).toBeNull();
expect(body.data.probeable).toBe(false);
expect(body.data.wakeConfigured).toBe('command');
expect(h.probe).not.toHaveBeenCalled();
});
});
describe('POST /api/sessions/:id/wake', () => {
const wake = (app: FastifyInstance) => app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/wake` });
it('wakes the host, reattaches the pane and reports both', async () => {
const h = await harness({ hostUp: false });
const session = h.ctx.sessions.get(SESSION_ID)!;
const body = (await wake(h.app)).json();
expect(body.success).toBe(true);
expect(body.data.woke).toBe(true);
expect(body.data.reachable).toBe(true);
expect(session.reattachRemote).toHaveBeenCalled();
});
it('answers with an error the UI can route to the config dialog', async () => {
const { wakeCommand, ...withoutWake } = remoteSession;
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
const res = await wake(h.app);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toMatch(/No wake-on-LAN target/);
expect(h.wake).not.toHaveBeenCalled();
});
it('does not send a wake when the host answers, but still settles the session', async () => {
const h = await harness({ hostUp: true });
const body = (await wake(h.app)).json();
expect(body.data.woke).toBe(true);
expect(h.wake).not.toHaveBeenCalled();
});
});
describe('POST /api/sessions + attachRemoteSession — authorization before the wake', () => {
// Remote hosts are admin-only infra everywhere else, and the attach wake spawns the
// host's `wakeCommand` (or broadcasts a packet). Before this gate a non-admin could
// post an attach for any configured hostId, have that executable run and the request
// held for the wake budget, and only THEN get a 403 for the workingDir (reproduced
// upstream: wake spy fired once, response 403).
it('403s a non-admin in multi-user mode without probing or waking the host', async () => {
const prev = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
try {
// `session-routes.ts` reads hosts from the sandboxed data dir (module-load-time
// constant), so a host with a wake command is written THERE: a regression would
// find it and fire the spy.
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([
{
id: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
username: 'j',
wakeCommand: '/home/joe/bin/whuff',
},
])
);
const h = await harness({ hostUp: false, authUser: { username: 'mallory', role: 'user' } });
const res = await h.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
});
expect(res.statusCode).toBe(403);
expect(res.json().error).toMatch(/admin-only/);
expect(h.probe).not.toHaveBeenCalled();
expect(h.wake).not.toHaveBeenCalled();
expect(h.events).toEqual([]);
await h.app.close();
} finally {
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prev;
}
});
});
describe('POST /api/sessions/:id/input — what the caller is told', () => {
it('says buffered, and dropped for a chunk over the wake buffer cap', async () => {
// The non-wait branch always answered a bare `{}`; these fields are additive. Without
// them a prompt over 4 KB posted to a sleeping host was accepted and silently lost.
const h = await harness({ hostUp: false, holdWake: true });
const small = await send(h.app, { input: 'hallo', useMux: true });
expect(small.statusCode).toBe(200);
expect(small.json()).toEqual({ success: true, data: { buffered: true } });
const big = await send(h.app, { input: 'x'.repeat(5000), useMux: true });
expect(big.statusCode).toBe(200);
expect(big.json()).toEqual({ success: true, data: { buffered: true, dropped: true } });
expect(h.registry.pendingBytes(SESSION_ID)).toBe(5);
h.releaseWake();
await h.registry.wake(h.ctx.sessions.get(SESSION_ID)!);
});
it('fails the send-and-wait path when the host never comes back, instead of writing into the stalled pane', async () => {
// Readiness never arrives: the wake resolves false.
const failing = await harnessWithFailingWake();
const session = failing.ctx.sessions.get(SESSION_ID)!;
const res = await send(failing.app, { input: 'hallo', useMux: true, wait: true, waitTimeout: 1000 });
expect(res.statusCode).toBe(422);
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(res.json().error).toMatch(/did not come back/);
expect(session.writeBuffer).toEqual([]);
await failing.app.close();
});
});
/** A harness whose readiness poll answers false: the wake command runs, the host stays down. */
async function harnessWithFailingWake(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
ctx.sessions.get(SESSION_ID)!.remote = remoteSession;
const probe = vi.fn(async () => false);
const wake = vi.fn(async () => true);
const events: string[] = [];
const registry = new RemoteWakeRegistry({
probe,
wake,
waitUntilReady: async () => false,
delay: async () => {},
noteReconnected: () => {},
broadcast: (event) => events.push(event),
log: () => {},
});
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
installEnvelope(app);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, registry, probe, wake, events, releaseWake: () => {} };
}
+149 -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 { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
interface LocalHarness {
@@ -62,6 +63,15 @@ interface LocalHarness {
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
* (server.ts preSerialization hook) so the test wire format matches the contract:
@@ -108,7 +118,17 @@ describe('session-routes', () => {
let harness: LocalHarness;
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
remoteStore.hosts = [];
remoteStore.cases = [];
@@ -1990,6 +2010,134 @@ describe('session-routes', () => {
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,
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
signal: expect.any(AbortSignal),
});
} 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 () => {
// 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.
+78
View File
@@ -0,0 +1,78 @@
/**
* @fileoverview Static guard: every SSE dispatch entry must actually resolve.
*
* `app.js` dispatches server events through a table of `[SSE_EVENTS.X, '_onFoo']`
* pairs. Both halves fail SILENTLY when they are wrong:
*
* - a handler name that exists in no module (renamed method, typo) → the event is
* received and nothing happens, with no error anywhere;
* - an `SSE_EVENTS.X` key that `constants.js` does not define → the table key is
* `undefined`, so the entry can never match an incoming event.
*
* Both have happened in this codebase's feature areas (a new banner/toast that simply
* never appears), and neither is visible to a test that only checks the modules compile.
* Pure static analysis — no server, no browser.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const PUBLIC_DIR = fileURLToPath(new URL('../src/web/public', import.meta.url));
const appJs = readFileSync(join(PUBLIC_DIR, 'app.js'), 'utf-8');
const constantsJs = readFileSync(join(PUBLIC_DIR, 'constants.js'), 'utf-8');
const allModules = readdirSync(PUBLIC_DIR)
.filter((name) => name.endsWith('.js'))
.map((name) => readFileSync(join(PUBLIC_DIR, name), 'utf-8'))
.join('\n');
/** `[SSE_EVENTS.FOO, '_onFoo'],` entries of the dispatch table. */
function dispatchEntries(): { constant: string; handler: string }[] {
const entries: { constant: string; handler: string }[] = [];
const re = /\[SSE_EVENTS\.([A-Z0-9_]+),\s*'(_[A-Za-z0-9_]+)'\]/g;
for (const match of appJs.matchAll(re)) {
entries.push({ constant: match[1], handler: match[2] });
}
return entries;
}
describe('SSE dispatch table', () => {
it('has entries to check (the table is what this guard exists for)', () => {
expect(dispatchEntries().length).toBeGreaterThan(20);
});
it('names only events that constants.js defines', () => {
const defined = new Set([...constantsJs.matchAll(/^\s{2}([A-Z0-9_]+):\s*'/gm)].map((m) => m[1]));
const missing = dispatchEntries()
.map((entry) => entry.constant)
.filter((name) => !defined.has(name));
expect(missing).toEqual([]);
});
it('names only handlers that some frontend module actually defines', () => {
const missing = dispatchEntries()
.map((entry) => entry.handler)
.filter((handler) => !new RegExp(`(^|\\s)${handler}\\s*\\(`, 'm').test(allModules));
expect(missing).toEqual([]);
});
it('defines every handler in exactly ONE module (a second copy is shadowed)', () => {
// Modules mix into `CodemanApp.prototype` and run in script order, so two
// definitions of the same handler name silently shadow each other: the later file
// wins and the earlier one never runs. The existence check above cannot see that
// (both names resolve), which is how a duplicate banner handler can leave a toast
// dead with no error anywhere.
const byModule = readdirSync(PUBLIC_DIR)
.filter((name) => name.endsWith('.js'))
.map((name) => ({ name, source: readFileSync(join(PUBLIC_DIR, name), 'utf-8') }));
const shadowed = dispatchEntries()
.map((entry) => entry.handler)
.filter((handler) => {
const re = new RegExp(`(^|\\s)${handler}\\s*\\(`, 'm');
return byModule.filter((mod) => re.test(mod.source)).length > 1;
});
expect(shadowed).toEqual([]);
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* @fileoverview Multi-user routing of the `remote:*` SSE family (server.ts `deriveSseHint`).
*
* The wake events carry `hostId`/`label`, which `GET /api/remote-hosts` withholds from
* non-admins, and their toast fires before any session check on the client — so an
* event that falls through to the global branch shows every logged-in user "Waking
* <label>" for a session they do not own. Constructs the server without starting it:
* the hint is a pure function of the event, the payload and the sessions map.
*/
import { describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
type Hint = { owner?: string; username?: string; adminOnly?: boolean; sessionScoped?: boolean } | undefined;
function hintFor(event: string, payload: Record<string, unknown>, owners: Record<string, string> = {}): Hint {
const server = new WebServer(3999, false, true) as unknown as {
sessions: Map<string, { owner?: string }>;
deriveSseHint(event: string, data: unknown): Hint;
};
for (const [id, owner] of Object.entries(owners)) server.sessions.set(id, { owner });
return server.deriveSseHint(event, payload);
}
describe('deriveSseHint — remote: events are session-scoped', () => {
it('routes a session wake to that session’s owner', () => {
expect(hintFor('remote:hostWaking', { sessionId: 's1', hostId: 'h', label: 'H' }, { s1: 'alice' })).toEqual({
owner: 'alice',
sessionScoped: true,
});
expect(hintFor('remote:sessionReconnected', { sessionId: 's1' }, { s1: 'alice' })).toEqual({
owner: 'alice',
sessionScoped: true,
});
});
it('routes a create/attach wake (no session yet) to the user who asked for it', () => {
expect(hintFor('remote:hostWaking', { forNewSession: true, username: 'bob', hostId: 'h', label: 'H' })).toEqual({
username: 'bob',
sessionScoped: true,
});
expect(hintFor('remote:hostWakeFailed', { forNewSession: true, username: 'bob', hostId: 'h' })).toEqual({
username: 'bob',
sessionScoped: true,
});
});
it('fails closed (admins only) when it names neither a session nor a requester', () => {
const hint = hintFor('remote:hostWaking', { forNewSession: true, hostId: 'h', label: 'H' });
expect(hint).toEqual({ owner: undefined, sessionScoped: true });
});
it('never lets a wake event reach the global branch', () => {
expect(hintFor('remote:hostWaking', {})).not.toBeUndefined();
expect(hintFor('remote:reconnectExhausted', {})).not.toBeUndefined();
});
});