Skip to content

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

Merged
Ark0N merged 17 commits into
Ark0N:masterfrom
Randalix:feat/remote-host-wake
Sep 19, 2026
Merged

Ark0N merged 17 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.
Pressing Run on a remote case whose host was asleep failed with
`could not verify tmux on remote host 192.168.50.137: …` — an ssh error that
blames tmux for a machine that is merely suspended. The only wake paths were
typed input on an established session and the banner's Wake button, so OPENING a
session (the moment the user actually decides to use that host) had none.

`RemoteWakeRegistry.ensureHostAwake()` reuses the existing probe/wake/readiness
machinery for a host that has no session yet, and is wired into the two
user-initiated create paths: `POST /api/quick-start` for a remote case (before
the tmux prereq probe, which is what surfaced the misleading error) and
`POST /api/sessions` with `attachRemoteSession`. A host without a wake target is
not even probed, so its behavior and latency are byte-identical. The wake is
blocking — the caller gets the session or an error — but bounded by
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS (40 s) instead of the 90 s session default,
because the dashboard sits behind a reverse proxy whose default
`proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while
the session was still being created. The budget has to cover the whole request
(40 s wake + 1.5 s probe + the tmux probe's own 15 s = 56.5 s worst case), which
is why it is 40 s and not 45. A timeout now says the host did not come back, and
an unreachable host without a wake target says so instead of pointing at tmux.

The wiring is deliberately in the HTTP ROUTE, never in the shared session
service: `cron-service.ts` builds sessions there with nobody waiting on the
answer, and a wake on that path would power the host on for every schedule —
the timer-driven re-wake invariant Ark0N#1 exists to prevent. Both halves are asserted
(importers of `remote-wake`, and `ensureHostAwake` having exactly one caller
file), so a future caller has to come through the guard test. A rejection from
the wake IO is caught too: a broken target must fail the wake, not the route.

`remote:hostWaking`/`remote:hostWakeFailed` now carry `forNewSession` for the
session-less case, where "input is queued" would be untrue; the toast then reads
"the session starts when it is back".

Live wake numbers are unchanged (this reuses the measured ~12 s S3 path); the
route behavior is covered by new tests in session-routes.test.ts with an injected
registry, so no test opens a real socket or ssh.
@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.

…ion too

Found by driving the real UI: with a MAC configured in remote-hosts.json, removing it
(here: to reach the "Configure WoL" dialog) changed nothing for a running session —
_effectiveRemote short-circuited on the session's own snapshot whenever that snapshot
HAD a target, so the resolver was only ever consulted in the one direction where the
feature was missing. The documented "host config is authoritative" promise therefore
failed in the direction a user can actually observe, and a wake target could live on
invisibly after being deleted from the config.

The resolver is now consulted on the TTL regardless, and wins for the wake fields in
both directions. Also adds a route test for the browser's real input shape: one POST
per keystroke, all buffered during a wake, replayed IN ORDER.
@Randalix
Randalix force-pushed the feat/remote-host-wake branch from 4a30f51 to 8dfc965 Compare September 15, 2026 21:29
…ession

refreshHostWakeBanner clears _hostWake before calling _hostWakeTick, so the
clear branch's `if (this._hostWake)` guard skipped the repaint: once the
banner had appeared for an unreachable remote session it stayed up on every
chat (local ones included) until a reload, and the 30s ticker never cleared
it either. Render unconditionally in that branch — _renderHostWakeBanner is
idempotent with a null state.

Reproduced in a real browser (Puppeteer, mobile viewport): state went null
but banner.hidden stayed false. Regression test added in
test/host-wake-banner.test.ts (red before, green after).
@Randalix

Randalix commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Updated the branch — it now carries three commits past the snapshot you have (8dfc965d). The d0a5a583 offer from the comment above is included here rather than kept local, so the branch is current as a whole.

What changed since 8dfc965d

d0a5a583 — wake on create/attach (the commit I described in the comment above). POST /api/quick-start and POST /api/sessions + attachRemoteSession now wake a sleeping host through ensureHostAwake() before probing tmux, instead of failing with an error that blames tmux. Wired into those two user-initiated routes only, never the shared session service (cron-service.ts is the reason). A host without a wake target is not probed at all. 40 s budget (reverse-proxy proxy_read_timeout is 60 s here). Docs in the same commit.

4a30f510 — host config is authoritative in BOTH directions. RemoteWakeRegistry._effectiveRemote returned the session snapshot as soon as it had any wake target and never re-read the host config, so a MAC deleted in the config kept living in a running session: the banner still offered "Wake" and the config dialog was therefore unreachable from the UI. It now always consults the host config on the 30 s TTL and wins both ways. Verified server-side: wakeConfigured flips live mac → none → mac with no session restart. Also added a route test for the concurrency surface — the browser sends one POST per keystroke, so inputs arriving during a wake must be buffered and re-delivered in order (test/routes/session-remote-wake.test.ts).

a7f74f37 — banner no longer sticks across sessions. Reported from a phone: once the "host not reachable" banner appeared it stayed up on every chat, local ones included, until a reload. Cause: refreshHostWakeBanner clears _hostWake before calling _hostWakeTick, but the tick's clear branch only re-rendered if (this._hostWake) — already null by then, so no repaint, and the 30 s ticker runs into the same silent branch. The clear branch now renders unconditionally; _renderHostWakeBanner is already idempotent with a null state. Reproduced in a real browser (Puppeteer, mobile viewport): state went null while banner.hidden stayed false; green after the fix. Regression test test/host-wake-banner.test.ts (red before, green after). This bug exists in the current PR head too, which is why it is here.

Verification

npm test: 7321 passed, 15 skipped. One red suite, unchanged and environmental: test/quick-start.test.ts (EADDRINUSE 127.0.0.1:3100 — an unrelated container holds the port on this machine, see #440). typecheck, lint, format:check, check:frontend-syntax, check:public-assets all clean.

Live on this deployment: create/attach wake 9–12 s measured; the banner fix verified end-to-end in a real browser.

@Ark0N

Ark0N commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the live test against a real sleeping machine: the bind before setBroadcast ordering bug is exactly the kind of thing mocks never find, and having it documented at the call site with a test that asserts the order is the right outcome. The PR adds Wake-on-LAN for remote SSH hosts: a wakeMac (magic packet built and broadcast by Codeman) or a wakeCommand on a host, triggered from the input route, a new Wake button and banner, and from Run/Attach, with input buffered and replayed in order after the reattach.

I ran typecheck, lint, format:check, check:frontend-syntax and check:public-assets (all clean) and the full suite: 387 files, 7324 tests, 12 skipped, exit 0. test/quick-start.test.ts is green here, so that one really was your port 3100.

The invariant work is the part I want to call out as right: only an explicit user action can wake, and it is enforced by two source-tree wiring guards rather than a comment. I checked by hand that nothing in tmux-manager.ts, remote-reconnect.ts, the dropped-session handler, boot recovery or cron-service.ts can reach the registry.

Nothing blocking. These are the things I would like fixed, and I am happy to do the first three at merge time if you would rather not round-trip.

1. Wake state is dropped on two of roughly eight cleanup paths (src/web/routes/session-routes.ts:1353, :1371). remoteWake.drop() is called from the two delete routes only. cron-service.ts:683, admin-routes.ts:205, ralph-routes.ts:438, the three scheduled-run calls in server.ts and the two error paths in session-routes.ts all call cleanupSession() without it, so the entry survives, including up to 4 KB of the user's buffered keystrokes. The right home is WebServer.cleanupSession() (server.ts:1266), next to sessionWaits.cancelAll() at server.ts:1457. Related: _effectiveRemote() (src/remote-wake.ts:610) calls this._state(session.id) before the if (!session.remote) return, so every local session that posts input also gets an entry. Swapping those two lines keeps local sessions out of the map.

2. An oversized paste is head-trimmed and then executed as a fragment (src/remote-wake.ts:135). MAX_INPUT_LENGTH is 64 KB and one paste is one input value, so pasting 10 KB into a session whose host is asleep keeps only the last 4 KB, and the flush writes that fragment into the pane (with Enter, if the payload carries a carriage return). Keeping the tail is right for typing and wrong for a chunk that was never typed: I would drop an oversized chunk outright and say so in the log line. test/remote-wake.test.ts:80 pins the current behaviour, so it moves with the change.

3. The Wake button uses the 90 s budget your own comment rules out (src/web/routes/session-routes.ts:1629). ensureAwake(session, { force: true }) passes no timeoutMs, so it inherits the 90 s session default, while the create and attach paths pass the 40 s REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS for the reverse proxy's 60 s proxy_read_timeout (the reasoning at src/remote-wake.ts:50). The button is pressed from that same dashboard and holds the request open the same way, so behind such a proxy it is cut at 60 s and the banner reports failure for a wake that is still running. Pass the request budget here too.

4. An in-flight wake has no cancellation path and delays shutdown. waitUntilRemoteReady polls for up to 90 s on a timer nothing can cancel, and WebServer.stop() ends with await this.app.close(), which does not abort in-flight requests (I checked against the Fastify in this tree: a request started before close() ran to completion and close() did not resolve first). So a restart during a wake waits it out. This is the case sessionWaits.cancelEverything() exists for, comment and all, at server.ts:3344. A stop() on the registry that resolves in-flight wakes false, called from WebServer.stop(), closes it. No data loss: the state flush happens earlier in stop().

5. The banner promises queueing on a path that never queues (src/web/public/host-wake-ui.js:160, and the toast at :337). Both strings key off state.waking, which the Wake button sets. Browser keystrokes go over the WebSocket (_reliableSend in app.js:3071, {"t":"i"} frames in ws-routes.ts), which does not pass through the registry, so what the user typed went into the stalled pane and is gone. The normal sequence is: type into a sleeping host, characters vanish, banner appears, press Wake, banner says "input is queued until it is back" when nothing was. I am not asking for the WS path to be made wake-aware, that is the hot path CLAUDE.md protects and the banner is the right answer for browsers. Just wording that does not promise queueing for the button path, and one line in docs/remote-sessions.md saying the WS keystroke path is deliberately not wake-aware.

Smaller things, and I will take these at merge unless you are touching the branch anyway:

  • src/web/sse-events.ts:8 still says 158 constants and :17 still says "Remote auto-reconnect (3)"; both moved. Same counts in CLAUDE.md (158 events, "+ 32 modules", "sessions (34)"), the frontend load-order chain at CLAUDE.md:301 does not list host-wake-ui.js (it loads between session-ui.js and webview-tabs.js), the Infra row now reads remote-wake "(pure)" although the module uses dgram/net/child_process, and the rule paragraph at CLAUDE.md:220 describes only the input route and the button, not the create/attach wake that can block Run for 40 s. That paragraph is what the next person reads, so it is the one I care about.
  • deriveSseHint (src/web/server.ts:2308) has no remote: prefix, so the two new events reach every client in multi-user mode, and _onRemoteHostWaking toasts unconditionally with the host label. Same shape as the existing remote: events, so not something you introduced, but worth deciding now.
  • src/web/public/host-wake-ui.js:200 branches on the error message text (includes('No wake-on-LAN target')). errorCode is the stable half of the contract; the message is not.
  • docs/architecture-invariants.md picked up eight lines of unrelated Prettier markdown churn (*why* to _why_, table padding) in sections the PR does not touch. docs/ is not in the format glob, so this looks like an editor. Reverting those hunks makes the diff honest about what it changes.
  • In multi-user mode GET /api/remote-hosts returns [] to non-admins, so "Configure WoL" opens a dialog whose Save fails with "Remote host not found" rather than saying it is admin-only (host-wake-ui.js:243, :288).
  • src/remote-wake.ts:467 says the host wake writes into "the same waking slot the session flow uses". They are different keys, so a session wake and a create-path wake for the same host can run concurrently. Harmless, but the comment reads as a guarantee.
  • runRemoteWakeCommand's timeout (src/remote-wake.ts:670) kills the direct child only, not the process group, so a wake wrapper that forks can outlive the 10 s budget.

One process note: you asked in the comment whether to push d0a5a583 and then pushed it. It is the same feature and I am fine keeping it here, but it does change the behaviour of two existing routes, so next time hold it until I answer.

Fix 1 through 5 and I will merge. The rest I will fold in at merge time.

Review follow-up on the wake-on-LAN PR (five findings, all of them about the
state the feature keeps and the budgets it inherits):

- Wake state is dropped by `WebServer.cleanupSession` instead of the two delete
  routes, so it now goes with the session on EVERY cleanup path (cron, admin,
  scheduled-run teardown, error paths) instead of surviving with up to 4 KB of
  the user's buffered keystrokes. `registerSessionRoutes` returns the registry
  so the server can own its lifetime without the wake-capable code living in
  `server.ts`; the wiring guard is updated to allow that and gains a second
  assertion that `server.ts` calls nothing but `drop`/`stop` on it.
- `_effectiveRemote` returns before `_state`, so a LOCAL session no longer gets
  a wake-state entry — the input gate runs on every keystroke, so that entry
  used to be allocated for every session the user types in.
- An input chunk larger than the 4 KB cap is dropped OUTRIGHT instead of being
  head-trimmed and then written as a fragment: one paste is one `input` value
  and was never typed character by character, so its tail is a partial command
  the user never sent. The drop is logged.
- The manual wake button passes `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS` (40 s)
  like the create/attach paths, instead of inheriting the 90 s session default
  that the dashboard's reverse proxy cuts off at 60 s.
- `RemoteWakeRegistry.stop()` aborts in-flight readiness polls (abortable
  sleep) and refuses new wakes, and `WebServer.stop()` calls it, so a restart
  during a wake no longer waits the poll out.
- The banner/toast wording keys off a new `queuedInput` flag on the two SSE
  events, which is true only when the server actually holds bytes: browser
  keystrokes travel over the WebSocket, which never passes through the
  registry, so the wake BUTTON must not promise queued input. The failed-wake
  path also stops pattern-matching the error message (it re-asks the
  reachability route) and the WoL dialog says "admin-only" instead of "host not
  found" for a non-admin in multi-user mode.
- `host-wake-ui.js` joins the documented load order (12.2) and gets its
  `@dependency`/`@loadorder` tags; the frontend module count is 33, not 32.
- `remote-wake` is not "(pure)" — the module uses `dgram`/`net`/`child_process`.
- SSE counts: 160 constants, and the category is "Remote auto-reconnect / wake
  (5)"; the route table's per-file counts are refreshed (sessions 37, cases 34).
- The CLAUDE.md wake rule now names the create/attach wake, the 40 s request
  budget, the whole-chunk paste drop, the registry's lifetime (drop on cleanup,
  stop on shutdown) and the deliberately non-wake-aware WebSocket keystroke
  path — that paragraph is what the next person reads.
- Reverted the eight lines of unrelated Prettier markdown churn in
  `docs/architecture-invariants.md` (docs/ is not in the format glob, so it was
  an editor): only the new wake paragraph remains in the diff.
@Randalix

Copy link
Copy Markdown
Contributor Author

Pushed acb8d4b0 (two commits on top of a7f74f37) — all five, plus most of the smaller items since I was touching the branch anyway.

1. Wake state lifetime. remoteWake.drop() now runs from WebServer.cleanupSession() (next to sessionWaits.cancelAll()), so it covers every cleanup path; the two delete-route calls are gone. That means registerSessionRoutes returns the registry so the server can own its lifetime, and therefore server.ts imports remote-wake — so I reworked the first wiring guard rather than quietly widening it: it now allows web/routes/session-routes.ts and web/server.ts, and a second guard asserts server.ts contains no remoteWake.wake( / ensureAwake( / ensureHostAwake( / handleInput( / checkReachable( / checkHostReachable( (with or without ?.) and does contain drop( / stop(. The import list alone would have been satisfied by the field's type, so the property you actually care about is now asserted directly. Both guards verified red against the old code.

_effectiveRemote() returns before _state(); stateCount() is the diagnostic that pins it (red before the swap).

2. Oversized paste. appendBoundedPending now returns the buffer untouched when the chunk exceeds the cap, and _enqueue logs the drop. tailWithinBytes is gone and the two tests that pinned the trim were replaced by ones that pin the drop (including the existing buffer being left alone).

3. Wake button budget. ensureAwake takes timeoutMs; the button passes REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS.

4. Cancellation. RemoteWakeRegistry.stop() sets a stopped flag, aborts an AbortController whose signal rides into waitUntilReady, and refuses new wakes; waitUntilRemoteReady checks it at the loop top and the interval sleep is abortable (delayOrAbort), so cancellation is immediate instead of "after the current 1.5 s". WebServer.stop() calls it next to sessionWaits.cancelEverything(). Tests: an in-flight ensureHostAwake resolves 'failed' after stop() (real readiness poll, fake probe/wake), a direct waitUntilRemoteReady abort test, and an already-aborted signal returning false without probing.

5. Wording. Both SSE events now carry queuedInput (true only when state.pending.length > 0), and the banner detail plus both toasts key off it: the button path says "waiting for the host to come back" / "did not wake up", never "input is queued". docs/remote-sessions.md states that the WebSocket keystroke path is deliberately not wake-aware.

Smaller ones taken: the failed-wake handler no longer pattern-matches the error string (it re-asks /reachability, which is the authority on wakeConfigured); the WoL dialog says "Wake-on-LAN configuration is admin-only" for a non-admin in multi-user mode, both up front and on save; the remote-wake.ts comment about "the same waking slot" now says the keys differ on purpose; counts corrected (160 SSE events, 33 frontend modules, host-wake-ui.js in the load order at 12.2 with its tags, remote-wake no longer "(pure)"); the CLAUDE.md rule paragraph names the create/attach wake, the 40 s request budget, the whole-chunk drop, the registry's lifetime and the non-wake-aware WS path; and the eight lines of Prettier markdown churn in docs/architecture-invariants.md are reverted — only the new paragraph is in the diff now.

Left for you (I did not want to decide these unilaterally):

  • deriveSseHint / the remote: prefix. Adding it would scope hostWaking/hostWakeFailed by sessionId where they have one, but the create-path variants carry no sessionId and there is no host→owner mapping (hosts are global config), so they would fail closed to admins and a non-admin creating a remote case would lose the toast. A correct fix needs an explicit owner on the create path — a new field, so I left it.
  • runRemoteWakeCommand kills the direct child only. Process-group kill means detached: true + process.kill(-pid), which changes how a wake wrapper is spawned; small, but a behaviour change I would rather you nod at.
  • The route table: approvals (4) and webviews (6) don't match a naive app.get|post|put|patch|delete count (1 and 3), and custom-model-routes.ts isn't in the enumeration at all. Pre-existing drift, so I only refreshed sessions (37) and cases (34) — same count that reproduces system (56) exactly. Revert those two if your method differs.

Verification. npm test: 7327 passed, 15 skipped. One red suite, unchanged and environmental: test/quick-start.test.ts (EADDRINUSE 127.0.0.1:3100, an unrelated container holds the port here — see #440). typecheck, lint, format:check, check:frontend-syntax, check:public-assets clean; CI green on the new head.

⚠️ No live curl pass this time, and I want to be explicit about why rather than imply one: this revision is built on the PR head, while this deployment's working tree carries unrelated local work, so a build/restart here would have reverted that. The fixes are state-lifetime and budget changes, pinned by tests; the wake path itself is byte-identical to the revision you already exercised. Say the word if you want a deployed run and I will do it from a clean checkout.

… wake fields

Own review pass over the PR:

- `_flush` took the chunk out of the buffer only AFTER awaiting the write. Input
  arriving during that await is enqueued (`waking` is still set, so it takes the
  buffer path), and the 4 KB cap then drops the OLDEST chunk — which is the one
  already on its way to the pane. The `shift()` that followed removed the NEXT
  chunk instead, so the drop-oldest bookkeeping silently lost a chunk that was
  never written, while the log line blamed the one that was. The chunk is now
  removed before the await and re-inserted at the FRONT on a failed write, so the
  order of the queue behind it is preserved. Regression test: a chunk enqueued
  during the first write of a full buffer must still reach the pane (red against
  the old order).
- `showCreateCaseModal()` reset the remote-host form fields but not the two new
  wake inputs, so one host's MAC/command carried over into the next host that
  form saved.
- The banner's pre-poll `wakeConfigured` labelled a command-only host as 'mac'.
  Nothing reads the distinction, but the field is documented as which path is
  configured, so it says the truth until the first poll corrects it.
- Stale `resolveRemote` comment ("only for sessions that have no usable target of
  their own"): after the host config became authoritative in both directions it is
  consulted on the TTL regardless.
@Randalix

Copy link
Copy Markdown
Contributor Author

Follow-up: I read the whole diff against master again (not just my own fixes) and found three more things. Pushed as 29984c63, so the head is 29984c63 now.

1. The flush could lose a chunk that was never written. _flush took the chunk out of the pending buffer only AFTER awaiting writeViaMux. Input arriving during that await is enqueued — a wake is still in flight, so handleInput takes the buffer path — and if the buffer is at the 4 KB cap, appendBoundedPending drops the OLDEST chunk, which is exactly the one already on its way to the pane. The shift() that followed then removed the NEXT chunk, so drop-oldest silently discarded a chunk that had never been delivered, and the log line blamed the one that had. The chunk is now removed before the await and re-inserted at the FRONT if the write fails (so the order behind it is preserved). Regression test: with a full buffer, a chunk enqueued during the first write must still reach the pane — red against the old ordering, green now. Narrow window (needs ~4 KB queued plus input during one tmux call), but it is the same class of failure this feature exists to eliminate.

2. showCreateCaseModal() reset the remote-host form but not the two new wake inputs. Open the Create Case modal for host A with a MAC, close it, open it for host B: the wake fields still held A's values, and the next host this form saved got A's MAC/command. Both ids are in the reset list now.

3. The banner's pre-poll wakeConfigured labelled a command-only host as 'mac'. Nothing reads the distinction today, but the field is documented as which path is configured, so it now derives the right one until the first poll corrects it. Also fixed a stale resolveRemote comment ("only for sessions that have no usable target of their own") that the both-directions change had outdated.

Verification unchanged in shape: npm test 7328 passed, 15 skipped; test/quick-start.test.ts still the only red suite and still the environmental EADDRINUSE 127.0.0.1:3100 (#440). typecheck, lint, format:check, check:frontend-syntax, check:public-assets clean.

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the self-review round that came with it. It adds Wake-on-LAN for remote SSH hosts: a sleeping machine can be woken from the HTTP input route, from an explicit Wake button on a new amber banner, or when you press Run or Attach, with the magic packet built in-process so the common case needs no external script. The wiring guards that pin "only an explicit user request may wake a host" are the right way to hold that invariant, and the bind-before-setBroadcast finding is the kind of thing only a live test catches.

Two things need a change before this goes in.

1. The reachability probe ignores the SSH proxy fields, so jump-host and SOCKS hosts read as permanently asleep (src/remote-wake.ts:737).

probeRemoteHostReachable() connects straight to remote.host:port, and WakeableRemote carries no jumpHost, socksProxy or extraSshOptions. Those are supported host config: docs/remote-sessions.md calls the SOCKS path "the cloudflared/SOCKS5 case", which exists precisely because the host is not directly reachable. Three consequences, all on setups that work today:

  • GET /api/sessions/:id/reachability answers reachable:false for every such session, with no wake target required, so host-wake-ui.js shows "... is not reachable / Configure WoL" permanently over a healthy session. Your own test at test/routes/session-remote-wake.test.ts:184 pins that a target-less host still reports reachable:false.
  • In quick-start, a genuine "needs tmux installed" failure is replaced by "... is not reachable, and this host has no wake-on-LAN target" (src/web/routes/session-routes.ts:3342).
  • With a wake target configured (a wakeCommand that reaches the host's LAN through a router is exactly what that field is for, and such hosts are often reached through a bastion) every HTTP input is buffered, the readiness poll can never succeed, and the bytes are held for the life of the session. I confirmed this with a throwaway test against the real registry: three inputs, nothing written to the pane, no reattach, buffer non-empty.

Suggested shape: carry jumpHost, socksProxy and extraSshOptions into WakeableRemote (they already exist on SessionRemote and RemoteHost) and treat a proxied host as reachability-unknown, so there is no banner, no buffering and no create-path gate. The Wake button can still be offered, it just cannot verify readiness afterwards.

2. The two new SSE events reach every user in multi-user mode (src/web/server.ts:2324).

deriveSseHint() has no remote: prefix in SESSION_PREFIXES, so remote:hostWaking and remote:hostWakeFailed fall through to the global branch, and _onRemoteHostWaking shows its toast before the session check (src/web/public/host-wake-ui.js:372). Every logged-in user then sees "Waking , input is queued" for a session they do not own, and the payload carries a hostId/label that GET /api/remote-hosts deliberately withholds from non-admins. The remote: gap predates this PR (the three COD-108 events are global too), but those carry only a session id. Adding 'remote:' to SESSION_PREFIXES fixes it and is a no-op in single-user mode; the create/attach broadcast has no sessionId, so it would then reach admins only unless you thread the requesting user through as username.

Smaller things, fine to fold into the same pass:

  • A failed flush strands the buffer (src/remote-wake.ts:690). If writeViaMux fails on the first chunk after a successful reattach, the chunk is retained, but the wake still resolves and reachable is set true, so the next input takes the deliver path while the older chunk sits in pending. It is then replayed by the next wake, possibly hours later, after everything typed since, and it may carry a trailing carriage return. Dropping the retained chunks with a log line would match the policy you already chose for an oversized paste.
  • The banner poller opens a TCP connection to the host twice a minute for as long as a remote tab is visible (src/web/public/host-wake-ui.js:31), whether or not WoL is configured. That is the same timer-driven traffic your invariant fix: rebuild node-pty from source for Node.js 22+ compatibility #2 rejects keepalives for: it can never wake the host, but it can keep an activity-based suspend timer from firing. Gating the poll on wakeConfigured !== 'none' would also cover most of point 1.
  • remote-wake.ts does its real IO under vitest too (TCP connect, spawn, UDP broadcast). remote-files.ts refuses under VITEST for exactly this reason, and session-routes.test.ts had to inject the fake registry to avoid it. A guard would make the seam non-optional.
  • Scope: the diff carries the "Auto-named sessions" section of docs/architecture-invariants.md, which master already has byte for byte (I checked the merge, it resolves to one copy, so nothing breaks, it just reads as unrelated), plus about 25 lines of Prettier reformatting in docs/remote-sessions.md on top of the new content.

Everything else checks out here: typecheck, lint, format:check, check:frontend-syntax and check:public-assets are clean, and the full npm test is green (387 files, 7331 tests). CLAUDE.md, the architecture invariants, the SSE registry parity and the route counts all moved with the code, and the stale cases (30) count got corrected on the way past, which is appreciated. test/quick-start.test.ts passes here, so that EADDRINUSE on 3100 is local to your machine.

Once 1 and 2 are addressed this is good to merge.

Randalix and others added 2 commits September 18, 2026 22:20
Resolves CLAUDE.md count tables (route counts recounted on the merged
tree: 235 handlers, sessions 37) and keeps both the host-wake and the
reboot-restore banner in index.html.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
…E per session

Review round 2 on Ark0N#439.

1. The bare TCP probe connects to host:port, which a host behind a jump host
   or SOCKS proxy does not answer even while ssh works. Acting on that
   verdict drew a permanent banner over a healthy session, replaced a real
   "needs tmux" error with "not reachable" in quick-start, and - with a wake
   target - buffered every HTTP input for the life of the session, since the
   readiness poll could never succeed. `WakeableRemote` now carries
   `jumpHost`/`socksProxy`/`extraSshOptions`, and `isProbeable()` turns such
   a host into reachability-UNKNOWN: input is delivered, `checkReachable` /
   `checkHostReachable` answer `null` (never `false`), `ensureHostAwake`
   returns `'unprobeable'` (handled like `'no-target'`), the quick-start gate
   fires on `=== false` only, and `GET …/reachability` reports
   `reachable: null, probeable: false` so the banner has nothing to key on.
   A wake target can still be fired for it, blind: no readiness poll, no
   reattach, no toast - the response says only whether the packet went out.

2. `'remote:'` joins the session-scoped SSE prefixes. The create/attach wake
   has no session yet, so the registry names the requesting user
   (`ensureHostAwake({ requestedBy })` -> `username` in the payload) and
   `deriveSseHint` routes on it; with neither it fails closed to admins.
   Single-user mode is unaffected.

Smaller, from the same review:

- A flush write that fails now drops the remaining buffer (logged) instead
  of retaining it: the wake still resolved and marked the host reachable, so
  the retained chunk waited for the NEXT wake and was replayed hours later,
  after everything typed since. Same policy as the oversized paste.
- The banner polls on tab activation (a user action) and on its 30 s timer
  only for a host with a wake target; a timer connecting to a host Codeman
  cannot wake is the traffic invariant Ark0N#2 rejects keepalives for. A proxied
  host is never polled.
- `probeRemoteHostReachable`, `runRemoteWakeCommand` and the default UDP
  socket refuse under VITEST, as remote-files.ts does. The guard caught a
  leak on the spot: `createDefaultRemoteWakeDeps({ probe })` overrode the
  probe but still polled readiness with the real one, so the shutdown test
  had been connecting to a production address. The poll now uses the
  injected probe.
- docs/remote-sessions.md is additions only again (the reformatting is
  gone); the architecture-invariants overlap resolved itself in the merge.

Live, against a throwaway instance with a non-routable ghost host: proxied
-> no probe, no wake, the genuine ssh error after 10 s; direct (control) ->
probe, magic packet, "did not come back" after the 40 s budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
@Randalix

Copy link
Copy Markdown
Contributor Author

Thanks — both required changes are in (1040f6c4), the smaller ones folded into the same commit, and the branch is merged up to master (e271a65e, so it is mergeable again; the only conflicts were the CLAUDE.md count tables and the two banners in index.html, both kept).

1. Proxied hosts are reachability-unknown. WakeableRemote carries jumpHost / socksProxy / extraSshOptions (wakeableHost() copies them from RemoteHost; a session's own remote already had them), and isProbeable() in remote-wake.ts decides from those three — a ProxyCommand=/ProxyJump= in the extra options counts too. For such a host the registry never acts on the probe: handleInput delivers, checkReachable and checkHostReachable answer null rather than false, ensureHostAwake returns a new 'unprobeable' that the create/attach routes treat like 'no-target', and the quick-start "not reachable" branch now fires on === false only, so the genuine tmux/ssh error comes through. GET …/reachability reports reachable: null, probeable: false; the banner keys on a proven false and stops polling once it sees probeable: false (or a jump/SOCKS field in the session payload, without a round trip). The wake button still works there, blind: POST …/wake fires the packet/command and reports only whether it went out — no readiness poll, no reattach (the COD-108 watcher owns the pane once ssh is back), and no hostWaking toast, since that promises a wait that does not happen. Your three consequences are pinned as tests: test/remote-wake.test.ts (the proxied registry block), test/routes/session-remote-wake.test.ts (three inputs into a proxied session with a target → all three written, no probe, no wake, empty buffer; /reachabilitynull/probeable:false).

2. remote: is session-scoped. Added to SESSION_PREFIXES. The create/attach wake has no session, so the routes pass requestedBy: ownerFor(req) into ensureHostAwake, the registry puts it on both events as username, and deriveSseHint routes a remote: payload without a sessionId on that; with neither it fails closed to admins as before. The three COD-108 events carry a sessionId, so they are owner-scoped now too. test/sse-routing-remote.test.ts constructs the server without starting it and pins the four cases.

Smaller:

  • Failed flush: the remaining buffer is dropped with a log line (_flush), same policy as the oversized paste; the test that pinned retention now pins the drop and that the next input takes the deliver path with nothing behind it.
  • Banner poller: the 30 s timer (and the visibility wake-up) polls only a host with a wake target; tab activation still does one poll for any direct host, so "Configure WoL" is still offered for a sleeping host without one — on a user action, not a timer. test/host-wake-banner.test.ts counts the fetches per case.
  • No real IO under vitest: probeRemoteHostReachable, runRemoteWakeCommand and the default UDP socket of sendWakePackets throw under VITEST (an injected socket still works). The guard found something on the first run: createDefaultRemoteWakeDeps({ probe }) overrode probe but waitUntilReady still polled with the module default, so the stop() shutdown test had been connecting to 192.168.50.137 all along. The poll now uses the injected probe (pinned).
  • Scope: docs/remote-sessions.md is additions only again (159+/0−); the architecture-invariants.md overlap resolved itself in the merge.

Live, on a throwaway instance (CODEMAN_INSTANCE, port 3460) with a non-routable ghost host carrying a wakeMac: with jumpHost set, POST /api/quick-start returns could not verify tmux on remote host 10.255.255.1: Connection timed out … after 10 s with no [RemoteWake] line at all; the same host without the proxy field logs is unreachablewaking … via macdid not come back after a wake-on-LAN request after the 40 s budget. Here: typecheck, lint, format:check, frontend-syntax and public-assets clean; npm test 7466 passed, 3 failed — quick-start.test.ts (port 3100, as before) and the two case-clone/git-clone "clone fails" cases, which fail on this machine's git 2.47 on an untouched checkout as well (I can open a separate issue for those if useful).

@Ark0N

Ark0N commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the depth you went to: the wiring guards, the dgram bind-before-broadcast finding, the proxied-host rework and the three docs sections are all well above what I expect from a feature PR. The change lets a suspended remote host be woken from the HTTP input path, an explicit wake button or a Run/Attach request, instead of silently swallowing input into a stalled ssh pane. Checks are green here: typecheck, lint, format, frontend-syntax and public-assets clean, and the full gate at 7472 passed / 12 skipped. (test/quick-start.test.ts is green on my machine, so that 3100 clash really is local to yours.)

One thing to fix before I merge:

src/web/routes/session-routes.ts:898 runs the wake before the multi-user authorization gate. In the attachRemoteSession branch of POST /api/sessions, ensureHostAwake is called at line 898, but the privileged-command gate is at 917 and isWorkingDirAllowed at 926. So in multi-user mode a non-admin who posts an attach request for any configured hostId gets the host probed and its wakeCommand spawned (or a WoL packet broadcast), and the request held for up to 40 s, and only then gets FORBIDDEN: workingDir is outside your workspace. I reproduced it with a route test: the wake spy fires once with the host's command while the response is a 403. Remote hosts are admin-only infra everywhere else (GET /api/remote-hosts returns [] to a non-admin, and the write and discovery routes go through adminOnly in case-routes.ts:645), so this hands an unprivileged account a way to invoke one of those executables. The quick-start path is fine, its remote case goes through canAccessOwned first.

Easiest fix, inside the attach branch and before ensureHostAwake (both helpers are already imported at :86 and :104):

if (isMultiUserMode() && !isAdmin(req)) {
  return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Remote hosts are admin-only in multi-user mode');
}

Moving the ensureHostAwake call below the isWorkingDirAllowed gate at :926 works too. Either way please add a route test in test/routes/session-remote-wake.test.ts, shaped like the multi-user block in test/routes/file-write-routes.test.ts:329, asserting the wake spy stays empty.

Three smaller ones I would take if you feel like it, otherwise I will fold them in at merge:

  • src/remote-wake.ts:755 and src/web/routes/session-routes.ts:1649: a chunk over 4 KB is dropped whole (right call) but the route still answers 200 {}, so a caller cannot tell delivered from buffered from dropped. MAX_INPUT_LENGTH is 64 KB, so any prompt over 4 KB posted without wait to a sleeping host is accepted and lost. The non-wait branch has always returned a bare {}, so { buffered: true } / { buffered: true, dropped: true } would be additive.
  • src/web/routes/session-routes.ts:1646: ensureAwake's boolean is discarded on the send-and-wait path, so a host that never comes back gets a write into the stalled pane and a delivered:true plus timeout, which is the combination docs/api-reference.md says points callers at the wrong recovery. Returning OPERATION_FAILED here, like the create and attach paths already do, would be honest.
  • src/remote-wake.ts:782: the flush writes without { fromUser: true } (the interface at :244 does not carry options), so a session whose first prompt was buffered through a wake can never be auto-named. Narrow, and it fails in the safe direction, but a two-line fix.

Two CLAUDE.md nits I will just do myself: line 174 still says 33 frontend modules (34 now), and line 168 lost the (pure) marker on remote-reconnect when remote-wake was added to that row.

Push the authorization fix and its test and I will merge.

…ppened to its bytes

Review round 3 on Ark0N#439.

- The attachRemoteSession branch of POST /api/sessions ran `ensureHostAwake`
  before the multi-user gates, so a non-admin could have any configured
  host's `wakeCommand` spawned (or a packet broadcast) and the request held
  for the wake budget, then be refused for the workingDir. The admin gate
  now comes first, before the host is even looked up; remote hosts are
  admin-only infrastructure everywhere else. Route test: wake spy empty,
  403.
- The non-wait input route answers `{buffered:true}` when the registry took
  the chunk and `{buffered:true, dropped:true}` when it was over the cap
  and is gone (`RemoteInputOutcome` gains 'dropped'); additive to the bare
  `{}`.
- The send-and-wait path answers OPERATION_FAILED when the host never comes
  back, like create and attach, instead of writing into the stalled pane
  and reporting delivered:true plus a timeout.
- The flush writes with `fromUser: true`, so a first prompt buffered
  through a wake can still name the tab.

Docs: api-reference (input route), remote-sessions.md (two invariants),
CLAUDE.md key pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
@Randalix

Copy link
Copy Markdown
Contributor Author

Thanks — the authorization fix is in (5bb489ad), and I took the three smaller ones as well.

Authorization before the wake. The attach branch now gates first, before the host is even looked up: if (isMultiUserMode() && !isAdmin(req)) return FORBIDDEN 'Remote hosts are admin-only in multi-user mode' (your first shape; the call-site comment says why the order matters). Route test in test/routes/session-remote-wake.test.ts, shaped like the file-write-routes multi-user block: CODEMAN_MULTIUSER=1, a user-role auth hook, and a host with a wakeCommand written into the sandboxed data dir so a regression would find it — the response is a 403 and the probe, wake and broadcast spies all stay empty. (The harness gained the envelope/status hook the inbox tests use, so error responses carry their real status there now.)

The three smaller ones:

  • Non-wait input answers {buffered:true} when the registry took the chunk and {buffered:true, dropped:true} when it was over the cap and is gone (RemoteInputOutcome gains 'dropped', _enqueue reports it); the reachable-host answer stays the bare {}. Pinned at the route and in the registry test; documented under the input route in api-reference.md.
  • Send-and-wait answers 422 OPERATION_FAILED ("… did not come back after a wake-on-LAN request — nothing was sent") when ensureAwake returns false, so a stalled pane never gets the write. Route test asserts the pane's write buffer is empty.
  • The flush writes with { fromUser: true } (the WakeableSession interface carries the option now); pinned by toHaveBeenCalledWith('ok', { fromUser: true }).

Both remote-sessions.md invariants are written up; CLAUDE.md's key pattern carries the short form. I left the two CLAUDE.md nits (module count, (pure) marker) to you as you offered.

Here: typecheck, lint, format:check clean; npm test 7470 passed, the same three local failures (port 3100, git 2.47 clone cases). CI is running on the push.

@Ark0N

Ark0N commented Sep 19, 2026

Copy link
Copy Markdown
Owner

This is going in. Both required changes from the last round are in, and the self-review you did between rounds caught things I would otherwise have been asking for now.

The part worth calling out is the import fence. Making it structurally impossible for a watcher, a dropped-session handler or a boot-recovery path to reach the wake, and then pinning it with a test that walks the source tree rather than trusting the types, is the right shape for this feature. A host woken by a reconnect watcher comes back seconds after every suspend, and that is the failure people would have reported as "my machine will not stay asleep" without ever connecting it to Codeman. It also caught me during the merge, which I will come back to.

Two things I fixed on top rather than sending back:

1. The MAC-count limit lived in two places that disagreed (schemas.ts and remote-wake.ts). RemoteHostSchema.wakeMac's 128-character cap admits seven comma-separated MACs, while parseMacList takes at most four and returns null all-or-nothing. So a five-MAC value passed validation, was written to remote-hosts.json, and then resolved to no wake target at all: POST /api/sessions/:id/wake answered "No wake-on-LAN target configured for this host" and the banner offered "Configure WoL" for a host the user had just configured. Accepted, stored, silently inert is the worst shape a validation gap can take.

MAX_WAKE_MACS now lives in src/config/remote-wake-limits.ts and both sides refine against it, with a test asserting neither can admit what the other drops.

That module exists because of your own guard. My first attempt imported the constant straight from remote-wake.ts into schemas.ts, and the wiring test failed exactly as designed. Which was the right outcome: beyond the wake-safety rule, schemas.ts is imported by everything that validates a request body, so that import would have dragged dgram, net and child_process along with it. A plain constant module satisfies both.

2. The documented 40s request budget did not include the wake itself. The comment works through 40s poll + 1.5s reachability probe + 15s tmux prereq = 56.5s, under the 60s proxy_read_timeout. But _wakeAndWait awaits deps.wake(target) before waitUntilReady, and for a command target that is bounded by REMOTE_WAKE_COMMAND_TIMEOUT_MS, so the real worst case was about 68s, past the limit the budget exists to respect. wakeMac is unaffected, which is presumably why live testing never showed it: a magic packet is effectively instant.

_wakeAndWait now subtracts the wake's measured elapsed time from the readiness budget, floored at one poll interval so a wake that ate the whole budget still gets one probe rather than being declared unreachable without asking. I also had to loosen one of your existing assertions, which compared the readiness timeout to REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS exactly and would have flaked the moment anything is subtracted from it, and added two cases for a slow wakeCommand.

I also took the small ones: the two new endpoints are documented in docs/api-reference.md with the import fence written down as the rule it is, and CLAUDE.md's frontend module count moves to 34.

Left as follow-ups, not merge conditions: the magic packet leaving only one interface and never leaving a Compose container, isProbeable not seeing ~/.ssh/config so an alias-configured host reads as permanently asleep, and the banner poller continuing to TCP-connect to a host that is already reachable. Each narrows the feature rather than breaking it, and it works on the setup you built and tested it against.

One thing I would still like from you: a page in docs/wiki/ for this. It is a user-visible feature with a setup step (finding the MAC, enabling WoL in the BIOS, the command alternative) and you are much better placed to write that than I am, having actually run it against a sleeping machine. No rush, and it does not hold up the release.

Thanks for the depth on this one, and for reading the whole diff again between rounds rather than only the parts that were asked about. It ships in 1.31.0.

@Ark0N
Ark0N merged commit 2c3ccdf into Ark0N:master Sep 19, 2026
2 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 19, 2026
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

2 participants