diff --git a/CLAUDE.md b/CLAUDE.md index 196ad22e..3c269cfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **AI** | `src/ai-checker-base.ts`, `ai-idle-checker.ts`, `ai-plan-checker.ts` | | | **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | | | **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts`, `intent-store.ts`, `tab-layout.ts` (pure model) + `-service` (sole mutation boundary) + `-persistence` + `-legacy-order` | | -| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns | +| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` + `remote-wake` (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-`, 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) and flushed in order after `reattachRemote()` — a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later; send-and-wait blocks instead. ⚠️ 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 ` 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 ``. 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` 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`). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index fb4c727f..6b915a9d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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 ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id || claude --resume ` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: ` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`. ### Docker cases diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 2c7f6b35..15dde0df 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -352,6 +352,161 @@ 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: + +- **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 +520,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._-]+$`), diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 37689ac2..3dea3bde 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -540,6 +540,32 @@ export function remoteDisplayPath( return `${remote.username}@${remote.host}:${path}`; } +/** + * Refresh HOST-level config on a RESTORED `SessionRemote`. + * + * A session's `remote` block is persisted at launch time (mux-sessions.json / + * state.json) and recovery uses that snapshot, so a field ADDED to the host config + * later never reaches an already-running session — not even across a Codeman + * restart. That is exactly how a `wakeCommand` added to `remote-hosts.json` would + * silently do nothing until the session is relaunched (which for an owned remote + * session means killing the remote tmux). + * + * Deliberately narrow: ONLY `wakeCommand`/`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( + remote: T | undefined, + hostsById: ReadonlyMap +): 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, diff --git a/src/remote-wake.ts b/src/remote-wake.ts new file mode 100644 index 00000000..170eff28 --- /dev/null +++ b/src/remote-wake.ts @@ -0,0 +1,1010 @@ +/** + * @fileoverview Wake a SLEEPING remote host from user input (user-triggered Wake-on-LAN). + * + * A durable remote session survives SSH drops (COD-104) and auto-reconnects + * (COD-108), but nothing brings the HOST back: if the remote machine suspended, + * the local tmux pane's `ssh` child stalls silently. `tmux send-keys` then + * SUCCEEDS against a pane that will never deliver the bytes, so typed input is + * lost with no error anywhere — the failure this module exists to close. + * + * Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN): + * - An EXPLICIT request wakes a host, and nothing else: user input on an + * established session (`handleInput`), the wake button (`ensureAwake`), or the + * user's own session create/attach request (`ensureHostAwake`, wired in the HTTP + * routes). Everything that runs on a TIMER — the auto-reconnect watcher, boot + * recovery, the reachability probe, session discovery — must never wake one, or + * a host would be re-woken ~45 s after each suspend and could never stay asleep + * (the "keepalive pings a sleeping host" failure already solved for a different + * consumer by `hufflepuff-mcp-lazy`). The create path is deliberately wired in + * `session-routes.ts` and NOT in the shared session service, because + * `cron-service.ts` builds sessions there without a user waiting on the answer. + * - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client, + * a few hundred bytes — below any meaningful activity threshold), throttled + * per session. No SSH keepalive is added to the launch command: keepalives + * would move bytes into an otherwise idle connection every interval, which is + * exactly the "an open pipe keeps the host awake" bug the remote-side idle + * detector was rewritten to avoid. + * - While a wake is in flight, input is BUFFERED and flushed in order once the + * pane is reattached, so the user's first characters after a long pause are + * not the ones that get eaten. + * + * The pure decisions and the IO are separated so the decision table can be + * unit-tested without tmux, ssh, or a real host. + * + * @module remote-wake + */ + +import { spawn } from 'node:child_process'; +import dgram from 'node:dgram'; +import net from 'node:net'; + +/** Minimum spacing between two reachability probes for the same session. */ +export const REMOTE_WAKE_PROBE_MIN_INTERVAL_MS = 30_000; +/** TCP-connect timeout for a reachability probe (host awake ≈ a few ms). */ +export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500; +/** Poll spacing while waiting for a woken host to accept SSH again. */ +export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500; +/** Bounded wait for the host to come back after the wake command ran. */ +export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000; +/** + * Budget for a wake that an HTTP REQUEST is waiting on (session create/attach). + * Deliberately shorter than {@link REMOTE_WAKE_READY_TIMEOUT_MS}: the dashboard is + * served through a reverse proxy whose default `proxy_read_timeout` is 60 s, so a + * 90 s wait would be cut off AT THE PROXY while the session was still being built — + * the browser reports a failure for a session that exists. The budget has to cover + * the WHOLE request, not just the wait: 40 s here + the 1.5 s reachability probe + + * the tmux prereq probe's own 15 s timeout = 56.5 s worst case, still under 60 s. + * A warm S3 resume measures ~12 s, so 40 s is >3× the observed wake. + */ +export const REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS = 40_000; +/** The wake command itself must not hang the wake flow. */ +export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000; +/** + * Settle time between respawning the ssh pane and flushing buffered input: the + * respawned `ssh` needs a moment to run `tmux -L codeman-remote … -A` and attach, + * and bytes written into a still-connecting pane land in nothing. + */ +export const REMOTE_WAKE_ATTACH_SETTLE_MS = 1_500; +/** + * Cap on buffered input per session while a host is being woken. 4 KB is a lot + * of typing for a ~10 s wake; beyond it the OLDEST bytes are dropped (keeping the + * tail preserves what the user just typed, and a silently unbounded buffer would + * be a memory leak keyed on user input). + */ +export const REMOTE_WAKE_PENDING_MAX_BYTES = 4096; +/** Default SSH port used when the host config has no explicit `port`. */ +export const DEFAULT_SSH_PORT = 22; + +/** What the input path should do with a chunk of user input. Pure. */ +export type RemoteInputAction = 'deliver' | 'probe' | 'buffer'; +/** + * The caller-facing outcome of {@link RemoteWakeRegistry.handleInput}: either the + * caller writes the bytes as usual, or the registry took ownership of them. + */ +export type RemoteInputOutcome = 'deliver' | 'buffered'; + +/** + * Decide what to do with an input chunk on an input route. Mirrors + * {@link RemoteWakeRegistry.handleInput} so the throttle table has exactly ONE + * definition and is unit-testable: + * + * - a wake already in flight → buffer (the flush owns delivery), + * - no wake command configured → deliver (feature off, today's behavior), + * - the last probe said "down" → buffer (no second probe; re-probing a known + * sleeping host on every keystroke would add seconds of latency per character), + * - never probed / throttle window elapsed → probe, + * - probed "up" inside the window → deliver. + * + * Pure — no clock, no IO. + */ +export function decideRemoteInputAction(args: { + hasWakeTarget: boolean; + waking: boolean; + probeAgeMs: number; + lastReachable?: boolean; + minProbeIntervalMs?: number; +}): RemoteInputAction { + if (args.waking) return 'buffer'; + if (!args.hasWakeTarget) return 'deliver'; + if (args.lastReachable === false) return 'buffer'; + const interval = args.minProbeIntervalMs ?? REMOTE_WAKE_PROBE_MIN_INTERVAL_MS; + if (args.probeAgeMs >= interval) return 'probe'; + return 'deliver'; +} + +/** + * Append `data` to the pending buffer, dropping the OLDEST whole chunks when the cap + * is exceeded. Returns the resulting buffer — the SAME array reference when the chunk + * was rejected, so the caller can tell the two apart. Pure. + * + * A chunk LARGER than the cap is dropped outright rather than trimmed: one paste is + * one `input` value, and it was never typed character by character, so delivering its + * tail would execute a fragment of it (with the trailing carriage return, if the paste + * had one) — a partial command the user never sent. Keeping the tail is right for + * typing, where the newest bytes are the ones the user just produced, and wrong for a + * chunk that arrived whole. + */ +export function appendBoundedPending( + pending: string[], + data: string, + maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES +): string[] { + if (Buffer.byteLength(data) > maxBytes) return pending; + const next = [...pending, data]; + let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + while (next.length > 1 && total > maxBytes) { + total -= Buffer.byteLength(next[0]); + next.shift(); + } + return next; +} + +/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */ +export interface WakeableRemote { + wakeCommand?: string; + wakeMac?: string; + hostId: string; + label: string; + host: string; + port?: number; + /** SSH jump host (`-J`): the host is reached THROUGH it, never directly. */ + jumpHost?: string; + /** SOCKS5 proxy (`ProxyCommand=nc -X 5 …`): same, the direct address may not even route. */ + socksProxy?: string; + /** Extra `-o KEY=VALUE` options; a `ProxyCommand`/`ProxyJump` in here proxies the host too. */ + extraSshOptions?: string[]; +} + +/** + * Whether the bare TCP probe can answer for this host at all. Pure. + * + * The probe connects straight to `host:port`. A host behind a jump host or a SOCKS + * proxy (the cloudflared case) is reachable ONLY through that proxy, so the direct + * connect fails while ssh works — and every consumer of the verdict would then act on + * a "sleeping" host that is fine: a permanent banner, a create-path gate that hides the + * real ssh error, and (with a wake target) input buffered for the life of the session + * because the readiness poll can never succeed. Such a host is reachability-UNKNOWN: + * the registry never buffers for it, never gates on it, and reports `null` rather than + * `false`. A wake target can still be fired for it, blind. + */ +export function isProbeable(remote: WakeableRemote): boolean { + if (remote.jumpHost || remote.socksProxy) return false; + return !(remote.extraSshOptions ?? []).some((option) => /^\s*proxy(command|jump)\s*=/i.test(option)); +} + +/** + * A resolved wake path for a host. `command` wins over `mac` (an explicit override + * beats the default path), and `null` means the host cannot be woken at all — which + * is what the UI turns into "configure WoL" instead of "wake". + */ +export type WakeTarget = { kind: 'command'; command: string } | { kind: 'mac'; macs: number[][] } | null; + +/** + * Resolve the wake target from host config. Pure. + * + * A malformed `wakeMac` resolves to `null` rather than throwing: the schema + * already rejects one at config time, so this can only be reached with a config + * written by hand, and a broken MAC must not break the input route. + */ +export function resolveWakeTarget(remote: WakeableRemote | undefined): WakeTarget { + if (!remote) return null; + if (remote.wakeCommand) return { kind: 'command', command: remote.wakeCommand }; + if (remote.wakeMac) { + const macs = parseMacList(remote.wakeMac); + if (macs && macs.length > 0) return { kind: 'mac', macs }; + } + return null; +} + +/** + * Parse a comma-separated MAC list into byte arrays. Pure; returns null when any + * entry is malformed (all-or-nothing, so a typo cannot half-arm a host). + */ +export function parseMacList(value: string, maxMacs = 4): number[][] | null { + const parts = value + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (parts.length === 0 || parts.length > maxMacs) return null; + const macs: number[][] = []; + for (const part of parts) { + const match = + /^([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})$/.exec( + part + ); + if (!match) return null; + macs.push(match.slice(1).map((hex) => Number.parseInt(hex, 16))); + } + return macs; +} + +/** + * Build a Wake-on-LAN "magic packet": six `0xFF` bytes then the MAC repeated 16 + * times. Pure — the shape is asserted byte-for-byte in the tests because a packet + * that is off by one byte simply never wakes anything. + */ +export function buildMagicPacket(mac: number[]): Buffer { + const packet = Buffer.alloc(6 + 16 * 6, 0xff); + for (let repeat = 0; repeat < 16; repeat++) { + Buffer.from(mac).copy(packet, 6 + repeat * 6); + } + return packet; +} + +/** + * The slice of `Session` the wake flow uses — an interface rather than the + * concrete class so the registry is testable without a tmux server. + */ +export interface WakeableSession { + readonly id: string; + readonly remote: WakeableRemote | undefined; + /** COD-108 reattach: respawns the local ssh pane, idempotently attaching the durable remote tmux. */ + reattachRemote(): Promise; + /** Write bytes to the session's pane. */ + writeViaMux(data: string): Promise; +} + +/** Injected IO so the registry holds no direct dependency on ssh/net/child_process in tests. */ +export interface RemoteWakeDeps { + /** Cheap reachability probe. Must resolve false (never throw) for a sleeping host. */ + probe(remote: WakeableRemote): Promise; + /** Run the resolved wake target (magic packet or host command). Resolves false on failure. */ + wake(target: NonNullable): Promise; + /** Poll until the woken host accepts connections again. */ + waitUntilReady(remote: WakeableRemote, opts?: { timeoutMs?: number; signal?: AbortSignal }): Promise; + /** Sleep helper (injected for tests). */ + delay(ms: number): Promise; + /** Notify the COD-108 watcher so an exhausted backoff is reset. */ + noteReconnected?(sessionId: string, success: boolean): void; + /** SSE broadcast. */ + broadcast?( + event: 'remote:hostWaking' | 'remote:hostWakeFailed' | 'remote:sessionReconnected', + payload: Record + ): void; + /** Structured diagnostics. */ + log?(message: string): void; + /** + * Resolve the host's CURRENT wake config for a session whose persisted `remote` + * snapshot predates it (or was configured after launch). Called at most once per + * `REMOTE_WAKE_RESOLVE_TTL_MS` per session, and only when the session's own copy + * has no wake target — so a config saved in the UI works without restarting the + * session, without a per-keystroke config read. + */ + resolveRemote?(session: WakeableSession): Promise; +} + +/** Probe freshness for the UI's reachability check (a tab switch is not a hammer). */ +export const REMOTE_WAKE_REACHABILITY_TTL_MS = 5_000; +/** How long a resolved host config is trusted before asking the resolver again. */ +export const REMOTE_WAKE_RESOLVE_TTL_MS = 30_000; + +/** + * What the UI is allowed to offer for a host: how it can be woken, if at all. The + * `'none'` case is what the banner turns into "configure WoL" instead of "wake". + */ +export type WakeConfigured = 'command' | 'mac' | 'none'; + +/** Which wake path a host config provides (mirrors {@link resolveWakeTarget}). Pure. */ +export function wakeConfigured(remote: WakeableRemote | undefined): WakeConfigured { + const target = resolveWakeTarget(remote); + if (!target) return 'none'; + return target.kind; +} + +/** + * Outcome of waking a host for a caller that has NO session yet (the create/attach + * routes). A union rather than a boolean because the cases need different handling: + * `'no-target'` must leave the caller's behavior byte-identical (no probe, no extra + * latency for a host without WoL), `'unprobeable'` likewise (a proxied host, see + * {@link isProbeable} — the probe cannot tell asleep from awake, so nothing is gated on + * it), and only `'failed'` is an error that deserves its own message instead of the + * caller's usual one. + */ +export type HostWakeOutcome = 'no-target' | 'unprobeable' | 'ready' | 'failed'; + +/** + * State key for a host-scoped wake. Prefixed so it can never collide with a session + * id, and keyed on the HOST rather than the case: two cases on one host share a + * single in-flight wake and one probe verdict. Such an entry is tiny (no input + * buffer) and bounded by the number of configured hosts, so it is never dropped. + */ +function hostWakeKey(hostId: string): string { + return `host:${hostId}`; +} + +/** Per-session wake bookkeeping. */ +interface WakeState { + probedAt: number; + reachable?: boolean; + waking: Promise | null; + pending: string[]; + /** Host config resolved after launch (see `RemoteWakeDeps.resolveRemote`). */ + resolvedRemote?: WakeableRemote; + resolvedAt: number; +} + +/** + * Per-session wake state + single-flight wake flow. + * + * One instance per web server (module singleton in the routes file, like the + * signal-wait registry). State is keyed by session id and dropped with the + * session. + */ +export class RemoteWakeRegistry { + private readonly states = new Map(); + /** + * Aborted by {@link stop} on shutdown. Every in-flight readiness poll is holding an + * HTTP request open (the wake route blocks on it), and Fastify's `close()` waits for + * in-flight requests — so without this a restart during a wake sits out the full 90 s + * budget. The same problem `sessionWaits.cancelEverything()` exists for. + */ + private readonly shutdown = new AbortController(); + private stopped = false; + + constructor(private readonly deps: RemoteWakeDeps) {} + + /** Drop a session's state (session closed/killed). The pending buffer goes with it. */ + drop(sessionId: string): void { + this.states.delete(sessionId); + } + + /** + * Resolve every in-flight wake as failed and refuse new ones (server shutdown). + * + * Called from `WebServer.stop()`: an in-flight wake is awaited by a request, and the + * server's own `app.close()` does not abort in-flight requests, so shutdown would wait + * out the poll. Nothing is lost by failing them — the state flush happens earlier in + * `stop()`, and the process is going away. + */ + stop(): void { + this.stopped = true; + this.shutdown.abort(); + } + + /** Whether a wake is currently in flight (diagnostics/tests). */ + isWaking(sessionId: string): boolean { + return this.states.get(sessionId)?.waking != null; + } + + /** Buffered input bytes for a session (diagnostics/tests). */ + pendingBytes(sessionId: string): number { + const state = this.states.get(sessionId); + if (!state) return 0; + return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + } + + /** + * Number of keys with wake state (diagnostics/tests). Pins that a LOCAL session never + * gets an entry: the input gate runs on every keystroke, so an entry per local session + * would be a map the size of the session list, swept only on cleanup. + */ + stateCount(): number { + return this.states.size; + } + + /** Whether this session's host has any wake path configured at all. */ + async hasWakeTarget(session: WakeableSession): Promise { + return resolveWakeTarget(await this._effectiveRemote(session)) !== null; + } + + /** Which wake path is configured (`'none'` when the UI should offer configuration). */ + async wakeConfigured(session: WakeableSession): Promise { + return wakeConfigured(await this._effectiveRemote(session)); + } + + /** + * Reachability for the UI: probe unless a recent result is still fresh. `null` for a + * host the probe cannot reach (see {@link isProbeable}): unknown is not unreachable. + * + * Shares the per-session probe state with the input path on purpose — a fresh + * answer is exactly what the input ladder wants, and an `unreachable` verdict here + * makes the next keystroke buffer + wake instead of vanishing into a stalled pane. + */ + async checkReachable( + session: WakeableSession, + opts: { force?: boolean; ttlMs?: number } = {} + ): Promise { + const remote = await this._effectiveRemote(session); + if (!remote) return true; + // `null`, never `false`: the UI keys the banner on a PROVEN unreachable host. + if (!isProbeable(remote)) return null; + const state = this._state(session.id); + const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS); + if (Date.now() - state.probedAt >= ttl) { + state.probedAt = Date.now(); + state.reachable = await this.deps.probe(remote); + } + return state.reachable === true; + } + + /** + * Decide + act for one input chunk. + * + * `'deliver'` means the caller writes it as usual (today's path, zero added + * cost). `'buffered'` means the registry took ownership of the bytes: it either + * queued them behind an in-flight wake or started a wake, and will flush them + * in order once the pane is reattached. + */ + async handleInput(session: WakeableSession, data: string): Promise { + const remote = await this._effectiveRemote(session); + // A proxied host can never pass the readiness poll, so buffering for it would hold + // the bytes for the life of the session (reproduced upstream: three inputs, nothing + // written, no reattach). Deliver, as if the feature were off. + if (remote && !isProbeable(remote)) return 'deliver'; + const state = this._state(session.id); + const target = resolveWakeTarget(remote); + const action = decideRemoteInputAction({ + hasWakeTarget: target !== null, + waking: state.waking != null, + probeAgeMs: Date.now() - state.probedAt, + lastReachable: state.reachable, + }); + + if (action === 'deliver') return 'deliver'; + if (action === 'buffer') { + this._enqueue(session.id, data); + // A buffered verdict with no wake in flight still has to DRIVE a wake (the + // previous one failed and reset the probe state, or the ladder landed here + // directly) — otherwise the bytes would sit in the buffer forever. + if (state.waking == null && target) void this.wake(session); + return 'buffered'; + } + + // action === 'probe' — the throttle window elapsed, so one TCP connect is owed. + state.probedAt = Date.now(); + state.reachable = remote ? await this.deps.probe(remote) : true; + if (state.reachable) return 'deliver'; + + this._enqueue(session.id, data); + void this.wake(session); + return 'buffered'; + } + + /** + * Block until the host is reachable and the pane is reattached — the + * send-and-wait path, where the HTTP response stays open anyway and buffering + * would break the wait contract. + */ + async ensureAwake(session: WakeableSession, opts: { force?: boolean; timeoutMs?: number } = {}): Promise { + if (this.stopped) return false; + const remote = await this._effectiveRemote(session); + const target = resolveWakeTarget(remote); + if (!remote || !target) return true; + // A proxied host: the send-and-wait path has nothing to gate on (unknown is not + // asleep), so it delivers; the manual button still wakes, blind (see `wake`). + if (!isProbeable(remote)) return opts.force ? this.wake(session, opts) : true; + const state = this._state(session.id); + // `force` is the manual path (a user pressed "wake"): a cached "reachable" from + // seconds ago must not talk the button out of waking a host that just slept. + if (opts.force || (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS)) { + state.probedAt = Date.now(); + state.reachable = await this.deps.probe(remote); + } + if (state.reachable) return true; + // The manual button is pressed from the SAME dashboard the create/attach paths are, + // so it holds its request open under the same reverse proxy — it needs the request + // budget, not the 90 s session default (see REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS). + return this.wake(session, { timeoutMs: opts.timeoutMs }); + } + + /** + * Host-scoped reachability, for a caller that has no session yet (create/attach). + * Shares the per-HOST probe state with {@link ensureHostAwake}, so the probe the + * wake flow just paid for also answers "was that ssh failure really a sleeping + * machine?". Never wakes anything — it is a question, not an action. `null` when the + * question cannot be answered (see {@link isProbeable}). + */ + async checkHostReachable( + remote: WakeableRemote, + opts: { force?: boolean; ttlMs?: number } = {} + ): Promise { + // `null` for a proxied host: callers gate on `=== false` (proven unreachable), so an + // unknown verdict leaves their ordinary error path — "needs tmux" — intact. + if (!isProbeable(remote)) return null; + const state = this._state(hostWakeKey(remote.hostId)); + const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS); + if (Date.now() - state.probedAt >= ttl) { + state.probedAt = Date.now(); + state.reachable = await this.deps.probe(remote); + } + return state.reachable === true; + } + + /** + * Wake a host for a REQUEST that is waiting on it — the session create/attach + * routes, where there is no session to reattach and no input to buffer yet. + * + * `'no-target'` returns without probing, so a host without WoL config costs + * nothing and behaves exactly as before. Single-flight per host, so a double click + * (or two cases on the same host) sends one packet and shares one readiness poll. + */ + async ensureHostAwake( + remote: WakeableRemote, + opts: { timeoutMs?: number; requestedBy?: string } = {} + ): Promise { + if (!resolveWakeTarget(remote)) return 'no-target'; + // The probe cannot tell a proxied host asleep from awake, and a wake that cannot + // verify readiness would only delay the request by its whole budget. Not gated. + if (!isProbeable(remote)) return 'unprobeable'; + if (this.stopped) return 'failed'; + const state = this._state(hostWakeKey(remote.hostId)); + if (state.waking) return (await state.waking) ? 'ready' : 'failed'; + + state.probedAt = Date.now(); + state.reachable = await this.deps.probe(remote); + if (state.reachable) return 'ready'; + this.deps.log?.(`[RemoteWake] ${remote.label} (${remote.host}) is unreachable — waking it for a new session`); + return (await this.wakeHost(remote, opts)) ? 'ready' : 'failed'; + } + + /** + * Single-flight wake for a host with no session (see {@link ensureHostAwake}). Uses the + * same single-flight `waking` slot the session flow uses — but a DIFFERENT key + * (`host:` vs the session id), so a session wake and a create-path wake for the same + * host are two independent flows rather than one shared poll. Harmless (both are + * user-initiated and the host only wakes once), and keying them together would mean a + * create request joining an unrelated session's wake and inheriting its budget. + */ + private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number; requestedBy?: string }): Promise { + const state = this._state(hostWakeKey(remote.hostId)); + if (state.waking) return state.waking; + state.waking = (async (): Promise => { + try { + return await this._wakeAndWait(remote, state, { + timeoutMs: opts.timeoutMs, + forNewSession: true, + requestedBy: opts.requestedBy, + }); + } catch (err) { + // Injected IO is documented not to throw, but a rejected promise here would + // surface as an unhandled rejection AND take the route down with it (the + // session path catches for exactly this reason). A broken wake target must + // fail the wake, never the create route beyond its own error response. + this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`); + return false; + } finally { + state.waking = null; + } + })(); + return state.waking; + } + + /** + * Single-flight wake: probe-free (the caller already knows the host is down), + * run the wake command, poll for readiness, reattach the pane, flush the buffer. + */ + async wake(session: WakeableSession, opts: { timeoutMs?: number } = {}): Promise { + if (this.stopped) return false; + const remote = await this._effectiveRemote(session); + const target = resolveWakeTarget(remote); + if (!remote || !target) return true; + if (!isProbeable(remote)) return this._wakeBlind(remote, target); + const state = this._state(session.id); + if (state.waking) return state.waking; + + state.waking = (async (): Promise => { + const id = session.id; + try { + const ready = await this._wakeAndWait(remote, state, { sessionId: id, timeoutMs: opts.timeoutMs }); + if (!ready) return false; + + const reattached = await session.reattachRemote(); + if (!reattached) { + this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`); + return false; + } + // The reset also clears an EXHAUSTED COD-108 backoff, which otherwise + // never fires again for this session (see remote-reconnect.ts). + this.deps.noteReconnected?.(id, true); + this.deps.broadcast?.('remote:sessionReconnected', { sessionId: id }); + this.deps.log?.(`[RemoteWake] ${remote.label} reattached for session ${id}`); + + await this.deps.delay(REMOTE_WAKE_ATTACH_SETTLE_MS); + await this._flush(state, session); + return true; + } catch (err) { + this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`); + return false; + } finally { + state.waking = null; + } + })(); + + return state.waking; + } + + /** + * Fire the wake target for a host whose readiness cannot be verified (see + * {@link isProbeable}): no readiness poll (it could never succeed), no reattach (the + * COD-108 watcher owns the pane once ssh works again), no `hostWaking` broadcast (its + * toast promises a wait that does not happen). The caller learns only whether the + * packet/command went out — and a wake IO that throws is a failed wake, never a + * rejected route. + */ + private async _wakeBlind(remote: WakeableRemote, target: NonNullable): Promise { + this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind}, blind: proxied host`); + try { + return await this.deps.wake(target); + } catch (err) { + this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + } + + /** + * Broadcast + run the wake target + wait for SSH. Shared by the session flow (which + * then reattaches and flushes the buffer) and the create/attach flow (which has no + * pane yet). On failure the probe state is reset so the NEXT attempt probes and + * retries instead of trusting a stale "down" verdict forever. + */ + private async _wakeAndWait( + remote: WakeableRemote, + state: WakeState, + opts: { sessionId?: string; timeoutMs?: number; forNewSession?: boolean; requestedBy?: string } = {} + ): Promise { + const target = resolveWakeTarget(remote); + if (!target) return true; + const forWhat = opts.sessionId ? `for session ${opts.sessionId}` : 'for a new session'; + // Routing for multi-user mode (server.ts `deriveSseHint`): a session-scoped event + // reaches its owner, and the create/attach wake has no session yet — so it names + // the requesting user instead, or it would reach admins only. The payload carries + // `hostId`/`label`, which non-admins are not shown elsewhere, so it must not go global. + const scope = opts.sessionId + ? { sessionId: opts.sessionId } + : { forNewSession: true, ...(opts.requestedBy ? { username: opts.requestedBy } : {}) }; + // No `sessionId` for a create-path wake: the toast handler is then the only one + // that acts (a banner for a session that does not exist yet would have no target), + // which is exactly the `forNewSession` distinction the UI renders. + // + // `queuedInput` is true only on the typing path, where bytes are actually held for + // this session. The wake BUTTON and the send-and-wait path hold nothing, so a UI + // that keyed "input is queued until it is back" off "a wake is running" would promise + // something the user can disprove by typing (browser keystrokes go over the + // WebSocket, which never passes through this registry). + this.deps.broadcast?.('remote:hostWaking', { + ...scope, + hostId: remote.hostId, + label: remote.label, + queuedInput: state.pending.length > 0, + }); + this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`); + + const woke = await this.deps.wake(target); + if (!woke) { + this.deps.log?.( + `[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}` + ); + } + + const ready = await this.deps.waitUntilReady(remote, { + timeoutMs: opts.timeoutMs, + signal: this.shutdown.signal, + }); + if (!ready) { + this.deps.log?.( + `[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}` + ); + this.deps.broadcast?.('remote:hostWakeFailed', { + ...scope, + hostId: remote.hostId, + label: remote.label, + queuedInput: state.pending.length > 0, + }); + state.probedAt = 0; + state.reachable = undefined; + return false; + } + + state.reachable = true; + state.probedAt = Date.now(); + return true; + } + + private _state(sessionId: string): WakeState { + let state = this.states.get(sessionId); + if (!state) { + state = { probedAt: 0, reachable: undefined, waking: null, pending: [], resolvedAt: 0 }; + this.states.set(sessionId, state); + } + return state; + } + + /** + * The host config to act on: the session's own `remote` when it is fresh enough, else a + * freshly resolved one. + * + * The persisted `remote` snapshot is taken at launch, so a wake target configured AFTER + * the session started (e.g. through the banner's config dialog, or by adding `wakeMac` + * to `remote-hosts.json`) is invisible to it. Recovery rehydration (server.ts) covers + * restarts; this covers the live session, and it is why saving the dialog takes effect + * without restarting anything. + * + * ⚠️ The host config wins in BOTH directions, so the resolver is consulted on the TTL + * regardless of whether the session already carries a target. Preferring the snapshot + * whenever it HAD one meant removing a MAC/command in the config (or the dialog) never + * took effect for a running session — the feature stayed on with a target nobody could + * see in the config any more, which is exactly the "host config is authoritative" + * promise failing in the one direction a user can observe. + */ + private async _effectiveRemote(session: WakeableSession): Promise { + // The local-session return comes FIRST, before `_state`: this runs on every input + // chunk (`hasWakeTarget` gates the route), so allocating state here would put an + // entry in the map for every local session the user types in — sessions the feature + // can never apply to, and whose pending buffers would then have to be swept. + if (!session.remote) return undefined; + const state = this._state(session.id); + if (!this.deps.resolveRemote) return state.resolvedRemote ?? session.remote; + if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) { + return state.resolvedRemote ?? session.remote; + } + state.resolvedAt = Date.now(); + try { + const resolved = await this.deps.resolveRemote(session); + if (resolved) state.resolvedRemote = resolved; + } catch (err) { + this.deps.log?.( + `[RemoteWake] host config lookup failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}` + ); + } + return state.resolvedRemote ?? session.remote; + } + + private _enqueue(sessionId: string, data: string): void { + const state = this._state(sessionId); + const next = appendBoundedPending(state.pending, data); + if (next === state.pending) { + // Oversized chunk: dropped whole (see `appendBoundedPending`), so the buffer is + // untouched and nothing is delivered as a fragment. Logged because the user's + // paste is gone — the 200 the route returns cannot say so. + this.deps.log?.( + `[RemoteWake] dropped a ${Buffer.byteLength(data)}-byte input chunk for session ${sessionId} — over the ${REMOTE_WAKE_PENDING_MAX_BYTES}-byte wake buffer, and a truncated paste must not be delivered as a fragment` + ); + return; + } + const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + const after = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); + if (before + Buffer.byteLength(data) > after) { + this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`); + } + state.pending = next; + } + + private async _flush(state: WakeState, session: WakeableSession): Promise { + while (state.pending.length > 0) { + const chunk = state.pending[0]; + // Take the chunk OUT before awaiting the write. Input arriving during the await is + // enqueued by `handleInput` (a wake is still in flight, so it takes the buffer + // path), and `appendBoundedPending` may then drop the OLDEST chunk to stay under + // the cap — which would be this one, already on its way to the pane. Shifting + // afterwards removed the NEXT chunk instead, so the drop-oldest bookkeeping lost a + // chunk that was never written while the log line blamed the one that was. + state.pending = state.pending.slice(1); + const ok = await session.writeViaMux(chunk).catch(() => false); + if (!ok) { + // Drop the rest, and say so. Retaining it looked safer but was worse: the wake + // still resolves and marks the host reachable, so the NEXT input takes the + // deliver path while the old chunks sit here — to be replayed by the next wake, + // possibly hours later, after everything typed since, and maybe ending in a + // carriage return. Same policy as the oversized paste: gone, with a log line. + const dropped = state.pending.length + 1; + state.pending = []; + this.deps.log?.( + `[RemoteWake] flush failed for session ${session.id} — ${dropped} buffered chunk(s) dropped rather than replayed on a later wake` + ); + return; + } + } + } +} + +// ========== Default IO ========== + +/** + * Under vitest none of this may do real IO (a TCP connect, a child process, a UDP + * broadcast) — mirrors `remote-files.ts`. Every consumer injects its deps + * (`RemoteWakeDeps`, the socket factory); this is what makes that seam non-optional + * instead of a convention the next test can forget. + */ +function assertNotUnderTest(what: string): void { + if (process.env.VITEST) { + throw new Error(`remote-wake: ${what} is disabled under test — inject a fake (RemoteWakeDeps / WakeSocketFactory)`); + } +} + +/** + * Cheap reachability probe: a bare TCP connect to the SSH port. Only meaningful for a + * host the registry deems probeable (see {@link isProbeable}); the registry never asks + * it about a proxied host. + * + * Deliberately NOT an `ssh … true` probe: that opens a full session (auth, + * remote log, process) every throttle window for a question a SYN already + * answers. Any byte count it does move is a few hundred bytes per probe, far + * below the remote idle detector's traffic threshold, so probing cannot keep a + * host awake. + */ +export function probeRemoteHostReachable( + remote: WakeableRemote, + timeoutMs = REMOTE_WAKE_PROBE_TIMEOUT_MS +): Promise { + assertNotUnderTest('the TCP probe'); + const port = remote.port ?? DEFAULT_SSH_PORT; + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(value); + }; + const socket = net.connect({ host: remote.host, port }); + socket.setTimeout(timeoutMs, () => finish(false)); + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + }); +} + +/** + * Run a host's wake command (e.g. a Wake-on-LAN wrapper script). No shell — the + * value is a single executable path, so nothing in it can be interpreted. + * Resolves false on any failure (missing binary, non-zero exit, timeout) rather + * than throwing: a broken wake command must not break the input route. + */ +export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_COMMAND_TIMEOUT_MS): Promise { + assertNotUnderTest('the wake command'); + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + resolve(value); + }; + let child: ReturnType; + try { + child = spawn(command, [], { stdio: 'ignore' }); + } catch { + finish(false); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(false); + }, timeoutMs); + child.once('error', () => { + clearTimeout(timer); + finish(false); + }); + child.once('exit', (code) => { + clearTimeout(timer); + finish(code === 0); + }); + }); +} + +/** + * Send Wake-on-LAN magic packets for every MAC, over UDP to the broadcast address. + * + * This is the whole reason `wakeMac` exists: the common case needs no external + * script. Broadcast on 255.255.255.255 is what the CLI `wakeonlan` does and what the + * NICs here answer to; the socket is closed as soon as the packets are queued, so a + * sleeping host cannot leave a handle behind. Resolves false on any failure (no + * interface to broadcast on, permission) rather than throwing — a broken network + * must not break the wake flow, which reports the failure itself. + */ +export function sendWakePackets( + addresses: number[][], + port = 9, + createSocket: WakeSocketFactory = () => { + assertNotUnderTest('the UDP broadcast'); + return dgram.createSocket('udp4'); + } +): Promise { + if (addresses.length === 0) return Promise.resolve(false); + return new Promise((resolve) => { + const socket = createSocket(); + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + try { + socket.close(); + } catch { + /* already closed */ + } + resolve(value); + }; + socket.once('error', () => finish(false)); + // ⚠️ `setBroadcast` BEFORE the socket is bound fails with EBADF on Linux, and the + // send that follows fails with EACCES — i.e. the packet silently never leaves the + // machine. So the broadcast flag is set in the bind callback, always. (Found by + // the live test: macOS/BSD tolerate the wrong order, Linux does not.) + socket.bind(() => { + try { + socket.setBroadcast(true); + } catch { + finish(false); + return; + } + let pending = addresses.length; + let failed = false; + for (const mac of addresses) { + socket.send(buildMagicPacket(mac), port, '255.255.255.255', (err?: Error | null) => { + if (err) failed = true; + pending--; + if (pending === 0) finish(!failed); + }); + } + }); + }); +} + +/** The `dgram` surface {@link sendWakePackets} uses — injectable so the bind/setBroadcast ORDER is testable. */ +export interface WakeSocket { + bind(callback: () => void): void; + setBroadcast(flag: boolean): void; + send(msg: Buffer, port: number, address: string, callback: (err?: Error | null) => void): void; + close(): void; + once(event: 'error', listener: (err: Error) => void): void; +} + +export type WakeSocketFactory = () => WakeSocket; + +/** Poll the host until it accepts connections again, or the bound is hit. */ +export async function waitUntilRemoteReady( + remote: WakeableRemote, + opts: { + intervalMs?: number; + timeoutMs?: number; + signal?: AbortSignal; + probe?: (remote: WakeableRemote) => Promise; + } = {} +): Promise { + const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS; + const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS; + const probe = opts.probe ?? probeRemoteHostReachable; + const deadline = Date.now() + timeoutMs; + // Probe immediately: WoL from a warm S3 is fast (~7.5 s measured on this setup), + // and the first poll is what turns "just woke" into a sub-interval response. + for (;;) { + if (opts.signal?.aborted) return false; + if (await probe(remote)) return true; + if (Date.now() + intervalMs > deadline) return false; + // Abortable sleep, so a shutdown does not wait out the current interval either. + await delayOrAbort(intervalMs, opts.signal); + } +} + +/** `delay`, but it also ends the moment `signal` aborts (so cancellation is immediate). */ +function delayOrAbort(ms: number, signal?: AbortSignal): Promise { + if (!signal) return delay(ms); + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer); + signal.removeEventListener('abort', done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal.addEventListener('abort', done, { once: true }); + }); +} + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Production wiring: all IO defaults, overridable for tests. + * + * The readiness poll uses the SAME probe as the rest of the deps, overridden or not. + * Wiring it to the module default instead let a caller that injected `probe` still + * poll the real host during the wait — under vitest, a TCP connect to a production + * address on every shutdown test (which the vitest guard is what finally caught). + */ +export function createDefaultRemoteWakeDeps(overrides: Partial = {}): RemoteWakeDeps { + const probe = overrides.probe ?? probeRemoteHostReachable; + return { + probe, + wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)), + waitUntilReady: (remote, opts) => waitUntilRemoteReady(remote, { ...opts, probe }), + delay, + ...overrides, + }; +} diff --git a/src/types/session.ts b/src/types/session.ts index c993666f..20d788b2 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -115,6 +115,25 @@ export interface RemoteHost extends RemoteSshOptions { username: string; port?: number; commands?: Partial>; + /** + * 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; } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index bb40152d..250af00e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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'], @@ -1818,6 +1819,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) { @@ -6159,6 +6163,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 diff --git a/src/web/public/constants.js b/src/web/public/constants.js index be9780b0..8345a166 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1057,6 +1057,9 @@ const SSE_EVENTS = { REMOTE_SESSION_DROPPED: 'remote:sessionDropped', REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected', REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted', + // Wake-on-LAN from user input on a sleeping remote host + REMOTE_HOST_WAKING: 'remote:hostWaking', + REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed', // Ralph SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate', diff --git a/src/web/public/host-wake-ui.js b/src/web/public/host-wake-ui.js new file mode 100644 index 00000000..0a1f76e2 --- /dev/null +++ b/src/web/public/host-wake-ui.js @@ -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(); + }, +}); diff --git a/src/web/public/index.html b/src/web/public/index.html index cbe9a480..90cd1b12 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -213,6 +213,18 @@ + + + @@ -2878,6 +2890,11 @@

Remote

Optional. Leave blank for the default port 22. +
+ + + Optional. Comma-separated for several NICs. Codeman sends the magic packet itself so a sleeping host can be woken from the session banner. +
@@ -2886,6 +2903,11 @@

Remote

Advanced SSH
+
+ + + 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. +
@@ -3481,6 +3503,36 @@

Can't reach the Codem 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). --> + + +