Skip to content

feat(remote): wake a sleeping host (Wake-on-LAN) from input, banner and native magic packet - #439

Open
Randalix wants to merge 8 commits into
Ark0N:masterfrom
Randalix:feat/remote-host-wake
Open

Randalix wants to merge 8 commits into
Ark0N:masterfrom
Randalix:feat/remote-host-wake

Conversation

@Randalix

@Randalix Randalix commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds Wake-on-LAN for remote (SSH) sessions, so a sleeping host can be woken instead of silently swallowing input.

A remote host is armed by either of two RemoteHost fields in remote-hosts.json:

  • wakeMac (new, the normal case) — a comma-separated MAC list. Codeman builds and sends the magic packet itself over dgram (UDP 9, broadcast), dependency-free.
  • wakeCommand (kept) — a single executable path, run without a shell (spawn(command, [], { stdio: 'ignore' })), for waking via a router, another machine, or a script. It takes precedence over wakeMac when both are set.

Two triggers, both gated on a real user action:

  • InputPOST /api/sessions/:id/input probes the host (bare TCP connect, throttled to one probe per 30 s per session, wake-enabled hosts only) and, if it is down, wakes it, calls reattachRemote(), and flushes buffered input in order.
  • Banner — an amber "host not reachable" banner with a Wake button, backed by GET /api/sessions/:id/reachability (probe only, never wakes) and POST /api/sessions/:id/wake. With no wake target configured, the button opens the existing host-config dialog pre-filled for that host.

Closes #433.

The two invariants

#1 — only user input or an explicit wake request may wake a host. The auto-reconnect watcher (COD-108), handleRemoteSessionDropped and boot recovery have no access to the registry. A wake there would re-wake the host seconds after every suspend, so it could never stay asleep — a worse bug than the one this fixes. This is asserted as a wiring guard (a src/ scan in test/remote-wake.test.ts), not a comment. GET …/reachability probes and never wakes (regression test: probe a sleeping fake host → the wake spy stays empty).

#2 — no ServerAliveInterval. Keepalives would move bytes into an otherwise idle connection every interval, which is exactly what the byte-threshold idle detector on the sleeping host must not read as activity. The probe (~200 B / 30 s) sits far below it and cannot wake anyone by SYN. The reasoning paragraph lives in docs/architecture-invariants.md, where you asked for it, so the next person does not "fix" the stalled pane by adding keepalives.

wakeCommand gating

The config dialog writes through PUT /api/remote-hosts/:id, which is already admin-only in multi-user mode (adminOnly, case-routes.ts:682) — same as the rest of the host config — so a non-admin cannot name an executable for the server to run. The schema accepts a single executable path only (no arguments, no $/backticks), and it is spawned with shell: false.

Pending buffer

  • Bounded at 4 KB per session, drop-oldest, with a log line ([RemoteWake] pending buffer cap reached for session … — oldest input dropped). Keeping the tail preserves what the user just typed; a silently unbounded buffer keyed on user input would be a memory leak.
  • Memory-only. It lives in the per-session registry state and goes away with the session — nothing is persisted. So if the wake fails and the host stays down, the keystrokes are held for the life of that session and then discarded. (Dropping them immediately was also defensible; this is the one I chose, so it is spelled out rather than left to inference.)
  • If a flush write fails part-way, the remaining chunks are retained in the same in-memory buffer, not dropped.
  • Send-and-wait blocks on the wake instead of buffering, because buffering would break the wait contract.

The dgram finding (in the code, not just the commit message)

setBroadcast() on an unbound socket throws EBADF on Linux, the following send fails with EACCES, and the function reports success — the magic packet never left the machine and it looked like "the host just did not come back". The order is load-bearing, so the broadcast flag is set inside the bind callback, with a ⚠️ comment at the call site (src/remote-wake.ts). The socket is injectable so a test asserts the bind → setBroadcast order (a real UDP broadcast in CI would be unwelcome). This is the bug the live test caught; unit tests with mocks would never have found it.

Testing

  • New: test/remote-wake.test.ts (decision table, single-flight, buffer order + drop-oldest, wiring guard, socket order), test/routes/session-remote-wake.test.ts (route behavior), test/sse-dispatch-table.test.ts (frontend SSE dispatch guard).
  • Extended: test/remote-hosts.test.ts (schema + rehydration), test/mocks/mock-session.ts.
  • tsc --noEmit, eslint, prettier, check:frontend-syntax, check:public-assets — clean.
  • Full npm test: 7303 passed, 15 skipped.
  • Live against a real sleeping machine: the magic packet brought the host back over SSH in ~9 s; POST /api/sessions/:id/wake returned {woke:true, reachable:true} in 12 s including reattach, and the durable remote tmux session — and the agent conversation — survived the suspend. The banner was verified in a real browser (appears, Wake → "Waking…" → gone in 10.0 s, no console errors).

test/quick-start.test.ts is red in my environment only: it binds 127.0.0.1:3100, which a local Docker container already holds (EADDRINUSE). Unrelated to this change — the port comes from TEST_PORT + 1 off 3099, which is why grepping the file for 3100 finds nothing. Flagging it in case the suite is not fully green for you either.

Review pass over the branch (commit 8dfc965d) found and fixed two things, both worth knowing since they are the silent-failure kind:

  • _onRemoteHostWaking / _onRemoteHostWakeFailed were defined in two frontend modules (panels-ui.js for the toast, host-wake-ui.js for the banner). Both mix into CodemanApp.prototype and host-wake-ui.js loads later, so the toast copy was silently shadowed — and a wake for a background session produced no notification at all. The handlers now live only in host-wake-ui.js.
  • appendBoundedPending dropped only whole chunks, so a single input value over the cap (one large paste is one input, up to 100 KB by the input schema) was kept in full — "bounded at 4 KB" held per chunk, not per session. The surviving chunk's head is now trimmed, code-point aware.

Both now have a guard: every SSE dispatch handler must be defined in exactly one module (the existing test only asserted one exists somewhere), and a test asserts a single oversized chunk is trimmed rather than kept.

No changeset, per your note.

A durable remote session survives SSH drops (COD-104/108), but nothing brought
the HOST back: after the remote machine suspended, the local tmux pane's ssh
child stalled silently and `send-keys` SUCCEEDS against it, so typed input
vanished with no error anywhere.

Add an optional per-host `wakeCommand` (Wake-on-LAN wrapper, e.g. whuff) that
the input route runs when a wake-enabled host is unreachable: input is buffered,
the host is woken, the pane is reattached, and the buffer is flushed in order.
Detection is a throttled bare TCP probe on wake-enabled hosts only, and only
REAL user input may wake a host - the auto-reconnect watcher and boot recovery
deliberately cannot, or the host would be re-woken seconds after every suspend
and could never stay asleep.
…sions

A session's remote block is persisted at launch time and recovery uses that
snapshot, so a wakeCommand added to remote-hosts.json afterwards never reached
an already-running session - not even across a Codeman restart (observed: the
live Hufflepuff session came back with no wakeCommand). Merge the host-level
field in on restore, with the host config authoritative.
… bulk delete

Self-review pass: the input-ladder's two 'buffer' branches were the same three
lines, and bulk delete left a session's (bounded, per-random-uuid) wake state
behind. Documents the design where the code refers to it - remote-sessions.md
section, the architecture invariant, and the CLAUDE.md key pattern.
…ke-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.
setBroadcast() on an unbound dgram socket throws EBADF on Linux and the following
send fails with EACCES, so the magic packet silently never left the machine — the
feature reported a wake that never happened. Caught by waking a real sleeping host
(a unit test with a real UDP broadcast would not be welcome in CI, so the socket is
injectable and the bind-before-setBroadcast ORDER is asserted).
A configured-but-broken target (host replaced NIC, command removed) had no way
out: the dialog hung off the 'no target configured' branch only, so the banner
would keep offering a Wake button that keeps failing.
…of tab switches

Reported as 'the tab shows no banner' while the host was verifiably unreachable: the
banner only started polling from selectSession, which RETURNS EARLY for the tab you
are already on (so a page loaded with the remote tab active never polled), and a
long-lived tab keeps running the JS it loaded — the feature was invisible to anyone
who did not switch tabs after the deploy.

The poller is now page-wide: one interval (created on init and on the first session
switch), re-targeted whenever the active session changes, plus a visibilitychange
wake-up. It no longer depends on any single selection path running.

Also adds test/sse-dispatch-table.test.ts: a static guard that every
[SSE_EVENTS.X, '_onFoo'] entry names an event constants.js defines AND a handler some
module defines. Both halves fail silently (a typo'd constant is an undefined table
key; a renamed handler just never runs), which is exactly how a new banner can never
appear with no error anywhere.
… input cap

Two findings from a final review pass over the wake-on-LAN feature.

`_onRemoteHostWaking` / `_onRemoteHostWakeFailed` were defined in BOTH
`panels-ui.js` (toasts) and `host-wake-ui.js` (banner). Both files mix into
`CodemanApp.prototype` and `host-wake-ui.js` loads later, so the panels-ui copies
were silently shadowed: the toast never fired, and a wake started for a BACKGROUND
session (input on a non-active tab) produced no notification at all, since the
banner handler only acts on the active session. The handlers now live only in
`host-wake-ui.js`, show the toast unconditionally, and update the banner when the
woken session is the active one.

`appendBoundedPending` dropped only WHOLE chunks, so a single input value over the
cap (one large paste is one `input` value, up to the 100 KB input schema) was kept
in full: "bounded at 4 KB" held per chunk, not per session, and nothing was logged.
The surviving chunk's head is now trimmed too, code-point aware so a multi-byte
character is never split into a replacement char.

Adds the guard that would have caught the first one: every SSE dispatch handler must
be defined in exactly ONE frontend module. The existing test only asserts a handler
EXISTS somewhere, which two modules both satisfy while one is shadowed.
@Randalix

Copy link
Copy Markdown
Contributor Author

One more wake path — asking before I push it

I have one more commit for this branch (d0a5a583) and have not pushed it, since the PR is with you now. It is the same feature, so my instinct is that it belongs here rather than in a second PR — but it changes the behaviour of two existing routes, which is not something I want to slide in mid-review. Say the word and I push it; say "not now" and it stays local.

The gap

Waking was reachable from input and from the banner button, but not from opening a session — which is the moment you actually decide to use that host. POST /api/quick-start resolves a remote case and then probes tmux (checkRemoteTmuxAvailable), and on a suspended host that probe fails over ssh:

OPERATION_FAILED: could not verify tmux on remote host 192.168.50.137: ssh: connect to host …

i.e. an error that blames tmux for a machine that is merely asleep, with nothing in the log. POST /api/sessions + attachRemoteSession had no wake path at all.

What the commit does

  • RemoteWakeRegistry.ensureHostAwake() runs the existing probe → wake → wait-for-readiness machinery for a host that has no session yet (host-scoped state, single-flight per host, so a double click or two cases on one host send one packet), plus checkHostReachable(), which only asks and never wakes.
  • Wired into those two user-initiated routes only, and deliberately not in the shared session service: cron-service.ts creates sessions there with nobody waiting on the answer, so a wake on that path would power the host on for every schedule — the timer-driven re-wake invariant feat: add HTTP Basic Auth for web interface security #1 exists to prevent. Kept as wiring rather than prose: only session-routes.ts may import remote-wake, and ensureHostAwake has exactly one caller file (test/remote-wake.test.ts, which also keeps the existing importers guard). A rejection from the wake IO is caught, so a broken target fails the wake and not the route.
  • A host with no wake target is not probed at all'no-target' returns before the first TCP connect, so its behaviour and latency are byte-identical to before.
  • Blocking, with its own budget: 40 s (REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS) rather than the 90 s session default. The deployment this was built against serves the dashboard through a reverse proxy whose default proxy_read_timeout is 60 s, so a longer wait is cut off at the proxy while the session is still being created — the browser reports a failure for a session that exists. 40 s + 1.5 s probe + the tmux probe's own 15 s timeout = 56.5 s worst case, and a wake on this setup measures 9–12 s.
  • Failure is honest now: a wake that does not come back says the host did not come back, and an unreachable host without a target says … is not reachable, and this host has no wake-on-LAN target instead of pointing at tmux. (Both branches are regression-tested.)
  • remote:hostWaking / remote:hostWakeFailed carry a forNewSession flag in the session-less case, where the existing toast text ("input is queued") would be untrue — it reads "the session starts when it is back".
  • Docs in the same commit: docs/remote-sessions.md §Wake-on-LAN and docs/architecture-invariants.md. The invariant wording had to move from "only real user input or an explicit wake request may wake a host" to "an explicit request — input, the wake button, or the user's own create/attach — and never a timer, probe or list path", with cron-service.ts named as the reason the create wake lives in the route.

Verification

before now
quick-start on a remote case, host suspended aborted with the tmux error session created and attached in 9 s
second run, host awake no wake, 2 s
attachRemoteSession, host awake no wake path attaches, no wake

Test sessions were deleted afterwards, and the remote tmux sessions with them (an attempt against a discovered, non-owned session detaches and leaves it alone).

npm test: 7316 passed, 15 skipped. Same environmental test/quick-start.test.ts red as before (127.0.0.1:3100 is held by an unrelated container here), plus one boundary flake in test/qr-auth.test.ts on the exact 90 s grace boundary that is green in isolation — flagging both rather than reporting a clean run.

No changeset, same as before.

@Randalix
Randalix force-pushed the feat/remote-host-wake branch from 4a30f51 to 8dfc965 Compare September 15, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(remote): wake a sleeping host — banner + manual WoL, and buffer input until it is back

1 participant