edge-ai-client-ts 1.0.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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ec-ts.js +18 -0
- package/dist/buffer/disk-queue.d.ts +140 -0
- package/dist/buffer/disk-queue.js +370 -0
- package/dist/cli/devices.d.ts +1 -0
- package/dist/cli/devices.js +61 -0
- package/dist/cli/enroll.d.ts +2 -0
- package/dist/cli/enroll.js +89 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +116 -0
- package/dist/cli/messages.d.ts +1 -0
- package/dist/cli/messages.js +59 -0
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +112 -0
- package/dist/cli/status.d.ts +1 -0
- package/dist/cli/status.js +56 -0
- package/dist/cli/whoami.d.ts +2 -0
- package/dist/cli/whoami.js +41 -0
- package/dist/config/cmd-gate.d.ts +65 -0
- package/dist/config/cmd-gate.js +128 -0
- package/dist/config/settings.d.ts +209 -0
- package/dist/config/settings.js +627 -0
- package/dist/crypto/aes-gcm.d.ts +38 -0
- package/dist/crypto/aes-gcm.js +90 -0
- package/dist/crypto/hmac.d.ts +31 -0
- package/dist/crypto/hmac.js +52 -0
- package/dist/crypto/tls-guard.d.ts +36 -0
- package/dist/crypto/tls-guard.js +54 -0
- package/dist/daemon/manager.d.ts +82 -0
- package/dist/daemon/manager.js +461 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +63 -0
- package/dist/logging/file-logger.d.ts +21 -0
- package/dist/logging/file-logger.js +71 -0
- package/dist/network/ws-client.d.ts +221 -0
- package/dist/network/ws-client.js +1134 -0
- package/dist/session/fail-fast.d.ts +70 -0
- package/dist/session/fail-fast.js +122 -0
- package/dist/session/manager.d.ts +136 -0
- package/dist/session/manager.js +291 -0
- package/dist/session/persistence.d.ts +103 -0
- package/dist/session/persistence.js +194 -0
- package/dist/session/pi-rpc.d.ts +164 -0
- package/dist/session/pi-rpc.js +412 -0
- package/dist/session/sftp.d.ts +64 -0
- package/dist/session/sftp.js +335 -0
- package/dist/session/shell-frame.d.ts +77 -0
- package/dist/session/shell-frame.js +199 -0
- package/dist/session/shell.d.ts +124 -0
- package/dist/session/shell.js +300 -0
- package/docs/CONFIGURATION.md +169 -0
- package/docs/INSTALLATION.md +164 -0
- package/docs/PROTOCOL.md +248 -0
- package/docs/TROUBLESHOOTING.md +177 -0
- package/package.json +79 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON `/edge` WebSocket client for the TypeScript edge-client.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `edge-client/src/network/ws_client.rs` for wire shapes, URL auth,
|
|
5
|
+
* registration, heartbeat metadata, ACK correlation, and reconnect backoff. The
|
|
6
|
+
* binary `/shell-edge` channel is deliberately out of scope for this module.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID, X509Certificate } from 'node:crypto';
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as tls from 'node:tls';
|
|
13
|
+
import WebSocket from 'ws';
|
|
14
|
+
import { edgeAuthEscapeHatchActive, loadEdgeAuthToken, } from '../config/settings.js';
|
|
15
|
+
import { constantTimeHexEquals, sha256Hex } from '../crypto/hmac.js';
|
|
16
|
+
import { decideTlsFromEnv } from '../crypto/tls-guard.js';
|
|
17
|
+
export const SUBPROTOCOL_V2 = 'edgeai-v2';
|
|
18
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
19
|
+
// C2 — SECURITY (review 2026-07-11): cap incoming WS frames at 2 MiB to match
|
|
20
|
+
// the Rust ws-service's `maxPayload: 2 * 1024 * 1024`. Without this, a hostile
|
|
21
|
+
// relay could stream a 100 MiB frame (the ws@8 default) and OOM-kill the client.
|
|
22
|
+
export const DEFAULT_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;
|
|
23
|
+
const WS_OPEN = 1;
|
|
24
|
+
const FAST_BACKOFF_MAX_MS = 30_000;
|
|
25
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
26
|
+
export function createRegisterMessage(machineId, agentId) {
|
|
27
|
+
return { type: 'register', machine_id: machineId, agent_id: agentId };
|
|
28
|
+
}
|
|
29
|
+
export function createStreamMessage(input) {
|
|
30
|
+
return {
|
|
31
|
+
msg_id: randomUUID(),
|
|
32
|
+
machine_id: input.machine_id,
|
|
33
|
+
agent_id: input.agent_id,
|
|
34
|
+
type: input.type,
|
|
35
|
+
payload: input.payload,
|
|
36
|
+
timestamp: input.timestamp ?? Date.now(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function createAckMessage(msgId) {
|
|
40
|
+
return { action: 'ack', msg_id: msgId };
|
|
41
|
+
}
|
|
42
|
+
export function encodeStreamMessage(message) {
|
|
43
|
+
if (!isStreamMessage(message)) {
|
|
44
|
+
throw new Error('Invalid StreamMessage');
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(message);
|
|
47
|
+
}
|
|
48
|
+
export function decodeStreamMessage(text) {
|
|
49
|
+
const value = JSON.parse(text);
|
|
50
|
+
if (!isStreamMessage(value)) {
|
|
51
|
+
throw new Error('Invalid StreamMessage JSON');
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
export function isRegisterMessage(value) {
|
|
56
|
+
return (isRecord(value) &&
|
|
57
|
+
value['type'] === 'register' &&
|
|
58
|
+
typeof value['machine_id'] === 'string' &&
|
|
59
|
+
typeof value['agent_id'] === 'string');
|
|
60
|
+
}
|
|
61
|
+
export function isStreamMessage(value) {
|
|
62
|
+
return (isRecord(value) &&
|
|
63
|
+
typeof value['msg_id'] === 'string' &&
|
|
64
|
+
typeof value['machine_id'] === 'string' &&
|
|
65
|
+
typeof value['agent_id'] === 'string' &&
|
|
66
|
+
typeof value['type'] === 'string' &&
|
|
67
|
+
isJsonValue(value['payload']) &&
|
|
68
|
+
typeof value['timestamp'] === 'number' &&
|
|
69
|
+
Number.isFinite(value['timestamp']));
|
|
70
|
+
}
|
|
71
|
+
export function isAckMessage(value) {
|
|
72
|
+
return (isRecord(value) &&
|
|
73
|
+
value['action'] === 'ack' &&
|
|
74
|
+
typeof value['msg_id'] === 'string');
|
|
75
|
+
}
|
|
76
|
+
export function isHeartbeatMessage(value) {
|
|
77
|
+
return (isRecord(value) &&
|
|
78
|
+
value['type'] === 'heartbeat' &&
|
|
79
|
+
typeof value['machine_id'] === 'string' &&
|
|
80
|
+
optionalString(value['platform']) &&
|
|
81
|
+
optionalString(value['hostname']) &&
|
|
82
|
+
optionalString(value['agent_version']) &&
|
|
83
|
+
optionalString(value['operator_id']) &&
|
|
84
|
+
(value['system'] === undefined || isSystemMetrics(value['system'])));
|
|
85
|
+
}
|
|
86
|
+
export function selectRelayUrl(mode, lanUrl, wanUrl, defaultUrl) {
|
|
87
|
+
if (mode === 'lan') {
|
|
88
|
+
return lanUrl ?? defaultUrl;
|
|
89
|
+
}
|
|
90
|
+
if (mode === 'wan') {
|
|
91
|
+
return wanUrl ?? defaultUrl;
|
|
92
|
+
}
|
|
93
|
+
return defaultUrl;
|
|
94
|
+
}
|
|
95
|
+
export function percentEncodeToken(token) {
|
|
96
|
+
const bytes = Buffer.from(token, 'utf8');
|
|
97
|
+
let out = '';
|
|
98
|
+
for (const byte of bytes) {
|
|
99
|
+
if ((byte >= 0x41 && byte <= 0x5a) ||
|
|
100
|
+
(byte >= 0x61 && byte <= 0x7a) ||
|
|
101
|
+
(byte >= 0x30 && byte <= 0x39) ||
|
|
102
|
+
byte === 0x2d ||
|
|
103
|
+
byte === 0x2e ||
|
|
104
|
+
byte === 0x5f ||
|
|
105
|
+
byte === 0x7e) {
|
|
106
|
+
out += String.fromCharCode(byte);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
out += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
export function appendEdgeToken(url, token) {
|
|
115
|
+
if (token === undefined) {
|
|
116
|
+
return url;
|
|
117
|
+
}
|
|
118
|
+
// Wire-compat workaround (debugged via e2e-smoke 2026-07-11) — REMOVE
|
|
119
|
+
// WHEN RUST BUG IS FIXED. The Rust ws-service's auth gate has TWO
|
|
120
|
+
// functions that disagree on the URL query param name:
|
|
121
|
+
// - `extractAuthToken` (in middleware/wsProtocolAuth.ts) reads `auth_token`
|
|
122
|
+
// out of the URL → resolves the opaque token → passes realKey forward.
|
|
123
|
+
// - `validateEdgeToken` (in services/ws-service/src/edgeAuth.ts) reads
|
|
124
|
+
// `edge_token` out of the URL and compares against the realKey it was
|
|
125
|
+
// passed.
|
|
126
|
+
// A client that only sends `?edge_token=...` gets rejected at
|
|
127
|
+
// extractAuthToken with "missing" before validateEdgeToken runs. A client
|
|
128
|
+
// that only sends `?auth_token=...` gets rejected at validateEdgeToken.
|
|
129
|
+
//
|
|
130
|
+
// Until the Rust side is fixed (one of these auth functions needs to read
|
|
131
|
+
// the same param name as the other), this TS client emits BOTH query
|
|
132
|
+
// params with the same token value. Tracking: see e2e-smoke/README.md
|
|
133
|
+
// §"Wire-compat notes" for the full investigation; this comment MUST be
|
|
134
|
+
// removed alongside any pair-down when the bug is fixed.
|
|
135
|
+
//
|
|
136
|
+
// Implementation note: we use the WHATWG URL parser for idempotency
|
|
137
|
+
// (calling appendEdgeToken twice does NOT duplicate `edge_token=` and
|
|
138
|
+
// `auth_token=`), URL fragment handling (params go BEFORE the fragment,
|
|
139
|
+
// per RFC 3986), and percent-encoding (tokens with URL-special chars
|
|
140
|
+
// are correctly encoded). `URL` requires http(s):// — we rewrite
|
|
141
|
+
// ws(s):// → http(s)://, parse, mutate, then restore the original scheme.
|
|
142
|
+
const isWs = url.startsWith('ws://');
|
|
143
|
+
const isWss = url.startsWith('wss://');
|
|
144
|
+
const httpish = isWss
|
|
145
|
+
? url.replace(/^wss:\/\//, 'https://')
|
|
146
|
+
: isWs
|
|
147
|
+
? url.replace(/^ws:\/\//, 'http://')
|
|
148
|
+
: url;
|
|
149
|
+
try {
|
|
150
|
+
const parsed = new URL(httpish);
|
|
151
|
+
parsed.searchParams.set('edge_token', token);
|
|
152
|
+
parsed.searchParams.set('auth_token', token);
|
|
153
|
+
const out = parsed.toString();
|
|
154
|
+
// Restore original ws(s):// scheme.
|
|
155
|
+
if (isWss)
|
|
156
|
+
return out.replace(/^https:\/\//, 'wss://');
|
|
157
|
+
if (isWs)
|
|
158
|
+
return out.replace(/^http:\/\//, 'ws://');
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Malformed URL — fall back to raw string concat (preserves legacy
|
|
163
|
+
// behaviour for callers that hand in incomplete URLs).
|
|
164
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
165
|
+
return `${url}${sep}edge_token=${percentEncodeToken(token)}&auth_token=${percentEncodeToken(token)}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function appendDeviceSecret(url, secret) {
|
|
169
|
+
if (secret === undefined) {
|
|
170
|
+
return url;
|
|
171
|
+
}
|
|
172
|
+
return `${url}${url.includes('?') ? '&' : '?'}device_secret=${percentEncodeToken(secret)}`;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Build the AUTH-TOKEN half of the `Sec-WebSocket-Protocol` header. Caller
|
|
176
|
+
* passes this to `ws` as a separate element in the `protocols` array
|
|
177
|
+
* alongside SUBPROTOCOL_V2 — the resulting HTTP header reads
|
|
178
|
+
* `Sec-WebSocket-Protocol: edgeai-v2, <returned-value>`.
|
|
179
|
+
*
|
|
180
|
+
* Edge-2-edge: the `deviceSecret` historically travelled INSIDE the
|
|
181
|
+
* subprotocol header (so reverse-proxy logs could never see it), but for
|
|
182
|
+
* v2 we omit it — we send only the opaque edge_token. Returns `undefined`
|
|
183
|
+
* when there is no token to send, OR when the token is NOT a v2 opaque
|
|
184
|
+
* token (a raw edge_token would trigger `malformed_opaque` 401 on the
|
|
185
|
+
* server's subprotocol channel; raw tokens should travel via the URL
|
|
186
|
+
* query string instead — which `decideEdgeAuth` already wires up).
|
|
187
|
+
*/
|
|
188
|
+
export function buildEdgeSubprotocolHeader(edgeToken, deviceSecret) {
|
|
189
|
+
if (edgeToken === undefined) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
// Trim BEFORE the isOpaqueToken check so leading/trailing whitespace doesn't
|
|
193
|
+
// break format detection. (Real callers rarely pass whitespace, but defensive.)
|
|
194
|
+
const trimmed = edgeToken.trim();
|
|
195
|
+
if (trimmed.length === 0 || !isOpaqueToken(trimmed)) {
|
|
196
|
+
// Raw edge_token → fall through to the query-string path (no subprotocol).
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const _deviceSecret = deviceSecret;
|
|
200
|
+
void _deviceSecret;
|
|
201
|
+
return trimmed;
|
|
202
|
+
}
|
|
203
|
+
/** True iff the token LOOKS like a v2 opaque token (`o.<exp>.<nonce>.<sig>`). */
|
|
204
|
+
function isOpaqueToken(token) {
|
|
205
|
+
const parts = token.split('.');
|
|
206
|
+
return parts.length === 4 && parts[0] === 'o' && parts[1].length > 0 && parts[2].length > 0 && parts[3].length > 0;
|
|
207
|
+
}
|
|
208
|
+
// Re-export for unit tests.
|
|
209
|
+
export { isOpaqueToken as _isOpaqueTokenForTests };
|
|
210
|
+
export function decideEdgeAuth(baseUrl, token, escapeActive) {
|
|
211
|
+
if (token !== undefined) {
|
|
212
|
+
return { kind: 'Connect', url: appendEdgeToken(baseUrl, token) };
|
|
213
|
+
}
|
|
214
|
+
if (escapeActive) {
|
|
215
|
+
return { kind: 'ConnectUnauthenticated', url: baseUrl };
|
|
216
|
+
}
|
|
217
|
+
return { kind: 'Refuse' };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* FIX 2026-07-12 (3.5 + 3.6 from review): wrap any Error before emitting
|
|
221
|
+
* it from EdgeWsClient so the URL (and other sensitive tokens) can never
|
|
222
|
+
* leak to listeners that log the raw error. EventEmitter's `emit('error', err)`
|
|
223
|
+
* propagates the error verbatim — if `err.message` contains a URL with
|
|
224
|
+
* `?edge_token=...&device_secret=...`, a single missed redaction in a
|
|
225
|
+
* listener leaks credentials to the log.
|
|
226
|
+
*
|
|
227
|
+
* This helper produces a NEW Error whose `.message` has `redactUrl()`
|
|
228
|
+
* applied. The original `.stack` is preserved (so debugging still works)
|
|
229
|
+
* but we strip the message portion of the stack (the first line) so the
|
|
230
|
+
* URL isn't in the stack either. Listeners that destructure
|
|
231
|
+
* `{ message, code }` are safe; listeners that log `err.toString()` are
|
|
232
|
+
* also safe (toString uses message).
|
|
233
|
+
*/
|
|
234
|
+
export function redactErrorUrl(err) {
|
|
235
|
+
if (!err || typeof err.message !== 'string')
|
|
236
|
+
return err;
|
|
237
|
+
const safeMessage = redactUrl(err.message);
|
|
238
|
+
if (safeMessage === err.message)
|
|
239
|
+
return err; // no URL in message → unchanged
|
|
240
|
+
const out = new Error(safeMessage);
|
|
241
|
+
out.name = err.name;
|
|
242
|
+
// Preserve the code property if present (RPC code, etc).
|
|
243
|
+
const anyErr = err;
|
|
244
|
+
if (anyErr.code)
|
|
245
|
+
out.code = anyErr.code;
|
|
246
|
+
// Strip the first line of the stack (which embeds the message) and
|
|
247
|
+
// re-attach the rest. Browsers/V8 put the message in the first stack line.
|
|
248
|
+
if (err.stack) {
|
|
249
|
+
const lines = err.stack.split('\n');
|
|
250
|
+
lines[0] = `${out.name}: ${out.message}`;
|
|
251
|
+
out.stack = lines.join('\n');
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
export function redactUrl(url) {
|
|
256
|
+
// `ws://...` URLs don't parse with `new URL()` (only http/https do), so
|
|
257
|
+
// we have to strip the `ws://` → `http://` prefix + reverse it. We also
|
|
258
|
+
// fall back to regex if the URL is otherwise malformed.
|
|
259
|
+
const tryUrlParse = (target) => {
|
|
260
|
+
try {
|
|
261
|
+
const parsed = new URL(target);
|
|
262
|
+
for (const param of ['edge_token', 'auth_token', 'device_secret']) {
|
|
263
|
+
if (parsed.searchParams.has(param)) {
|
|
264
|
+
parsed.searchParams.set(param, 'REDACTED');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return parsed.toString();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// fall through to regex
|
|
271
|
+
return target
|
|
272
|
+
.replace(/([?&]edge_token=)[^&]*/g, '$1REDACTED')
|
|
273
|
+
.replace(/([?&]auth_token=)[^&]*/g, '$1REDACTED')
|
|
274
|
+
.replace(/([?&]device_secret=)[^&]*/g, '$1REDACTED');
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
// Handle ws:// / wss:// by rewriting to http(s):// for URL parsing.
|
|
278
|
+
if (url.startsWith('ws://') || url.startsWith('wss://')) {
|
|
279
|
+
const rewritten = url.replace(/^wss?:/, 'http:').replace(/^http:/, 'http:');
|
|
280
|
+
const parsed = tryUrlParse(rewritten);
|
|
281
|
+
// Restore the original scheme.
|
|
282
|
+
return url.startsWith('wss://')
|
|
283
|
+
? parsed.replace(/^https?:/, 'wss:')
|
|
284
|
+
: parsed.replace(/^https?:/, 'ws:');
|
|
285
|
+
}
|
|
286
|
+
return tryUrlParse(url);
|
|
287
|
+
}
|
|
288
|
+
export function buildHeartbeatMessage(settings, packageVersion = resolvePackageVersion()) {
|
|
289
|
+
const heartbeat = {
|
|
290
|
+
type: 'heartbeat',
|
|
291
|
+
machine_id: settings.machine_id,
|
|
292
|
+
};
|
|
293
|
+
const platform = detectPlatform();
|
|
294
|
+
if (platform.length > 0) {
|
|
295
|
+
heartbeat.platform = platform;
|
|
296
|
+
}
|
|
297
|
+
const hostname = readHostname();
|
|
298
|
+
if (hostname !== undefined) {
|
|
299
|
+
heartbeat.hostname = hostname;
|
|
300
|
+
}
|
|
301
|
+
if (packageVersion.length > 0) {
|
|
302
|
+
heartbeat.agent_version = packageVersion;
|
|
303
|
+
}
|
|
304
|
+
heartbeat.system = collectSystemMetrics();
|
|
305
|
+
const operatorId = settings.owner_operator_id?.trim();
|
|
306
|
+
if (operatorId !== undefined && operatorId.length > 0) {
|
|
307
|
+
heartbeat.operator_id = operatorId;
|
|
308
|
+
}
|
|
309
|
+
return heartbeat;
|
|
310
|
+
}
|
|
311
|
+
export class EdgeWsClient extends EventEmitter {
|
|
312
|
+
settings;
|
|
313
|
+
edgeToken;
|
|
314
|
+
deviceSecret;
|
|
315
|
+
pinnedFingerprint;
|
|
316
|
+
buffer;
|
|
317
|
+
heartbeatIntervalMs;
|
|
318
|
+
requestTimeoutMs;
|
|
319
|
+
reconnect;
|
|
320
|
+
packageVersion;
|
|
321
|
+
localQueue = [];
|
|
322
|
+
pending = new Map();
|
|
323
|
+
ws;
|
|
324
|
+
started = false;
|
|
325
|
+
stopping = false;
|
|
326
|
+
registered = false;
|
|
327
|
+
online = false;
|
|
328
|
+
flushing = false;
|
|
329
|
+
networkMode;
|
|
330
|
+
reconnectAttempts = 0;
|
|
331
|
+
heartbeatTimer;
|
|
332
|
+
reconnectTimer;
|
|
333
|
+
lastRelayHeartbeatMs;
|
|
334
|
+
constructor(options) {
|
|
335
|
+
super();
|
|
336
|
+
this.settings = options.settings;
|
|
337
|
+
this.edgeToken = options.edgeToken ?? loadEdgeAuthToken(options.settings);
|
|
338
|
+
this.deviceSecret = options.deviceSecret ?? options.settings.relay.device_secret;
|
|
339
|
+
this.pinnedFingerprint = normalizeFingerprint(options.pinnedFingerprint ?? process.env.EDGE_RELAY_FINGERPRINT ?? options.settings.relay.fingerprint);
|
|
340
|
+
this.buffer = options.buffer;
|
|
341
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
342
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
343
|
+
this.reconnect = {
|
|
344
|
+
initialDelayMs: options.reconnect?.initialDelayMs ?? 1_000,
|
|
345
|
+
maxDelayMs: options.reconnect?.maxDelayMs ?? FAST_BACKOFF_MAX_MS,
|
|
346
|
+
multiplier: options.reconnect?.multiplier ?? 2,
|
|
347
|
+
};
|
|
348
|
+
this.packageVersion = options.packageVersion ?? resolvePackageVersion();
|
|
349
|
+
this.networkMode = options.networkMode;
|
|
350
|
+
}
|
|
351
|
+
on(event, listener) {
|
|
352
|
+
return super.on(event, listener);
|
|
353
|
+
}
|
|
354
|
+
once(event, listener) {
|
|
355
|
+
return super.once(event, listener);
|
|
356
|
+
}
|
|
357
|
+
off(event, listener) {
|
|
358
|
+
return super.off(event, listener);
|
|
359
|
+
}
|
|
360
|
+
emit(event, ...args) {
|
|
361
|
+
return super.emit(event, ...args);
|
|
362
|
+
}
|
|
363
|
+
async start() {
|
|
364
|
+
if (this.started) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.started = true;
|
|
368
|
+
this.stopping = false;
|
|
369
|
+
try {
|
|
370
|
+
await this.connect();
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
const err = toError(error);
|
|
374
|
+
this.emit('error', redactErrorUrl(err));
|
|
375
|
+
this.scheduleReconnect();
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async stop() {
|
|
380
|
+
this.stopping = true;
|
|
381
|
+
this.started = false;
|
|
382
|
+
this.clearReconnectTimer();
|
|
383
|
+
this.stopHeartbeat();
|
|
384
|
+
this.rejectAllPending(new Error('WebSocket client stopped'));
|
|
385
|
+
const socket = this.ws;
|
|
386
|
+
this.ws = undefined;
|
|
387
|
+
if (socket !== undefined && socket.readyState === WS_OPEN) {
|
|
388
|
+
socket.close();
|
|
389
|
+
}
|
|
390
|
+
this.markOffline();
|
|
391
|
+
}
|
|
392
|
+
async send(message) {
|
|
393
|
+
if (!isStreamMessage(message)) {
|
|
394
|
+
throw new Error('send requires a valid StreamMessage');
|
|
395
|
+
}
|
|
396
|
+
if (!this.canSendNow()) {
|
|
397
|
+
await this.enqueueOffline(message);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
await this.sendStreamNow(message);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
await this.enqueueOffline(message);
|
|
405
|
+
throw toError(error);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
request(message, timeoutMs = this.requestTimeoutMs) {
|
|
409
|
+
if (!isStreamMessage(message)) {
|
|
410
|
+
return Promise.reject(new Error('request requires a valid StreamMessage'));
|
|
411
|
+
}
|
|
412
|
+
return new Promise((resolve, reject) => {
|
|
413
|
+
const timer = setTimeout(() => {
|
|
414
|
+
this.pending.delete(message.msg_id);
|
|
415
|
+
reject(new Error(`WebSocket request timed out after ${timeoutMs}ms for msg_id ${message.msg_id}`));
|
|
416
|
+
}, timeoutMs);
|
|
417
|
+
this.pending.set(message.msg_id, { resolve, reject, timer });
|
|
418
|
+
this.send(message).catch((error) => {
|
|
419
|
+
const pending = this.pending.get(message.msg_id);
|
|
420
|
+
if (pending !== undefined) {
|
|
421
|
+
clearTimeout(pending.timer);
|
|
422
|
+
this.pending.delete(message.msg_id);
|
|
423
|
+
pending.reject(toError(error));
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
async sendHeartbeat() {
|
|
429
|
+
const heartbeat = buildHeartbeatMessage(this.settings, this.packageVersion);
|
|
430
|
+
await this.sendJsonObject(heartbeatToJsonObject(heartbeat));
|
|
431
|
+
}
|
|
432
|
+
isOnline() {
|
|
433
|
+
return this.online;
|
|
434
|
+
}
|
|
435
|
+
isRegistered() {
|
|
436
|
+
return this.registered;
|
|
437
|
+
}
|
|
438
|
+
lastRelayHeartbeat() {
|
|
439
|
+
return this.lastRelayHeartbeatMs;
|
|
440
|
+
}
|
|
441
|
+
currentUrl() {
|
|
442
|
+
return this.resolveCurrentUrl();
|
|
443
|
+
}
|
|
444
|
+
async connect(rejectEarlyClose = true) {
|
|
445
|
+
const url = this.resolveCurrentUrl();
|
|
446
|
+
const tlsDecision = decideTlsFromEnv(url);
|
|
447
|
+
if (tlsDecision.kind === 'Refuse') {
|
|
448
|
+
throw new Error(tlsDecision.reason);
|
|
449
|
+
}
|
|
450
|
+
const wsOptions = this.buildWebSocketOptions(url);
|
|
451
|
+
const socket = new WebSocket(url, wsOptions);
|
|
452
|
+
this.ws = socket;
|
|
453
|
+
this.registered = false;
|
|
454
|
+
await new Promise((resolve, reject) => {
|
|
455
|
+
let settled = false;
|
|
456
|
+
const failIfOpening = (error) => {
|
|
457
|
+
// Coerce nullish → real Error at the source so ALL callers (socket.on('error'),
|
|
458
|
+
// sendText.catch, and any future emit site) are protected. Without this,
|
|
459
|
+
// `this.emit('error', null)` propagates null through to every 'error' listener
|
|
460
|
+
// — e.g., run.ts's `ws.on('error', (err) => logger.error(err.message))` crashes
|
|
461
|
+
// with TypeError: Cannot read properties of null.
|
|
462
|
+
const safeError = error ?? new Error('WebSocket upgrade rejected (no detail)');
|
|
463
|
+
this.emit('error', safeError);
|
|
464
|
+
if (!settled) {
|
|
465
|
+
settled = true;
|
|
466
|
+
reject(safeError);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
socket.on('open', () => {
|
|
470
|
+
const register = createRegisterMessage(this.settings.machine_id, this.settings.agent.default_agent_id);
|
|
471
|
+
this.sendText(JSON.stringify(register))
|
|
472
|
+
.then(() => {
|
|
473
|
+
if (!settled) {
|
|
474
|
+
settled = true;
|
|
475
|
+
resolve();
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
.catch(failIfOpening);
|
|
479
|
+
});
|
|
480
|
+
socket.on('message', (raw, isBinary) => {
|
|
481
|
+
if (!isBinary) {
|
|
482
|
+
this.handleRawMessage(raw);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
socket.on('error', (error) => {
|
|
486
|
+
// failIfOpening itself coerces null → Error, so we can pass through directly.
|
|
487
|
+
failIfOpening(error);
|
|
488
|
+
});
|
|
489
|
+
socket.on('close', () => {
|
|
490
|
+
if (!settled && !this.stopping) {
|
|
491
|
+
settled = true;
|
|
492
|
+
if (rejectEarlyClose) {
|
|
493
|
+
reject(new Error('WebSocket closed before registration could be sent'));
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
resolve();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
this.handleClose();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
buildWebSocketOptions(url) {
|
|
504
|
+
const subprotocolHeader = buildEdgeSubprotocolHeader(this.edgeToken, this.deviceSecret);
|
|
505
|
+
// CRITICAL wire-compat (debugged via e2e-smoke 2026-07-11):
|
|
506
|
+
// The Rust ws-service's `parseSubprotocolHeader` splits the
|
|
507
|
+
// `Sec-WebSocket-Protocol` HTTP header on commas and takes parts[0] as
|
|
508
|
+
// the protocol identifier ("edgeai-v2") + parts[1] as the opaque auth
|
|
509
|
+
// token. The `ws@8` library's `protocols` option requires an ARRAY of
|
|
510
|
+
// distinct protocol values — passing a single string with a comma in it
|
|
511
|
+
// (e.g. "edgeai-v2, <token>") triggers `ws@8` to throw
|
|
512
|
+
// "An invalid or duplicated subprotocol was specified".
|
|
513
|
+
//
|
|
514
|
+
// We must hand `ws` TWO elements: ["edgeai-v2", "<token>"]. The library
|
|
515
|
+
// then emits the correct `Sec-WebSocket-Protocol: edgeai-v2, <token>`
|
|
516
|
+
// header that the Rust parser expects.
|
|
517
|
+
const protocols = subprotocolHeader !== undefined ? [SUBPROTOCOL_V2, subprotocolHeader] : undefined;
|
|
518
|
+
const options = {
|
|
519
|
+
perMessageDeflate: false,
|
|
520
|
+
headers: {},
|
|
521
|
+
...(protocols ? { protocols } : {}),
|
|
522
|
+
// C2 — SECURITY FIX (security-review 2026-07-11): cap incoming WS
|
|
523
|
+
// frames at 2 MiB to match the Rust ws-service's `maxPayload:
|
|
524
|
+
// 2 * 1024 * 1024`. Without this, a hostile relay could stream a
|
|
525
|
+
// 100 MiB frame (the ws@8 default `maxPayload`) and OOM-kill the
|
|
526
|
+
// client. The Rust unit tests use small messages so this cap is
|
|
527
|
+
// invisible to the happy path.
|
|
528
|
+
maxPayload: DEFAULT_MAX_PAYLOAD_BYTES,
|
|
529
|
+
};
|
|
530
|
+
if (url.startsWith('wss://')) {
|
|
531
|
+
options.createConnection = this.createPinnedTlsConnection(url);
|
|
532
|
+
}
|
|
533
|
+
return options;
|
|
534
|
+
}
|
|
535
|
+
createPinnedTlsConnection(url) {
|
|
536
|
+
const parsed = new URL(url);
|
|
537
|
+
const expectedFingerprint = this.pinnedFingerprint;
|
|
538
|
+
const createConnection = (options, listener) => {
|
|
539
|
+
const baseOptions = options;
|
|
540
|
+
return tls.connect({
|
|
541
|
+
...baseOptions,
|
|
542
|
+
servername: baseOptions.servername ?? parsed.hostname,
|
|
543
|
+
checkServerIdentity: (servername, cert) => {
|
|
544
|
+
const defaultError = tls.checkServerIdentity(servername, cert);
|
|
545
|
+
if (defaultError !== undefined) {
|
|
546
|
+
return defaultError;
|
|
547
|
+
}
|
|
548
|
+
return verifyPeerFingerprint(cert, expectedFingerprint);
|
|
549
|
+
},
|
|
550
|
+
}, listener);
|
|
551
|
+
};
|
|
552
|
+
return createConnection;
|
|
553
|
+
}
|
|
554
|
+
resolveCurrentUrl() {
|
|
555
|
+
const baseUrl = selectRelayUrl(this.networkMode, this.settings.server.ws_url_lan, this.settings.server.ws_url_wan, this.settings.server.ws_url);
|
|
556
|
+
const decision = decideEdgeAuth(baseUrl, this.edgeToken, edgeAuthEscapeHatchActive());
|
|
557
|
+
if (decision.kind === 'Refuse') {
|
|
558
|
+
throw new Error('Refusing unauthenticated edge WebSocket connection');
|
|
559
|
+
}
|
|
560
|
+
return appendDeviceSecret(decision.url, this.deviceSecret);
|
|
561
|
+
}
|
|
562
|
+
handleRawMessage(raw) {
|
|
563
|
+
let parsed;
|
|
564
|
+
try {
|
|
565
|
+
parsed = JSON.parse(rawDataToString(raw));
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (!isRecord(parsed)) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const message = unknownRecordToJsonObject(parsed);
|
|
575
|
+
const isAction = this.dispatchAction(parsed);
|
|
576
|
+
const isType = this.dispatchAckOrType(parsed, message);
|
|
577
|
+
if (isAction || isType) {
|
|
578
|
+
this.emit('inbound', message);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
dispatchAction(data) {
|
|
582
|
+
const action = stringField(data, 'action');
|
|
583
|
+
if (action === undefined) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
return matchesAction(action);
|
|
587
|
+
}
|
|
588
|
+
dispatchAckOrType(data, message) {
|
|
589
|
+
const action = stringField(data, 'action');
|
|
590
|
+
const msgType = stringField(data, 'type');
|
|
591
|
+
const isAck = msgType === 'ack' || action === 'ack';
|
|
592
|
+
if (isAck) {
|
|
593
|
+
const msgId = stringField(data, 'msg_id');
|
|
594
|
+
if (msgId !== undefined) {
|
|
595
|
+
this.resolvePending(msgId, message);
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
if (msgType === undefined) {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
switch (msgType) {
|
|
603
|
+
case 'registered':
|
|
604
|
+
this.handleRegistered(message);
|
|
605
|
+
break;
|
|
606
|
+
case 'get_config':
|
|
607
|
+
this.replyWithConfigSnapshot(stringField(data, 'msg_id')).catch((error) => {
|
|
608
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
609
|
+
});
|
|
610
|
+
break;
|
|
611
|
+
case 'set_config':
|
|
612
|
+
this.applyConfigMessage(data);
|
|
613
|
+
this.ackConfigMessage(data).catch((error) => {
|
|
614
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
615
|
+
});
|
|
616
|
+
break;
|
|
617
|
+
case 'config_snapshot':
|
|
618
|
+
this.applyConfigMessage(data);
|
|
619
|
+
break;
|
|
620
|
+
case 'agent_list':
|
|
621
|
+
this.emit('agent_list', message);
|
|
622
|
+
break;
|
|
623
|
+
case 'audit_event':
|
|
624
|
+
this.emit('audit_event', message);
|
|
625
|
+
break;
|
|
626
|
+
case 'heartbeat':
|
|
627
|
+
this.lastRelayHeartbeatMs = Date.now();
|
|
628
|
+
this.emit('heartbeat', message);
|
|
629
|
+
break;
|
|
630
|
+
case 'device_secret_issued':
|
|
631
|
+
break;
|
|
632
|
+
default:
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
handleRegistered(message) {
|
|
638
|
+
this.registered = true;
|
|
639
|
+
this.reconnectAttempts = 0;
|
|
640
|
+
if (!this.online) {
|
|
641
|
+
this.online = true;
|
|
642
|
+
this.emit('online');
|
|
643
|
+
}
|
|
644
|
+
this.emit('registered', message);
|
|
645
|
+
this.startHeartbeat();
|
|
646
|
+
this.flushOutbound().catch((error) => {
|
|
647
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
648
|
+
});
|
|
649
|
+
this.requestConfigSnapshot().catch((error) => {
|
|
650
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
applyConfigMessage(data) {
|
|
654
|
+
const config = data['config'];
|
|
655
|
+
if (config === null) {
|
|
656
|
+
this.emit('config', null);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (!isRecord(config)) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const snapshot = unknownRecordToJsonObject(config);
|
|
663
|
+
this.emit('config', snapshot);
|
|
664
|
+
const heartbeatIntervalSeconds = numberField(config, 'heartbeat_interval_s');
|
|
665
|
+
if (heartbeatIntervalSeconds !== undefined &&
|
|
666
|
+
heartbeatIntervalSeconds >= 1 &&
|
|
667
|
+
heartbeatIntervalSeconds <= 300) {
|
|
668
|
+
this.restartHeartbeat(heartbeatIntervalSeconds * 1_000);
|
|
669
|
+
}
|
|
670
|
+
const nextMode = stringField(config, 'network_mode');
|
|
671
|
+
if (nextMode !== undefined) {
|
|
672
|
+
this.handleNetworkModeChange(nextMode);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
handleNetworkModeChange(nextMode) {
|
|
676
|
+
if (this.networkMode === nextMode) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const modeUrl = nextMode === 'lan'
|
|
680
|
+
? this.settings.server.ws_url_lan
|
|
681
|
+
: nextMode === 'wan'
|
|
682
|
+
? this.settings.server.ws_url_wan
|
|
683
|
+
: undefined;
|
|
684
|
+
this.networkMode = nextMode;
|
|
685
|
+
if (modeUrl !== undefined) {
|
|
686
|
+
this.forceReconnect();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async ackConfigMessage(data) {
|
|
690
|
+
const msgId = stringField(data, 'msg_id');
|
|
691
|
+
if (msgId !== undefined) {
|
|
692
|
+
await this.sendJsonObject(ackToJsonObject(createAckMessage(msgId)));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
async requestConfigSnapshot() {
|
|
696
|
+
await this.sendJsonObject({
|
|
697
|
+
type: 'get_config',
|
|
698
|
+
machine_id: this.settings.machine_id,
|
|
699
|
+
msg_id: randomUUID(),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
async replyWithConfigSnapshot(msgId) {
|
|
703
|
+
await this.sendJsonObject({
|
|
704
|
+
type: 'config_snapshot',
|
|
705
|
+
machine_id: this.settings.machine_id,
|
|
706
|
+
msg_id: msgId ?? randomUUID(),
|
|
707
|
+
config: settingsSnapshot(this.settings),
|
|
708
|
+
timestamp: Date.now(),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
resolvePending(msgId, message) {
|
|
712
|
+
const pending = this.pending.get(msgId);
|
|
713
|
+
if (pending === undefined) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
clearTimeout(pending.timer);
|
|
717
|
+
this.pending.delete(msgId);
|
|
718
|
+
pending.resolve(message);
|
|
719
|
+
}
|
|
720
|
+
canSendNow() {
|
|
721
|
+
return this.ws !== undefined && this.ws.readyState === WS_OPEN;
|
|
722
|
+
}
|
|
723
|
+
async sendStreamNow(message) {
|
|
724
|
+
await this.sendText(encodeStreamMessage(message));
|
|
725
|
+
}
|
|
726
|
+
async sendJsonObject(message) {
|
|
727
|
+
await this.sendText(JSON.stringify(message));
|
|
728
|
+
}
|
|
729
|
+
async sendText(text) {
|
|
730
|
+
const socket = this.ws;
|
|
731
|
+
if (socket === undefined || socket.readyState !== WS_OPEN) {
|
|
732
|
+
throw new Error('WebSocket is not open');
|
|
733
|
+
}
|
|
734
|
+
await new Promise((resolve, reject) => {
|
|
735
|
+
socket.send(text, (error) => {
|
|
736
|
+
if (error !== undefined) {
|
|
737
|
+
reject(error);
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
resolve();
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
async enqueueOffline(message) {
|
|
746
|
+
if (this.buffer !== undefined) {
|
|
747
|
+
await this.buffer.enqueue(message);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
this.localQueue.push(message);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
async flushOutbound() {
|
|
754
|
+
if (this.flushing || !this.canSendNow()) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
this.flushing = true;
|
|
758
|
+
try {
|
|
759
|
+
while (this.localQueue.length > 0 && this.canSendNow()) {
|
|
760
|
+
const next = this.localQueue.shift();
|
|
761
|
+
if (next !== undefined) {
|
|
762
|
+
await this.sendStreamNow(next);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (this.buffer !== undefined && this.canSendNow()) {
|
|
766
|
+
for await (const frame of this.buffer.drain()) {
|
|
767
|
+
if (!this.canSendNow()) {
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
await this.sendStreamNow(frame);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
this.flushing = false;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
startHeartbeat() {
|
|
779
|
+
if (this.heartbeatTimer !== undefined) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
this.heartbeatTimer = setInterval(() => {
|
|
783
|
+
this.sendHeartbeat().catch((error) => {
|
|
784
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
785
|
+
});
|
|
786
|
+
}, this.heartbeatIntervalMs);
|
|
787
|
+
}
|
|
788
|
+
restartHeartbeat(intervalMs) {
|
|
789
|
+
this.stopHeartbeat();
|
|
790
|
+
this.heartbeatTimer = setInterval(() => {
|
|
791
|
+
this.sendHeartbeat().catch((error) => {
|
|
792
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
793
|
+
});
|
|
794
|
+
}, intervalMs);
|
|
795
|
+
}
|
|
796
|
+
stopHeartbeat() {
|
|
797
|
+
if (this.heartbeatTimer !== undefined) {
|
|
798
|
+
clearInterval(this.heartbeatTimer);
|
|
799
|
+
this.heartbeatTimer = undefined;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
handleClose() {
|
|
803
|
+
this.ws = undefined;
|
|
804
|
+
this.registered = false;
|
|
805
|
+
this.stopHeartbeat();
|
|
806
|
+
this.markOffline();
|
|
807
|
+
if (this.started && !this.stopping) {
|
|
808
|
+
this.scheduleReconnect();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
markOffline() {
|
|
812
|
+
if (this.online) {
|
|
813
|
+
this.online = false;
|
|
814
|
+
this.emit('offline');
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
forceReconnect() {
|
|
818
|
+
const socket = this.ws;
|
|
819
|
+
if (socket !== undefined && socket.readyState === WS_OPEN) {
|
|
820
|
+
socket.close();
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
this.handleClose();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
scheduleReconnect() {
|
|
827
|
+
if (!this.started || this.stopping || this.reconnectTimer !== undefined) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const delayMs = reconnectDelayMs(this.reconnectAttempts, this.reconnect);
|
|
831
|
+
this.reconnectAttempts += 1;
|
|
832
|
+
this.reconnectTimer = setTimeout(() => {
|
|
833
|
+
this.reconnectTimer = undefined;
|
|
834
|
+
this.connect(false)
|
|
835
|
+
.catch((error) => {
|
|
836
|
+
this.emit('error', redactErrorUrl(toError(error)));
|
|
837
|
+
this.scheduleReconnect();
|
|
838
|
+
});
|
|
839
|
+
}, delayMs);
|
|
840
|
+
}
|
|
841
|
+
clearReconnectTimer() {
|
|
842
|
+
if (this.reconnectTimer !== undefined) {
|
|
843
|
+
clearTimeout(this.reconnectTimer);
|
|
844
|
+
this.reconnectTimer = undefined;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
rejectAllPending(error) {
|
|
848
|
+
for (const [msgId, pending] of this.pending) {
|
|
849
|
+
clearTimeout(pending.timer);
|
|
850
|
+
pending.reject(error);
|
|
851
|
+
this.pending.delete(msgId);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
export function reconnectDelayMs(attempt, options) {
|
|
856
|
+
const delay = options.initialDelayMs * options.multiplier ** attempt;
|
|
857
|
+
return Math.min(Math.trunc(delay), options.maxDelayMs);
|
|
858
|
+
}
|
|
859
|
+
function heartbeatToJsonObject(heartbeat) {
|
|
860
|
+
const obj = {
|
|
861
|
+
type: heartbeat.type,
|
|
862
|
+
machine_id: heartbeat.machine_id,
|
|
863
|
+
};
|
|
864
|
+
if (heartbeat.platform !== undefined) {
|
|
865
|
+
obj['platform'] = heartbeat.platform;
|
|
866
|
+
}
|
|
867
|
+
if (heartbeat.hostname !== undefined) {
|
|
868
|
+
obj['hostname'] = heartbeat.hostname;
|
|
869
|
+
}
|
|
870
|
+
if (heartbeat.agent_version !== undefined) {
|
|
871
|
+
obj['agent_version'] = heartbeat.agent_version;
|
|
872
|
+
}
|
|
873
|
+
if (heartbeat.system !== undefined) {
|
|
874
|
+
obj['system'] = systemMetricsToJsonObject(heartbeat.system);
|
|
875
|
+
}
|
|
876
|
+
if (heartbeat.operator_id !== undefined) {
|
|
877
|
+
obj['operator_id'] = heartbeat.operator_id;
|
|
878
|
+
}
|
|
879
|
+
return obj;
|
|
880
|
+
}
|
|
881
|
+
function ackToJsonObject(ack) {
|
|
882
|
+
return { action: ack.action, msg_id: ack.msg_id };
|
|
883
|
+
}
|
|
884
|
+
function systemMetricsToJsonObject(metrics) {
|
|
885
|
+
return {
|
|
886
|
+
cpu_cores: metrics.cpu_cores,
|
|
887
|
+
cpu_speed_mhz: metrics.cpu_speed_mhz,
|
|
888
|
+
load_avg_1m: metrics.load_avg_1m,
|
|
889
|
+
load_avg_5m: metrics.load_avg_5m,
|
|
890
|
+
cpu_usage_pct: metrics.cpu_usage_pct,
|
|
891
|
+
mem_total_mb: metrics.mem_total_mb,
|
|
892
|
+
mem_available_mb: metrics.mem_available_mb,
|
|
893
|
+
disk_total_gb: metrics.disk_total_gb,
|
|
894
|
+
disk_free_gb: metrics.disk_free_gb,
|
|
895
|
+
process_count: metrics.process_count,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function settingsSnapshot(settings) {
|
|
899
|
+
const root = {
|
|
900
|
+
server: settings.server.ws_url_lan === undefined && settings.server.ws_url_wan === undefined
|
|
901
|
+
? { ws_url: settings.server.ws_url }
|
|
902
|
+
: {
|
|
903
|
+
ws_url: settings.server.ws_url,
|
|
904
|
+
...(settings.server.ws_url_lan !== undefined ? { ws_url_lan: settings.server.ws_url_lan } : {}),
|
|
905
|
+
...(settings.server.ws_url_wan !== undefined ? { ws_url_wan: settings.server.ws_url_wan } : {}),
|
|
906
|
+
},
|
|
907
|
+
agent: { default_agent_id: settings.agent.default_agent_id },
|
|
908
|
+
buffer: {
|
|
909
|
+
max_memory_mb: settings.buffer.max_memory_mb,
|
|
910
|
+
flush_interval_ms: settings.buffer.flush_interval_ms,
|
|
911
|
+
ack_timeout_ms: settings.buffer.ack_timeout_ms,
|
|
912
|
+
max_retries: settings.buffer.max_retries,
|
|
913
|
+
...(settings.buffer.dump_dir !== undefined ? { dump_dir: settings.buffer.dump_dir } : {}),
|
|
914
|
+
},
|
|
915
|
+
logging: {
|
|
916
|
+
level: settings.logging.level,
|
|
917
|
+
local_log_dir: settings.logging.local_log_dir,
|
|
918
|
+
},
|
|
919
|
+
machine_id: settings.machine_id,
|
|
920
|
+
shell: {
|
|
921
|
+
enabled: settings.shell.enabled,
|
|
922
|
+
default_cols: settings.shell.default_cols,
|
|
923
|
+
default_rows: settings.shell.default_rows,
|
|
924
|
+
},
|
|
925
|
+
relay: settings.relay.fingerprint === undefined ? {} : { fingerprint: settings.relay.fingerprint },
|
|
926
|
+
resources: {
|
|
927
|
+
memory_max_mb: settings.resources.memory_max_mb,
|
|
928
|
+
cpu_quota_percent: settings.resources.cpu_quota_percent,
|
|
929
|
+
tasks_max: settings.resources.tasks_max,
|
|
930
|
+
},
|
|
931
|
+
cmd_gate: {
|
|
932
|
+
mode: settings.cmd_gate.mode,
|
|
933
|
+
allowlist: [...settings.cmd_gate.allowlist],
|
|
934
|
+
always_approve: [...settings.cmd_gate.always_approve],
|
|
935
|
+
denylist: [...settings.cmd_gate.denylist],
|
|
936
|
+
timeout_s: settings.cmd_gate.timeout_s,
|
|
937
|
+
},
|
|
938
|
+
};
|
|
939
|
+
if (settings.owner_operator_id !== undefined) {
|
|
940
|
+
root['owner_operator_id'] = settings.owner_operator_id;
|
|
941
|
+
}
|
|
942
|
+
if (settings.pi_path !== undefined) {
|
|
943
|
+
root['pi_path'] = settings.pi_path;
|
|
944
|
+
}
|
|
945
|
+
return root;
|
|
946
|
+
}
|
|
947
|
+
function collectSystemMetrics() {
|
|
948
|
+
const cpus = os.cpus();
|
|
949
|
+
const load = os.loadavg();
|
|
950
|
+
const disk = readDiskMetrics();
|
|
951
|
+
return {
|
|
952
|
+
cpu_cores: cpus.length > 0 ? cpus.length : null,
|
|
953
|
+
cpu_speed_mhz: cpus[0]?.speed ?? null,
|
|
954
|
+
load_avg_1m: finiteNumberOrNull(load[0]),
|
|
955
|
+
load_avg_5m: finiteNumberOrNull(load[1]),
|
|
956
|
+
cpu_usage_pct: null,
|
|
957
|
+
mem_total_mb: bytesToMb(os.totalmem()),
|
|
958
|
+
mem_available_mb: bytesToMb(os.freemem()),
|
|
959
|
+
disk_total_gb: disk.totalGb,
|
|
960
|
+
disk_free_gb: disk.freeGb,
|
|
961
|
+
process_count: readProcessCount(),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
function readDiskMetrics() {
|
|
965
|
+
try {
|
|
966
|
+
const rootPath = process.platform === 'win32' ? process.cwd().slice(0, 3) : '/';
|
|
967
|
+
const stat = fs.statfsSync(rootPath);
|
|
968
|
+
const total = (stat.blocks * stat.bsize) / 1_000_000_000;
|
|
969
|
+
const free = (stat.bavail * stat.bsize) / 1_000_000_000;
|
|
970
|
+
return { totalGb: roundTwo(total), freeGb: roundTwo(free) };
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return { totalGb: null, freeGb: null };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
function readProcessCount() {
|
|
977
|
+
if (process.platform !== 'linux') {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
return fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)).length;
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function detectPlatform() {
|
|
988
|
+
if (process.platform === 'win32') {
|
|
989
|
+
return 'windows';
|
|
990
|
+
}
|
|
991
|
+
if (process.platform === 'darwin') {
|
|
992
|
+
return 'darwin';
|
|
993
|
+
}
|
|
994
|
+
if (process.platform === 'linux') {
|
|
995
|
+
return 'linux';
|
|
996
|
+
}
|
|
997
|
+
if (process.platform === 'freebsd') {
|
|
998
|
+
return 'freebsd';
|
|
999
|
+
}
|
|
1000
|
+
return 'unknown';
|
|
1001
|
+
}
|
|
1002
|
+
function readHostname() {
|
|
1003
|
+
const hostname = os.hostname().trim();
|
|
1004
|
+
return hostname.length > 0 ? hostname : undefined;
|
|
1005
|
+
}
|
|
1006
|
+
let packageVersionCache;
|
|
1007
|
+
function resolvePackageVersion() {
|
|
1008
|
+
if (packageVersionCache !== undefined) {
|
|
1009
|
+
return packageVersionCache;
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
const packagePath = new URL('../../package.json', import.meta.url);
|
|
1013
|
+
const parsed = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
1014
|
+
packageVersionCache = isRecord(parsed) && typeof parsed['version'] === 'string'
|
|
1015
|
+
? parsed['version']
|
|
1016
|
+
: '0.0.0';
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
packageVersionCache = process.env.npm_package_version ?? '0.0.0';
|
|
1020
|
+
}
|
|
1021
|
+
return packageVersionCache;
|
|
1022
|
+
}
|
|
1023
|
+
function verifyPeerFingerprint(cert, expectedFingerprint) {
|
|
1024
|
+
if (expectedFingerprint === undefined) {
|
|
1025
|
+
return undefined;
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const x509 = new X509Certificate(cert.raw);
|
|
1029
|
+
const spki = x509.publicKey.export({ type: 'spki', format: 'der' });
|
|
1030
|
+
const actual = sha256Hex(spki);
|
|
1031
|
+
if (!constantTimeHexEquals(expectedFingerprint, actual)) {
|
|
1032
|
+
return new Error('TLS SPKI fingerprint mismatch');
|
|
1033
|
+
}
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
catch (error) {
|
|
1037
|
+
return toError(error);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function normalizeFingerprint(value) {
|
|
1041
|
+
if (value === undefined) {
|
|
1042
|
+
return undefined;
|
|
1043
|
+
}
|
|
1044
|
+
const trimmed = value.trim().toLowerCase();
|
|
1045
|
+
return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : undefined;
|
|
1046
|
+
}
|
|
1047
|
+
function matchesAction(action) {
|
|
1048
|
+
return (action === 'execute' ||
|
|
1049
|
+
action === 'extension_ui_response' ||
|
|
1050
|
+
action === 'set_model' ||
|
|
1051
|
+
action === 'set_thinking_level' ||
|
|
1052
|
+
action === 'compact' ||
|
|
1053
|
+
action === 'list_agents' ||
|
|
1054
|
+
action === 'get_available_models' ||
|
|
1055
|
+
action === 'browser_fetch');
|
|
1056
|
+
}
|
|
1057
|
+
function rawDataToString(raw) {
|
|
1058
|
+
if (typeof raw === 'string') {
|
|
1059
|
+
return raw;
|
|
1060
|
+
}
|
|
1061
|
+
if (Buffer.isBuffer(raw)) {
|
|
1062
|
+
return raw.toString('utf8');
|
|
1063
|
+
}
|
|
1064
|
+
if (Array.isArray(raw)) {
|
|
1065
|
+
return Buffer.concat(raw).toString('utf8');
|
|
1066
|
+
}
|
|
1067
|
+
return Buffer.from(raw).toString('utf8');
|
|
1068
|
+
}
|
|
1069
|
+
function isRecord(value) {
|
|
1070
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1071
|
+
}
|
|
1072
|
+
function isJsonValue(value) {
|
|
1073
|
+
if (value === null ||
|
|
1074
|
+
typeof value === 'string' ||
|
|
1075
|
+
typeof value === 'boolean' ||
|
|
1076
|
+
(typeof value === 'number' && Number.isFinite(value))) {
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
if (Array.isArray(value)) {
|
|
1080
|
+
return value.every(isJsonValue);
|
|
1081
|
+
}
|
|
1082
|
+
if (isRecord(value)) {
|
|
1083
|
+
return Object.values(value).every(isJsonValue);
|
|
1084
|
+
}
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
function unknownRecordToJsonObject(record) {
|
|
1088
|
+
const out = {};
|
|
1089
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1090
|
+
out[key] = isJsonValue(value) ? value : null;
|
|
1091
|
+
}
|
|
1092
|
+
return out;
|
|
1093
|
+
}
|
|
1094
|
+
function isSystemMetrics(value) {
|
|
1095
|
+
if (!isRecord(value)) {
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
const keys = [
|
|
1099
|
+
'cpu_cores',
|
|
1100
|
+
'cpu_speed_mhz',
|
|
1101
|
+
'load_avg_1m',
|
|
1102
|
+
'load_avg_5m',
|
|
1103
|
+
'cpu_usage_pct',
|
|
1104
|
+
'mem_total_mb',
|
|
1105
|
+
'mem_available_mb',
|
|
1106
|
+
'disk_total_gb',
|
|
1107
|
+
'disk_free_gb',
|
|
1108
|
+
'process_count',
|
|
1109
|
+
];
|
|
1110
|
+
return keys.every((key) => value[key] === null || typeof value[key] === 'number');
|
|
1111
|
+
}
|
|
1112
|
+
function optionalString(value) {
|
|
1113
|
+
return value === undefined || typeof value === 'string';
|
|
1114
|
+
}
|
|
1115
|
+
function stringField(record, key) {
|
|
1116
|
+
const value = record[key];
|
|
1117
|
+
return typeof value === 'string' ? value : undefined;
|
|
1118
|
+
}
|
|
1119
|
+
function numberField(record, key) {
|
|
1120
|
+
const value = record[key];
|
|
1121
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
1122
|
+
}
|
|
1123
|
+
function bytesToMb(bytes) {
|
|
1124
|
+
return Number.isFinite(bytes) ? Math.floor(bytes / 1_048_576) : null;
|
|
1125
|
+
}
|
|
1126
|
+
function finiteNumberOrNull(value) {
|
|
1127
|
+
return Number.isFinite(value) ? value : null;
|
|
1128
|
+
}
|
|
1129
|
+
function roundTwo(value) {
|
|
1130
|
+
return Math.round(value * 100) / 100;
|
|
1131
|
+
}
|
|
1132
|
+
function toError(value) {
|
|
1133
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
1134
|
+
}
|