agent-companion 0.1.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.
@@ -0,0 +1,1179 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import WebSocket from "ws";
8
+
9
+ const argv = process.argv.slice(2);
10
+ const args = parseArgs(argv);
11
+
12
+ const relayBaseUrl = trimTrailingSlash(args.relay || process.env.AGENT_RELAY_URL || "https://agent-companion-relay.onrender.com");
13
+ const bridgeBaseUrl = trimTrailingSlash(args.bridge || process.env.AGENT_LOCAL_BRIDGE_URL || "http://localhost:8787");
14
+ const bridgeToken = String(args.bridgeToken || process.env.AGENT_BRIDGE_TOKEN || "").trim();
15
+ const quietLogs = String(args.quiet || process.env.AGENT_COMPANION_QUIET || "1").trim() !== "0";
16
+ const snapshotIntervalMs = clamp(toInt(args["snapshot-interval"], 2000), 1000, 60_000);
17
+ const rpcTimeoutMs = clamp(toInt(args["rpc-timeout"], 12_000), 1000, 60_000);
18
+ const bridgeStartupTimeoutMs = clamp(toInt(args["bridge-startup-timeout"], 9000), 2000, 60_000);
19
+ const bridgeProbeBeforeSpawnMs = clamp(toInt(args["bridge-probe-before-spawn"], 1200), 200, 10_000);
20
+ const relayWarmupTimeoutMs = clamp(toInt(args["relay-warmup-timeout"], 25_000), 5_000, 120_000);
21
+ const relayWarmupProbeTimeoutMs = clamp(toInt(args["relay-warmup-probe-timeout"], 4_000), 1_000, 20_000);
22
+ const companionStateFile = path.resolve(
23
+ String(args["state-file"] || process.env.AGENT_COMPANION_STATE_FILE || path.join(os.homedir(), ".agent-companion", "companion.json"))
24
+ );
25
+ const registerRetryDelayMs = 3000;
26
+ const explicitWakeMac = normalizeMacAddress(
27
+ args["wake-mac"] || args.wakeMac || process.env.AGENT_WAKE_MAC || process.env.WAKE_MAC || ""
28
+ );
29
+ const detectedWakeMac = detectPrimaryWakeMacAddress();
30
+ const wakeMacAddress = explicitWakeMac || detectedWakeMac || "";
31
+
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = path.dirname(__filename);
34
+ const projectRoot = path.resolve(__dirname, "..");
35
+ const bridgeScript = path.resolve(projectRoot, "bridge", "server.mjs");
36
+
37
+ let shuttingDown = false;
38
+ let snapshotTimer = null;
39
+ let snapshotInFlight = false;
40
+ let ensureBridgePromise = null;
41
+ let websocket = null;
42
+ let reconnectDelayMs = 1200;
43
+
44
+ let bridgeChild = null;
45
+ let bridgeStartedByCompanion = false;
46
+
47
+ process.on("SIGINT", () => shutdown("SIGINT"));
48
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
49
+
50
+ await main();
51
+
52
+ async function main() {
53
+ if (!relayBaseUrl.startsWith("http://") && !relayBaseUrl.startsWith("https://")) {
54
+ throw new Error(`relay URL must start with http:// or https:// (received "${relayBaseUrl}")`);
55
+ }
56
+
57
+ if (!bridgeBaseUrl.startsWith("http://") && !bridgeBaseUrl.startsWith("https://")) {
58
+ throw new Error(`bridge URL must start with http:// or https:// (received "${bridgeBaseUrl}")`);
59
+ }
60
+
61
+ await ensureBridgeAvailable();
62
+ await waitForRelayAvailability();
63
+
64
+ let registration = await getOrCreateLaptopRegistration();
65
+ printPairingInfo(registration);
66
+
67
+ while (!shuttingDown) {
68
+ const cached = loadCompanionState();
69
+ const activeLaptopToken = safeText(cached?.laptopToken, 1000) || registration.laptopToken;
70
+ const connectionResult = await connectWebSocketOnce(activeLaptopToken);
71
+ if (shuttingDown) break;
72
+
73
+ const latest = loadCompanionState();
74
+ if (latest?.relayBaseUrl === relayBaseUrl && latest?.laptopToken) {
75
+ registration = {
76
+ ...registration,
77
+ laptopId: latest.laptopId || registration.laptopId,
78
+ deviceId: latest.deviceId || registration.deviceId,
79
+ laptopToken: latest.laptopToken,
80
+ pairCode: latest.pairCode || registration.pairCode,
81
+ pairingExpiresAt: latest.pairingExpiresAt || registration.pairingExpiresAt,
82
+ pairingUrl: latest.pairingUrl || registration.pairingUrl
83
+ };
84
+ } else if (connectionResult?.authRejected) {
85
+ registration = await getOrCreateLaptopRegistration();
86
+ printPairingInfo(registration);
87
+ }
88
+
89
+ const waitMs = reconnectDelayMs;
90
+ await sleep(waitMs);
91
+ reconnectDelayMs = Math.min(10_000, Math.round(reconnectDelayMs * 1.7));
92
+ }
93
+ }
94
+
95
+ async function getOrCreateLaptopRegistration() {
96
+ const cached = loadCompanionState();
97
+ if (cached && cached.relayBaseUrl === relayBaseUrl && cached.laptopToken) {
98
+ const reused = await fetchExistingLaptopRegistration(cached.laptopToken);
99
+ if (reused) {
100
+ persistCompanionState({
101
+ relayBaseUrl,
102
+ laptopId: reused.laptopId,
103
+ deviceId: reused.deviceId,
104
+ laptopToken: reused.laptopToken,
105
+ pairCode: reused.pairCode,
106
+ pairingExpiresAt: reused.pairingExpiresAt,
107
+ pairingUrl: reused.pairingUrl
108
+ });
109
+ return { ...reused, reused: true };
110
+ }
111
+ }
112
+
113
+ const created = await registerLaptopWithRetry();
114
+ persistCompanionState({
115
+ relayBaseUrl,
116
+ laptopId: created.laptopId,
117
+ deviceId: created.deviceId,
118
+ laptopToken: created.laptopToken,
119
+ pairCode: created.pairCode,
120
+ pairingExpiresAt: created.pairingExpiresAt,
121
+ pairingUrl: created.pairingUrl
122
+ });
123
+ return { ...created, reused: false };
124
+ }
125
+
126
+ async function fetchExistingLaptopRegistration(laptopToken) {
127
+ try {
128
+ const meResponse = await fetchWithTimeout(
129
+ `${relayBaseUrl}/api/laptops/me`,
130
+ {
131
+ method: "GET",
132
+ headers: {
133
+ Accept: "application/json",
134
+ Authorization: `Bearer ${laptopToken}`
135
+ }
136
+ },
137
+ 5000
138
+ );
139
+
140
+ if (!meResponse.ok) {
141
+ return null;
142
+ }
143
+
144
+ const me = await safeParseJson(meResponse);
145
+ if (!me?.laptopId || !me?.deviceId) {
146
+ return null;
147
+ }
148
+ const preferredLaptopToken = safeText(me?.laptopToken, 1000) || laptopToken;
149
+
150
+ const pairingResponse = await fetchWithTimeout(
151
+ `${relayBaseUrl}/api/laptops/pairing`,
152
+ {
153
+ method: "POST",
154
+ headers: {
155
+ "Content-Type": "application/json",
156
+ Accept: "application/json",
157
+ Authorization: `Bearer ${laptopToken}`
158
+ },
159
+ body: JSON.stringify({
160
+ force: false,
161
+ ...(wakeMacAddress ? { wakeMac: wakeMacAddress } : {})
162
+ })
163
+ },
164
+ 5000
165
+ );
166
+
167
+ if (!pairingResponse.ok) {
168
+ return null;
169
+ }
170
+
171
+ const pairing = await safeParseJson(pairingResponse);
172
+ if (!pairing?.pairCode) {
173
+ return null;
174
+ }
175
+
176
+ return {
177
+ laptopId: me.laptopId,
178
+ deviceId: me.deviceId,
179
+ laptopToken: safeText(pairing?.laptopToken, 1000) || preferredLaptopToken,
180
+ pairCode: pairing.pairCode,
181
+ pairingExpiresAt: pairing.pairingExpiresAt,
182
+ pairingUrl: pairing.pairingUrl,
183
+ pairingPayload: pairing.pairingPayload
184
+ };
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ async function registerLaptopWithRetry() {
191
+ const payload = {
192
+ name: safeText(args.name, 120) || os.hostname(),
193
+ hostname: os.hostname(),
194
+ platform: process.platform,
195
+ ...(wakeMacAddress ? { wakeMac: wakeMacAddress } : {})
196
+ };
197
+ let attempt = 0;
198
+ let reportedWarmup = false;
199
+
200
+ while (!shuttingDown) {
201
+ try {
202
+ attempt += 1;
203
+ const timeoutMs = Math.min(20_000, 8_000 + (attempt - 1) * 4_000);
204
+ const response = await fetchWithTimeout(
205
+ `${relayBaseUrl}/api/laptops/register`,
206
+ {
207
+ method: "POST",
208
+ headers: {
209
+ "Content-Type": "application/json",
210
+ Accept: "application/json"
211
+ },
212
+ body: JSON.stringify(payload)
213
+ },
214
+ timeoutMs
215
+ );
216
+
217
+ const body = await safeParseJson(response);
218
+ if (!response.ok) {
219
+ throw new Error(`register failed (${response.status}): ${JSON.stringify(body)}`);
220
+ }
221
+
222
+ assertRegistrationShape(body);
223
+ return body;
224
+ } catch (error) {
225
+ if (shuttingDown) break;
226
+ const message = String(error?.message || error);
227
+ if (isTransientTimeoutError(error)) {
228
+ if (!reportedWarmup) {
229
+ console.error("[companion] relay is waking up, retrying...");
230
+ reportedWarmup = true;
231
+ } else if (!quietLogs) {
232
+ console.error(`[companion] register retry ${attempt} timed out, retrying...`);
233
+ }
234
+ } else {
235
+ console.error(`[companion] register failed: ${message}`);
236
+ }
237
+ await sleep(registerRetryDelayMs);
238
+ }
239
+ }
240
+
241
+ throw new Error("registration aborted");
242
+ }
243
+
244
+ async function waitForRelayAvailability() {
245
+ const deadline = Date.now() + relayWarmupTimeoutMs;
246
+ let announced = false;
247
+
248
+ while (!shuttingDown && Date.now() < deadline) {
249
+ try {
250
+ const response = await fetchWithTimeout(
251
+ `${relayBaseUrl}/health`,
252
+ {
253
+ method: "GET",
254
+ headers: {
255
+ Accept: "application/json"
256
+ }
257
+ },
258
+ relayWarmupProbeTimeoutMs
259
+ );
260
+ if (response.ok) {
261
+ return true;
262
+ }
263
+ } catch {
264
+ // relay might still be cold-starting
265
+ }
266
+
267
+ if (!announced && !quietLogs) {
268
+ console.log("[companion] waiting for relay...");
269
+ announced = true;
270
+ }
271
+ await sleep(1200);
272
+ }
273
+
274
+ return false;
275
+ }
276
+
277
+ function printPairingInfo(registration) {
278
+ console.log("");
279
+ console.log("Agent Companion computer is ready.");
280
+ console.log(`Pairing code: ${registration.pairCode}`);
281
+ console.log("Enter this code in the app to pair.");
282
+ if (!quietLogs) {
283
+ if (wakeMacAddress) {
284
+ console.log(`Auto-wake MAC: ${wakeMacAddress}`);
285
+ } else {
286
+ console.log("Auto-wake: MAC not detected (set --wake-mac AA:BB:CC:DD:EE:FF)");
287
+ }
288
+ }
289
+ if (!quietLogs && registration.pairingUrl) {
290
+ console.log(`Pair URL: ${registration.pairingUrl}`);
291
+ }
292
+ console.log("");
293
+ }
294
+
295
+ function connectWebSocketOnce(laptopToken) {
296
+ const wsUrl = buildLaptopWsUrl(relayBaseUrl, laptopToken);
297
+
298
+ return new Promise((resolve) => {
299
+ const socket = new WebSocket(wsUrl);
300
+ websocket = socket;
301
+ let authRejected = false;
302
+
303
+ socket.on("open", () => {
304
+ reconnectDelayMs = 1200;
305
+ startSnapshotLoop(socket);
306
+ void pushSnapshot(socket);
307
+ if (!quietLogs) {
308
+ console.log("[companion] connected to relay");
309
+ }
310
+ });
311
+
312
+ socket.on("message", (chunk, isBinary) => {
313
+ if (isBinary) return;
314
+ let message;
315
+ try {
316
+ message = JSON.parse(chunk.toString());
317
+ } catch {
318
+ return;
319
+ }
320
+ if (!isObject(message)) return;
321
+ if (message.type === "welcome" && typeof message.laptopToken === "string" && message.laptopToken.trim()) {
322
+ const cached = loadCompanionState();
323
+ persistCompanionState({
324
+ ...(cached || {}),
325
+ relayBaseUrl,
326
+ laptopId: safeText(message.laptopId, 200) || cached?.laptopId || "",
327
+ deviceId: safeText(message.deviceId, 200) || cached?.deviceId || "",
328
+ laptopToken: safeText(message.laptopToken, 1000),
329
+ pairCode: cached?.pairCode || null,
330
+ pairingExpiresAt: cached?.pairingExpiresAt || null,
331
+ pairingUrl: cached?.pairingUrl || null
332
+ });
333
+ }
334
+ if (message.type === "rpc_request" && typeof message.id === "string") {
335
+ void handleRpcRequest(socket, message);
336
+ }
337
+ });
338
+
339
+ socket.on("close", (code, reasonRaw) => {
340
+ if (websocket === socket) {
341
+ websocket = null;
342
+ }
343
+ stopSnapshotLoop();
344
+
345
+ const reason = Buffer.isBuffer(reasonRaw) ? reasonRaw.toString("utf8") : String(reasonRaw || "");
346
+ if (!quietLogs) {
347
+ console.error(`[companion] websocket closed (${code})${reason ? ` ${reason}` : ""}`);
348
+ } else if (!shuttingDown) {
349
+ console.error("[companion] disconnected from relay, retrying...");
350
+ }
351
+ resolve({ authRejected });
352
+ });
353
+
354
+ socket.on("error", (error) => {
355
+ const message = String(error?.message || error || "");
356
+ if (message.includes("401") || message.includes("403")) {
357
+ authRejected = true;
358
+ }
359
+ if (!quietLogs) {
360
+ console.error(`[companion] websocket error: ${message}`);
361
+ }
362
+ });
363
+ });
364
+ }
365
+
366
+ async function handleRpcRequest(socket, message) {
367
+ const id = String(message.id || "").trim();
368
+ if (!id) return;
369
+
370
+ const request = isObject(message.request) ? message.request : {};
371
+ const method = normalizeMethod(request.method);
372
+ const relayPath = normalizeRelayPath(request.path);
373
+ const headers = sanitizeHeaders(request.headers);
374
+
375
+ try {
376
+ const rpcResult = await forwardToLocalBridge({
377
+ method,
378
+ path: relayPath,
379
+ headers,
380
+ body: request.body
381
+ });
382
+
383
+ sendWsJson(socket, {
384
+ type: "rpc_response",
385
+ id,
386
+ ...rpcResult
387
+ });
388
+ } catch (error) {
389
+ sendWsJson(socket, {
390
+ type: "rpc_response",
391
+ id,
392
+ ok: false,
393
+ status: 502,
394
+ bodyType: "json",
395
+ body: {
396
+ ok: false,
397
+ error: String(error?.message || error)
398
+ },
399
+ error: String(error?.message || error)
400
+ });
401
+ }
402
+ }
403
+
404
+ async function forwardToLocalBridge(input) {
405
+ if (input.path.startsWith("/__relay/preview/proxy")) {
406
+ return forwardPreviewTargetRequest(input);
407
+ }
408
+
409
+ const healthy = await ensureBridgeAvailable();
410
+ if (!healthy) {
411
+ return {
412
+ ok: false,
413
+ status: 503,
414
+ bodyType: "json",
415
+ body: {
416
+ ok: false,
417
+ error: "local bridge is unavailable"
418
+ },
419
+ error: "local bridge is unavailable"
420
+ };
421
+ }
422
+
423
+ const headers = {
424
+ Accept: "application/json",
425
+ ...input.headers
426
+ };
427
+
428
+ if (bridgeToken && input.path.startsWith("/api/launcher")) {
429
+ headers["x-bridge-token"] = bridgeToken;
430
+ }
431
+
432
+ let body = undefined;
433
+ if (input.body !== undefined && input.body !== null && input.method !== "GET" && input.method !== "HEAD") {
434
+ body = JSON.stringify(input.body);
435
+ if (!hasHeader(headers, "content-type")) {
436
+ headers["Content-Type"] = "application/json";
437
+ }
438
+ }
439
+
440
+ const response = await fetchWithTimeout(
441
+ `${bridgeBaseUrl}${input.path}`,
442
+ {
443
+ method: input.method,
444
+ headers,
445
+ body
446
+ },
447
+ rpcTimeoutMs
448
+ );
449
+
450
+ const parsed = await parseResponseBody(response);
451
+ return {
452
+ ok: response.ok,
453
+ status: response.status,
454
+ bodyType: parsed.bodyType,
455
+ body: parsed.body,
456
+ bodyEncoding: parsed.bodyEncoding || null,
457
+ responseHeaders: parsed.responseHeaders || null,
458
+ error: response.ok ? null : extractErrorMessage(parsed.body)
459
+ };
460
+ }
461
+
462
+ async function forwardPreviewTargetRequest(input) {
463
+ const relayPath = normalizeRelayPath(input.path);
464
+ const parsedRelayPath = new URL(relayPath, "http://relay.local");
465
+ const previewTarget = normalizePreviewTarget(parsedRelayPath.searchParams.get("target"));
466
+ const forwardedPath = normalizePreviewRequestPath(parsedRelayPath.searchParams.get("path"));
467
+
468
+ if (!previewTarget || !forwardedPath) {
469
+ return {
470
+ ok: false,
471
+ status: 400,
472
+ bodyType: "json",
473
+ body: {
474
+ ok: false,
475
+ error: "invalid preview target"
476
+ },
477
+ error: "invalid preview target"
478
+ };
479
+ }
480
+
481
+ const candidateUrls = resolvePreviewCandidateUrls(previewTarget, forwardedPath);
482
+ if (!candidateUrls.length) {
483
+ return {
484
+ ok: false,
485
+ status: 400,
486
+ bodyType: "json",
487
+ body: {
488
+ ok: false,
489
+ error: "invalid preview path"
490
+ },
491
+ error: "invalid preview path"
492
+ };
493
+ }
494
+
495
+ const headers = {
496
+ Accept: "*/*",
497
+ ...sanitizePreviewHeaders(input.headers)
498
+ };
499
+
500
+ let body = undefined;
501
+ if (input.body !== undefined && input.body !== null && input.method !== "GET" && input.method !== "HEAD") {
502
+ if (typeof input.body === "string" || input.body instanceof String) {
503
+ body = String(input.body);
504
+ } else {
505
+ body = JSON.stringify(input.body);
506
+ if (!hasHeader(headers, "content-type")) {
507
+ headers["Content-Type"] = "application/json";
508
+ }
509
+ }
510
+ }
511
+
512
+ const timeoutMs = Math.max(15_000, rpcTimeoutMs);
513
+ const perAttemptTimeoutMs = Math.max(1500, Math.floor(timeoutMs / Math.max(1, candidateUrls.length)));
514
+ let lastError = "fetch failed";
515
+
516
+ for (const candidateUrl of candidateUrls) {
517
+ try {
518
+ const response = await fetchWithTimeout(
519
+ candidateUrl,
520
+ {
521
+ method: input.method,
522
+ headers,
523
+ body
524
+ },
525
+ perAttemptTimeoutMs
526
+ );
527
+
528
+ const parsed = await parseResponseBody(response);
529
+ return {
530
+ ok: response.ok,
531
+ status: response.status,
532
+ bodyType: parsed.bodyType,
533
+ body: parsed.body,
534
+ bodyEncoding: parsed.bodyEncoding || null,
535
+ responseHeaders: parsed.responseHeaders || null,
536
+ error: response.ok ? null : extractErrorMessage(parsed.body)
537
+ };
538
+ } catch (error) {
539
+ lastError = formatPreviewFetchError(error, candidateUrl);
540
+ }
541
+ }
542
+
543
+ return {
544
+ ok: false,
545
+ status: 502,
546
+ bodyType: "json",
547
+ body: {
548
+ ok: false,
549
+ error: lastError
550
+ },
551
+ error: lastError
552
+ };
553
+ }
554
+
555
+ function startSnapshotLoop(socket) {
556
+ stopSnapshotLoop();
557
+ snapshotTimer = setInterval(() => {
558
+ void pushSnapshot(socket);
559
+ }, snapshotIntervalMs);
560
+ snapshotTimer.unref?.();
561
+ }
562
+
563
+ function stopSnapshotLoop() {
564
+ if (!snapshotTimer) return;
565
+ clearInterval(snapshotTimer);
566
+ snapshotTimer = null;
567
+ }
568
+
569
+ async function pushSnapshot(socket) {
570
+ if (snapshotInFlight) return;
571
+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
572
+
573
+ snapshotInFlight = true;
574
+ try {
575
+ const healthy = await ensureBridgeAvailable();
576
+ if (!healthy) return;
577
+
578
+ const response = await fetchWithTimeout(
579
+ `${bridgeBaseUrl}/api/bootstrap`,
580
+ {
581
+ method: "GET",
582
+ headers: { Accept: "application/json" }
583
+ },
584
+ Math.min(7000, rpcTimeoutMs)
585
+ );
586
+
587
+ if (!response.ok) return;
588
+ const snapshot = await response.json().catch(() => null);
589
+ if (!isObject(snapshot)) return;
590
+
591
+ sendWsJson(socket, {
592
+ type: "snapshot",
593
+ snapshot
594
+ });
595
+ } catch {
596
+ // periodic sync keeps retrying
597
+ } finally {
598
+ snapshotInFlight = false;
599
+ }
600
+ }
601
+
602
+ async function ensureBridgeAvailable() {
603
+ if (await isBridgeHealthy()) {
604
+ return true;
605
+ }
606
+
607
+ if (ensureBridgePromise) {
608
+ return ensureBridgePromise;
609
+ }
610
+
611
+ ensureBridgePromise = (async () => {
612
+ const probeDeadline = Date.now() + bridgeProbeBeforeSpawnMs;
613
+ while (Date.now() < probeDeadline) {
614
+ if (await isBridgeHealthy()) return true;
615
+ await sleep(250);
616
+ }
617
+
618
+ if (!(await isBridgeHealthy()) && shouldAutoStartLocalBridge(bridgeBaseUrl)) {
619
+ startBridgeIfNeeded(bridgeBaseUrl);
620
+ }
621
+
622
+ const deadline = Date.now() + bridgeStartupTimeoutMs;
623
+ while (Date.now() < deadline) {
624
+ if (await isBridgeHealthy()) return true;
625
+ await sleep(300);
626
+ }
627
+
628
+ return false;
629
+ })();
630
+
631
+ try {
632
+ return await ensureBridgePromise;
633
+ } finally {
634
+ ensureBridgePromise = null;
635
+ }
636
+ }
637
+
638
+ function startBridgeIfNeeded(targetBridgeUrl) {
639
+ if (!fs.existsSync(bridgeScript)) {
640
+ console.error(`[companion] cannot auto-start bridge: missing ${bridgeScript}`);
641
+ return;
642
+ }
643
+
644
+ if (bridgeChild && bridgeChild.exitCode === null) {
645
+ return;
646
+ }
647
+
648
+ const port = resolveBridgePort(targetBridgeUrl);
649
+ if (!quietLogs) {
650
+ console.log(`[companion] local bridge unreachable, starting bridge/server.mjs on port ${port}`);
651
+ }
652
+
653
+ const childEnv = {
654
+ ...process.env,
655
+ AGENT_BRIDGE_PORT: String(port)
656
+ };
657
+
658
+ if (bridgeToken && !childEnv.AGENT_BRIDGE_TOKEN) {
659
+ childEnv.AGENT_BRIDGE_TOKEN = bridgeToken;
660
+ }
661
+
662
+ bridgeChild = spawn(process.execPath, [bridgeScript], {
663
+ cwd: projectRoot,
664
+ env: childEnv,
665
+ stdio: ["ignore", "pipe", "pipe"]
666
+ });
667
+ bridgeStartedByCompanion = true;
668
+
669
+ if (!quietLogs) {
670
+ bridgeChild.stdout.on("data", (chunk) => {
671
+ process.stdout.write(`[local-bridge] ${chunk}`);
672
+ });
673
+
674
+ bridgeChild.stderr.on("data", (chunk) => {
675
+ process.stderr.write(`[local-bridge] ${chunk}`);
676
+ });
677
+ }
678
+
679
+ bridgeChild.on("close", (code) => {
680
+ if (shuttingDown) return;
681
+ console.error(`[companion] local bridge process exited with code ${code}`);
682
+ });
683
+ }
684
+
685
+ function shouldAutoStartLocalBridge(bridgeUrl) {
686
+ try {
687
+ const parsed = new URL(bridgeUrl);
688
+ const host = String(parsed.hostname || "").toLowerCase();
689
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
690
+ } catch {
691
+ return false;
692
+ }
693
+ }
694
+
695
+ function resolveBridgePort(bridgeUrl) {
696
+ try {
697
+ const parsed = new URL(bridgeUrl);
698
+ if (parsed.port) {
699
+ const numeric = toInt(parsed.port, 8787);
700
+ if (numeric > 0 && numeric < 65536) return numeric;
701
+ }
702
+ return parsed.protocol === "https:" ? 443 : 80;
703
+ } catch {
704
+ return toInt(process.env.AGENT_BRIDGE_PORT, 8787);
705
+ }
706
+ }
707
+
708
+ async function isBridgeHealthy() {
709
+ try {
710
+ const response = await fetchWithTimeout(
711
+ `${bridgeBaseUrl}/health`,
712
+ {
713
+ method: "GET",
714
+ headers: { Accept: "application/json" }
715
+ },
716
+ 1500
717
+ );
718
+ return response.ok;
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+
724
+ function sendWsJson(socket, payload) {
725
+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
726
+ socket.send(JSON.stringify(payload));
727
+ }
728
+
729
+ async function parseResponseBody(response) {
730
+ const responseHeaders = extractResponseHeaders(response.headers);
731
+ if (response.status === 204 || response.status === 205) {
732
+ return { bodyType: "empty", body: null, responseHeaders };
733
+ }
734
+
735
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
736
+ const buffer = Buffer.from(await response.arrayBuffer());
737
+ if (!buffer.length) {
738
+ return { bodyType: "empty", body: null, responseHeaders };
739
+ }
740
+
741
+ if (shouldTreatAsText(contentType)) {
742
+ const text = buffer.toString("utf8");
743
+ if (contentType.includes("application/json")) {
744
+ try {
745
+ return { bodyType: "json", body: JSON.parse(text), responseHeaders };
746
+ } catch {
747
+ return { bodyType: "text", body: text, responseHeaders };
748
+ }
749
+ }
750
+
751
+ try {
752
+ return { bodyType: "json", body: JSON.parse(text), responseHeaders };
753
+ } catch {
754
+ return { bodyType: "text", body: text, responseHeaders };
755
+ }
756
+ }
757
+
758
+ return {
759
+ bodyType: "base64",
760
+ bodyEncoding: "base64",
761
+ body: buffer.toString("base64"),
762
+ responseHeaders
763
+ };
764
+ }
765
+
766
+ function shouldTreatAsText(contentType) {
767
+ if (!contentType) return true;
768
+ return (
769
+ contentType.startsWith("text/") ||
770
+ contentType.includes("application/json") ||
771
+ contentType.includes("application/javascript") ||
772
+ contentType.includes("application/xml") ||
773
+ contentType.includes("application/xhtml+xml") ||
774
+ contentType.includes("application/x-www-form-urlencoded") ||
775
+ contentType.includes("application/graphql") ||
776
+ contentType.includes("image/svg+xml")
777
+ );
778
+ }
779
+
780
+ function extractResponseHeaders(headers) {
781
+ const allowed = [
782
+ "content-type",
783
+ "cache-control",
784
+ "etag",
785
+ "last-modified",
786
+ "expires",
787
+ "vary",
788
+ "pragma",
789
+ "x-powered-by"
790
+ ];
791
+ const out = {};
792
+ for (const name of allowed) {
793
+ const value = headers.get(name);
794
+ if (value) out[name] = value;
795
+ }
796
+ return out;
797
+ }
798
+
799
+ function normalizePreviewTarget(value) {
800
+ const raw = safeText(value, 2000);
801
+ if (!raw) return "";
802
+ let parsed;
803
+ try {
804
+ parsed = new URL(raw);
805
+ } catch {
806
+ return "";
807
+ }
808
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
809
+ const host = String(parsed.hostname || "").toLowerCase();
810
+ if (host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "0.0.0.0") return "";
811
+
812
+ const normalizedHost = host === "localhost" || host === "::1" || host === "0.0.0.0" ? "127.0.0.1" : host;
813
+ const normalized = new URL(`${parsed.protocol}//${normalizedHost}`);
814
+ if (parsed.port) {
815
+ const port = toInt(parsed.port, 0);
816
+ if (port <= 0 || port > 65535) return "";
817
+ normalized.port = String(port);
818
+ }
819
+ return trimTrailingSlash(normalized.toString());
820
+ }
821
+
822
+ function normalizePreviewRequestPath(value) {
823
+ const path = safeText(value, 5000);
824
+ if (!path) return "/";
825
+ return path.startsWith("/") ? path : `/${path}`;
826
+ }
827
+
828
+ function resolvePreviewCandidateUrls(previewTarget, requestPath) {
829
+ try {
830
+ const targetBase = new URL(previewTarget);
831
+ const isLoopbackHost = (host) => host === "127.0.0.1" || host === "localhost" || host === "::1";
832
+
833
+ const hosts = [targetBase.hostname, "127.0.0.1", "localhost", "::1"]
834
+ .map((host) => String(host || "").trim().toLowerCase())
835
+ .filter((host) => isLoopbackHost(host))
836
+ .filter((host, index, all) => all.indexOf(host) === index);
837
+ if (!hosts.length) return [];
838
+
839
+ const protocols = [targetBase.protocol];
840
+ if (targetBase.protocol === "https:") {
841
+ protocols.push("http:");
842
+ }
843
+
844
+ const hasExplicitPort = Boolean(targetBase.port);
845
+ const defaultPort = targetBase.protocol === "https:" ? "443" : "80";
846
+ const commonDevPorts = ["5173", "3000", "8080", "4173", "4200", "8000", "8888"];
847
+ const ports = hasExplicitPort
848
+ ? [targetBase.port]
849
+ : [defaultPort, ...commonDevPorts].filter((port, index, all) => all.indexOf(port) === index);
850
+ const out = [];
851
+
852
+ for (const protocol of protocols) {
853
+ for (const host of hosts) {
854
+ for (const port of ports) {
855
+ const hostForUrl = host === "::1" ? "[::1]" : host;
856
+ const base = `${protocol}//${hostForUrl}:${port}`;
857
+ const candidate = new URL(requestPath, base);
858
+ if (candidate.protocol !== protocol || !isLoopbackHost(candidate.hostname.toLowerCase())) {
859
+ continue;
860
+ }
861
+ out.push(candidate.toString());
862
+ }
863
+ }
864
+ }
865
+
866
+ return out.filter((url, index, all) => all.indexOf(url) === index);
867
+ } catch {
868
+ return [];
869
+ }
870
+ }
871
+
872
+ function formatPreviewFetchError(error, attemptedUrl) {
873
+ const baseMessage = String(error?.message || error || "fetch failed");
874
+ const cause = error && typeof error === "object" ? error.cause : null;
875
+ const causeCode = cause && typeof cause === "object" ? safeText(cause.code, 80) : "";
876
+ const causeMessage = cause && typeof cause === "object" ? safeText(cause.message, 240) : "";
877
+
878
+ const detail = [causeCode, causeMessage].filter(Boolean).join(" ");
879
+ if (detail) {
880
+ return `${baseMessage} (${detail}) at ${attemptedUrl}`;
881
+ }
882
+ return `${baseMessage} at ${attemptedUrl}`;
883
+ }
884
+
885
+ function sanitizePreviewHeaders(input) {
886
+ if (!isObject(input)) return {};
887
+ const out = {};
888
+ for (const [key, value] of Object.entries(input)) {
889
+ if (typeof value !== "string") continue;
890
+ const lower = key.toLowerCase();
891
+ if (
892
+ lower === "host" ||
893
+ lower === "content-length" ||
894
+ lower === "connection" ||
895
+ lower === "transfer-encoding" ||
896
+ lower === "upgrade" ||
897
+ lower === "authorization" ||
898
+ lower === "x-phone-token" ||
899
+ lower === "x-laptop-token"
900
+ ) {
901
+ continue;
902
+ }
903
+ out[key] = value;
904
+ }
905
+ return out;
906
+ }
907
+
908
+ function buildLaptopWsUrl(baseUrl, laptopToken) {
909
+ const parsed = new URL(baseUrl);
910
+ parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
911
+ parsed.pathname = "/ws/laptop";
912
+ parsed.search = "";
913
+ parsed.searchParams.set("token", laptopToken);
914
+ return parsed.toString();
915
+ }
916
+
917
+ function normalizeRelayPath(inputPath) {
918
+ const asText = safeText(inputPath, 12_000) || "/";
919
+ if (asText.startsWith("/")) return asText;
920
+ return `/${asText}`;
921
+ }
922
+
923
+ function normalizeMethod(value) {
924
+ const candidate = String(value || "GET")
925
+ .trim()
926
+ .toUpperCase();
927
+ if (candidate === "POST" || candidate === "PUT" || candidate === "PATCH" || candidate === "DELETE" || candidate === "HEAD") {
928
+ return candidate;
929
+ }
930
+ return "GET";
931
+ }
932
+
933
+ function sanitizeHeaders(input) {
934
+ if (!isObject(input)) return {};
935
+ const out = {};
936
+
937
+ for (const [key, value] of Object.entries(input)) {
938
+ if (typeof value !== "string") continue;
939
+ const lower = key.toLowerCase();
940
+ if (lower === "host" || lower === "content-length" || lower === "connection") continue;
941
+ out[key] = value;
942
+ }
943
+
944
+ return out;
945
+ }
946
+
947
+ function hasHeader(headers, headerName) {
948
+ const target = String(headerName || "").toLowerCase();
949
+ return Object.keys(headers || {}).some((key) => key.toLowerCase() === target);
950
+ }
951
+
952
+ function extractErrorMessage(payload) {
953
+ if (isObject(payload) && typeof payload.error === "string") {
954
+ return payload.error;
955
+ }
956
+ if (typeof payload === "string") {
957
+ return payload.slice(0, 500);
958
+ }
959
+ return "request failed";
960
+ }
961
+
962
+ async function safeParseJson(response) {
963
+ const text = await response.text();
964
+ if (!text) return {};
965
+ try {
966
+ return JSON.parse(text);
967
+ } catch {
968
+ return { raw: text };
969
+ }
970
+ }
971
+
972
+ function assertRegistrationShape(payload) {
973
+ const requiredKeys = ["laptopId", "deviceId", "laptopToken", "pairCode", "pairingExpiresAt"];
974
+ for (const key of requiredKeys) {
975
+ if (!payload || payload[key] === undefined || payload[key] === null || payload[key] === "") {
976
+ throw new Error(`register response missing "${key}"`);
977
+ }
978
+ }
979
+ }
980
+
981
+ function loadCompanionState() {
982
+ try {
983
+ if (!fs.existsSync(companionStateFile)) return null;
984
+ const raw = JSON.parse(fs.readFileSync(companionStateFile, "utf8"));
985
+ if (!isObject(raw)) return null;
986
+ return {
987
+ relayBaseUrl: trimTrailingSlash(raw.relayBaseUrl || ""),
988
+ laptopId: safeText(raw.laptopId, 200),
989
+ deviceId: safeText(raw.deviceId, 200),
990
+ laptopToken: safeText(raw.laptopToken, 500),
991
+ pairCode: safeText(raw.pairCode, 32) || null,
992
+ pairingExpiresAt: raw.pairingExpiresAt ? toInt(raw.pairingExpiresAt, null) : null,
993
+ pairingUrl: safeText(raw.pairingUrl, 2000) || null,
994
+ updatedAt: raw.updatedAt ? toInt(raw.updatedAt, Date.now()) : Date.now()
995
+ };
996
+ } catch {
997
+ return null;
998
+ }
999
+ }
1000
+
1001
+ function persistCompanionState(next) {
1002
+ try {
1003
+ const payload = {
1004
+ relayBaseUrl: trimTrailingSlash(next?.relayBaseUrl || relayBaseUrl),
1005
+ laptopId: safeText(next?.laptopId, 200),
1006
+ deviceId: safeText(next?.deviceId, 200),
1007
+ laptopToken: safeText(next?.laptopToken, 500),
1008
+ pairCode: safeText(next?.pairCode, 32) || null,
1009
+ pairingExpiresAt: next?.pairingExpiresAt ? toInt(next.pairingExpiresAt, Date.now()) : null,
1010
+ pairingUrl: safeText(next?.pairingUrl, 2000) || null,
1011
+ wakeMac: wakeMacAddress || null,
1012
+ updatedAt: Date.now()
1013
+ };
1014
+
1015
+ fs.mkdirSync(path.dirname(companionStateFile), { recursive: true });
1016
+ fs.writeFileSync(companionStateFile, JSON.stringify(payload, null, 2));
1017
+ } catch (error) {
1018
+ console.error(`[companion] failed to persist state: ${String(error?.message || error)}`);
1019
+ }
1020
+ }
1021
+
1022
+ async function fetchWithTimeout(url, options, timeoutMs) {
1023
+ const controller = new AbortController();
1024
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1025
+ try {
1026
+ return await fetch(url, {
1027
+ ...options,
1028
+ signal: controller.signal
1029
+ });
1030
+ } catch (error) {
1031
+ if (isAbortError(error)) {
1032
+ throw new Error(`request timed out after ${timeoutMs}ms`);
1033
+ }
1034
+ throw error;
1035
+ } finally {
1036
+ clearTimeout(timer);
1037
+ }
1038
+ }
1039
+
1040
+ function isAbortError(error) {
1041
+ if (!error) return false;
1042
+ if (error?.name === "AbortError") return true;
1043
+ const message = String(error?.message || error).toLowerCase();
1044
+ return message.includes("aborted");
1045
+ }
1046
+
1047
+ function isTransientTimeoutError(error) {
1048
+ const message = String(error?.message || error).toLowerCase();
1049
+ return isAbortError(error) || message.includes("timed out");
1050
+ }
1051
+
1052
+ function parseArgs(argsInput) {
1053
+ const out = {};
1054
+
1055
+ for (let index = 0; index < argsInput.length; index += 1) {
1056
+ const arg = argsInput[index];
1057
+ if (!arg.startsWith("--")) continue;
1058
+
1059
+ const key = arg.slice(2);
1060
+ const next = argsInput[index + 1];
1061
+
1062
+ if (!next || next.startsWith("--")) {
1063
+ out[key] = "true";
1064
+ continue;
1065
+ }
1066
+
1067
+ out[key] = next;
1068
+ index += 1;
1069
+ }
1070
+
1071
+ return out;
1072
+ }
1073
+
1074
+ async function shutdown(signal) {
1075
+ if (shuttingDown) return;
1076
+ shuttingDown = true;
1077
+
1078
+ if (!quietLogs) {
1079
+ console.log(`[companion] received ${signal}, shutting down`);
1080
+ }
1081
+ stopSnapshotLoop();
1082
+
1083
+ if (websocket && websocket.readyState === WebSocket.OPEN) {
1084
+ websocket.close(1000, "shutdown");
1085
+ }
1086
+
1087
+ if (bridgeStartedByCompanion && bridgeChild && bridgeChild.exitCode === null) {
1088
+ bridgeChild.kill("SIGTERM");
1089
+ setTimeout(() => {
1090
+ if (bridgeChild && bridgeChild.exitCode === null) {
1091
+ bridgeChild.kill("SIGKILL");
1092
+ }
1093
+ }, 1500).unref();
1094
+ }
1095
+
1096
+ await sleep(100);
1097
+ process.exit(0);
1098
+ }
1099
+
1100
+ function sleep(ms) {
1101
+ return new Promise((resolve) => setTimeout(resolve, ms));
1102
+ }
1103
+
1104
+ function isObject(value) {
1105
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1106
+ }
1107
+
1108
+ function toInt(value, fallback = 0) {
1109
+ const parsed = Number.parseInt(String(value), 10);
1110
+ return Number.isFinite(parsed) ? parsed : fallback;
1111
+ }
1112
+
1113
+ function clamp(value, min, max) {
1114
+ return Math.max(min, Math.min(max, value));
1115
+ }
1116
+
1117
+ function safeText(value, maxLength) {
1118
+ if (typeof value !== "string") return "";
1119
+ const trimmed = value.trim();
1120
+ if (!trimmed) return "";
1121
+ return trimmed.length <= maxLength ? trimmed : trimmed.slice(0, maxLength);
1122
+ }
1123
+
1124
+ function trimTrailingSlash(value) {
1125
+ const trimmed = String(value || "").trim();
1126
+ if (!trimmed) return "";
1127
+ return trimmed.replace(/\/+$/, "");
1128
+ }
1129
+
1130
+ function detectPrimaryWakeMacAddress() {
1131
+ const interfaces = os.networkInterfaces();
1132
+ const preferredPrefix = [
1133
+ "en",
1134
+ "eth",
1135
+ "wlan",
1136
+ "wi-fi",
1137
+ "wifi",
1138
+ "wl",
1139
+ "thunderbolt"
1140
+ ];
1141
+ const candidates = [];
1142
+
1143
+ for (const [ifaceName, entries] of Object.entries(interfaces || {})) {
1144
+ if (!Array.isArray(entries) || !entries.length) continue;
1145
+ const preferred = preferredPrefix.some((prefix) => ifaceName.toLowerCase().startsWith(prefix));
1146
+
1147
+ for (const entry of entries) {
1148
+ if (!entry || entry.internal) continue;
1149
+ const normalizedMac = normalizeMacAddress(entry.mac);
1150
+ if (!normalizedMac) continue;
1151
+ candidates.push({
1152
+ ifaceName,
1153
+ normalizedMac,
1154
+ preferred
1155
+ });
1156
+ break;
1157
+ }
1158
+ }
1159
+
1160
+ if (!candidates.length) return "";
1161
+
1162
+ const preferredCandidate = candidates.find((item) => item.preferred);
1163
+ if (preferredCandidate) {
1164
+ return preferredCandidate.normalizedMac;
1165
+ }
1166
+
1167
+ return candidates[0].normalizedMac;
1168
+ }
1169
+
1170
+ function normalizeMacAddress(value) {
1171
+ const raw = String(value || "")
1172
+ .trim()
1173
+ .toUpperCase()
1174
+ .replace(/[^0-9A-F]/g, "");
1175
+ if (raw.length !== 12) return "";
1176
+ if (raw === "000000000000" || raw === "FFFFFFFFFFFF") return "";
1177
+ const chunks = raw.match(/.{1,2}/g);
1178
+ return chunks ? chunks.join(":") : "";
1179
+ }