mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
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:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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._-]+$`),
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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 · <span id="wakeConfigHostLabel"></span></h3>
|
||||
<button class="modal-close" onclick="app.closeWakeConfigDialog()" aria-label="Close">×</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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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: () => {} };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user