aicodeman 1.30.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/cli-registry/schema.d.ts +8 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +29 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +62 -7
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +62 -0
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/remote-wake-limits.d.ts +23 -0
- package/dist/config/remote-wake-limits.d.ts.map +1 -0
- package/dist/config/remote-wake-limits.js +23 -0
- package/dist/config/remote-wake-limits.js.map +1 -0
- package/dist/custom-model-hosts.d.ts +32 -0
- package/dist/custom-model-hosts.d.ts.map +1 -1
- package/dist/custom-model-hosts.js.map +1 -1
- package/dist/custom-model-injection-apply.d.ts +29 -1
- package/dist/custom-model-injection-apply.d.ts.map +1 -1
- package/dist/custom-model-injection-apply.js +191 -5
- package/dist/custom-model-injection-apply.js.map +1 -1
- package/dist/custom-model-injection.d.ts +35 -9
- package/dist/custom-model-injection.d.ts.map +1 -1
- package/dist/custom-model-injection.js +37 -16
- package/dist/custom-model-injection.js.map +1 -1
- package/dist/mux-interface.d.ts +12 -0
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-hosts.d.ts +20 -0
- package/dist/remote-hosts.d.ts.map +1 -1
- package/dist/remote-hosts.js +33 -0
- package/dist/remote-hosts.js.map +1 -1
- package/dist/remote-wake.d.ts +446 -0
- package/dist/remote-wake.d.ts.map +1 -0
- package/dist/remote-wake.js +880 -0
- package/dist/remote-wake.js.map +1 -0
- package/dist/session-env-clamp.d.ts +12 -7
- package/dist/session-env-clamp.d.ts.map +1 -1
- package/dist/session-env-clamp.js +12 -7
- package/dist/session-env-clamp.js.map +1 -1
- package/dist/session-submit-verifier.d.ts +44 -0
- package/dist/session-submit-verifier.d.ts.map +1 -0
- package/dist/session-submit-verifier.js +122 -0
- package/dist/session-submit-verifier.js.map +1 -0
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +42 -1
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +9 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/session.d.ts +26 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.8df358cd.js +38 -0
- package/dist/web/public/app.8df358cd.js.br +0 -0
- package/dist/web/public/app.8df358cd.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.258b140f.js → constants.85ed12f3.js} +74 -0
- package/dist/web/public/constants.85ed12f3.js.br +0 -0
- package/dist/web/public/constants.85ed12f3.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js +440 -0
- package/dist/web/public/host-wake-ui.js.br +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.83924614.js +1 -0
- package/dist/web/public/i18n.83924614.js.br +0 -0
- package/dist/web/public/i18n.83924614.js.gz +0 -0
- package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
- package/dist/web/public/index.html +186 -10
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
- package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
- package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.5b07ad14.js → panels-ui.28df5053.js} +5 -5
- package/dist/web/public/panels-ui.28df5053.js.br +0 -0
- package/dist/web/public/panels-ui.28df5053.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.d4aea71a.js +88 -0
- package/dist/web/public/session-ui.d4aea71a.js.br +0 -0
- package/dist/web/public/session-ui.d4aea71a.js.gz +0 -0
- package/dist/web/public/settings-ui.4762f480.js +78 -0
- package/dist/web/public/settings-ui.4762f480.js.br +0 -0
- package/dist/web/public/settings-ui.4762f480.js.gz +0 -0
- package/dist/web/public/styles.e34fea04.css +1 -0
- package/dist/web/public/styles.e34fea04.css.br +0 -0
- package/dist/web/public/styles.e34fea04.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
- package/dist/web/public/terminal-ui.35812aa2.js +2 -0
- package/dist/web/public/terminal-ui.35812aa2.js.br +0 -0
- package/dist/web/public/terminal-ui.35812aa2.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/custom-model-routes.d.ts +173 -0
- package/dist/web/routes/custom-model-routes.d.ts.map +1 -1
- package/dist/web/routes/custom-model-routes.js +628 -11
- package/dist/web/routes/custom-model-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +1 -1
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -1
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +6 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +448 -17
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +15 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +76 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +21 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +130 -5
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +25 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +30 -3
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +1 -1
- package/skills/codeman/SKILL.md +75 -26
- package/skills/codeman/preamble.sh +66 -19
- package/skills/codeman/reference/recipes.md +1 -1
- package/dist/web/public/app.6d2dc4e8.js +0 -38
- package/dist/web/public/app.6d2dc4e8.js.br +0 -0
- package/dist/web/public/app.6d2dc4e8.js.gz +0 -0
- package/dist/web/public/constants.258b140f.js.br +0 -0
- package/dist/web/public/constants.258b140f.js.gz +0 -0
- package/dist/web/public/i18n.5f897ed5.js +0 -1
- package/dist/web/public/i18n.5f897ed5.js.br +0 -0
- package/dist/web/public/i18n.5f897ed5.js.gz +0 -0
- package/dist/web/public/panels-ui.5b07ad14.js.br +0 -0
- package/dist/web/public/panels-ui.5b07ad14.js.gz +0 -0
- package/dist/web/public/session-ui.42b81477.js +0 -77
- package/dist/web/public/session-ui.42b81477.js.br +0 -0
- package/dist/web/public/session-ui.42b81477.js.gz +0 -0
- package/dist/web/public/settings-ui.58f1d756.js +0 -67
- package/dist/web/public/settings-ui.58f1d756.js.br +0 -0
- package/dist/web/public/settings-ui.58f1d756.js.gz +0 -0
- package/dist/web/public/styles.e6edfb0b.css +0 -1
- package/dist/web/public/styles.e6edfb0b.css.br +0 -0
- package/dist/web/public/styles.e6edfb0b.css.gz +0 -0
- package/dist/web/public/terminal-ui.4f8d5820.js +0 -2
- package/dist/web/public/terminal-ui.4f8d5820.js.br +0 -0
- package/dist/web/public/terminal-ui.4f8d5820.js.gz +0 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Wake a SLEEPING remote host from user input (user-triggered Wake-on-LAN).
|
|
3
|
+
*
|
|
4
|
+
* A durable remote session survives SSH drops (COD-104) and auto-reconnects
|
|
5
|
+
* (COD-108), but nothing brings the HOST back: if the remote machine suspended,
|
|
6
|
+
* the local tmux pane's `ssh` child stalls silently. `tmux send-keys` then
|
|
7
|
+
* SUCCEEDS against a pane that will never deliver the bytes, so typed input is
|
|
8
|
+
* lost with no error anywhere — the failure this module exists to close.
|
|
9
|
+
*
|
|
10
|
+
* Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN):
|
|
11
|
+
* - An EXPLICIT request wakes a host, and nothing else: user input on an
|
|
12
|
+
* established session (`handleInput`), the wake button (`ensureAwake`), or the
|
|
13
|
+
* user's own session create/attach request (`ensureHostAwake`, wired in the HTTP
|
|
14
|
+
* routes). Everything that runs on a TIMER — the auto-reconnect watcher, boot
|
|
15
|
+
* recovery, the reachability probe, session discovery — must never wake one, or
|
|
16
|
+
* a host would be re-woken ~45 s after each suspend and could never stay asleep
|
|
17
|
+
* (the "keepalive pings a sleeping host" failure already solved for a different
|
|
18
|
+
* consumer by `hufflepuff-mcp-lazy`). The create path is deliberately wired in
|
|
19
|
+
* `session-routes.ts` and NOT in the shared session service, because
|
|
20
|
+
* `cron-service.ts` builds sessions there without a user waiting on the answer.
|
|
21
|
+
* - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client,
|
|
22
|
+
* a few hundred bytes — below any meaningful activity threshold), throttled
|
|
23
|
+
* per session. No SSH keepalive is added to the launch command: keepalives
|
|
24
|
+
* would move bytes into an otherwise idle connection every interval, which is
|
|
25
|
+
* exactly the "an open pipe keeps the host awake" bug the remote-side idle
|
|
26
|
+
* detector was rewritten to avoid.
|
|
27
|
+
* - While a wake is in flight, input is BUFFERED and flushed in order once the
|
|
28
|
+
* pane is reattached, so the user's first characters after a long pause are
|
|
29
|
+
* not the ones that get eaten.
|
|
30
|
+
*
|
|
31
|
+
* The pure decisions and the IO are separated so the decision table can be
|
|
32
|
+
* unit-tested without tmux, ssh, or a real host.
|
|
33
|
+
*
|
|
34
|
+
* @module remote-wake
|
|
35
|
+
*/
|
|
36
|
+
import { spawn } from 'node:child_process';
|
|
37
|
+
import dgram from 'node:dgram';
|
|
38
|
+
import net from 'node:net';
|
|
39
|
+
import { MAX_WAKE_MACS } from './config/remote-wake-limits.js';
|
|
40
|
+
/** Minimum spacing between two reachability probes for the same session. */
|
|
41
|
+
export const REMOTE_WAKE_PROBE_MIN_INTERVAL_MS = 30_000;
|
|
42
|
+
/** TCP-connect timeout for a reachability probe (host awake ≈ a few ms). */
|
|
43
|
+
export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500;
|
|
44
|
+
/** Poll spacing while waiting for a woken host to accept SSH again. */
|
|
45
|
+
export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500;
|
|
46
|
+
/** Bounded wait for the host to come back after the wake command ran. */
|
|
47
|
+
export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000;
|
|
48
|
+
/**
|
|
49
|
+
* Budget for a wake that an HTTP REQUEST is waiting on (session create/attach).
|
|
50
|
+
* Deliberately shorter than {@link REMOTE_WAKE_READY_TIMEOUT_MS}: the dashboard is
|
|
51
|
+
* served through a reverse proxy whose default `proxy_read_timeout` is 60 s, so a
|
|
52
|
+
* 90 s wait would be cut off AT THE PROXY while the session was still being built —
|
|
53
|
+
* the browser reports a failure for a session that exists. The budget has to cover
|
|
54
|
+
* the WHOLE request, not just the wait: 40 s here + the 1.5 s reachability probe +
|
|
55
|
+
* the tmux prereq probe's own 15 s timeout = 56.5 s worst case, still under 60 s.
|
|
56
|
+
* ⚠ The wake ITSELF counts against this, which the original arithmetic omitted: a
|
|
57
|
+
* `command` target can spend REMOTE_WAKE_COMMAND_TIMEOUT_MS before the readiness poll
|
|
58
|
+
* begins, which would have made the real worst case ~68 s. `_wakeAndWait` therefore
|
|
59
|
+
* subtracts the wake's measured elapsed time from this budget rather than adding to it.
|
|
60
|
+
* A warm S3 resume measures ~12 s, so 40 s is >3× the observed wake.
|
|
61
|
+
*/
|
|
62
|
+
export const REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS = 40_000;
|
|
63
|
+
/** The wake command itself must not hang the wake flow. */
|
|
64
|
+
export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000;
|
|
65
|
+
/**
|
|
66
|
+
* Settle time between respawning the ssh pane and flushing buffered input: the
|
|
67
|
+
* respawned `ssh` needs a moment to run `tmux -L codeman-remote … -A` and attach,
|
|
68
|
+
* and bytes written into a still-connecting pane land in nothing.
|
|
69
|
+
*/
|
|
70
|
+
export const REMOTE_WAKE_ATTACH_SETTLE_MS = 1_500;
|
|
71
|
+
/**
|
|
72
|
+
* Cap on buffered input per session while a host is being woken. 4 KB is a lot
|
|
73
|
+
* of typing for a ~10 s wake; beyond it the OLDEST bytes are dropped (keeping the
|
|
74
|
+
* tail preserves what the user just typed, and a silently unbounded buffer would
|
|
75
|
+
* be a memory leak keyed on user input).
|
|
76
|
+
*/
|
|
77
|
+
export const REMOTE_WAKE_PENDING_MAX_BYTES = 4096;
|
|
78
|
+
/** Default SSH port used when the host config has no explicit `port`. */
|
|
79
|
+
export const DEFAULT_SSH_PORT = 22;
|
|
80
|
+
/**
|
|
81
|
+
* Decide what to do with an input chunk on an input route. Mirrors
|
|
82
|
+
* {@link RemoteWakeRegistry.handleInput} so the throttle table has exactly ONE
|
|
83
|
+
* definition and is unit-testable:
|
|
84
|
+
*
|
|
85
|
+
* - a wake already in flight → buffer (the flush owns delivery),
|
|
86
|
+
* - no wake command configured → deliver (feature off, today's behavior),
|
|
87
|
+
* - the last probe said "down" → buffer (no second probe; re-probing a known
|
|
88
|
+
* sleeping host on every keystroke would add seconds of latency per character),
|
|
89
|
+
* - never probed / throttle window elapsed → probe,
|
|
90
|
+
* - probed "up" inside the window → deliver.
|
|
91
|
+
*
|
|
92
|
+
* Pure — no clock, no IO.
|
|
93
|
+
*/
|
|
94
|
+
export function decideRemoteInputAction(args) {
|
|
95
|
+
if (args.waking)
|
|
96
|
+
return 'buffer';
|
|
97
|
+
if (!args.hasWakeTarget)
|
|
98
|
+
return 'deliver';
|
|
99
|
+
if (args.lastReachable === false)
|
|
100
|
+
return 'buffer';
|
|
101
|
+
const interval = args.minProbeIntervalMs ?? REMOTE_WAKE_PROBE_MIN_INTERVAL_MS;
|
|
102
|
+
if (args.probeAgeMs >= interval)
|
|
103
|
+
return 'probe';
|
|
104
|
+
return 'deliver';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Append `data` to the pending buffer, dropping the OLDEST whole chunks when the cap
|
|
108
|
+
* is exceeded. Returns the resulting buffer — the SAME array reference when the chunk
|
|
109
|
+
* was rejected, so the caller can tell the two apart. Pure.
|
|
110
|
+
*
|
|
111
|
+
* A chunk LARGER than the cap is dropped outright rather than trimmed: one paste is
|
|
112
|
+
* one `input` value, and it was never typed character by character, so delivering its
|
|
113
|
+
* tail would execute a fragment of it (with the trailing carriage return, if the paste
|
|
114
|
+
* had one) — a partial command the user never sent. Keeping the tail is right for
|
|
115
|
+
* typing, where the newest bytes are the ones the user just produced, and wrong for a
|
|
116
|
+
* chunk that arrived whole.
|
|
117
|
+
*/
|
|
118
|
+
export function appendBoundedPending(pending, data, maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES) {
|
|
119
|
+
if (Buffer.byteLength(data) > maxBytes)
|
|
120
|
+
return pending;
|
|
121
|
+
const next = [...pending, data];
|
|
122
|
+
let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
|
123
|
+
while (next.length > 1 && total > maxBytes) {
|
|
124
|
+
total -= Buffer.byteLength(next[0]);
|
|
125
|
+
next.shift();
|
|
126
|
+
}
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Whether the bare TCP probe can answer for this host at all. Pure.
|
|
131
|
+
*
|
|
132
|
+
* The probe connects straight to `host:port`. A host behind a jump host or a SOCKS
|
|
133
|
+
* proxy (the cloudflared case) is reachable ONLY through that proxy, so the direct
|
|
134
|
+
* connect fails while ssh works — and every consumer of the verdict would then act on
|
|
135
|
+
* a "sleeping" host that is fine: a permanent banner, a create-path gate that hides the
|
|
136
|
+
* real ssh error, and (with a wake target) input buffered for the life of the session
|
|
137
|
+
* because the readiness poll can never succeed. Such a host is reachability-UNKNOWN:
|
|
138
|
+
* the registry never buffers for it, never gates on it, and reports `null` rather than
|
|
139
|
+
* `false`. A wake target can still be fired for it, blind.
|
|
140
|
+
*/
|
|
141
|
+
export function isProbeable(remote) {
|
|
142
|
+
if (remote.jumpHost || remote.socksProxy)
|
|
143
|
+
return false;
|
|
144
|
+
return !(remote.extraSshOptions ?? []).some((option) => /^\s*proxy(command|jump)\s*=/i.test(option));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the wake target from host config. Pure.
|
|
148
|
+
*
|
|
149
|
+
* A malformed `wakeMac` resolves to `null` rather than throwing: the schema
|
|
150
|
+
* already rejects one at config time, so this can only be reached with a config
|
|
151
|
+
* written by hand, and a broken MAC must not break the input route.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveWakeTarget(remote) {
|
|
154
|
+
if (!remote)
|
|
155
|
+
return null;
|
|
156
|
+
if (remote.wakeCommand)
|
|
157
|
+
return { kind: 'command', command: remote.wakeCommand };
|
|
158
|
+
if (remote.wakeMac) {
|
|
159
|
+
const macs = parseMacList(remote.wakeMac);
|
|
160
|
+
if (macs && macs.length > 0)
|
|
161
|
+
return { kind: 'mac', macs };
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Parse a comma-separated MAC list into byte arrays. Pure; returns null when any
|
|
167
|
+
* entry is malformed (all-or-nothing, so a typo cannot half-arm a host).
|
|
168
|
+
*/
|
|
169
|
+
export function parseMacList(value, maxMacs = MAX_WAKE_MACS) {
|
|
170
|
+
const parts = value
|
|
171
|
+
.split(',')
|
|
172
|
+
.map((part) => part.trim())
|
|
173
|
+
.filter((part) => part.length > 0);
|
|
174
|
+
if (parts.length === 0 || parts.length > maxMacs)
|
|
175
|
+
return null;
|
|
176
|
+
const macs = [];
|
|
177
|
+
for (const part of parts) {
|
|
178
|
+
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);
|
|
179
|
+
if (!match)
|
|
180
|
+
return null;
|
|
181
|
+
macs.push(match.slice(1).map((hex) => Number.parseInt(hex, 16)));
|
|
182
|
+
}
|
|
183
|
+
return macs;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build a Wake-on-LAN "magic packet": six `0xFF` bytes then the MAC repeated 16
|
|
187
|
+
* times. Pure — the shape is asserted byte-for-byte in the tests because a packet
|
|
188
|
+
* that is off by one byte simply never wakes anything.
|
|
189
|
+
*/
|
|
190
|
+
export function buildMagicPacket(mac) {
|
|
191
|
+
const packet = Buffer.alloc(6 + 16 * 6, 0xff);
|
|
192
|
+
for (let repeat = 0; repeat < 16; repeat++) {
|
|
193
|
+
Buffer.from(mac).copy(packet, 6 + repeat * 6);
|
|
194
|
+
}
|
|
195
|
+
return packet;
|
|
196
|
+
}
|
|
197
|
+
/** Probe freshness for the UI's reachability check (a tab switch is not a hammer). */
|
|
198
|
+
export const REMOTE_WAKE_REACHABILITY_TTL_MS = 5_000;
|
|
199
|
+
/** How long a resolved host config is trusted before asking the resolver again. */
|
|
200
|
+
export const REMOTE_WAKE_RESOLVE_TTL_MS = 30_000;
|
|
201
|
+
/** Which wake path a host config provides (mirrors {@link resolveWakeTarget}). Pure. */
|
|
202
|
+
export function wakeConfigured(remote) {
|
|
203
|
+
const target = resolveWakeTarget(remote);
|
|
204
|
+
if (!target)
|
|
205
|
+
return 'none';
|
|
206
|
+
return target.kind;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* State key for a host-scoped wake. Prefixed so it can never collide with a session
|
|
210
|
+
* id, and keyed on the HOST rather than the case: two cases on one host share a
|
|
211
|
+
* single in-flight wake and one probe verdict. Such an entry is tiny (no input
|
|
212
|
+
* buffer) and bounded by the number of configured hosts, so it is never dropped.
|
|
213
|
+
*/
|
|
214
|
+
function hostWakeKey(hostId) {
|
|
215
|
+
return `host:${hostId}`;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Per-session wake state + single-flight wake flow.
|
|
219
|
+
*
|
|
220
|
+
* One instance per web server (module singleton in the routes file, like the
|
|
221
|
+
* signal-wait registry). State is keyed by session id and dropped with the
|
|
222
|
+
* session.
|
|
223
|
+
*/
|
|
224
|
+
export class RemoteWakeRegistry {
|
|
225
|
+
deps;
|
|
226
|
+
states = new Map();
|
|
227
|
+
/**
|
|
228
|
+
* Aborted by {@link stop} on shutdown. Every in-flight readiness poll is holding an
|
|
229
|
+
* HTTP request open (the wake route blocks on it), and Fastify's `close()` waits for
|
|
230
|
+
* in-flight requests — so without this a restart during a wake sits out the full 90 s
|
|
231
|
+
* budget. The same problem `sessionWaits.cancelEverything()` exists for.
|
|
232
|
+
*/
|
|
233
|
+
shutdown = new AbortController();
|
|
234
|
+
stopped = false;
|
|
235
|
+
constructor(deps) {
|
|
236
|
+
this.deps = deps;
|
|
237
|
+
}
|
|
238
|
+
/** Drop a session's state (session closed/killed). The pending buffer goes with it. */
|
|
239
|
+
drop(sessionId) {
|
|
240
|
+
this.states.delete(sessionId);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Resolve every in-flight wake as failed and refuse new ones (server shutdown).
|
|
244
|
+
*
|
|
245
|
+
* Called from `WebServer.stop()`: an in-flight wake is awaited by a request, and the
|
|
246
|
+
* server's own `app.close()` does not abort in-flight requests, so shutdown would wait
|
|
247
|
+
* out the poll. Nothing is lost by failing them — the state flush happens earlier in
|
|
248
|
+
* `stop()`, and the process is going away.
|
|
249
|
+
*/
|
|
250
|
+
stop() {
|
|
251
|
+
this.stopped = true;
|
|
252
|
+
this.shutdown.abort();
|
|
253
|
+
}
|
|
254
|
+
/** Whether a wake is currently in flight (diagnostics/tests). */
|
|
255
|
+
isWaking(sessionId) {
|
|
256
|
+
return this.states.get(sessionId)?.waking != null;
|
|
257
|
+
}
|
|
258
|
+
/** Buffered input bytes for a session (diagnostics/tests). */
|
|
259
|
+
pendingBytes(sessionId) {
|
|
260
|
+
const state = this.states.get(sessionId);
|
|
261
|
+
if (!state)
|
|
262
|
+
return 0;
|
|
263
|
+
return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Number of keys with wake state (diagnostics/tests). Pins that a LOCAL session never
|
|
267
|
+
* gets an entry: the input gate runs on every keystroke, so an entry per local session
|
|
268
|
+
* would be a map the size of the session list, swept only on cleanup.
|
|
269
|
+
*/
|
|
270
|
+
stateCount() {
|
|
271
|
+
return this.states.size;
|
|
272
|
+
}
|
|
273
|
+
/** Whether this session's host has any wake path configured at all. */
|
|
274
|
+
async hasWakeTarget(session) {
|
|
275
|
+
return resolveWakeTarget(await this._effectiveRemote(session)) !== null;
|
|
276
|
+
}
|
|
277
|
+
/** Which wake path is configured (`'none'` when the UI should offer configuration). */
|
|
278
|
+
async wakeConfigured(session) {
|
|
279
|
+
return wakeConfigured(await this._effectiveRemote(session));
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Reachability for the UI: probe unless a recent result is still fresh. `null` for a
|
|
283
|
+
* host the probe cannot reach (see {@link isProbeable}): unknown is not unreachable.
|
|
284
|
+
*
|
|
285
|
+
* Shares the per-session probe state with the input path on purpose — a fresh
|
|
286
|
+
* answer is exactly what the input ladder wants, and an `unreachable` verdict here
|
|
287
|
+
* makes the next keystroke buffer + wake instead of vanishing into a stalled pane.
|
|
288
|
+
*/
|
|
289
|
+
async checkReachable(session, opts = {}) {
|
|
290
|
+
const remote = await this._effectiveRemote(session);
|
|
291
|
+
if (!remote)
|
|
292
|
+
return true;
|
|
293
|
+
// `null`, never `false`: the UI keys the banner on a PROVEN unreachable host.
|
|
294
|
+
if (!isProbeable(remote))
|
|
295
|
+
return null;
|
|
296
|
+
const state = this._state(session.id);
|
|
297
|
+
const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
|
|
298
|
+
if (Date.now() - state.probedAt >= ttl) {
|
|
299
|
+
state.probedAt = Date.now();
|
|
300
|
+
state.reachable = await this.deps.probe(remote);
|
|
301
|
+
}
|
|
302
|
+
return state.reachable === true;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Decide + act for one input chunk.
|
|
306
|
+
*
|
|
307
|
+
* `'deliver'` means the caller writes it as usual (today's path, zero added
|
|
308
|
+
* cost). `'buffered'` means the registry took ownership of the bytes: it either
|
|
309
|
+
* queued them behind an in-flight wake or started a wake, and will flush them
|
|
310
|
+
* in order once the pane is reattached.
|
|
311
|
+
*/
|
|
312
|
+
async handleInput(session, data) {
|
|
313
|
+
const remote = await this._effectiveRemote(session);
|
|
314
|
+
// A proxied host can never pass the readiness poll, so buffering for it would hold
|
|
315
|
+
// the bytes for the life of the session (reproduced upstream: three inputs, nothing
|
|
316
|
+
// written, no reattach). Deliver, as if the feature were off.
|
|
317
|
+
if (remote && !isProbeable(remote))
|
|
318
|
+
return 'deliver';
|
|
319
|
+
const state = this._state(session.id);
|
|
320
|
+
const target = resolveWakeTarget(remote);
|
|
321
|
+
const action = decideRemoteInputAction({
|
|
322
|
+
hasWakeTarget: target !== null,
|
|
323
|
+
waking: state.waking != null,
|
|
324
|
+
probeAgeMs: Date.now() - state.probedAt,
|
|
325
|
+
lastReachable: state.reachable,
|
|
326
|
+
});
|
|
327
|
+
if (action === 'deliver')
|
|
328
|
+
return 'deliver';
|
|
329
|
+
if (action === 'buffer') {
|
|
330
|
+
const queued = this._enqueue(session.id, data);
|
|
331
|
+
// A buffered verdict with no wake in flight still has to DRIVE a wake (the
|
|
332
|
+
// previous one failed and reset the probe state, or the ladder landed here
|
|
333
|
+
// directly) — otherwise the bytes would sit in the buffer forever.
|
|
334
|
+
if (state.waking == null && target)
|
|
335
|
+
void this.wake(session);
|
|
336
|
+
return queued;
|
|
337
|
+
}
|
|
338
|
+
// action === 'probe' — the throttle window elapsed, so one TCP connect is owed.
|
|
339
|
+
state.probedAt = Date.now();
|
|
340
|
+
state.reachable = remote ? await this.deps.probe(remote) : true;
|
|
341
|
+
if (state.reachable)
|
|
342
|
+
return 'deliver';
|
|
343
|
+
const queued = this._enqueue(session.id, data);
|
|
344
|
+
void this.wake(session);
|
|
345
|
+
return queued;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Block until the host is reachable and the pane is reattached — the
|
|
349
|
+
* send-and-wait path, where the HTTP response stays open anyway and buffering
|
|
350
|
+
* would break the wait contract.
|
|
351
|
+
*/
|
|
352
|
+
async ensureAwake(session, opts = {}) {
|
|
353
|
+
if (this.stopped)
|
|
354
|
+
return false;
|
|
355
|
+
const remote = await this._effectiveRemote(session);
|
|
356
|
+
const target = resolveWakeTarget(remote);
|
|
357
|
+
if (!remote || !target)
|
|
358
|
+
return true;
|
|
359
|
+
// A proxied host: the send-and-wait path has nothing to gate on (unknown is not
|
|
360
|
+
// asleep), so it delivers; the manual button still wakes, blind (see `wake`).
|
|
361
|
+
if (!isProbeable(remote))
|
|
362
|
+
return opts.force ? this.wake(session, opts) : true;
|
|
363
|
+
const state = this._state(session.id);
|
|
364
|
+
// `force` is the manual path (a user pressed "wake"): a cached "reachable" from
|
|
365
|
+
// seconds ago must not talk the button out of waking a host that just slept.
|
|
366
|
+
if (opts.force || (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS)) {
|
|
367
|
+
state.probedAt = Date.now();
|
|
368
|
+
state.reachable = await this.deps.probe(remote);
|
|
369
|
+
}
|
|
370
|
+
if (state.reachable)
|
|
371
|
+
return true;
|
|
372
|
+
// The manual button is pressed from the SAME dashboard the create/attach paths are,
|
|
373
|
+
// so it holds its request open under the same reverse proxy — it needs the request
|
|
374
|
+
// budget, not the 90 s session default (see REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS).
|
|
375
|
+
return this.wake(session, { timeoutMs: opts.timeoutMs });
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Host-scoped reachability, for a caller that has no session yet (create/attach).
|
|
379
|
+
* Shares the per-HOST probe state with {@link ensureHostAwake}, so the probe the
|
|
380
|
+
* wake flow just paid for also answers "was that ssh failure really a sleeping
|
|
381
|
+
* machine?". Never wakes anything — it is a question, not an action. `null` when the
|
|
382
|
+
* question cannot be answered (see {@link isProbeable}).
|
|
383
|
+
*/
|
|
384
|
+
async checkHostReachable(remote, opts = {}) {
|
|
385
|
+
// `null` for a proxied host: callers gate on `=== false` (proven unreachable), so an
|
|
386
|
+
// unknown verdict leaves their ordinary error path — "needs tmux" — intact.
|
|
387
|
+
if (!isProbeable(remote))
|
|
388
|
+
return null;
|
|
389
|
+
const state = this._state(hostWakeKey(remote.hostId));
|
|
390
|
+
const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
|
|
391
|
+
if (Date.now() - state.probedAt >= ttl) {
|
|
392
|
+
state.probedAt = Date.now();
|
|
393
|
+
state.reachable = await this.deps.probe(remote);
|
|
394
|
+
}
|
|
395
|
+
return state.reachable === true;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Wake a host for a REQUEST that is waiting on it — the session create/attach
|
|
399
|
+
* routes, where there is no session to reattach and no input to buffer yet.
|
|
400
|
+
*
|
|
401
|
+
* `'no-target'` returns without probing, so a host without WoL config costs
|
|
402
|
+
* nothing and behaves exactly as before. Single-flight per host, so a double click
|
|
403
|
+
* (or two cases on the same host) sends one packet and shares one readiness poll.
|
|
404
|
+
*/
|
|
405
|
+
async ensureHostAwake(remote, opts = {}) {
|
|
406
|
+
if (!resolveWakeTarget(remote))
|
|
407
|
+
return 'no-target';
|
|
408
|
+
// The probe cannot tell a proxied host asleep from awake, and a wake that cannot
|
|
409
|
+
// verify readiness would only delay the request by its whole budget. Not gated.
|
|
410
|
+
if (!isProbeable(remote))
|
|
411
|
+
return 'unprobeable';
|
|
412
|
+
if (this.stopped)
|
|
413
|
+
return 'failed';
|
|
414
|
+
const state = this._state(hostWakeKey(remote.hostId));
|
|
415
|
+
if (state.waking)
|
|
416
|
+
return (await state.waking) ? 'ready' : 'failed';
|
|
417
|
+
state.probedAt = Date.now();
|
|
418
|
+
state.reachable = await this.deps.probe(remote);
|
|
419
|
+
if (state.reachable)
|
|
420
|
+
return 'ready';
|
|
421
|
+
this.deps.log?.(`[RemoteWake] ${remote.label} (${remote.host}) is unreachable — waking it for a new session`);
|
|
422
|
+
return (await this.wakeHost(remote, opts)) ? 'ready' : 'failed';
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Single-flight wake for a host with no session (see {@link ensureHostAwake}). Uses the
|
|
426
|
+
* same single-flight `waking` slot the session flow uses — but a DIFFERENT key
|
|
427
|
+
* (`host:<id>` vs the session id), so a session wake and a create-path wake for the same
|
|
428
|
+
* host are two independent flows rather than one shared poll. Harmless (both are
|
|
429
|
+
* user-initiated and the host only wakes once), and keying them together would mean a
|
|
430
|
+
* create request joining an unrelated session's wake and inheriting its budget.
|
|
431
|
+
*/
|
|
432
|
+
async wakeHost(remote, opts) {
|
|
433
|
+
const state = this._state(hostWakeKey(remote.hostId));
|
|
434
|
+
if (state.waking)
|
|
435
|
+
return state.waking;
|
|
436
|
+
state.waking = (async () => {
|
|
437
|
+
try {
|
|
438
|
+
return await this._wakeAndWait(remote, state, {
|
|
439
|
+
timeoutMs: opts.timeoutMs,
|
|
440
|
+
forNewSession: true,
|
|
441
|
+
requestedBy: opts.requestedBy,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
// Injected IO is documented not to throw, but a rejected promise here would
|
|
446
|
+
// surface as an unhandled rejection AND take the route down with it (the
|
|
447
|
+
// session path catches for exactly this reason). A broken wake target must
|
|
448
|
+
// fail the wake, never the create route beyond its own error response.
|
|
449
|
+
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
state.waking = null;
|
|
454
|
+
}
|
|
455
|
+
})();
|
|
456
|
+
return state.waking;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Single-flight wake: probe-free (the caller already knows the host is down),
|
|
460
|
+
* run the wake command, poll for readiness, reattach the pane, flush the buffer.
|
|
461
|
+
*/
|
|
462
|
+
async wake(session, opts = {}) {
|
|
463
|
+
if (this.stopped)
|
|
464
|
+
return false;
|
|
465
|
+
const remote = await this._effectiveRemote(session);
|
|
466
|
+
const target = resolveWakeTarget(remote);
|
|
467
|
+
if (!remote || !target)
|
|
468
|
+
return true;
|
|
469
|
+
if (!isProbeable(remote))
|
|
470
|
+
return this._wakeBlind(remote, target);
|
|
471
|
+
const state = this._state(session.id);
|
|
472
|
+
if (state.waking)
|
|
473
|
+
return state.waking;
|
|
474
|
+
state.waking = (async () => {
|
|
475
|
+
const id = session.id;
|
|
476
|
+
try {
|
|
477
|
+
const ready = await this._wakeAndWait(remote, state, { sessionId: id, timeoutMs: opts.timeoutMs });
|
|
478
|
+
if (!ready)
|
|
479
|
+
return false;
|
|
480
|
+
const reattached = await session.reattachRemote();
|
|
481
|
+
if (!reattached) {
|
|
482
|
+
this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`);
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
// The reset also clears an EXHAUSTED COD-108 backoff, which otherwise
|
|
486
|
+
// never fires again for this session (see remote-reconnect.ts).
|
|
487
|
+
this.deps.noteReconnected?.(id, true);
|
|
488
|
+
this.deps.broadcast?.('remote:sessionReconnected', { sessionId: id });
|
|
489
|
+
this.deps.log?.(`[RemoteWake] ${remote.label} reattached for session ${id}`);
|
|
490
|
+
await this.deps.delay(REMOTE_WAKE_ATTACH_SETTLE_MS);
|
|
491
|
+
await this._flush(state, session);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
state.waking = null;
|
|
500
|
+
}
|
|
501
|
+
})();
|
|
502
|
+
return state.waking;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Fire the wake target for a host whose readiness cannot be verified (see
|
|
506
|
+
* {@link isProbeable}): no readiness poll (it could never succeed), no reattach (the
|
|
507
|
+
* COD-108 watcher owns the pane once ssh works again), no `hostWaking` broadcast (its
|
|
508
|
+
* toast promises a wait that does not happen). The caller learns only whether the
|
|
509
|
+
* packet/command went out — and a wake IO that throws is a failed wake, never a
|
|
510
|
+
* rejected route.
|
|
511
|
+
*/
|
|
512
|
+
async _wakeBlind(remote, target) {
|
|
513
|
+
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind}, blind: proxied host`);
|
|
514
|
+
try {
|
|
515
|
+
return await this.deps.wake(target);
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Broadcast + run the wake target + wait for SSH. Shared by the session flow (which
|
|
524
|
+
* then reattaches and flushes the buffer) and the create/attach flow (which has no
|
|
525
|
+
* pane yet). On failure the probe state is reset so the NEXT attempt probes and
|
|
526
|
+
* retries instead of trusting a stale "down" verdict forever.
|
|
527
|
+
*/
|
|
528
|
+
async _wakeAndWait(remote, state, opts = {}) {
|
|
529
|
+
const target = resolveWakeTarget(remote);
|
|
530
|
+
if (!target)
|
|
531
|
+
return true;
|
|
532
|
+
const forWhat = opts.sessionId ? `for session ${opts.sessionId}` : 'for a new session';
|
|
533
|
+
// Routing for multi-user mode (server.ts `deriveSseHint`): a session-scoped event
|
|
534
|
+
// reaches its owner, and the create/attach wake has no session yet — so it names
|
|
535
|
+
// the requesting user instead, or it would reach admins only. The payload carries
|
|
536
|
+
// `hostId`/`label`, which non-admins are not shown elsewhere, so it must not go global.
|
|
537
|
+
const scope = opts.sessionId
|
|
538
|
+
? { sessionId: opts.sessionId }
|
|
539
|
+
: { forNewSession: true, ...(opts.requestedBy ? { username: opts.requestedBy } : {}) };
|
|
540
|
+
// No `sessionId` for a create-path wake: the toast handler is then the only one
|
|
541
|
+
// that acts (a banner for a session that does not exist yet would have no target),
|
|
542
|
+
// which is exactly the `forNewSession` distinction the UI renders.
|
|
543
|
+
//
|
|
544
|
+
// `queuedInput` is true only on the typing path, where bytes are actually held for
|
|
545
|
+
// this session. The wake BUTTON and the send-and-wait path hold nothing, so a UI
|
|
546
|
+
// that keyed "input is queued until it is back" off "a wake is running" would promise
|
|
547
|
+
// something the user can disprove by typing (browser keystrokes go over the
|
|
548
|
+
// WebSocket, which never passes through this registry).
|
|
549
|
+
this.deps.broadcast?.('remote:hostWaking', {
|
|
550
|
+
...scope,
|
|
551
|
+
hostId: remote.hostId,
|
|
552
|
+
label: remote.label,
|
|
553
|
+
queuedInput: state.pending.length > 0,
|
|
554
|
+
});
|
|
555
|
+
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`);
|
|
556
|
+
const wakeStartedAt = Date.now();
|
|
557
|
+
const woke = await this.deps.wake(target);
|
|
558
|
+
if (!woke) {
|
|
559
|
+
this.deps.log?.(`[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}`);
|
|
560
|
+
}
|
|
561
|
+
// The request-scoped budget has to cover the WHOLE request, and the wake is part
|
|
562
|
+
// of it. A `command` target is bounded by REMOTE_WAKE_COMMAND_TIMEOUT_MS, so a slow
|
|
563
|
+
// one burned 10 s before the readiness poll even started and pushed a wakeCommand
|
|
564
|
+
// host's worst case to ~68 s, past the 60 s proxy_read_timeout this budget exists to
|
|
565
|
+
// stay under. A magic packet is effectively instant, so this subtracts nothing there.
|
|
566
|
+
// Floored at one poll interval so a wake that ate the whole budget still gets one
|
|
567
|
+
// probe rather than being declared unreachable without asking.
|
|
568
|
+
const wakeElapsedMs = Date.now() - wakeStartedAt;
|
|
569
|
+
const readyTimeoutMs = opts.timeoutMs === undefined
|
|
570
|
+
? undefined
|
|
571
|
+
: Math.max(REMOTE_WAKE_READY_INTERVAL_MS, opts.timeoutMs - wakeElapsedMs);
|
|
572
|
+
const ready = await this.deps.waitUntilReady(remote, {
|
|
573
|
+
timeoutMs: readyTimeoutMs,
|
|
574
|
+
signal: this.shutdown.signal,
|
|
575
|
+
});
|
|
576
|
+
if (!ready) {
|
|
577
|
+
this.deps.log?.(`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`);
|
|
578
|
+
this.deps.broadcast?.('remote:hostWakeFailed', {
|
|
579
|
+
...scope,
|
|
580
|
+
hostId: remote.hostId,
|
|
581
|
+
label: remote.label,
|
|
582
|
+
queuedInput: state.pending.length > 0,
|
|
583
|
+
});
|
|
584
|
+
state.probedAt = 0;
|
|
585
|
+
state.reachable = undefined;
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
state.reachable = true;
|
|
589
|
+
state.probedAt = Date.now();
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
_state(sessionId) {
|
|
593
|
+
let state = this.states.get(sessionId);
|
|
594
|
+
if (!state) {
|
|
595
|
+
state = { probedAt: 0, reachable: undefined, waking: null, pending: [], resolvedAt: 0 };
|
|
596
|
+
this.states.set(sessionId, state);
|
|
597
|
+
}
|
|
598
|
+
return state;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The host config to act on: the session's own `remote` when it is fresh enough, else a
|
|
602
|
+
* freshly resolved one.
|
|
603
|
+
*
|
|
604
|
+
* The persisted `remote` snapshot is taken at launch, so a wake target configured AFTER
|
|
605
|
+
* the session started (e.g. through the banner's config dialog, or by adding `wakeMac`
|
|
606
|
+
* to `remote-hosts.json`) is invisible to it. Recovery rehydration (server.ts) covers
|
|
607
|
+
* restarts; this covers the live session, and it is why saving the dialog takes effect
|
|
608
|
+
* without restarting anything.
|
|
609
|
+
*
|
|
610
|
+
* ⚠️ The host config wins in BOTH directions, so the resolver is consulted on the TTL
|
|
611
|
+
* regardless of whether the session already carries a target. Preferring the snapshot
|
|
612
|
+
* whenever it HAD one meant removing a MAC/command in the config (or the dialog) never
|
|
613
|
+
* took effect for a running session — the feature stayed on with a target nobody could
|
|
614
|
+
* see in the config any more, which is exactly the "host config is authoritative"
|
|
615
|
+
* promise failing in the one direction a user can observe.
|
|
616
|
+
*/
|
|
617
|
+
async _effectiveRemote(session) {
|
|
618
|
+
// The local-session return comes FIRST, before `_state`: this runs on every input
|
|
619
|
+
// chunk (`hasWakeTarget` gates the route), so allocating state here would put an
|
|
620
|
+
// entry in the map for every local session the user types in — sessions the feature
|
|
621
|
+
// can never apply to, and whose pending buffers would then have to be swept.
|
|
622
|
+
if (!session.remote)
|
|
623
|
+
return undefined;
|
|
624
|
+
const state = this._state(session.id);
|
|
625
|
+
if (!this.deps.resolveRemote)
|
|
626
|
+
return state.resolvedRemote ?? session.remote;
|
|
627
|
+
if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) {
|
|
628
|
+
return state.resolvedRemote ?? session.remote;
|
|
629
|
+
}
|
|
630
|
+
state.resolvedAt = Date.now();
|
|
631
|
+
try {
|
|
632
|
+
const resolved = await this.deps.resolveRemote(session);
|
|
633
|
+
if (resolved)
|
|
634
|
+
state.resolvedRemote = resolved;
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
this.deps.log?.(`[RemoteWake] host config lookup failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
638
|
+
}
|
|
639
|
+
return state.resolvedRemote ?? session.remote;
|
|
640
|
+
}
|
|
641
|
+
/** Queue a chunk; `'dropped'` when it was over the cap and never entered the buffer. */
|
|
642
|
+
_enqueue(sessionId, data) {
|
|
643
|
+
const state = this._state(sessionId);
|
|
644
|
+
const next = appendBoundedPending(state.pending, data);
|
|
645
|
+
if (next === state.pending) {
|
|
646
|
+
// Oversized chunk: dropped whole (see `appendBoundedPending`), so the buffer is
|
|
647
|
+
// untouched and nothing is delivered as a fragment. Logged, and reported to the
|
|
648
|
+
// route, which answers `dropped:true` — the user's paste is gone and a bare 200
|
|
649
|
+
// could not say so.
|
|
650
|
+
this.deps.log?.(`[RemoteWake] dropped a ${Buffer.byteLength(data)}-byte input chunk for session ${sessionId} — over the ${REMOTE_WAKE_PENDING_MAX_BYTES}-byte wake buffer, and a truncated paste must not be delivered as a fragment`);
|
|
651
|
+
return 'dropped';
|
|
652
|
+
}
|
|
653
|
+
const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
|
654
|
+
const after = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
|
655
|
+
if (before + Buffer.byteLength(data) > after) {
|
|
656
|
+
this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`);
|
|
657
|
+
}
|
|
658
|
+
state.pending = next;
|
|
659
|
+
return 'buffered';
|
|
660
|
+
}
|
|
661
|
+
async _flush(state, session) {
|
|
662
|
+
while (state.pending.length > 0) {
|
|
663
|
+
const chunk = state.pending[0];
|
|
664
|
+
// Take the chunk OUT before awaiting the write. Input arriving during the await is
|
|
665
|
+
// enqueued by `handleInput` (a wake is still in flight, so it takes the buffer
|
|
666
|
+
// path), and `appendBoundedPending` may then drop the OLDEST chunk to stay under
|
|
667
|
+
// the cap — which would be this one, already on its way to the pane. Shifting
|
|
668
|
+
// afterwards removed the NEXT chunk instead, so the drop-oldest bookkeeping lost a
|
|
669
|
+
// chunk that was never written while the log line blamed the one that was.
|
|
670
|
+
state.pending = state.pending.slice(1);
|
|
671
|
+
// `fromUser`: these bytes came through the input route as a person's prompt, so
|
|
672
|
+
// they may name the tab — without it a session whose FIRST prompt was buffered
|
|
673
|
+
// through a wake could never be auto-named.
|
|
674
|
+
const ok = await session.writeViaMux(chunk, { fromUser: true }).catch(() => false);
|
|
675
|
+
if (!ok) {
|
|
676
|
+
// Drop the rest, and say so. Retaining it looked safer but was worse: the wake
|
|
677
|
+
// still resolves and marks the host reachable, so the NEXT input takes the
|
|
678
|
+
// deliver path while the old chunks sit here — to be replayed by the next wake,
|
|
679
|
+
// possibly hours later, after everything typed since, and maybe ending in a
|
|
680
|
+
// carriage return. Same policy as the oversized paste: gone, with a log line.
|
|
681
|
+
const dropped = state.pending.length + 1;
|
|
682
|
+
state.pending = [];
|
|
683
|
+
this.deps.log?.(`[RemoteWake] flush failed for session ${session.id} — ${dropped} buffered chunk(s) dropped rather than replayed on a later wake`);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// ========== Default IO ==========
|
|
690
|
+
/**
|
|
691
|
+
* Under vitest none of this may do real IO (a TCP connect, a child process, a UDP
|
|
692
|
+
* broadcast) — mirrors `remote-files.ts`. Every consumer injects its deps
|
|
693
|
+
* (`RemoteWakeDeps`, the socket factory); this is what makes that seam non-optional
|
|
694
|
+
* instead of a convention the next test can forget.
|
|
695
|
+
*/
|
|
696
|
+
function assertNotUnderTest(what) {
|
|
697
|
+
if (process.env.VITEST) {
|
|
698
|
+
throw new Error(`remote-wake: ${what} is disabled under test — inject a fake (RemoteWakeDeps / WakeSocketFactory)`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Cheap reachability probe: a bare TCP connect to the SSH port. Only meaningful for a
|
|
703
|
+
* host the registry deems probeable (see {@link isProbeable}); the registry never asks
|
|
704
|
+
* it about a proxied host.
|
|
705
|
+
*
|
|
706
|
+
* Deliberately NOT an `ssh … true` probe: that opens a full session (auth,
|
|
707
|
+
* remote log, process) every throttle window for a question a SYN already
|
|
708
|
+
* answers. Any byte count it does move is a few hundred bytes per probe, far
|
|
709
|
+
* below the remote idle detector's traffic threshold, so probing cannot keep a
|
|
710
|
+
* host awake.
|
|
711
|
+
*/
|
|
712
|
+
export function probeRemoteHostReachable(remote, timeoutMs = REMOTE_WAKE_PROBE_TIMEOUT_MS) {
|
|
713
|
+
assertNotUnderTest('the TCP probe');
|
|
714
|
+
const port = remote.port ?? DEFAULT_SSH_PORT;
|
|
715
|
+
return new Promise((resolve) => {
|
|
716
|
+
let settled = false;
|
|
717
|
+
const finish = (value) => {
|
|
718
|
+
if (settled)
|
|
719
|
+
return;
|
|
720
|
+
settled = true;
|
|
721
|
+
socket.destroy();
|
|
722
|
+
resolve(value);
|
|
723
|
+
};
|
|
724
|
+
const socket = net.connect({ host: remote.host, port });
|
|
725
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
726
|
+
socket.once('connect', () => finish(true));
|
|
727
|
+
socket.once('error', () => finish(false));
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Run a host's wake command (e.g. a Wake-on-LAN wrapper script). No shell — the
|
|
732
|
+
* value is a single executable path, so nothing in it can be interpreted.
|
|
733
|
+
* Resolves false on any failure (missing binary, non-zero exit, timeout) rather
|
|
734
|
+
* than throwing: a broken wake command must not break the input route.
|
|
735
|
+
*/
|
|
736
|
+
export function runRemoteWakeCommand(command, timeoutMs = REMOTE_WAKE_COMMAND_TIMEOUT_MS) {
|
|
737
|
+
assertNotUnderTest('the wake command');
|
|
738
|
+
return new Promise((resolve) => {
|
|
739
|
+
let settled = false;
|
|
740
|
+
const finish = (value) => {
|
|
741
|
+
if (settled)
|
|
742
|
+
return;
|
|
743
|
+
settled = true;
|
|
744
|
+
resolve(value);
|
|
745
|
+
};
|
|
746
|
+
let child;
|
|
747
|
+
try {
|
|
748
|
+
child = spawn(command, [], { stdio: 'ignore' });
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
finish(false);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const timer = setTimeout(() => {
|
|
755
|
+
child.kill('SIGKILL');
|
|
756
|
+
finish(false);
|
|
757
|
+
}, timeoutMs);
|
|
758
|
+
child.once('error', () => {
|
|
759
|
+
clearTimeout(timer);
|
|
760
|
+
finish(false);
|
|
761
|
+
});
|
|
762
|
+
child.once('exit', (code) => {
|
|
763
|
+
clearTimeout(timer);
|
|
764
|
+
finish(code === 0);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Send Wake-on-LAN magic packets for every MAC, over UDP to the broadcast address.
|
|
770
|
+
*
|
|
771
|
+
* This is the whole reason `wakeMac` exists: the common case needs no external
|
|
772
|
+
* script. Broadcast on 255.255.255.255 is what the CLI `wakeonlan` does and what the
|
|
773
|
+
* NICs here answer to; the socket is closed as soon as the packets are queued, so a
|
|
774
|
+
* sleeping host cannot leave a handle behind. Resolves false on any failure (no
|
|
775
|
+
* interface to broadcast on, permission) rather than throwing — a broken network
|
|
776
|
+
* must not break the wake flow, which reports the failure itself.
|
|
777
|
+
*/
|
|
778
|
+
export function sendWakePackets(addresses, port = 9, createSocket = () => {
|
|
779
|
+
assertNotUnderTest('the UDP broadcast');
|
|
780
|
+
return dgram.createSocket('udp4');
|
|
781
|
+
}) {
|
|
782
|
+
if (addresses.length === 0)
|
|
783
|
+
return Promise.resolve(false);
|
|
784
|
+
return new Promise((resolve) => {
|
|
785
|
+
const socket = createSocket();
|
|
786
|
+
let settled = false;
|
|
787
|
+
const finish = (value) => {
|
|
788
|
+
if (settled)
|
|
789
|
+
return;
|
|
790
|
+
settled = true;
|
|
791
|
+
try {
|
|
792
|
+
socket.close();
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
/* already closed */
|
|
796
|
+
}
|
|
797
|
+
resolve(value);
|
|
798
|
+
};
|
|
799
|
+
socket.once('error', () => finish(false));
|
|
800
|
+
// ⚠️ `setBroadcast` BEFORE the socket is bound fails with EBADF on Linux, and the
|
|
801
|
+
// send that follows fails with EACCES — i.e. the packet silently never leaves the
|
|
802
|
+
// machine. So the broadcast flag is set in the bind callback, always. (Found by
|
|
803
|
+
// the live test: macOS/BSD tolerate the wrong order, Linux does not.)
|
|
804
|
+
socket.bind(() => {
|
|
805
|
+
try {
|
|
806
|
+
socket.setBroadcast(true);
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
finish(false);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
let pending = addresses.length;
|
|
813
|
+
let failed = false;
|
|
814
|
+
for (const mac of addresses) {
|
|
815
|
+
socket.send(buildMagicPacket(mac), port, '255.255.255.255', (err) => {
|
|
816
|
+
if (err)
|
|
817
|
+
failed = true;
|
|
818
|
+
pending--;
|
|
819
|
+
if (pending === 0)
|
|
820
|
+
finish(!failed);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/** Poll the host until it accepts connections again, or the bound is hit. */
|
|
827
|
+
export async function waitUntilRemoteReady(remote, opts = {}) {
|
|
828
|
+
const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
|
|
829
|
+
const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS;
|
|
830
|
+
const probe = opts.probe ?? probeRemoteHostReachable;
|
|
831
|
+
const deadline = Date.now() + timeoutMs;
|
|
832
|
+
// Probe immediately: WoL from a warm S3 is fast (~7.5 s measured on this setup),
|
|
833
|
+
// and the first poll is what turns "just woke" into a sub-interval response.
|
|
834
|
+
for (;;) {
|
|
835
|
+
if (opts.signal?.aborted)
|
|
836
|
+
return false;
|
|
837
|
+
if (await probe(remote))
|
|
838
|
+
return true;
|
|
839
|
+
if (Date.now() + intervalMs > deadline)
|
|
840
|
+
return false;
|
|
841
|
+
// Abortable sleep, so a shutdown does not wait out the current interval either.
|
|
842
|
+
await delayOrAbort(intervalMs, opts.signal);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/** `delay`, but it also ends the moment `signal` aborts (so cancellation is immediate). */
|
|
846
|
+
function delayOrAbort(ms, signal) {
|
|
847
|
+
if (!signal)
|
|
848
|
+
return delay(ms);
|
|
849
|
+
if (signal.aborted)
|
|
850
|
+
return Promise.resolve();
|
|
851
|
+
return new Promise((resolve) => {
|
|
852
|
+
const done = () => {
|
|
853
|
+
clearTimeout(timer);
|
|
854
|
+
signal.removeEventListener('abort', done);
|
|
855
|
+
resolve();
|
|
856
|
+
};
|
|
857
|
+
const timer = setTimeout(done, ms);
|
|
858
|
+
signal.addEventListener('abort', done, { once: true });
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
862
|
+
/**
|
|
863
|
+
* Production wiring: all IO defaults, overridable for tests.
|
|
864
|
+
*
|
|
865
|
+
* The readiness poll uses the SAME probe as the rest of the deps, overridden or not.
|
|
866
|
+
* Wiring it to the module default instead let a caller that injected `probe` still
|
|
867
|
+
* poll the real host during the wait — under vitest, a TCP connect to a production
|
|
868
|
+
* address on every shutdown test (which the vitest guard is what finally caught).
|
|
869
|
+
*/
|
|
870
|
+
export function createDefaultRemoteWakeDeps(overrides = {}) {
|
|
871
|
+
const probe = overrides.probe ?? probeRemoteHostReachable;
|
|
872
|
+
return {
|
|
873
|
+
probe,
|
|
874
|
+
wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)),
|
|
875
|
+
waitUntilReady: (remote, opts) => waitUntilRemoteReady(remote, { ...opts, probe }),
|
|
876
|
+
delay,
|
|
877
|
+
...overrides,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
//# sourceMappingURL=remote-wake.js.map
|