From 8b5a13435a91bbf5052649b13069553c48d9510c Mon Sep 17 00:00:00 2001 From: Randalix Date: Tue, 15 Sep 2026 14:20:31 +0200 Subject: [PATCH] feat(remote): host-unreachable banner, manual wake, and native MAC wake-on-LAN The reactive wake (typing into a session whose host slept) left the state invisible: nothing told the user the machine was asleep, and with no wake target configured there was nothing to do about it. Adds: - RemoteHost.wakeMac (comma-separated) - Codeman builds and broadcasts the magic packet itself (UDP port 9), so the common case needs no external script. The existing wakeCommand stays as the explicit override. - GET /api/sessions/:id/reachability - probes (throttled, cached, and it never wakes) and reports HOW the host can be woken, or that nothing is configured. - POST /api/sessions/:id/wake - wakes, waits, reattaches the pane and flushes buffered input; 400 with a routable message when no target is configured. - The amber host-unreachable banner + its 'Wake' / 'Configure WoL' action, and a small config dialog that saves via PUT /api/remote-hosts/:id. - RemoteWakeDeps.resolveRemote: host config is re-resolved for LIVE sessions (throttled + cached), so saving the dialog takes effect without a restart. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/remote-sessions.md | 91 ++++--- src/remote-hosts.ts | 22 +- src/remote-wake.ts | 240 ++++++++++++++++-- src/types/session.ts | 22 +- src/web/public/app.js | 4 +- src/web/public/host-wake-ui.js | 307 ++++++++++++++++++++++++ src/web/public/index.html | 53 ++++ src/web/public/panels-ui.js | 3 + src/web/public/session-ui.js | 10 + src/web/public/styles.css | 12 + src/web/routes/session-routes.ts | 63 ++++- src/web/schemas.ts | 12 + test/remote-hosts.test.ts | 18 ++ test/remote-wake.test.ts | 112 ++++++++- test/routes/session-remote-wake.test.ts | 63 ++++- 17 files changed, 959 insertions(+), 77 deletions(-) create mode 100644 src/web/public/host-wake-ui.js diff --git a/CLAUDE.md b/CLAUDE.md index 2cacd33e..76aa0ffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **It revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ **File reads in a remote case are the second ssh surface** (#415, `src/remote-files.ts`): they go through `buildSshConnectionArgs()` as well, a browser-supplied path is only ever a `shellescape`d token, an unreachable host answers 502 (never 404), the size cap uses the REMOTE size, and no remote file is ever copied onto the server's disk — which is why writes, office previews and thumbnails are deliberately unsupported over ssh (the `PUT` guard sits BEFORE the local path validation, or a same-named local directory such as an sshfs mount takes the write). The probe's symlink resolution FAILS CLOSED (a path it cannot canonicalize is a 404, never its own unresolved string: the directory-only fallback let a `notes.txt -> ~/.ssh/id_rsa` link pass containment), and ssh children are BOUNDED by `src/remote-ssh-limiter.ts` plus one batched probe per attachment-history listing, because terminal output in a remote session is written on the remote host and a prompt-injected agent can print hundreds of `codeman://attach` links. The ATTACHMENT routes (a clicked path outside the case dir) go through the same layer, and which host a record is read from follows the SESSION, never the path string. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md` -**Wake-on-LAN (`remote-wake.ts`)**: an optional `RemoteHost.wakeCommand` (single executable path, run without a shell) lets the INPUT route wake a sleeping host instead of writing into a stalled ssh pane. ⚠️ Only user input may wake: the auto-reconnect watcher, `handleRemoteSessionDropped` and boot recovery have no access to the registry (a wake there would re-wake the host seconds after every suspend), which `test/remote-wake.test.ts` asserts as a wiring guard. Detection is a throttled bare TCP probe — deliberately no `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. Input arriving during a wake is buffered and flushed in order after `reattachRemote()`; send-and-wait blocks instead. `wakeCommand` is re-read from `remote-hosts.json` on recovery, since the persisted `remote` snapshot never sees a field added later. +**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 and `POST /api/sessions/:id/wake` wake a sleeping host instead of writing into a stalled ssh pane. ⚠️ Only user input or an explicit wake request may wake: the auto-reconnect watcher, `handleRemoteSessionDropped` and boot recovery have no access to the registry (a wake there would re-wake the host seconds after every suspend), which `test/remote-wake.test.ts` asserts as a wiring guard; `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. Input arriving during a wake is buffered and flushed in order after `reattachRemote()`; send-and-wait blocks instead. 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`. **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) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 95e4bdad..b6c047a9 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -54,7 +54,7 @@ Model is NOT a session field: it is a composition entry in the profile's config ### Remote SSH cases -**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell) lets the input route wake a SLEEPING host instead of writing into a stalled ssh pane — `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from `POST /api/sessions/:id/input`: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery must never wake a host, or it would be re-woken seconds after each suspend and could never stay asleep (asserted by a wiring guard in `test/remote-wake.test.ts`, not just documented). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead), and `wakeCommand` is re-read from `remote-hosts.json` on session recovery because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. +**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from `POST /api/sessions/:id/input` and that explicit wake route: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery must never wake a host, or it would be re-woken seconds after each suspend and could never stay asleep (asserted by a wiring guard in `test/remote-wake.test.ts`, not just documented). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. **Remote 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`. diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 3a1097a2..72ff0274 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -361,49 +361,71 @@ vanished with no error anywhere, and without a keepalive the pane could look ali 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** `wakeCommand` on a remote host (a Wake-on-LAN wrapper such as -`/home/joe/bin/whuff`) closes that: on user input, `POST /api/sessions/:id/input` -probes the host, and if it is unreachable it runs the wake command, 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`. +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`. + +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`. Reachability for the +banner comes from `GET /api/sessions/:id/reachability`, polled for the active remote session +(30 s, visible tab only). The invariants worth keeping: -- **Only real user input may wake a host.** The COD-108 watcher, the server's - dropped-session handler and boot recovery have no access to the wake registry — a - wake there 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). Enforced by `test/remote-wake.test.ts`'s wiring guard, not a comment. -- **Detection is a bare TCP connect** to the SSH port (then the configured `port`, else - 22), throttled to one probe per `REMOTE_WAKE_PROBE_MIN_INTERVAL_MS` (30 s) 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. +- **Only real user input or an explicit wake request may wake a host.** The COD-108 watcher, + the server's dropped-session handler and boot recovery have no access to the wake registry — + a wake there 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 never wakes: it is a question, not an action. Both are enforced by tests + in `test/remote-wake.test.ts` and `test/routes/session-remote-wake.test.ts`, not 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 bytes 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. The **send-and-wait** path blocks on the wake instead — its response is open anyway, and buffering would break the wait contract. -- **The command runs without a shell** (`spawn(path, [], { shell: false })`), and the - schema requires a single executable path: no arguments, no `$`/backtick. A broken or - missing wake command fails the wake, never the input route. -- **`wakeCommand` is host-level config, refreshed on session recovery** - (`rehydrateRemoteHostFields` in `src/remote-hosts.ts`). 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. - The host config is authoritative (removing the field disables the feature again); - other host-level fields deliberately stay as persisted, so recovery cannot silently - re-point an existing pane's SSH options. +- **The command runs without a shell** (`spawn(path, [], { shell: 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 toasts in `panels-ui.js`. + `remote:sessionReconnected`) drive the banner and toasts in `host-wake-ui.js` / + `panels-ui.js`. Tests: `test/remote-wake.test.ts` (decision/throttle table, single-flight registry, -buffering + flush order, and the wiring guard) and -`test/routes/session-remote-wake.test.ts` (the input route buffers instead of writing -into a sleeping host, and the non-wake paths are unchanged). +buffering + flush order, MAC parsing/magic packet, live host-config resolution, and the wiring +guard) and `test/routes/session-remote-wake.test.ts` (the input route buffers instead of writing +into a sleeping host, the reachability route never wakes, and the wake route reports the +no-target case the UI turns into "configure WoL"). ## API @@ -418,8 +440,9 @@ 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 `wakeCommand` (single executable path, run without a -shell) — see **Wake-on-LAN from user input** above. +`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 }` diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 7eeb3b6c..3dea3bde 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -550,20 +550,20 @@ export function remoteDisplayPath( * silently do nothing until the session is relaunched (which for an owned remote * session means killing the remote tmux). * - * Deliberately narrow: ONLY `wakeCommand` is taken from the host config, and the - * host is authoritative for it (removing it in the config turns the feature off - * again). The other host-level fields (`commands`, ssh options) stay as persisted - * so this cannot silently change how an existing pane connects. + * 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: SessionRemote | undefined, +export function rehydrateRemoteHostFields( + remote: T | undefined, hostsById: ReadonlyMap -): SessionRemote | undefined { +): T | undefined { if (!remote) return remote; const host = hostsById.get(remote.hostId); if (!host) return remote; - if (remote.wakeCommand === host.wakeCommand) return remote; - return { ...remote, wakeCommand: host.wakeCommand }; + 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 { @@ -575,9 +575,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi port: host.port, remotePath: remoteCase.remotePath, commands: host.commands, - // Wake-on-LAN command travels with the session so the input route can wake a + // 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`. @@ -619,6 +620,7 @@ export function toAttachedSessionRemote( // 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 index 6d8cb585..6a04f377 100644 --- a/src/remote-wake.ts +++ b/src/remote-wake.ts @@ -30,6 +30,7 @@ */ 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. */ @@ -60,7 +61,6 @@ 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. @@ -82,14 +82,14 @@ export type RemoteInputOutcome = 'deliver' | 'buffered'; * Pure — no clock, no IO. */ export function decideRemoteInputAction(args: { - hasWakeCommand: boolean; + hasWakeTarget: boolean; waking: boolean; probeAgeMs: number; lastReachable?: boolean; minProbeIntervalMs?: number; }): RemoteInputAction { if (args.waking) return 'buffer'; - if (!args.hasWakeCommand) return 'deliver'; + 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'; @@ -117,12 +117,72 @@ export function appendBoundedPending( /** 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; } +/** + * 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. @@ -140,8 +200,8 @@ export interface WakeableSession { export interface RemoteWakeDeps { /** Cheap reachability probe. Must resolve false (never throw) for a sleeping host. */ probe(remote: WakeableRemote): Promise; - /** Run the host's wake command. Resolves false when it fails to run. */ - wake(command: string): Promise; + /** 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): Promise; /** Sleep helper (injected for tests). */ @@ -155,6 +215,32 @@ export interface RemoteWakeDeps { ): 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; } /** Per-session wake bookkeeping. */ @@ -163,6 +249,9 @@ interface WakeState { reachable?: boolean; waking: Promise | null; pending: string[]; + /** Host config resolved after launch (see `RemoteWakeDeps.resolveRemote`). */ + resolvedRemote?: WakeableRemote; + resolvedAt: number; } /** @@ -194,6 +283,35 @@ export class RemoteWakeRegistry { return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); } + /** 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. + * + * 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; + 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. * @@ -203,10 +321,11 @@ export class RemoteWakeRegistry { * in order once the pane is reattached. */ async handleInput(session: WakeableSession, data: string): Promise { - const remote = session.remote; + const remote = await this._effectiveRemote(session); const state = this._state(session.id); + const target = resolveWakeTarget(remote); const action = decideRemoteInputAction({ - hasWakeCommand: Boolean(remote?.wakeCommand), + hasWakeTarget: target !== null, waking: state.waking != null, probeAgeMs: Date.now() - state.probedAt, lastReachable: state.reachable, @@ -218,7 +337,7 @@ export class RemoteWakeRegistry { // 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 && remote?.wakeCommand) void this.wake(session); + if (state.waking == null && target) void this.wake(session); return 'buffered'; } @@ -237,11 +356,13 @@ export class RemoteWakeRegistry { * send-and-wait path, where the HTTP response stays open anyway and buffering * would break the wait contract. */ - async ensureAwake(session: WakeableSession): Promise { - const remote = session.remote; - if (!remote?.wakeCommand) return true; + async ensureAwake(session: WakeableSession, opts: { force?: boolean } = {}): Promise { + const remote = await this._effectiveRemote(session); + if (!remote || !resolveWakeTarget(remote)) return true; const state = this._state(session.id); - if (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS) { + // `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); } @@ -254,8 +375,9 @@ export class RemoteWakeRegistry { * run the wake command, poll for readiness, reattach the pane, flush the buffer. */ async wake(session: WakeableSession): Promise { - const remote = session.remote; - if (!remote?.wakeCommand) return true; + const remote = await this._effectiveRemote(session); + const target = resolveWakeTarget(remote); + if (!remote || !target) return true; const state = this._state(session.id); if (state.waking) return state.waking; @@ -263,10 +385,14 @@ export class RemoteWakeRegistry { const id = session.id; try { this.deps.broadcast?.('remote:hostWaking', { sessionId: id, hostId: remote.hostId, label: remote.label }); - this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) for session ${id}`); + this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} for session ${id}`); - const woke = await this.deps.wake(remote.wakeCommand as string); - if (!woke) this.deps.log?.(`[RemoteWake] wake command failed for ${remote.label}: ${remote.wakeCommand}`); + const woke = await this.deps.wake(target); + if (!woke) { + this.deps.log?.( + `[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}` + ); + } const ready = await this.deps.waitUntilReady(remote); if (!ready) { @@ -309,12 +435,43 @@ export class RemoteWakeRegistry { private _state(sessionId: string): WakeState { let state = this.states.get(sessionId); if (!state) { - state = { probedAt: 0, reachable: undefined, waking: null, pending: [] }; + 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 can wake, 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 resolver is asked at + * most once per TTL, and never for a session that already has a usable target. + */ + private async _effectiveRemote(session: WakeableSession): Promise { + const state = this._state(session.id); + if (state.resolvedRemote && resolveWakeTarget(state.resolvedRemote)) return state.resolvedRemote; + if (resolveWakeTarget(session.remote)) return session.remote; + if (!session.remote || !this.deps.resolveRemote) return state.resolvedRemote ?? session.remote; + if (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 before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); @@ -407,6 +564,51 @@ export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_CO }); } +/** + * 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): Promise { + if (addresses.length === 0) return Promise.resolve(false); + return new Promise((resolve) => { + const socket = dgram.createSocket('udp4'); + 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)); + try { + socket.setBroadcast(true); + } catch { + finish(false); + return; + } + let pending = addresses.length; + let failed = false; + for (const mac of addresses) { + const packet = buildMagicPacket(mac); + socket.send(packet, port, '255.255.255.255', (err) => { + if (err) failed = true; + pending--; + if (pending === 0) finish(!failed); + }); + } + }); +} + /** Poll the host until it accepts connections again, or the bound is hit. */ export async function waitUntilRemoteReady( remote: WakeableRemote, @@ -431,7 +633,7 @@ const delay = (ms: number): Promise => new Promise((resolve) => setTimeout export function createDefaultRemoteWakeDeps(overrides: Partial = {}): RemoteWakeDeps { return { probe: probeRemoteHostReachable, - wake: runRemoteWakeCommand, + wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)), waitUntilReady: (remote) => waitUntilRemoteReady(remote), delay, ...overrides, diff --git a/src/types/session.ts b/src/types/session.ts index 8460687b..eca3670b 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -97,13 +97,23 @@ 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`). Absent = no wake support and - * today's behavior exactly. Executed WITHOUT a shell (a single executable - * path, never a command line), only from user input on a session whose host - * is unreachable — never from the auto-reconnect/boot-recovery path, which - * would re-wake a host seconds after each suspend. + * 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; } @@ -151,6 +161,8 @@ export interface SessionRemote extends RemoteSshOptions { * 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 27e5456d..d03d6853 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -221,7 +221,6 @@ const _SSE_HANDLER_MAP = [ [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'], @@ -6076,6 +6075,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/host-wake-ui.js b/src/web/public/host-wake-ui.js new file mode 100644 index 00000000..f70dd7e5 --- /dev/null +++ b/src/web/public/host-wake-ui.js @@ -0,0 +1,307 @@ +/** + * @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: + * - Polls `GET /api/sessions/:id/reachability` for the ACTIVE remote session only + * (on tab activation and every `POLL_MS` while the tab is visible). 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 + */ + +const HOST_WAKE_POLL_MS = 30_000; + +Object.assign(CodemanApp.prototype, { + /** Per-tab banner state (single active session at a time). */ + _hostWake: null, + + /** Fresh state for a session we just switched to. */ + _hostWakeState() { + return { + sessionId: null, + timer: 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: '', + /** True between clicking Wake and the answer coming back. */ + waking: 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. + */ + refreshHostWakeBanner(sessionId) { + const state = (this._hostWake = this._hostWakeState()); + if (state.timer) clearInterval(state.timer); + state.sessionId = sessionId || null; + + const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null; + if (!session || !session.remote) { + this._renderHostWakeBanner(); + return; + } + + 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. + state.wakeConfigured = session.remote.wakeMac || session.remote.wakeCommand ? 'mac' : 'none'; + this._renderHostWakeBanner(); + + this._pollHostReachability(); + if (state.timer) clearInterval(state.timer); + state.timer = setInterval(() => { + if (document.visibilityState === 'hidden') return; + if (this.activeSessionId !== state.sessionId) return; + this._pollHostReachability(); + }, HOST_WAKE_POLL_MS); + }, + + /** 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; + state.reachable = data.data.reachable !== 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 + ? 'input is queued until it is back' + : hasTarget + ? `ssh ${state.host}` + : 'no wake-on-LAN configured'; + } + action.textContent = state.waking ? 'Waking …' : hasTarget ? 'Wake' : 'Configure WoL'; + 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') { + 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; + 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) { + // Most likely: no wake target configured after all (the route is the authority). + state.error = data.error || 'Wake failed'; + if (String(data.error || '').includes('No wake-on-LAN target')) state.wakeConfigured = 'none'; + this._renderHostWakeBanner(); + 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(); + } + }, + + /** 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 || ''; + } + } 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('Remote host not found'); + // 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). */ + _onRemoteHostWaking(data) { + const state = this._hostWake; + if (!state || !data || state.sessionId !== data.sessionId) return; + state.waking = true; + 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 state = this._hostWake; + if (!state || !data || state.sessionId !== data.sessionId) return; + state.waking = false; + state.error = 'timeout'; + state.reachable = false; + this._renderHostWakeBanner(); + }, +}); diff --git a/src/web/public/index.html b/src/web/public/index.html index 39e956f1..68283f32 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -213,6 +213,18 @@ + + +