mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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 · <span id="wakeConfigHostLabel"></span></h3>
|
||||
<button class="modal-close" onclick="app.closeWakeConfigDialog()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label>MAC address(es)</label>
|
||||
<input type="text" id="wakeConfigMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Comma-separated for several NICs. Codeman sends the magic packet itself (UDP port 9, broadcast).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Wake command (optional)</label>
|
||||
<input type="text" id="wakeConfigCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Takes precedence over the MAC. A single executable path, run without a shell.</span>
|
||||
</div>
|
||||
<div class="form-hint" id="wakeConfigStatus"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-toolbar" onclick="app.closeWakeConfigDialog()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" id="wakeConfigSave" onclick="app.saveWakeConfig()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="readMyMindModal">
|
||||
<div class="modal-backdrop" onclick="app.closeReadMyMind()"></div>
|
||||
<div class="modal-content readmymind-modal">
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user