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.
This commit is contained in:
Randalix
2026-09-15 14:20:31 +02:00
parent 3f0bfde54a
commit 8b5a13435a
17 changed files with 959 additions and 77 deletions
+1 -1
View File
@@ -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-<id>`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **It revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ **File reads in a remote case are the second ssh surface** (#415, `src/remote-files.ts`): they go through `buildSshConnectionArgs()` as well, a browser-supplied path is only ever a `shellescape`d token, an unreachable host answers 502 (never 404), the size cap uses the REMOTE size, and no remote file is ever copied onto the server's disk — which is why writes, office previews and thumbnails are deliberately unsupported over ssh (the `PUT` guard sits BEFORE the local path validation, or a same-named local directory such as an sshfs mount takes the write). The probe's symlink resolution FAILS CLOSED (a path it cannot canonicalize is a 404, never its own unresolved string: the directory-only fallback let a `notes.txt -> ~/.ssh/id_rsa` link pass containment), and ssh children are BOUNDED by `src/remote-ssh-limiter.ts` plus one batched probe per attachment-history listing, because terminal output in a remote session is written on the remote host and a prompt-injected agent can print hundreds of `codeman://attach` links. The ATTACHMENT routes (a clicked path outside the case dir) go through the same layer, and which host a record is read from follows the SESSION, never the path string. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Wake-on-LAN (`remote-wake.ts`)**: an optional `RemoteHost.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)
+1 -1
View File
@@ -54,7 +54,7 @@ Model is NOT a session field: it is a composition entry in the profile's config
### Remote SSH cases
**Remote 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 <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
+57 -34
View File
@@ -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 }`
+12 -10
View File
@@ -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<T extends { hostId: string; wakeCommand?: string; wakeMac?: string }>(
remote: T | undefined,
hostsById: ReadonlyMap<string, RemoteHost>
): 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,
+221 -19
View File
@@ -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<boolean>;
/** Run the host's wake command. Resolves false when it fails to run. */
wake(command: string): Promise<boolean>;
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
wake(target: NonNullable<WakeTarget>): Promise<boolean>;
/** Poll until the woken host accepts connections again. */
waitUntilReady(remote: WakeableRemote): Promise<boolean>;
/** 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<WakeableRemote | undefined>;
}
/** 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<boolean> | 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<boolean> {
return resolveWakeTarget(await this._effectiveRemote(session)) !== null;
}
/** Which wake path is configured (`'none'` when the UI should offer configuration). */
async wakeConfigured(session: WakeableSession): Promise<WakeConfigured> {
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<boolean> {
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<RemoteInputOutcome> {
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<boolean> {
const remote = session.remote;
if (!remote?.wakeCommand) return true;
async ensureAwake(session: WakeableSession, opts: { force?: boolean } = {}): Promise<boolean> {
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<boolean> {
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<WakeableRemote | undefined> {
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<boolean> {
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<void> => new Promise((resolve) => setTimeout
export function createDefaultRemoteWakeDeps(overrides: Partial<RemoteWakeDeps> = {}): RemoteWakeDeps {
return {
probe: probeRemoteHostReachable,
wake: runRemoteWakeCommand,
wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)),
waitUntilReady: (remote) => waitUntilRemoteReady(remote),
delay,
...overrides,
+17 -5
View File
@@ -97,13 +97,23 @@ export interface RemoteHost extends RemoteSshOptions {
username: string;
port?: number;
commands?: Partial<Record<RemoteCommandMode, string>>;
/**
* Optional Wake-on-LAN MAC address(es), comma-separated (e.g.
* `04:d9:f5:80:c6:58`). Codeman sends the magic packet itself (UDP port 9
* broadcast), so the common case needs no external script. A SLEEPING host's
* port-22 probe still fails, which is what triggers the wake — this only
* controls HOW the host is woken.
*/
wakeMac?: string;
/**
* Optional Wake-on-LAN command that powers this host on from SLEEP (e.g. a
* wrapper script like `/home/joe/bin/whuff`). 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;
}
/**
+3 -1
View File
@@ -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
+307
View File
@@ -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();
},
});
+53
View File
@@ -213,6 +213,18 @@
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
</div>
<!-- Remote-host unreachable: the machine SLEEPS, the local ssh pane stalls
silently (send-keys succeeds against it, so typed input would vanish) and
Codeman can wake it. Amber, not red: the session is fine, the host is
asleep. Without a configured wake target the action becomes "Configure
WoL" and opens the small config dialog. -->
<div class="offline-banner host-wake-banner" id="hostWakeBanner" role="status" hidden>
<span class="offline-banner-dot" aria-hidden="true"></span>
<span class="offline-banner-text" id="hostWakeBannerText">Remote host is unreachable</span>
<span class="offline-banner-detail" id="hostWakeBannerDetail"></span>
<button class="offline-banner-retry" id="hostWakeBannerAction" onclick="app.hostWakeAction()">Wake</button>
</div>
<!-- Timer Banner (shown when timed run is active) -->
<div class="timer-banner" id="timerBanner" style="display: none;">
<div class="timer-content">
@@ -2853,6 +2865,11 @@
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
</div>
<div class="form-row">
<label>Wake-on-LAN MAC</label>
<input type="text" id="remoteHostWakeMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. Comma-separated for several NICs. Codeman sends the magic packet itself so a sleeping host can be woken from the session banner.</span>
</div>
<div class="form-row">
<label>Codex Command Override</label>
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
@@ -2861,6 +2878,11 @@
<details class="advanced-options">
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced SSH</span></summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Wake Command</label>
<input type="text" id="remoteHostWakeCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Optional override for the MAC above (takes precedence). A single executable path, run without a shell — use it when the host needs a router/other machine to send the packet.</span>
</div>
<div class="form-row">
<label>Identity File</label>
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
@@ -3456,6 +3478,36 @@
text is set via value/textContent only: predictor output derives from
observable (injectable) content, and the explicit click here is the
security boundary (nothing is ever auto-sent). -->
<!-- Wake-on-LAN setup for a remote host whose session cannot be woken yet. Kept
deliberately small (host is fixed, only the wake fields are editable) so it can
be opened from the banner with one click. Persists via PUT /api/remote-hosts/:id. -->
<div class="modal" id="wakeConfigModal">
<div class="modal-backdrop" onclick="app.closeWakeConfigDialog()"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Wake-on-LAN &middot; <span id="wakeConfigHostLabel"></span></h3>
<button class="modal-close" onclick="app.closeWakeConfigDialog()" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<label>MAC address(es)</label>
<input type="text" id="wakeConfigMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Comma-separated for several NICs. Codeman sends the magic packet itself (UDP port 9, broadcast).</span>
</div>
<div class="form-row">
<label>Wake command (optional)</label>
<input type="text" id="wakeConfigCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Takes precedence over the MAC. A single executable path, run without a shell.</span>
</div>
<div class="form-hint" id="wakeConfigStatus"></div>
</div>
<div class="modal-footer">
<button class="btn-toolbar" onclick="app.closeWakeConfigDialog()">Cancel</button>
<button class="btn-toolbar btn-primary" id="wakeConfigSave" onclick="app.saveWakeConfig()">Save</button>
</div>
</div>
</div>
<div class="modal" id="readMyMindModal">
<div class="modal-backdrop" onclick="app.closeReadMyMind()"></div>
<div class="modal-content readmymind-modal">
@@ -3530,6 +3582,7 @@
<script defer src="approvals-ui.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="host-wake-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
+3
View File
@@ -92,6 +92,9 @@ Object.assign(CodemanApp.prototype, {
_onRemoteSessionReconnected(data) {
const id = this.getShortId(data.sessionId);
this.showToast(`Remote session ${id} reconnected`, 'success');
// A successful reattach (the wake flow's own, or the watcher's) means the host is
// back: drop the "unreachable" banner without waiting out the poll interval.
if (this.activeSessionId === data.sessionId) this._pollHostReachability?.(true);
},
_onRemoteReconnectExhausted(data) {
+10
View File
@@ -3071,6 +3071,9 @@ Object.assign(CodemanApp.prototype, {
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
// Wake-on-LAN: keep in sync with `_readRemoteHostFromForm` (the Discover path).
const wakeMac = document.getElementById('remoteHostWakeMac').value.trim();
const wakeCommand = document.getElementById('remoteHostWakeCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
@@ -3108,6 +3111,8 @@ Object.assign(CodemanApp.prototype, {
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(wakeMac ? { wakeMac } : {}),
...(wakeCommand ? { wakeCommand } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
const hostRes = await fetch('/api/remote-hosts', {
@@ -3568,6 +3573,9 @@ Object.assign(CodemanApp.prototype, {
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
// Wake-on-LAN: keep in sync with `linkRemoteCase`'s inline payload.
const wakeMac = document.getElementById('remoteHostWakeMac').value.trim();
const wakeCommand = document.getElementById('remoteHostWakeCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
@@ -3587,6 +3595,8 @@ Object.assign(CodemanApp.prototype, {
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(wakeMac ? { wakeMac } : {}),
...(wakeCommand ? { wakeCommand } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
},
+12
View File
@@ -15306,6 +15306,18 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: rgba(255, 255, 255, 0.24);
}
/* Remote-host unreachable (host asleep, can be woken). Reuses the offline-banner
layout and children; amber instead of red because the Codeman session itself is
perfectly healthy — only the machine is asleep. */
.host-wake-banner {
background: linear-gradient(90deg, #b45309, #92400e);
}
.host-wake-banner .offline-banner-retry:disabled {
opacity: 0.6;
cursor: default;
}
/* Above the mobile fixed header (1200) and modals (1300): this is a blocking
"nothing works right now" state, and it only appears before any session
state has loaded, so there is no modal underneath to bury. Stays below the
+62 -1
View File
@@ -134,6 +134,7 @@ import {
checkRemoteTmuxAvailable,
readRemoteCases,
readRemoteHosts,
rehydrateRemoteHostFields,
toAttachedSessionRemote,
toSessionRemote,
} from '../../remote-hosts.js';
@@ -841,6 +842,16 @@ export function registerSessionRoutes(
},
broadcast: (event, payload) => ctx.broadcast(event, payload),
log: (message) => console.log(message),
// The session's `remote` block is a launch-time snapshot, so a wake target
// configured later (banner's config dialog, or a hand-edited remote-hosts.json)
// is resolved here — throttled by the registry, and only for sessions that
// have no usable target of their own.
resolveRemote: async (session) => {
const remote = session.remote;
if (!remote) return undefined;
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
return rehydrateRemoteHostFields(remote, new Map(hosts.map((host) => [host.id, host])));
},
})
);
// ═══════════════════════════════════════════════════════════════
@@ -1541,6 +1552,56 @@ export function registerSessionRoutes(
// Terminal I/O (input, resize, buffer)
// ═══════════════════════════════════════════════════════════════
// ========== Wake-on-LAN: state + manual trigger ==========
//
// Both routes are session-scoped (not host-scoped) because the wake flow needs the
// SESSION: a woken host whose pane is not reattached is still a dead terminal, and an
// exhausted COD-108 backoff never retries on its own. The probe in `/reachability` is
// the same cheap TCP connect the input path uses and it NEVER wakes a host — the UI
// decides that, with the button.
app.get('/api/sessions/:id/reachability', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const remote = session.remote;
if (!remote) return { success: true, data: { reachable: true, wakeConfigured: 'none' as const } };
const force = (req.query as { force?: string })?.force === '1';
const reachable = await remoteWake.checkReachable(session, { force });
return {
success: true,
data: {
reachable,
wakeConfigured: await remoteWake.wakeConfigured(session),
host: remote.host,
label: remote.label,
},
};
});
app.post('/api/sessions/:id/wake', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
if (!session.remote) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not a remote session');
}
// The UI uses this to route to the host config dialog instead of a dead button.
if (!(await remoteWake.hasWakeTarget(session))) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'No wake-on-LAN target configured for this host (set a MAC address or a wake command)'
);
}
const woke = await remoteWake.ensureAwake(session, { force: true });
return {
success: true,
data: {
woke,
reachable: await remoteWake.checkReachable(session),
wakeConfigured: await remoteWake.wakeConfigured(session),
},
};
});
// ========== Send Input ==========
app.post('/api/sessions/:id/input', async (req, reply) => {
@@ -1591,7 +1652,7 @@ export function registerSessionRoutes(
// known reachable inside the probe throttle window; the probe itself is a bare
// TCP connect on wake-enabled hosts only, at most once per
// REMOTE_WAKE_PROBE_MIN_INTERVAL_MS.
if (!duplicate && session.remote?.wakeCommand) {
if (!duplicate && (await remoteWake.hasWakeTarget(session))) {
if (wantsWait) {
// Send-and-wait keeps the response open anyway, so blocking on the wake is
// simpler and more correct than buffering (buffering would break the wait).
+12
View File
@@ -749,6 +749,18 @@ export const RemoteHostSchema = z.object({
.regex(/^\S+$/, 'Wake command must be a single executable path (no arguments)')
.regex(NO_SHELL_META, 'Invalid characters in wake command')
.optional(),
// Wake-on-LAN MAC address(es), comma-separated. Structural: only hex pairs with
// `:`/`-` separators, so nothing here can be a shell token even by accident (the
// value never reaches a shell — Codeman builds the magic packet itself).
wakeMac: z
.string()
.min(11)
.max(128)
.regex(
/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/,
'Wake MAC must be one or more MAC addresses, comma-separated'
)
.optional(),
});
export const RemoteCaseLinkSchema = z.object({
+18
View File
@@ -115,6 +115,15 @@ describe('remote-hosts domain', () => {
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/sh$(id)' }).success).toBe(false);
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false);
});
it('accepts one or more MAC addresses and rejects anything else', () => {
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58' }).success).toBe(true);
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04-d9-f5-80-c6-58, 1C:61:B4:20:58:EB' }).success).toBe(
true
);
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6' }).success).toBe(false);
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58; rm -rf /' }).success).toBe(false);
});
});
describe('rehydrateRemoteHostFields', () => {
@@ -152,6 +161,15 @@ describe('remote-hosts domain', () => {
expect(rehydrateRemoteHostFields(remote, hosts())?.wakeCommand).toBeUndefined();
});
it('refreshes a MAC that only exists in the host config', () => {
const withMac = new Map(
hosts()
.entries()
.map(([id, host]) => [id, { ...host, wakeMac: '04:d9:f5:80:c6:58' }] as const)
);
expect(rehydrateRemoteHostFields(persisted, withMac)?.wakeMac).toBe('04:d9:f5:80:c6:58');
});
it('leaves the block untouched when the host is gone or the session is local', () => {
expect(rehydrateRemoteHostFields(persisted, new Map())).toBe(persisted);
expect(rehydrateRemoteHostFields(undefined, hosts('/x'))).toBeUndefined();
+108 -4
View File
@@ -19,7 +19,11 @@ import { describe, it, expect, vi } from 'vitest';
import {
RemoteWakeRegistry,
appendBoundedPending,
buildMagicPacket,
decideRemoteInputAction,
parseMacList,
resolveWakeTarget,
wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES,
type RemoteWakeDeps,
type WakeableRemote,
@@ -29,10 +33,10 @@ import {
// ========== Pure decisions ==========
describe('decideRemoteInputAction', () => {
const base = { hasWakeCommand: true, waking: false, probeAgeMs: 0, lastReachable: undefined as boolean | undefined };
const base = { hasWakeTarget: true, waking: false, probeAgeMs: 0, lastReachable: undefined as boolean | undefined };
it('delivers unchanged when the host has no wake command (feature off)', () => {
expect(decideRemoteInputAction({ ...base, hasWakeCommand: false, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe(
expect(decideRemoteInputAction({ ...base, hasWakeTarget: false, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe(
'deliver'
);
});
@@ -77,6 +81,59 @@ describe('appendBoundedPending', () => {
});
});
describe('MAC parsing + magic packet', () => {
it('parses one or more MACs with either separator', () => {
expect(parseMacList('04:d9:f5:80:c6:58')).toEqual([[4, 217, 245, 128, 198, 88]]);
expect(parseMacList('04-d9-f5-80-c6-58, 1c:61:b4:20:58:eb')).toEqual([
[4, 217, 245, 128, 198, 88],
[28, 97, 180, 32, 88, 235],
]);
});
it('is all-or-nothing so a typo cannot half-arm a host', () => {
expect(parseMacList('04:d9:f5:80:c6')).toBeNull();
expect(parseMacList('04:d9:f5:80:c6:58, nonsense')).toBeNull();
expect(parseMacList('')).toBeNull();
expect(
parseMacList('04:d9:f5:80:c6:58,1c:61:b4:20:58:eb,aa:bb:cc:dd:ee:ff,11:22:33:44:55:66,99:88:77:66:55:44')
).toBeNull();
});
it('builds the documented magic packet byte-for-byte', () => {
// 6 x 0xFF then the MAC repeated 16 times — a packet off by one byte simply never
// wakes anything, so the shape is pinned rather than described.
const mac = [4, 217, 245, 128, 198, 88];
const packet = buildMagicPacket(mac);
expect(packet.length).toBe(6 + 16 * 6);
expect([...packet.subarray(0, 6)]).toEqual([255, 255, 255, 255, 255, 255]);
for (let repeat = 0; repeat < 16; repeat++) {
expect([...packet.subarray(6 + repeat * 6, 12 + repeat * 6)]).toEqual(mac);
}
});
it('resolves the wake target with the command as the explicit override', () => {
const mac = '04:d9:f5:80:c6:58';
expect(resolveWakeTarget(undefined)).toBeNull();
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1' })).toBeNull();
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac })).toEqual({
kind: 'mac',
macs: [[4, 217, 245, 128, 198, 88]],
});
expect(
resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac, wakeCommand: '/bin/wake' })
).toEqual({ kind: 'command', command: '/bin/wake' });
// A malformed MAC (hand-written config) must not arm a broken wake.
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: 'nope' })).toBeNull();
});
it('reports which wake path the UI should offer', () => {
expect(wakeConfigured(undefined)).toBe('none');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x' })).toBe('none');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeMac: '04:d9:f5:80:c6:58' })).toBe('mac');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeCommand: '/bin/wake' })).toBe('command');
});
});
// ========== Registry ==========
const remote: WakeableRemote = {
@@ -98,7 +155,9 @@ interface Harness {
events: string[];
}
function harness(opts: { remote?: WakeableRemote; writesFail?: boolean } = {}): Harness {
function harness(
opts: { remote?: WakeableRemote; writesFail?: boolean; resolveRemote?: RemoteWakeDeps['resolveRemote'] } = {}
): Harness {
const probe = vi.fn(async () => false);
const wake = vi.fn(async () => true);
const waitUntilReady = vi.fn(async () => true);
@@ -115,6 +174,7 @@ function harness(opts: { remote?: WakeableRemote; writesFail?: boolean } = {}):
noteReconnected,
broadcast: (event) => events.push(event),
log: () => {},
...(opts.resolveRemote ? { resolveRemote: opts.resolveRemote } : {}),
};
const session: WakeableSession = {
@@ -185,7 +245,7 @@ describe('RemoteWakeRegistry', () => {
releaseWake?.();
await h.registry.wake(h.session);
expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff');
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
expect(h.reattachRemote).toHaveBeenCalledTimes(1);
expect(h.noteReconnected).toHaveBeenCalledWith('sess-1', true);
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hal', 'lo']);
@@ -263,6 +323,50 @@ describe('RemoteWakeRegistry', () => {
expect(h.registry.pendingBytes('sess-1')).toBe(0);
});
it('wakes a MAC-configured host by magic packet, with no external command', async () => {
const h = harness({
remote: { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' },
});
h.probe.mockResolvedValue(false);
await expect(h.registry.handleInput(h.session, 'hi')).resolves.toBe('buffered');
await h.registry.wake(h.session);
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hi']);
});
it('resolves host config for a session that predates it, so a saved MAC works live', async () => {
// The persisted `remote` snapshot is taken at launch: without this the banner's
// config dialog would only take effect after restarting the session.
const resolveRemote = vi.fn(async () => ({
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
}));
const h = harness({
remote: { hostId: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137' },
resolveRemote,
});
h.probe.mockResolvedValue(false);
expect(await h.registry.hasWakeTarget(h.session)).toBe(true);
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('buffered');
await h.registry.wake(h.session);
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
expect(resolveRemote).toHaveBeenCalledTimes(1);
// Cached: the next keystroke must not re-read the host config.
await h.registry.hasWakeTarget(h.session);
expect(resolveRemote).toHaveBeenCalledTimes(1);
});
it('does not consult the resolver when the session already has a wake target', async () => {
const resolveRemote = vi.fn(async () => undefined);
const h = harness({ resolveRemote });
expect(await h.registry.hasWakeTarget(h.session)).toBe(true);
expect(resolveRemote).not.toHaveBeenCalled();
});
it('drops buffered input with the session', async () => {
const h = harness();
h.probe.mockResolvedValue(false);
+62 -1
View File
@@ -98,7 +98,7 @@ describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
expect(res.json()).toEqual({});
// Nothing reached the pane: writing now would be swallowed by the stalled ssh.
expect(session.writeBuffer).toEqual([]);
expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff');
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
expect(h.registry.isWaking(SESSION_ID)).toBe(true);
h.releaseWake();
@@ -143,3 +143,64 @@ describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
expect(session.writeBuffer).toEqual(['hallo']);
});
});
describe('GET /api/sessions/:id/reachability', () => {
const get = (app: FastifyInstance, url: string) => app.inject({ method: 'GET', url });
it('reports the probe result and how the host can be woken', async () => {
const up = await harness({ hostUp: true });
const upBody = (await get(up.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
expect(upBody.data.reachable).toBe(true);
expect(upBody.data.wakeConfigured).toBe('command');
expect(upBody.data.label).toBe('Hufflepuff');
const down = await harness({ hostUp: false });
const downBody = (await get(down.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
expect(downBody.data.reachable).toBe(false);
// A reachability check is a QUESTION, never an action: the host stays asleep.
expect(down.wake).not.toHaveBeenCalled();
});
it('says nothing can wake a host without a configured target', async () => {
const { wakeCommand, ...withoutWake } = remoteSession;
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
expect(body.data.reachable).toBe(false);
expect(body.data.wakeConfigured).toBe('none');
});
});
describe('POST /api/sessions/:id/wake', () => {
const wake = (app: FastifyInstance) => app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/wake` });
it('wakes the host, reattaches the pane and reports both', async () => {
const h = await harness({ hostUp: false });
const session = h.ctx.sessions.get(SESSION_ID)!;
const body = (await wake(h.app)).json();
expect(body.success).toBe(true);
expect(body.data.woke).toBe(true);
expect(body.data.reachable).toBe(true);
expect(session.reattachRemote).toHaveBeenCalled();
});
it('answers with an error the UI can route to the config dialog', async () => {
const { wakeCommand, ...withoutWake } = remoteSession;
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
const res = await wake(h.app);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toMatch(/No wake-on-LAN target/);
expect(h.wake).not.toHaveBeenCalled();
});
it('does not send a wake when the host answers, but still settles the session', async () => {
const h = await harness({ hostUp: true });
const body = (await wake(h.app)).json();
expect(body.data.woke).toBe(true);
expect(h.wake).not.toHaveBeenCalled();
});
});