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.
- package/README.md +401 -0
- package/bridge/defaultState.mjs +504 -0
- package/bridge/directIngest.mjs +712 -0
- package/bridge/server.mjs +2812 -0
- package/package.json +86 -0
- package/relay/server.mjs +2056 -0
- package/scripts/add-pending.mjs +51 -0
- package/scripts/agent-runner.mjs +1475 -0
- package/scripts/background-service.mjs +122 -0
- package/scripts/banner.mjs +36 -0
- package/scripts/cli.mjs +77 -0
- package/scripts/dev-stack.mjs +64 -0
- package/scripts/laptop-companion.mjs +1179 -0
- package/scripts/laptop-service.mjs +282 -0
- package/scripts/repair-codex-resume.mjs +300 -0
- package/scripts/reset-bridge.mjs +19 -0
- package/scripts/start-task.mjs +108 -0
- package/scripts/ui-claude-delegate.mjs +220 -0
- package/wake-proxy/server.mjs +162 -0
package/relay/server.mjs
ADDED
|
@@ -0,0 +1,2056 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
|
14
|
+
|
|
15
|
+
const RELAY_PORT = toInt(process.env.PORT || process.env.RELAY_PORT, 9797);
|
|
16
|
+
const RELAY_PUBLIC_URL = trimTrailingSlash(
|
|
17
|
+
process.env.RELAY_PUBLIC_URL || process.env.RENDER_EXTERNAL_URL || `http://localhost:${RELAY_PORT}`
|
|
18
|
+
);
|
|
19
|
+
const RELAY_TOKEN_SECRET = resolveRelayTokenSecret();
|
|
20
|
+
const RELAY_WAKE_PROXY_URL = trimTrailingSlash(process.env.RELAY_WAKE_PROXY_URL || process.env.WAKE_PROXY_URL || "");
|
|
21
|
+
const RELAY_WAKE_PROXY_TOKEN = safeText(process.env.RELAY_WAKE_PROXY_TOKEN || process.env.WAKE_PROXY_TOKEN || "", 500);
|
|
22
|
+
const RELAY_WAKE_TIMEOUT_MS = clamp(toInt(process.env.RELAY_WAKE_TIMEOUT_MS, 90_000), 5_000, 5 * 60 * 1000);
|
|
23
|
+
const RELAY_WAKE_POLL_INTERVAL_MS = clamp(toInt(process.env.RELAY_WAKE_POLL_INTERVAL_MS, 1200), 250, 10_000);
|
|
24
|
+
const RELAY_WAKE_REQUEST_TIMEOUT_MS = clamp(toInt(process.env.RELAY_WAKE_REQUEST_TIMEOUT_MS, 6000), 1000, 60_000);
|
|
25
|
+
const PAIRING_TTL_MS = clamp(toInt(process.env.PAIRING_TTL_MS, 10 * 60 * 1000), 30_000, 24 * 60 * 60 * 1000);
|
|
26
|
+
const RPC_TIMEOUT_MS = clamp(toInt(process.env.RELAY_RPC_TIMEOUT_MS, 15_000), 500, 60_000);
|
|
27
|
+
const PREVIEW_DEFAULT_TTL_MS = clamp(toInt(process.env.RELAY_PREVIEW_TTL_MS, 2 * 60 * 60 * 1000), 60_000, 7 * 24 * 60 * 60 * 1000);
|
|
28
|
+
const PREVIEW_MAX_TTL_MS = clamp(
|
|
29
|
+
toInt(process.env.RELAY_PREVIEW_MAX_TTL_MS, 24 * 60 * 60 * 1000),
|
|
30
|
+
PREVIEW_DEFAULT_TTL_MS,
|
|
31
|
+
14 * 24 * 60 * 60 * 1000
|
|
32
|
+
);
|
|
33
|
+
const PREVIEW_RPC_TIMEOUT_MS = clamp(toInt(process.env.RELAY_PREVIEW_RPC_TIMEOUT_MS, 30_000), 2000, 120_000);
|
|
34
|
+
const CLEANUP_INTERVAL_MS = 30_000;
|
|
35
|
+
const MAX_LAPTOP_RECORDS = 500;
|
|
36
|
+
const MAX_PHONE_TOKENS = 2_000;
|
|
37
|
+
const MAX_PREVIEW_RECORDS = 4_000;
|
|
38
|
+
const PHONE_TOKEN_HEADER = "x-agent-companion-phone-token";
|
|
39
|
+
const LAPTOP_TOKEN_HEADER = "x-agent-companion-laptop-token";
|
|
40
|
+
|
|
41
|
+
const STATE_FILE = path.resolve(PROJECT_ROOT, "relay", "state.json");
|
|
42
|
+
const app = express();
|
|
43
|
+
const server = createServer(app);
|
|
44
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
45
|
+
|
|
46
|
+
let state = loadState();
|
|
47
|
+
let persistTimer = null;
|
|
48
|
+
let shuttingDown = false;
|
|
49
|
+
|
|
50
|
+
const laptopSockets = new Map();
|
|
51
|
+
const pendingRpcs = new Map();
|
|
52
|
+
const pendingWakeAttempts = new Map();
|
|
53
|
+
|
|
54
|
+
app.use(
|
|
55
|
+
cors({
|
|
56
|
+
exposedHeaders: [PHONE_TOKEN_HEADER, LAPTOP_TOKEN_HEADER]
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
app.use(express.json({ limit: "2mb" }));
|
|
60
|
+
|
|
61
|
+
if (!RELAY_TOKEN_SECRET) {
|
|
62
|
+
console.warn("[relay] durable token secret missing; pairing may break after relay restarts");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
app.get("/health", (_req, res) => {
|
|
66
|
+
cleanupExpiredPairings();
|
|
67
|
+
cleanupExpiredPreviews();
|
|
68
|
+
const onlineLaptops = [...laptopSockets.values()].filter((ws) => ws.readyState === WebSocket.OPEN).length;
|
|
69
|
+
const activePairings = state.pairings.filter((item) => !item.claimedAt && item.expiresAt > Date.now()).length;
|
|
70
|
+
const activePreviews = state.previews.filter((item) => item.expiresAt > Date.now()).length;
|
|
71
|
+
|
|
72
|
+
res.json({
|
|
73
|
+
ok: true,
|
|
74
|
+
service: "agent-relay",
|
|
75
|
+
onlineLaptops,
|
|
76
|
+
totalLaptops: state.laptops.length,
|
|
77
|
+
activePairings,
|
|
78
|
+
activePreviews,
|
|
79
|
+
issuedPhoneTokens: state.phones.length,
|
|
80
|
+
wakeProxyEnabled: Boolean(RELAY_WAKE_PROXY_URL)
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
app.get("/pair", (req, res) => {
|
|
85
|
+
const code = normalizePairCode(req.query?.code);
|
|
86
|
+
const pairing = code ? findPairingByCode(code) : null;
|
|
87
|
+
|
|
88
|
+
const body = {
|
|
89
|
+
ok: true,
|
|
90
|
+
message: "Use this code in the Agent Companion app to pair your laptop.",
|
|
91
|
+
code: code || null,
|
|
92
|
+
valid: Boolean(pairing && pairing.expiresAt > Date.now()),
|
|
93
|
+
claimed: Boolean(pairing?.claimedAt),
|
|
94
|
+
expiresAt: pairing?.expiresAt ?? null
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return res.json(body);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
app.post("/api/laptops/register", (req, res) => {
|
|
101
|
+
cleanupExpiredPairings();
|
|
102
|
+
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const laptopId = createId("lap");
|
|
105
|
+
const deviceId = createId("dev");
|
|
106
|
+
const laptopToken = issueLaptopToken(laptopId, deviceId);
|
|
107
|
+
const wakeMacAddress = normalizeMacAddress(
|
|
108
|
+
req.body?.wakeMac || req.body?.wake_mac || req.body?.macAddress || req.body?.wake?.macAddress
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const laptop = {
|
|
112
|
+
laptopId,
|
|
113
|
+
deviceId,
|
|
114
|
+
laptopToken,
|
|
115
|
+
name: safeText(req.body?.name, 120) || safeText(req.body?.hostname, 120) || null,
|
|
116
|
+
createdAt: now,
|
|
117
|
+
updatedAt: now,
|
|
118
|
+
pairedAt: null,
|
|
119
|
+
pairCode: null,
|
|
120
|
+
pairingExpiresAt: null,
|
|
121
|
+
pairingUrl: null,
|
|
122
|
+
pairingPayload: null,
|
|
123
|
+
lastConnectedAt: null,
|
|
124
|
+
lastDisconnectedAt: null,
|
|
125
|
+
lastSnapshotAt: null,
|
|
126
|
+
latestSnapshot: null,
|
|
127
|
+
wake: {
|
|
128
|
+
macAddress: wakeMacAddress || null
|
|
129
|
+
},
|
|
130
|
+
lastWakeRequestedAt: null,
|
|
131
|
+
lastWakeResult: null
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
mutateState(() => {
|
|
135
|
+
state.laptops.push(laptop);
|
|
136
|
+
trimStateCollections();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const pairing = createPairingForLaptop(laptop);
|
|
140
|
+
|
|
141
|
+
res.status(201).json({
|
|
142
|
+
laptopId: laptop.laptopId,
|
|
143
|
+
laptopToken: laptop.laptopToken,
|
|
144
|
+
deviceId: laptop.deviceId,
|
|
145
|
+
pairCode: pairing.code,
|
|
146
|
+
pairingExpiresAt: pairing.expiresAt,
|
|
147
|
+
pairingUrl: pairing.pairingUrl,
|
|
148
|
+
pairingPayload: pairing.pairingPayload
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
app.use("/api/laptops", requireLaptopToken);
|
|
153
|
+
|
|
154
|
+
app.get("/api/laptops/me", (req, res) => {
|
|
155
|
+
const laptop = req.laptopSession;
|
|
156
|
+
const activePairing =
|
|
157
|
+
state.pairings
|
|
158
|
+
.filter((item) => item.laptopId === laptop.laptopId && item.expiresAt > Date.now())
|
|
159
|
+
.sort((a, b) => b.createdAt - a.createdAt)[0] || null;
|
|
160
|
+
|
|
161
|
+
return res.json({
|
|
162
|
+
ok: true,
|
|
163
|
+
laptopId: laptop.laptopId,
|
|
164
|
+
deviceId: laptop.deviceId,
|
|
165
|
+
laptopToken: preferredLaptopToken(laptop),
|
|
166
|
+
name: laptop.name,
|
|
167
|
+
pairedAt: laptop.pairedAt,
|
|
168
|
+
connected: isLaptopConnected(laptop.laptopId),
|
|
169
|
+
lastConnectedAt: laptop.lastConnectedAt,
|
|
170
|
+
lastDisconnectedAt: laptop.lastDisconnectedAt,
|
|
171
|
+
lastSnapshotAt: laptop.lastSnapshotAt,
|
|
172
|
+
wake: {
|
|
173
|
+
macAddress: laptop?.wake?.macAddress || null
|
|
174
|
+
},
|
|
175
|
+
wakeConfigured: Boolean(laptop?.wake?.macAddress),
|
|
176
|
+
lastWakeRequestedAt: laptop.lastWakeRequestedAt || null,
|
|
177
|
+
lastWakeResult: laptop.lastWakeResult || null,
|
|
178
|
+
activePairing: activePairing
|
|
179
|
+
? {
|
|
180
|
+
code: activePairing.code,
|
|
181
|
+
expiresAt: activePairing.expiresAt,
|
|
182
|
+
pairingUrl: activePairing.pairingUrl
|
|
183
|
+
}
|
|
184
|
+
: null
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
app.post("/api/laptops/pairing", (req, res) => {
|
|
189
|
+
cleanupExpiredPairings();
|
|
190
|
+
const laptop = req.laptopSession;
|
|
191
|
+
const force = Boolean(req.body?.force);
|
|
192
|
+
const wakeMacAddress = normalizeMacAddress(
|
|
193
|
+
req.body?.wakeMac || req.body?.wake_mac || req.body?.macAddress || req.body?.wake?.macAddress
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (wakeMacAddress && wakeMacAddress !== laptop?.wake?.macAddress) {
|
|
197
|
+
mutateState(() => {
|
|
198
|
+
laptop.wake = {
|
|
199
|
+
...(isObject(laptop.wake) ? laptop.wake : {}),
|
|
200
|
+
macAddress: wakeMacAddress
|
|
201
|
+
};
|
|
202
|
+
laptop.updatedAt = Date.now();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const existingPairing =
|
|
207
|
+
!force
|
|
208
|
+
? state.pairings
|
|
209
|
+
.filter((item) => item.laptopId === laptop.laptopId && item.expiresAt > Date.now())
|
|
210
|
+
.sort((a, b) => b.createdAt - a.createdAt)[0] || null
|
|
211
|
+
: null;
|
|
212
|
+
|
|
213
|
+
const pairing = existingPairing || createPairingForLaptop(laptop);
|
|
214
|
+
|
|
215
|
+
return res.json({
|
|
216
|
+
ok: true,
|
|
217
|
+
laptopId: laptop.laptopId,
|
|
218
|
+
deviceId: laptop.deviceId,
|
|
219
|
+
laptopToken: preferredLaptopToken(laptop),
|
|
220
|
+
pairCode: pairing.code,
|
|
221
|
+
pairingExpiresAt: pairing.expiresAt,
|
|
222
|
+
pairingUrl: pairing.pairingUrl,
|
|
223
|
+
pairingPayload: pairing.pairingPayload
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
app.get("/api/pairings/:code", (req, res) => {
|
|
228
|
+
cleanupExpiredPairings();
|
|
229
|
+
|
|
230
|
+
const code = normalizePairCode(req.params.code);
|
|
231
|
+
if (!code) {
|
|
232
|
+
return res.status(400).json({ ok: false, error: "pairing code is required" });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const pairing = findPairingByCode(code);
|
|
236
|
+
if (!pairing) {
|
|
237
|
+
return res.status(404).json({ ok: false, error: "pairing code not found" });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const laptop = findLaptopById(pairing.laptopId);
|
|
241
|
+
return res.json({
|
|
242
|
+
ok: true,
|
|
243
|
+
code: pairing.code,
|
|
244
|
+
laptopId: pairing.laptopId,
|
|
245
|
+
deviceId: pairing.deviceId,
|
|
246
|
+
pairingExpiresAt: pairing.expiresAt,
|
|
247
|
+
claimed: Boolean(pairing.claimedAt),
|
|
248
|
+
connected: laptop ? isLaptopConnected(laptop.laptopId) : false,
|
|
249
|
+
hasSnapshot: Boolean(laptop?.latestSnapshot)
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
app.post("/api/pairings/claim", (req, res) => {
|
|
254
|
+
cleanupExpiredPairings();
|
|
255
|
+
|
|
256
|
+
const code = normalizePairCode(req.body?.code || req.body?.pairCode);
|
|
257
|
+
if (!code) {
|
|
258
|
+
return res.status(400).json({ ok: false, error: "code is required" });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const pairing = findPairingByCode(code);
|
|
262
|
+
if (!pairing) {
|
|
263
|
+
return res.status(404).json({ ok: false, error: "pairing code not found" });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (pairing.expiresAt < Date.now() && !pairing.claimedAt) {
|
|
267
|
+
return res.status(410).json({ ok: false, error: "pairing code expired" });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (pairing.phoneToken) {
|
|
271
|
+
return res.json({
|
|
272
|
+
phoneToken: pairing.phoneToken,
|
|
273
|
+
deviceId: pairing.deviceId
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const phoneToken = issuePhoneToken(pairing.deviceId);
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
|
|
280
|
+
mutateState(() => {
|
|
281
|
+
pairing.claimedAt = now;
|
|
282
|
+
pairing.phoneToken = phoneToken;
|
|
283
|
+
|
|
284
|
+
state.phones.push({
|
|
285
|
+
phoneToken,
|
|
286
|
+
deviceId: pairing.deviceId,
|
|
287
|
+
createdAt: now,
|
|
288
|
+
lastUsedAt: now
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const laptop = findLaptopById(pairing.laptopId);
|
|
292
|
+
if (laptop) {
|
|
293
|
+
laptop.pairedAt = now;
|
|
294
|
+
laptop.updatedAt = now;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
trimStateCollections();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return res.json({
|
|
301
|
+
phoneToken,
|
|
302
|
+
deviceId: pairing.deviceId
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
app.use("/api/devices/:id", requirePhoneToken, requireDeviceAccess);
|
|
307
|
+
|
|
308
|
+
app.get("/api/devices/:id/status", (req, res) => {
|
|
309
|
+
const laptop = req.deviceLaptop;
|
|
310
|
+
const connected = isLaptopConnected(laptop.laptopId);
|
|
311
|
+
|
|
312
|
+
return res.json({
|
|
313
|
+
ok: true,
|
|
314
|
+
deviceId: laptop.deviceId,
|
|
315
|
+
laptopId: laptop.laptopId,
|
|
316
|
+
connected,
|
|
317
|
+
pairedAt: laptop.pairedAt,
|
|
318
|
+
lastConnectedAt: laptop.lastConnectedAt,
|
|
319
|
+
lastDisconnectedAt: laptop.lastDisconnectedAt,
|
|
320
|
+
latestSnapshotAt: laptop.lastSnapshotAt,
|
|
321
|
+
pairingExpiresAt: laptop.pairingExpiresAt,
|
|
322
|
+
wakeConfigured: Boolean(laptop?.wake?.macAddress),
|
|
323
|
+
wakeProxyEnabled: Boolean(RELAY_WAKE_PROXY_URL),
|
|
324
|
+
autoWakeCapable: Boolean(!connected && RELAY_WAKE_PROXY_URL && laptop?.wake?.macAddress),
|
|
325
|
+
lastWakeRequestedAt: laptop.lastWakeRequestedAt || null,
|
|
326
|
+
lastWakeResult: laptop.lastWakeResult || null
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
app.get("/api/devices/:id/bootstrap", async (req, res) => {
|
|
331
|
+
const laptop = req.deviceLaptop;
|
|
332
|
+
const freshFlag = String(req.query?.fresh || "").trim().toLowerCase();
|
|
333
|
+
const requireFresh = freshFlag === "1" || freshFlag === "true";
|
|
334
|
+
|
|
335
|
+
if (!requireFresh && isObject(laptop.latestSnapshot)) {
|
|
336
|
+
return res.json(laptop.latestSnapshot);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const rpc = await sendLaptopRpc(laptop.laptopId, {
|
|
341
|
+
method: "GET",
|
|
342
|
+
path: "/api/bootstrap"
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (rpc.ok && isObject(rpc.body)) {
|
|
346
|
+
mutateState(() => {
|
|
347
|
+
laptop.latestSnapshot = rpc.body;
|
|
348
|
+
laptop.lastSnapshotAt = Date.now();
|
|
349
|
+
laptop.updatedAt = Date.now();
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return relayRpcResponse(res, rpc);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (isObject(laptop.latestSnapshot)) {
|
|
356
|
+
return res.json(laptop.latestSnapshot);
|
|
357
|
+
}
|
|
358
|
+
return res.status(resolveRpcErrorStatus(error)).json({
|
|
359
|
+
ok: false,
|
|
360
|
+
error: String(error?.message || error)
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
app.post("/api/devices/:id/actions", async (req, res) => {
|
|
366
|
+
return proxyToLaptopBridge(req, res, {
|
|
367
|
+
method: "POST",
|
|
368
|
+
path: "/api/actions",
|
|
369
|
+
body: req.body || {}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
app.get("/api/devices/:id/launcher/workspaces", async (req, res) => {
|
|
374
|
+
const pathWithQuery = withQuery("/api/launcher/workspaces", req.query);
|
|
375
|
+
return proxyToLaptopBridge(req, res, {
|
|
376
|
+
method: "GET",
|
|
377
|
+
path: pathWithQuery
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
app.post("/api/devices/:id/launcher/workspaces/create", async (req, res) => {
|
|
382
|
+
return proxyToLaptopBridge(req, res, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
path: "/api/launcher/workspaces/create",
|
|
385
|
+
body: req.body || {},
|
|
386
|
+
autoWake: true,
|
|
387
|
+
wakeIntent: "create_workspace"
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
app.get("/api/devices/:id/launcher/runs", async (req, res) => {
|
|
392
|
+
const pathWithQuery = withQuery("/api/launcher/runs", req.query);
|
|
393
|
+
return proxyToLaptopBridge(req, res, {
|
|
394
|
+
method: "GET",
|
|
395
|
+
path: pathWithQuery
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
app.get("/api/devices/:id/launcher/services", async (req, res) => {
|
|
400
|
+
const pathWithQuery = withQuery("/api/launcher/services", req.query);
|
|
401
|
+
return proxyToLaptopBridge(req, res, {
|
|
402
|
+
method: "GET",
|
|
403
|
+
path: pathWithQuery
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
app.post("/api/devices/:id/launcher/start", async (req, res) => {
|
|
408
|
+
return proxyToLaptopBridge(req, res, {
|
|
409
|
+
method: "POST",
|
|
410
|
+
path: "/api/launcher/start",
|
|
411
|
+
body: req.body || {},
|
|
412
|
+
autoWake: true,
|
|
413
|
+
wakeIntent: "launch_run"
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
app.post("/api/devices/:id/launcher/runs/:runId/stop", async (req, res) => {
|
|
418
|
+
const runId = encodeURIComponent(String(req.params.runId || ""));
|
|
419
|
+
return proxyToLaptopBridge(req, res, {
|
|
420
|
+
method: "POST",
|
|
421
|
+
path: `/api/launcher/runs/${runId}/stop`,
|
|
422
|
+
body: req.body || {}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
app.post("/api/devices/:id/launcher/services/start", async (req, res) => {
|
|
427
|
+
return proxyToLaptopBridge(req, res, {
|
|
428
|
+
method: "POST",
|
|
429
|
+
path: "/api/launcher/services/start",
|
|
430
|
+
body: req.body || {},
|
|
431
|
+
autoWake: true,
|
|
432
|
+
wakeIntent: "start_background_service"
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
app.post("/api/devices/:id/launcher/services/:serviceId/stop", async (req, res) => {
|
|
437
|
+
const serviceId = encodeURIComponent(String(req.params.serviceId || ""));
|
|
438
|
+
return proxyToLaptopBridge(req, res, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
path: `/api/launcher/services/${serviceId}/stop`,
|
|
441
|
+
body: req.body || {}
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
app.post("/api/devices/:id/settings/update", async (req, res) => {
|
|
446
|
+
return proxyToLaptopBridge(req, res, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
path: "/api/settings/update",
|
|
449
|
+
body: req.body || {},
|
|
450
|
+
autoWake: true,
|
|
451
|
+
wakeIntent: "update_settings"
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
app.post("/api/devices/:id/sessions/:sessionId/messages", async (req, res) => {
|
|
456
|
+
const sessionId = encodeURIComponent(safeText(req.params.sessionId, 200));
|
|
457
|
+
if (!sessionId) {
|
|
458
|
+
return res.status(400).json({ ok: false, error: "sessionId is required" });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return proxyToLaptopBridge(req, res, {
|
|
462
|
+
method: "POST",
|
|
463
|
+
path: `/api/sessions/${sessionId}/messages`,
|
|
464
|
+
body: req.body || {},
|
|
465
|
+
autoWake: true,
|
|
466
|
+
wakeIntent: "send_message"
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
app.get("/api/devices/:id/previews", (req, res) => {
|
|
471
|
+
cleanupExpiredPreviews();
|
|
472
|
+
const laptop = req.deviceLaptop;
|
|
473
|
+
const previews = listPreviewsForDevice(laptop.deviceId).map((item) =>
|
|
474
|
+
serializePreview(item, {
|
|
475
|
+
connected: isLaptopConnected(item.laptopId)
|
|
476
|
+
})
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
return res.json({
|
|
480
|
+
ok: true,
|
|
481
|
+
previews
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
app.post("/api/devices/:id/previews", (req, res) => {
|
|
486
|
+
cleanupExpiredPreviews();
|
|
487
|
+
const laptop = req.deviceLaptop;
|
|
488
|
+
const previewTarget = normalizePreviewTarget(req.body?.targetUrl || req.body?.url, req.body?.port);
|
|
489
|
+
if (!previewTarget) {
|
|
490
|
+
return res.status(400).json({
|
|
491
|
+
ok: false,
|
|
492
|
+
error: "invalid preview target (use localhost/127.0.0.1 URL or a valid port)"
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const requestedTtlSec = toInt(req.body?.expiresInSec, Math.round(PREVIEW_DEFAULT_TTL_MS / 1000));
|
|
497
|
+
const expiresInSec = clamp(requestedTtlSec, 60, Math.round(PREVIEW_MAX_TTL_MS / 1000));
|
|
498
|
+
const now = Date.now();
|
|
499
|
+
|
|
500
|
+
const preview = {
|
|
501
|
+
previewId: createId("preview"),
|
|
502
|
+
accessToken: createToken("pvw"),
|
|
503
|
+
laptopId: laptop.laptopId,
|
|
504
|
+
deviceId: laptop.deviceId,
|
|
505
|
+
label: safeText(req.body?.label, 120) || null,
|
|
506
|
+
target: previewTarget,
|
|
507
|
+
createdAt: now,
|
|
508
|
+
updatedAt: now,
|
|
509
|
+
lastAccessedAt: null,
|
|
510
|
+
expiresAt: now + expiresInSec * 1000,
|
|
511
|
+
createdByPhoneToken: safeText(req.phoneSession?.phoneToken, 500) || null
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
mutateState(() => {
|
|
515
|
+
state.previews.push(preview);
|
|
516
|
+
trimStateCollections();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
return res.status(201).json({
|
|
520
|
+
ok: true,
|
|
521
|
+
preview: serializePreview(preview, {
|
|
522
|
+
connected: isLaptopConnected(preview.laptopId)
|
|
523
|
+
})
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
app.delete("/api/devices/:id/previews/:previewId", (req, res) => {
|
|
528
|
+
cleanupExpiredPreviews();
|
|
529
|
+
const previewId = safeText(req.params.previewId, 200);
|
|
530
|
+
if (!previewId) {
|
|
531
|
+
return res.status(400).json({ ok: false, error: "previewId is required" });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let removed = false;
|
|
535
|
+
mutateState(() => {
|
|
536
|
+
const before = state.previews.length;
|
|
537
|
+
state.previews = state.previews.filter((item) => !(item.previewId === previewId && item.deviceId === req.deviceLaptop.deviceId));
|
|
538
|
+
removed = state.previews.length !== before;
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (!removed) {
|
|
542
|
+
return res.status(404).json({ ok: false, error: "preview not found" });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return res.json({ ok: true });
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
app.post("/api/devices/:id/wake", async (req, res) => {
|
|
549
|
+
const laptop = req.deviceLaptop;
|
|
550
|
+
const result = await ensureLaptopOnline(laptop, {
|
|
551
|
+
autoWake: true,
|
|
552
|
+
wakeIntent: "manual_wake",
|
|
553
|
+
timeoutMs: clamp(toInt(req.body?.timeoutMs, RELAY_WAKE_TIMEOUT_MS), 2_000, 5 * 60 * 1000)
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
if (!result.ok) {
|
|
557
|
+
return res.status(503).json({
|
|
558
|
+
ok: false,
|
|
559
|
+
error: result.error || "unable to wake device",
|
|
560
|
+
wakeAttempted: result.wakeAttempted
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return res.json({
|
|
565
|
+
ok: true,
|
|
566
|
+
connected: isLaptopConnected(laptop.laptopId),
|
|
567
|
+
wakeAttempted: result.wakeAttempted
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
app.all(/^\/(?:preview|p)\/([^/]+)(?:\/(.*))?$/, (req, res) => {
|
|
572
|
+
return handlePreviewProxy(req, res);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Support assets requested via absolute root paths (e.g. /styles.css) from preview pages.
|
|
576
|
+
// Many local dev servers emit root-absolute URLs, which would otherwise bypass /p/:token.
|
|
577
|
+
app.all(/^\/(?!api(?:\/|$)|ws(?:\/|$)|pair(?:\/|$)|health$|preview(?:\/|$)|p(?:\/|$)).+/, (req, res, next) => {
|
|
578
|
+
const previewToken = extractPreviewTokenFromCookie(req) || extractPreviewTokenFromReferer(req);
|
|
579
|
+
if (!previewToken) return next();
|
|
580
|
+
return handlePreviewProxy(req, res, {
|
|
581
|
+
forcedToken: previewToken,
|
|
582
|
+
forcedSuffix: req.path || "/"
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
server.on("upgrade", (request, socket, head) => {
|
|
587
|
+
let parsedUrl;
|
|
588
|
+
try {
|
|
589
|
+
parsedUrl = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
|
590
|
+
} catch {
|
|
591
|
+
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
592
|
+
socket.destroy();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (parsedUrl.pathname !== "/ws/laptop") {
|
|
597
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
598
|
+
socket.destroy();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const token = safeText(parsedUrl.searchParams.get("token"), 4000);
|
|
603
|
+
const resolved = resolveLaptopAuth(token);
|
|
604
|
+
const laptop = resolved?.laptop || null;
|
|
605
|
+
if (!laptop) {
|
|
606
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
607
|
+
socket.destroy();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
612
|
+
wss.emit("connection", ws, laptop, resolved);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
wss.on("connection", (ws, laptop, authInfo) => {
|
|
617
|
+
const existing = laptopSockets.get(laptop.laptopId);
|
|
618
|
+
if (existing && existing !== ws && existing.readyState === WebSocket.OPEN) {
|
|
619
|
+
existing.close(4000, "replaced by newer connection");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
laptopSockets.set(laptop.laptopId, ws);
|
|
623
|
+
|
|
624
|
+
mutateState(() => {
|
|
625
|
+
laptop.lastConnectedAt = Date.now();
|
|
626
|
+
laptop.updatedAt = Date.now();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
sendWsJson(ws, {
|
|
630
|
+
type: "welcome",
|
|
631
|
+
laptopId: laptop.laptopId,
|
|
632
|
+
deviceId: laptop.deviceId,
|
|
633
|
+
laptopToken: authInfo?.refreshedToken || null,
|
|
634
|
+
relayTime: Date.now()
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
ws.on("message", (chunk, isBinary) => {
|
|
638
|
+
if (isBinary) return;
|
|
639
|
+
|
|
640
|
+
let message;
|
|
641
|
+
try {
|
|
642
|
+
message = JSON.parse(chunk.toString());
|
|
643
|
+
} catch {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (!isObject(message)) return;
|
|
648
|
+
|
|
649
|
+
if (message.type === "rpc_response" && typeof message.id === "string") {
|
|
650
|
+
settlePendingRpc(laptop.laptopId, message);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (message.type === "snapshot" && isObject(message.snapshot)) {
|
|
655
|
+
mutateState(() => {
|
|
656
|
+
laptop.latestSnapshot = message.snapshot;
|
|
657
|
+
laptop.lastSnapshotAt = Date.now();
|
|
658
|
+
laptop.updatedAt = Date.now();
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (message.type === "ping") {
|
|
664
|
+
sendWsJson(ws, { type: "pong", ts: Date.now() });
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
ws.on("close", () => {
|
|
669
|
+
if (laptopSockets.get(laptop.laptopId) === ws) {
|
|
670
|
+
laptopSockets.delete(laptop.laptopId);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
rejectPendingRpcsForLaptop(laptop.laptopId, "laptop disconnected");
|
|
674
|
+
|
|
675
|
+
mutateState(() => {
|
|
676
|
+
laptop.lastDisconnectedAt = Date.now();
|
|
677
|
+
laptop.updatedAt = Date.now();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
ws.on("error", () => {
|
|
682
|
+
// errors are handled by close/retry paths
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const cleanupTicker = setInterval(() => {
|
|
687
|
+
cleanupExpiredPairings();
|
|
688
|
+
cleanupExpiredPreviews();
|
|
689
|
+
}, CLEANUP_INTERVAL_MS);
|
|
690
|
+
cleanupTicker.unref();
|
|
691
|
+
|
|
692
|
+
server.listen(RELAY_PORT);
|
|
693
|
+
|
|
694
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
695
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
696
|
+
|
|
697
|
+
async function proxyToLaptopBridge(req, res, input) {
|
|
698
|
+
const laptop = req.deviceLaptop;
|
|
699
|
+
|
|
700
|
+
const online = await ensureLaptopOnline(laptop, {
|
|
701
|
+
autoWake: Boolean(input?.autoWake),
|
|
702
|
+
wakeIntent: safeText(input?.wakeIntent, 80) || "proxy_request"
|
|
703
|
+
});
|
|
704
|
+
if (!online.ok) {
|
|
705
|
+
return res.status(503).json({
|
|
706
|
+
ok: false,
|
|
707
|
+
error: online.error || "laptop is not connected",
|
|
708
|
+
wakeAttempted: online.wakeAttempted
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
const rpc = await sendLaptopRpc(laptop.laptopId, input);
|
|
714
|
+
return relayRpcResponse(res, rpc);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
return res.status(resolveRpcErrorStatus(error)).json({
|
|
717
|
+
ok: false,
|
|
718
|
+
error: String(error?.message || error)
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function handlePreviewProxy(req, res, options = {}) {
|
|
724
|
+
cleanupExpiredPreviews();
|
|
725
|
+
|
|
726
|
+
const rawToken =
|
|
727
|
+
safeText(options?.forcedToken, 500) ||
|
|
728
|
+
(req.params && typeof req.params === "object" ? req.params.token ?? req.params[0] : "");
|
|
729
|
+
const accessToken = safeText(rawToken, 500);
|
|
730
|
+
if (!accessToken) {
|
|
731
|
+
return res.status(400).type("text/plain").send("preview token is required");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const hasForcedSuffix = Boolean(safeText(options?.forcedSuffix, 4000));
|
|
735
|
+
if (!hasForcedSuffix && isPreviewRootPathWithoutTrailingSlash(req.path) && isSafeMethod(req.method)) {
|
|
736
|
+
const query = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
|
|
737
|
+
return res.redirect(307, `${req.path}/${query}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const preview = findPreviewByAccessToken(accessToken);
|
|
741
|
+
if (!preview) {
|
|
742
|
+
return res.status(404).type("text/plain").send("preview link expired or not found");
|
|
743
|
+
}
|
|
744
|
+
setPreviewTokenCookie(res, accessToken, preview.expiresAt);
|
|
745
|
+
|
|
746
|
+
const laptop = findLaptopById(preview.laptopId);
|
|
747
|
+
if (!laptop) {
|
|
748
|
+
return res.status(404).type("text/plain").send("preview device not found");
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const online = await ensureLaptopOnline(laptop, {
|
|
752
|
+
autoWake: true,
|
|
753
|
+
wakeIntent: "preview_request"
|
|
754
|
+
});
|
|
755
|
+
if (!online.ok) {
|
|
756
|
+
return res.status(503).type("text/plain").send("laptop is offline");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const rawSuffix =
|
|
760
|
+
safeText(options?.forcedSuffix, 4000) ||
|
|
761
|
+
(req.params && typeof req.params === "object" ? req.params.rest ?? req.params[1] : "");
|
|
762
|
+
const suffix = safePreviewPathSuffix(rawSuffix);
|
|
763
|
+
const query = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
|
|
764
|
+
const proxiedPath = `${suffix}${query}`;
|
|
765
|
+
const rpcPath =
|
|
766
|
+
"/__relay/preview/proxy" +
|
|
767
|
+
withQuery("", {
|
|
768
|
+
target: preview.target,
|
|
769
|
+
path: proxiedPath
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const headers = sanitizePreviewForwardHeaders(req.headers);
|
|
773
|
+
|
|
774
|
+
try {
|
|
775
|
+
const rpc = await sendLaptopRpc(
|
|
776
|
+
preview.laptopId,
|
|
777
|
+
{
|
|
778
|
+
method: String(req.method || "GET").toUpperCase(),
|
|
779
|
+
path: rpcPath,
|
|
780
|
+
headers,
|
|
781
|
+
body: normalizePreviewForwardBody(req)
|
|
782
|
+
},
|
|
783
|
+
PREVIEW_RPC_TIMEOUT_MS
|
|
784
|
+
);
|
|
785
|
+
const rewrittenRpc = maybeRewritePreviewTextResponse(rpc, accessToken, preview.target, proxiedPath);
|
|
786
|
+
|
|
787
|
+
mutateState(() => {
|
|
788
|
+
preview.lastAccessedAt = Date.now();
|
|
789
|
+
preview.updatedAt = Date.now();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
return relayRpcResponse(res, rewrittenRpc);
|
|
793
|
+
} catch (error) {
|
|
794
|
+
return res.status(resolveRpcErrorStatus(error)).type("text/plain").send(String(error?.message || error));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function setPreviewTokenCookie(res, accessToken, expiresAt) {
|
|
799
|
+
const token = safeText(accessToken, 500);
|
|
800
|
+
if (!token) return;
|
|
801
|
+
const now = Date.now();
|
|
802
|
+
const maxAgeSec = Math.max(60, Math.floor((toInt(expiresAt, now + 60_000) - now) / 1000));
|
|
803
|
+
const cookie = `ac_preview_token=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAgeSec}; SameSite=Lax`;
|
|
804
|
+
res.append("Set-Cookie", cookie);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function maybeRewritePreviewTextResponse(rpc, accessToken, previewTarget, requestPath) {
|
|
808
|
+
if (!rpc || typeof rpc !== "object") return rpc;
|
|
809
|
+
if (safeText(rpc.bodyType, 20) !== "text") return rpc;
|
|
810
|
+
if (typeof rpc.body !== "string" || !rpc.body) return rpc;
|
|
811
|
+
|
|
812
|
+
const token = safeText(accessToken, 500);
|
|
813
|
+
if (!token) return rpc;
|
|
814
|
+
const kind = detectPreviewTextKind(rpc.responseHeaders, rpc.body, requestPath);
|
|
815
|
+
if (!kind) return rpc;
|
|
816
|
+
|
|
817
|
+
const prefix = `/p/${encodeURIComponent(token)}`;
|
|
818
|
+
let text = String(rpc.body);
|
|
819
|
+
|
|
820
|
+
// Rewrite absolute localhost URLs to preview-prefixed paths.
|
|
821
|
+
const origins = buildLocalhostOrigins(previewTarget);
|
|
822
|
+
for (const origin of origins) {
|
|
823
|
+
const escaped = escapeRegExp(origin);
|
|
824
|
+
text = text.replace(new RegExp(`${escaped}/`, "gi"), `${prefix}/`);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (kind === "html") {
|
|
828
|
+
text = ensurePreviewBaseTag(text, `${prefix}/`);
|
|
829
|
+
|
|
830
|
+
// Prefix root-relative resource attributes so they keep preview token context.
|
|
831
|
+
text = text.replace(
|
|
832
|
+
/(\b(?:href|src|action|poster)\s*=\s*["'])\/(?!\/|p\/|preview\/)/gi,
|
|
833
|
+
`$1${prefix}/`
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (kind === "html" || kind === "css" || kind === "js") {
|
|
838
|
+
// Prefix root-relative URL literals in text assets.
|
|
839
|
+
text = text.replace(/(["'`])\/(?!\/|p\/|preview\/)/g, `$1${prefix}/`);
|
|
840
|
+
text = text.replace(/url\((['"]?)\/(?!\/|p\/|preview\/)/gi, `url($1${prefix}/`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (kind === "js") {
|
|
844
|
+
text = text.replace(/\bfrom\s+(['"])\/(?!\/|p\/|preview\/)/g, `from $1${prefix}/`);
|
|
845
|
+
text = text.replace(/\bimport\(\s*(['"])\/(?!\/|p\/|preview\/)/g, `import($1${prefix}/`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const nextHeaders = {
|
|
849
|
+
...(isObject(rpc.responseHeaders) ? rpc.responseHeaders : {})
|
|
850
|
+
};
|
|
851
|
+
const existingContentType = getHeaderCaseInsensitive(nextHeaders, "content-type");
|
|
852
|
+
if (!existingContentType) {
|
|
853
|
+
if (kind === "html") nextHeaders["content-type"] = "text/html; charset=utf-8";
|
|
854
|
+
if (kind === "css") nextHeaders["content-type"] = "text/css; charset=utf-8";
|
|
855
|
+
if (kind === "js") nextHeaders["content-type"] = "application/javascript; charset=utf-8";
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return { ...rpc, bodyType: "text", body: text, responseHeaders: nextHeaders };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function ensurePreviewBaseTag(htmlText, baseHref) {
|
|
862
|
+
let html = String(htmlText || "");
|
|
863
|
+
const base = String(baseHref || "").trim();
|
|
864
|
+
if (!base) return html;
|
|
865
|
+
|
|
866
|
+
if (/<base\b/i.test(html)) {
|
|
867
|
+
return html.replace(/<base\b[^>]*href\s*=\s*["'][^"']*["'][^>]*>/i, `<base href="${base}">`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (/<head\b[^>]*>/i.test(html)) {
|
|
871
|
+
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n<base href="${base}">`);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return `<base href="${base}">\n${html}`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function isHtmlResponse(headersInput, bodyText) {
|
|
878
|
+
const contentType = getHeaderCaseInsensitive(headersInput, "content-type");
|
|
879
|
+
if (contentType && /text\/html|application\/xhtml\+xml/i.test(contentType)) return true;
|
|
880
|
+
const sample = safeText(String(bodyText || "").slice(0, 300), 300).toLowerCase();
|
|
881
|
+
if (!sample) return false;
|
|
882
|
+
return sample.includes("<html") || sample.includes("<!doctype html");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function detectPreviewTextKind(headersInput, bodyText, requestPath) {
|
|
886
|
+
const contentType = getHeaderCaseInsensitive(headersInput, "content-type").toLowerCase();
|
|
887
|
+
if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) return "html";
|
|
888
|
+
if (contentType.includes("text/css")) return "css";
|
|
889
|
+
if (contentType.includes("javascript") || contentType.includes("ecmascript")) return "js";
|
|
890
|
+
|
|
891
|
+
const pathOnly = safeText(String(requestPath || "").split("?")[0], 2000).toLowerCase();
|
|
892
|
+
if (pathOnly.endsWith(".html") || pathOnly.endsWith(".htm")) return "html";
|
|
893
|
+
if (pathOnly.endsWith(".css")) return "css";
|
|
894
|
+
if (
|
|
895
|
+
pathOnly.endsWith(".js") ||
|
|
896
|
+
pathOnly.endsWith(".mjs") ||
|
|
897
|
+
pathOnly.endsWith(".cjs") ||
|
|
898
|
+
pathOnly.endsWith(".ts") ||
|
|
899
|
+
pathOnly.endsWith(".tsx")
|
|
900
|
+
) {
|
|
901
|
+
return "js";
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (isHtmlResponse(headersInput, bodyText)) return "html";
|
|
905
|
+
return "";
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function buildLocalhostOrigins(previewTarget) {
|
|
909
|
+
const target = safeText(previewTarget, 2000);
|
|
910
|
+
if (!target) return [];
|
|
911
|
+
try {
|
|
912
|
+
const parsed = new URL(target);
|
|
913
|
+
const protocol = parsed.protocol === "https:" ? "https" : "http";
|
|
914
|
+
const port = parsed.port ? `:${parsed.port}` : "";
|
|
915
|
+
const origins = new Set([
|
|
916
|
+
`${protocol}://127.0.0.1${port}`,
|
|
917
|
+
`${protocol}://localhost${port}`,
|
|
918
|
+
`${protocol}://[::1]${port}`
|
|
919
|
+
]);
|
|
920
|
+
return [...origins];
|
|
921
|
+
} catch {
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function escapeRegExp(value) {
|
|
927
|
+
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function extractPreviewTokenFromReferer(req) {
|
|
931
|
+
const refererRaw = safeText(req.header("referer"), 4000);
|
|
932
|
+
if (!refererRaw) return "";
|
|
933
|
+
|
|
934
|
+
try {
|
|
935
|
+
const parsed = new URL(refererRaw);
|
|
936
|
+
const match = parsed.pathname.match(/^\/(?:preview|p)\/([^/]+)/i);
|
|
937
|
+
if (!match || !match[1]) return "";
|
|
938
|
+
return safeText(decodeURIComponent(match[1]), 500);
|
|
939
|
+
} catch {
|
|
940
|
+
return "";
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function isPreviewRootPathWithoutTrailingSlash(pathname) {
|
|
945
|
+
const path = safeText(pathname, 2000);
|
|
946
|
+
if (!path) return false;
|
|
947
|
+
return /^\/(?:preview|p)\/[^/]+$/i.test(path);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function isSafeMethod(method) {
|
|
951
|
+
const value = String(method || "").toUpperCase();
|
|
952
|
+
return value === "GET" || value === "HEAD";
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function extractPreviewTokenFromCookie(req) {
|
|
956
|
+
const cookieHeader = safeText(req.header("cookie"), 8000);
|
|
957
|
+
if (!cookieHeader) return "";
|
|
958
|
+
const pairs = cookieHeader.split(";");
|
|
959
|
+
for (const pair of pairs) {
|
|
960
|
+
const [rawKey, ...rest] = pair.split("=");
|
|
961
|
+
if (!rawKey || rest.length === 0) continue;
|
|
962
|
+
const key = rawKey.trim();
|
|
963
|
+
if (key !== "ac_preview_token") continue;
|
|
964
|
+
try {
|
|
965
|
+
return safeText(decodeURIComponent(rest.join("=").trim()), 500);
|
|
966
|
+
} catch {
|
|
967
|
+
return safeText(rest.join("=").trim(), 500);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return "";
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function getHeaderCaseInsensitive(headersInput, name) {
|
|
974
|
+
if (!isObject(headersInput)) return "";
|
|
975
|
+
const target = String(name || "").toLowerCase();
|
|
976
|
+
for (const [key, value] of Object.entries(headersInput)) {
|
|
977
|
+
if (String(key || "").toLowerCase() !== target) continue;
|
|
978
|
+
if (typeof value !== "string") continue;
|
|
979
|
+
return value;
|
|
980
|
+
}
|
|
981
|
+
return "";
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function relayRpcResponse(res, rpc) {
|
|
985
|
+
const status = clamp(toInt(rpc?.status, rpc?.ok ? 200 : 500), 100, 599);
|
|
986
|
+
const bodyType = rpc?.bodyType;
|
|
987
|
+
const hasExplicitContentType = hasHeaderCaseInsensitive(rpc?.responseHeaders, "content-type");
|
|
988
|
+
applyRpcResponseHeaders(res, rpc?.responseHeaders);
|
|
989
|
+
|
|
990
|
+
if (bodyType === "empty") {
|
|
991
|
+
return res.status(status).end();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (bodyType === "base64" || rpc?.bodyEncoding === "base64") {
|
|
995
|
+
const payload = typeof rpc?.body === "string" ? Buffer.from(rpc.body, "base64") : Buffer.alloc(0);
|
|
996
|
+
return res.status(status).send(payload);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (bodyType === "text") {
|
|
1000
|
+
const response = res.status(status);
|
|
1001
|
+
if (!hasExplicitContentType) {
|
|
1002
|
+
response.type("text/plain");
|
|
1003
|
+
}
|
|
1004
|
+
return response.send(String(rpc?.body ?? ""));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (rpc?.body !== undefined) {
|
|
1008
|
+
if (typeof rpc.body === "string") {
|
|
1009
|
+
const response = res.status(status);
|
|
1010
|
+
if (!hasExplicitContentType) {
|
|
1011
|
+
response.type("text/plain");
|
|
1012
|
+
}
|
|
1013
|
+
return response.send(rpc.body);
|
|
1014
|
+
}
|
|
1015
|
+
return res.status(status).json(rpc.body);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (rpc?.error) {
|
|
1019
|
+
return res.status(status).json({ ok: false, error: rpc.error });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return res.status(status).json({ ok: Boolean(rpc?.ok) });
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function hasHeaderCaseInsensitive(headersInput, name) {
|
|
1026
|
+
if (!isObject(headersInput)) return false;
|
|
1027
|
+
const target = String(name || "").toLowerCase();
|
|
1028
|
+
return Object.keys(headersInput).some((key) => String(key || "").toLowerCase() === target);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function applyRpcResponseHeaders(res, headersInput) {
|
|
1032
|
+
if (!isObject(headersInput)) return;
|
|
1033
|
+
|
|
1034
|
+
for (const [rawName, rawValue] of Object.entries(headersInput)) {
|
|
1035
|
+
if (typeof rawValue !== "string") continue;
|
|
1036
|
+
const name = String(rawName || "").trim();
|
|
1037
|
+
if (!name || /[\r\n]/.test(name)) continue;
|
|
1038
|
+
if (/[^\t\x20-\x7e]/.test(name)) continue;
|
|
1039
|
+
const lower = name.toLowerCase();
|
|
1040
|
+
if (lower === "content-length" || lower === "transfer-encoding" || lower === "connection") continue;
|
|
1041
|
+
|
|
1042
|
+
const value = rawValue.replace(/[\r\n]+/g, " ").trim();
|
|
1043
|
+
if (!value) continue;
|
|
1044
|
+
res.setHeader(name, value);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function resolveRpcErrorStatus(error) {
|
|
1049
|
+
if (error?.code === "timeout") return 504;
|
|
1050
|
+
if (error?.code === "offline") return 503;
|
|
1051
|
+
return 502;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function requirePhoneToken(req, res, next) {
|
|
1055
|
+
const token = extractPhoneToken(req);
|
|
1056
|
+
if (!token) {
|
|
1057
|
+
return res.status(401).json({ ok: false, error: "phone token missing" });
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const resolved = resolvePhoneSession(token);
|
|
1061
|
+
const phone = resolved?.phone || null;
|
|
1062
|
+
if (!phone) {
|
|
1063
|
+
return res.status(401).json({ ok: false, error: "invalid phone token" });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (resolved?.refreshedToken && resolved.refreshedToken !== token) {
|
|
1067
|
+
res.setHeader(PHONE_TOKEN_HEADER, resolved.refreshedToken);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
phone.lastUsedAt = Date.now();
|
|
1071
|
+
state.updatedAt = Date.now();
|
|
1072
|
+
schedulePersist();
|
|
1073
|
+
|
|
1074
|
+
req.phoneSession = phone;
|
|
1075
|
+
return next();
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function requireLaptopToken(req, res, next) {
|
|
1079
|
+
const token = extractLaptopToken(req);
|
|
1080
|
+
if (!token) {
|
|
1081
|
+
return res.status(401).json({ ok: false, error: "laptop token missing" });
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const resolved = resolveLaptopAuth(token);
|
|
1085
|
+
const laptop = resolved?.laptop || null;
|
|
1086
|
+
if (!laptop) {
|
|
1087
|
+
return res.status(401).json({ ok: false, error: "invalid laptop token" });
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (resolved?.refreshedToken && resolved.refreshedToken !== token) {
|
|
1091
|
+
res.setHeader(LAPTOP_TOKEN_HEADER, resolved.refreshedToken);
|
|
1092
|
+
req.refreshedLaptopToken = resolved.refreshedToken;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
laptop.updatedAt = Date.now();
|
|
1096
|
+
state.updatedAt = Date.now();
|
|
1097
|
+
schedulePersist();
|
|
1098
|
+
|
|
1099
|
+
req.laptopSession = laptop;
|
|
1100
|
+
return next();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function requireDeviceAccess(req, res, next) {
|
|
1104
|
+
const deviceId = safeText(req.params.id, 200);
|
|
1105
|
+
const phone = req.phoneSession;
|
|
1106
|
+
|
|
1107
|
+
if (!phone || phone.deviceId !== deviceId) {
|
|
1108
|
+
return res.status(403).json({ ok: false, error: "token cannot access this device" });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const laptop = findLaptopByDeviceId(deviceId);
|
|
1112
|
+
if (!laptop) {
|
|
1113
|
+
return res.status(404).json({ ok: false, error: "device not found" });
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
req.deviceLaptop = laptop;
|
|
1117
|
+
return next();
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function extractPhoneToken(req) {
|
|
1121
|
+
const authHeader = String(req.header("authorization") || "");
|
|
1122
|
+
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
1123
|
+
const bearer = authHeader.slice(7).trim();
|
|
1124
|
+
if (bearer) return bearer;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const fromHeader = safeText(req.header("x-phone-token"), 400);
|
|
1128
|
+
if (fromHeader) return fromHeader;
|
|
1129
|
+
|
|
1130
|
+
const fromQuery = safeText(req.query?.phoneToken, 400);
|
|
1131
|
+
if (fromQuery) return fromQuery;
|
|
1132
|
+
|
|
1133
|
+
return "";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function extractLaptopToken(req) {
|
|
1137
|
+
const authHeader = String(req.header("authorization") || "");
|
|
1138
|
+
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
1139
|
+
const bearer = authHeader.slice(7).trim();
|
|
1140
|
+
if (bearer) return bearer;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const fromHeader = safeText(req.header("x-laptop-token"), 500);
|
|
1144
|
+
if (fromHeader) return fromHeader;
|
|
1145
|
+
|
|
1146
|
+
const fromBody = safeText(req.body?.laptopToken, 500);
|
|
1147
|
+
if (fromBody) return fromBody;
|
|
1148
|
+
|
|
1149
|
+
const fromQuery = safeText(req.query?.laptopToken, 500);
|
|
1150
|
+
if (fromQuery) return fromQuery;
|
|
1151
|
+
|
|
1152
|
+
return "";
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async function sendLaptopRpc(laptopId, request, timeoutMs = RPC_TIMEOUT_MS) {
|
|
1156
|
+
const socket = laptopSockets.get(laptopId);
|
|
1157
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
1158
|
+
const error = new Error("laptop is not connected");
|
|
1159
|
+
error.code = "offline";
|
|
1160
|
+
throw error;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const id = createId("rpc");
|
|
1164
|
+
|
|
1165
|
+
return new Promise((resolve, reject) => {
|
|
1166
|
+
const timer = setTimeout(() => {
|
|
1167
|
+
pendingRpcs.delete(id);
|
|
1168
|
+
const error = new Error("rpc request timed out");
|
|
1169
|
+
error.code = "timeout";
|
|
1170
|
+
reject(error);
|
|
1171
|
+
}, timeoutMs);
|
|
1172
|
+
|
|
1173
|
+
pendingRpcs.set(id, {
|
|
1174
|
+
id,
|
|
1175
|
+
laptopId,
|
|
1176
|
+
resolve,
|
|
1177
|
+
reject,
|
|
1178
|
+
timer
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
try {
|
|
1182
|
+
sendWsJson(socket, {
|
|
1183
|
+
type: "rpc_request",
|
|
1184
|
+
id,
|
|
1185
|
+
request: {
|
|
1186
|
+
method: String(request?.method || "GET").toUpperCase(),
|
|
1187
|
+
path: String(request?.path || "/"),
|
|
1188
|
+
headers: isObject(request?.headers) ? request.headers : {},
|
|
1189
|
+
body: request?.body
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
clearTimeout(timer);
|
|
1194
|
+
pendingRpcs.delete(id);
|
|
1195
|
+
reject(error);
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function ensureLaptopOnline(laptop, options = {}) {
|
|
1201
|
+
if (!laptop) {
|
|
1202
|
+
return { ok: false, error: "device not found", wakeAttempted: false };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (isLaptopConnected(laptop.laptopId)) {
|
|
1206
|
+
return { ok: true, wakeAttempted: false };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (!options.autoWake) {
|
|
1210
|
+
return { ok: false, error: "laptop is not connected", wakeAttempted: false };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const wakeResult = await triggerWakeAndWait(laptop, {
|
|
1214
|
+
wakeIntent: options.wakeIntent,
|
|
1215
|
+
timeoutMs: options.timeoutMs
|
|
1216
|
+
});
|
|
1217
|
+
if (wakeResult.ok) {
|
|
1218
|
+
return { ok: true, wakeAttempted: true };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
ok: false,
|
|
1223
|
+
error: wakeResult.error || "laptop is not connected",
|
|
1224
|
+
wakeAttempted: true
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async function triggerWakeAndWait(laptop, options = {}) {
|
|
1229
|
+
const pending = pendingWakeAttempts.get(laptop.laptopId);
|
|
1230
|
+
if (pending) return pending;
|
|
1231
|
+
|
|
1232
|
+
const attempt = (async () => {
|
|
1233
|
+
const wakeMacAddress = normalizeMacAddress(laptop?.wake?.macAddress);
|
|
1234
|
+
if (!RELAY_WAKE_PROXY_URL) {
|
|
1235
|
+
return { ok: false, error: "wake proxy is not configured" };
|
|
1236
|
+
}
|
|
1237
|
+
if (!wakeMacAddress) {
|
|
1238
|
+
return { ok: false, error: "wake MAC address is not configured for this laptop" };
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
mutateState(() => {
|
|
1242
|
+
laptop.lastWakeRequestedAt = Date.now();
|
|
1243
|
+
laptop.lastWakeResult = "requested";
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
const wakeProxyResponse = await callWakeProxy({
|
|
1247
|
+
laptopId: laptop.laptopId,
|
|
1248
|
+
deviceId: laptop.deviceId,
|
|
1249
|
+
macAddress: wakeMacAddress,
|
|
1250
|
+
intent: safeText(options?.wakeIntent, 80) || "auto_wake",
|
|
1251
|
+
laptopName: safeText(laptop.name, 120) || null
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
if (!wakeProxyResponse.ok) {
|
|
1255
|
+
mutateState(() => {
|
|
1256
|
+
laptop.lastWakeResult = `failed:${wakeProxyResponse.error || "unknown"}`;
|
|
1257
|
+
});
|
|
1258
|
+
return { ok: false, error: wakeProxyResponse.error || "wake proxy request failed" };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const timeoutMs = clamp(toInt(options?.timeoutMs, RELAY_WAKE_TIMEOUT_MS), 2_000, 5 * 60 * 1000);
|
|
1262
|
+
const deadline = Date.now() + timeoutMs;
|
|
1263
|
+
|
|
1264
|
+
while (Date.now() < deadline) {
|
|
1265
|
+
if (isLaptopConnected(laptop.laptopId)) {
|
|
1266
|
+
mutateState(() => {
|
|
1267
|
+
laptop.lastWakeResult = "online";
|
|
1268
|
+
});
|
|
1269
|
+
return { ok: true };
|
|
1270
|
+
}
|
|
1271
|
+
await sleep(RELAY_WAKE_POLL_INTERVAL_MS);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
mutateState(() => {
|
|
1275
|
+
laptop.lastWakeResult = "timeout";
|
|
1276
|
+
});
|
|
1277
|
+
return { ok: false, error: "wake timed out; laptop did not reconnect" };
|
|
1278
|
+
})()
|
|
1279
|
+
.catch((error) => ({ ok: false, error: String(error?.message || error) }))
|
|
1280
|
+
.finally(() => {
|
|
1281
|
+
pendingWakeAttempts.delete(laptop.laptopId);
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
pendingWakeAttempts.set(laptop.laptopId, attempt);
|
|
1285
|
+
return attempt;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function callWakeProxy(payload) {
|
|
1289
|
+
const controller = new AbortController();
|
|
1290
|
+
const timeout = setTimeout(() => controller.abort(), RELAY_WAKE_REQUEST_TIMEOUT_MS);
|
|
1291
|
+
|
|
1292
|
+
try {
|
|
1293
|
+
const headers = {
|
|
1294
|
+
"Content-Type": "application/json",
|
|
1295
|
+
Accept: "application/json"
|
|
1296
|
+
};
|
|
1297
|
+
if (RELAY_WAKE_PROXY_TOKEN) {
|
|
1298
|
+
headers.Authorization = `Bearer ${RELAY_WAKE_PROXY_TOKEN}`;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const response = await fetch(`${RELAY_WAKE_PROXY_URL}/api/wake`, {
|
|
1302
|
+
method: "POST",
|
|
1303
|
+
headers,
|
|
1304
|
+
body: JSON.stringify(payload),
|
|
1305
|
+
signal: controller.signal
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
const body = await safeParseJsonResponse(response);
|
|
1309
|
+
if (!response.ok) {
|
|
1310
|
+
return { ok: false, error: safeText(body?.error, 240) || `wake proxy error (${response.status})` };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
return { ok: true };
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
return { ok: false, error: String(error?.message || error) };
|
|
1316
|
+
} finally {
|
|
1317
|
+
clearTimeout(timeout);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function settlePendingRpc(laptopId, message) {
|
|
1322
|
+
const pending = pendingRpcs.get(message.id);
|
|
1323
|
+
if (!pending || pending.laptopId !== laptopId) return;
|
|
1324
|
+
|
|
1325
|
+
clearTimeout(pending.timer);
|
|
1326
|
+
pendingRpcs.delete(message.id);
|
|
1327
|
+
|
|
1328
|
+
pending.resolve({
|
|
1329
|
+
ok: Boolean(message.ok),
|
|
1330
|
+
status: toInt(message.status, message.ok ? 200 : 500),
|
|
1331
|
+
bodyType: safeText(message.bodyType, 20) || "json",
|
|
1332
|
+
body: message.body,
|
|
1333
|
+
bodyEncoding: safeText(message.bodyEncoding, 20) || null,
|
|
1334
|
+
responseHeaders: isObject(message.responseHeaders) ? message.responseHeaders : null,
|
|
1335
|
+
error: safeText(message.error, 1000) || null
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function rejectPendingRpcsForLaptop(laptopId, reason) {
|
|
1340
|
+
for (const [id, pending] of pendingRpcs.entries()) {
|
|
1341
|
+
if (pending.laptopId !== laptopId) continue;
|
|
1342
|
+
clearTimeout(pending.timer);
|
|
1343
|
+
pendingRpcs.delete(id);
|
|
1344
|
+
const error = new Error(reason);
|
|
1345
|
+
error.code = "offline";
|
|
1346
|
+
pending.reject(error);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function sendWsJson(ws, payload) {
|
|
1351
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1352
|
+
ws.send(JSON.stringify(payload));
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function isLaptopConnected(laptopId) {
|
|
1356
|
+
const socket = laptopSockets.get(laptopId);
|
|
1357
|
+
return Boolean(socket && socket.readyState === WebSocket.OPEN);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function createPairingForLaptop(laptop) {
|
|
1361
|
+
let code = "";
|
|
1362
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
1363
|
+
const candidate = generatePairCode();
|
|
1364
|
+
if (!findPairingByCode(candidate)) {
|
|
1365
|
+
code = candidate;
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (!code) {
|
|
1371
|
+
code = `${generatePairCode()}${Math.floor(Math.random() * 10)}`;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const now = Date.now();
|
|
1375
|
+
const expiresAt = now + PAIRING_TTL_MS;
|
|
1376
|
+
const pairingPayload = {
|
|
1377
|
+
relayUrl: RELAY_PUBLIC_URL,
|
|
1378
|
+
code,
|
|
1379
|
+
deviceId: laptop.deviceId,
|
|
1380
|
+
laptopId: laptop.laptopId
|
|
1381
|
+
};
|
|
1382
|
+
const pairingUrl = `${RELAY_PUBLIC_URL}/pair?code=${code}`;
|
|
1383
|
+
|
|
1384
|
+
const pairing = {
|
|
1385
|
+
code,
|
|
1386
|
+
laptopId: laptop.laptopId,
|
|
1387
|
+
deviceId: laptop.deviceId,
|
|
1388
|
+
createdAt: now,
|
|
1389
|
+
expiresAt,
|
|
1390
|
+
claimedAt: null,
|
|
1391
|
+
phoneToken: null,
|
|
1392
|
+
pairingUrl,
|
|
1393
|
+
pairingPayload
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
mutateState(() => {
|
|
1397
|
+
state.pairings = state.pairings.filter((item) => item.laptopId !== laptop.laptopId || Boolean(item.claimedAt));
|
|
1398
|
+
state.pairings.push(pairing);
|
|
1399
|
+
laptop.pairCode = code;
|
|
1400
|
+
laptop.pairingExpiresAt = expiresAt;
|
|
1401
|
+
laptop.pairingUrl = pairingUrl;
|
|
1402
|
+
laptop.pairingPayload = pairingPayload;
|
|
1403
|
+
laptop.updatedAt = now;
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
return pairing;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function cleanupExpiredPairings() {
|
|
1410
|
+
const now = Date.now();
|
|
1411
|
+
let changed = false;
|
|
1412
|
+
|
|
1413
|
+
const nextPairings = [];
|
|
1414
|
+
for (const pairing of state.pairings) {
|
|
1415
|
+
const isExpired = pairing.expiresAt <= now;
|
|
1416
|
+
const keepClaimed = pairing.claimedAt && now - pairing.claimedAt < 7 * 24 * 60 * 60 * 1000;
|
|
1417
|
+
|
|
1418
|
+
if (!isExpired || keepClaimed) {
|
|
1419
|
+
nextPairings.push(pairing);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (!pairing.claimedAt) {
|
|
1424
|
+
const laptop = findLaptopById(pairing.laptopId);
|
|
1425
|
+
if (laptop && laptop.pairCode === pairing.code) {
|
|
1426
|
+
laptop.pairCode = null;
|
|
1427
|
+
laptop.pairingExpiresAt = null;
|
|
1428
|
+
laptop.pairingUrl = null;
|
|
1429
|
+
laptop.pairingPayload = null;
|
|
1430
|
+
laptop.updatedAt = now;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
changed = true;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (!changed) return;
|
|
1438
|
+
state.pairings = nextPairings;
|
|
1439
|
+
state.updatedAt = now;
|
|
1440
|
+
schedulePersist();
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function cleanupExpiredPreviews() {
|
|
1444
|
+
const now = Date.now();
|
|
1445
|
+
const next = state.previews.filter((item) => item.expiresAt > now);
|
|
1446
|
+
if (next.length === state.previews.length) return;
|
|
1447
|
+
|
|
1448
|
+
state.previews = next;
|
|
1449
|
+
state.updatedAt = now;
|
|
1450
|
+
schedulePersist();
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function findLaptopById(laptopId) {
|
|
1454
|
+
return state.laptops.find((item) => item.laptopId === laptopId) || null;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function findLaptopByToken(token) {
|
|
1458
|
+
if (!token) return null;
|
|
1459
|
+
return state.laptops.find((item) => item.laptopToken === token) || null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function findLaptopByDeviceId(deviceId) {
|
|
1463
|
+
if (!deviceId) return null;
|
|
1464
|
+
return state.laptops.find((item) => item.deviceId === deviceId) || null;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function findPairingByCode(code) {
|
|
1468
|
+
if (!code) return null;
|
|
1469
|
+
return state.pairings.find((item) => item.code === code) || null;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function findPhoneByToken(token) {
|
|
1473
|
+
if (!token) return null;
|
|
1474
|
+
return state.phones.find((item) => item.phoneToken === token) || null;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function resolvePhoneSession(token) {
|
|
1478
|
+
const exact = findPhoneByToken(token);
|
|
1479
|
+
if (exact) {
|
|
1480
|
+
const refreshedToken = issuePhoneToken(exact.deviceId);
|
|
1481
|
+
return {
|
|
1482
|
+
phone: exact,
|
|
1483
|
+
refreshedToken: refreshedToken.startsWith("ptkn1_") ? refreshedToken : null
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const claims = parseSignedToken(token, "ptkn1");
|
|
1488
|
+
if (!claims || safeText(claims.kind, 40) !== "phone") return null;
|
|
1489
|
+
|
|
1490
|
+
const deviceId = safeText(claims.deviceId, 200);
|
|
1491
|
+
if (!deviceId) return null;
|
|
1492
|
+
|
|
1493
|
+
const restored = {
|
|
1494
|
+
phoneToken: token,
|
|
1495
|
+
deviceId,
|
|
1496
|
+
createdAt: Date.now(),
|
|
1497
|
+
lastUsedAt: Date.now()
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
mutateState(() => {
|
|
1501
|
+
if (!state.phones.some((item) => item.phoneToken === token)) {
|
|
1502
|
+
state.phones.push(restored);
|
|
1503
|
+
trimStateCollections();
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
return {
|
|
1508
|
+
phone: findPhoneByToken(token) || restored,
|
|
1509
|
+
refreshedToken: null
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function resolveLaptopAuth(token) {
|
|
1514
|
+
const exact = findLaptopByToken(token);
|
|
1515
|
+
if (exact) {
|
|
1516
|
+
const refreshedToken = issueLaptopToken(exact.laptopId, exact.deviceId);
|
|
1517
|
+
return {
|
|
1518
|
+
laptop: exact,
|
|
1519
|
+
refreshedToken: refreshedToken.startsWith("ltkn1_") ? refreshedToken : null
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const restored = restoreLaptopFromSignedToken(token);
|
|
1524
|
+
if (!restored) return null;
|
|
1525
|
+
|
|
1526
|
+
return {
|
|
1527
|
+
laptop: restored,
|
|
1528
|
+
refreshedToken: null
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function resolveLaptopSession(token) {
|
|
1533
|
+
const resolved = resolveLaptopAuth(token);
|
|
1534
|
+
return resolved?.laptop || null;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function restoreLaptopFromSignedToken(token) {
|
|
1538
|
+
const claims = parseSignedToken(token, "ltkn1");
|
|
1539
|
+
if (!claims || safeText(claims.kind, 40) !== "laptop") return null;
|
|
1540
|
+
|
|
1541
|
+
const laptopId = safeText(claims.laptopId, 200);
|
|
1542
|
+
const deviceId = safeText(claims.deviceId, 200);
|
|
1543
|
+
if (!laptopId || !deviceId) return null;
|
|
1544
|
+
|
|
1545
|
+
const existing = findLaptopById(laptopId) || findLaptopByDeviceId(deviceId);
|
|
1546
|
+
if (existing) {
|
|
1547
|
+
return existing;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const restored = {
|
|
1551
|
+
laptopId,
|
|
1552
|
+
deviceId,
|
|
1553
|
+
laptopToken: token,
|
|
1554
|
+
name: null,
|
|
1555
|
+
createdAt: Date.now(),
|
|
1556
|
+
updatedAt: Date.now(),
|
|
1557
|
+
pairedAt: null,
|
|
1558
|
+
pairCode: null,
|
|
1559
|
+
pairingExpiresAt: null,
|
|
1560
|
+
pairingUrl: null,
|
|
1561
|
+
pairingPayload: null,
|
|
1562
|
+
lastConnectedAt: null,
|
|
1563
|
+
lastDisconnectedAt: null,
|
|
1564
|
+
lastSnapshotAt: null,
|
|
1565
|
+
latestSnapshot: null,
|
|
1566
|
+
wake: {
|
|
1567
|
+
macAddress: null
|
|
1568
|
+
},
|
|
1569
|
+
lastWakeRequestedAt: null,
|
|
1570
|
+
lastWakeResult: null
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
mutateState(() => {
|
|
1574
|
+
const alreadyThere = findLaptopById(laptopId) || findLaptopByDeviceId(deviceId);
|
|
1575
|
+
if (!alreadyThere) {
|
|
1576
|
+
state.laptops.push(restored);
|
|
1577
|
+
trimStateCollections();
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
return findLaptopById(laptopId) || restored;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function findPreviewByAccessToken(accessToken) {
|
|
1585
|
+
if (!accessToken) return null;
|
|
1586
|
+
return state.previews.find((item) => item.accessToken === accessToken) || null;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function listPreviewsForDevice(deviceId) {
|
|
1590
|
+
if (!deviceId) return [];
|
|
1591
|
+
return state.previews
|
|
1592
|
+
.filter((item) => item.deviceId === deviceId && item.expiresAt > Date.now())
|
|
1593
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function serializePreview(preview, options = {}) {
|
|
1597
|
+
const token = safeText(preview?.accessToken, 500);
|
|
1598
|
+
const publicUrl = token ? `${RELAY_PUBLIC_URL}/p/${encodeURIComponent(token)}/` : "";
|
|
1599
|
+
return {
|
|
1600
|
+
id: safeText(preview?.previewId, 200),
|
|
1601
|
+
deviceId: safeText(preview?.deviceId, 200),
|
|
1602
|
+
laptopId: safeText(preview?.laptopId, 200),
|
|
1603
|
+
label: safeText(preview?.label, 120) || null,
|
|
1604
|
+
target: safeText(preview?.target, 2000),
|
|
1605
|
+
createdAt: toInt(preview?.createdAt, Date.now()),
|
|
1606
|
+
updatedAt: toInt(preview?.updatedAt, Date.now()),
|
|
1607
|
+
lastAccessedAt: preview?.lastAccessedAt ? toInt(preview.lastAccessedAt, null) : null,
|
|
1608
|
+
expiresAt: toInt(preview?.expiresAt, Date.now()),
|
|
1609
|
+
connected: options.connected === true,
|
|
1610
|
+
publicUrl
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function normalizePreviewTarget(targetUrlInput, portInput) {
|
|
1615
|
+
let target = safeText(targetUrlInput, 2000);
|
|
1616
|
+
if (!target && portInput !== undefined && portInput !== null && String(portInput).trim()) {
|
|
1617
|
+
const port = toInt(portInput, 0);
|
|
1618
|
+
if (port <= 0 || port > 65535) return "";
|
|
1619
|
+
target = `http://127.0.0.1:${port}`;
|
|
1620
|
+
}
|
|
1621
|
+
if (!target) return "";
|
|
1622
|
+
|
|
1623
|
+
let parsed;
|
|
1624
|
+
try {
|
|
1625
|
+
parsed = new URL(target);
|
|
1626
|
+
} catch {
|
|
1627
|
+
return "";
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
|
|
1631
|
+
const host = String(parsed.hostname || "").toLowerCase();
|
|
1632
|
+
if (host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "0.0.0.0") return "";
|
|
1633
|
+
|
|
1634
|
+
const normalizedHost = host === "localhost" || host === "::1" || host === "0.0.0.0" ? "127.0.0.1" : host;
|
|
1635
|
+
const normalized = new URL(`${parsed.protocol}//${normalizedHost}`);
|
|
1636
|
+
if (parsed.port) {
|
|
1637
|
+
const port = toInt(parsed.port, 0);
|
|
1638
|
+
if (port <= 0 || port > 65535) return "";
|
|
1639
|
+
normalized.port = String(port);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
return trimTrailingSlash(normalized.toString());
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function safePreviewPathSuffix(value) {
|
|
1646
|
+
const segment = safeText(value, 4000);
|
|
1647
|
+
if (!segment) return "/";
|
|
1648
|
+
return segment.startsWith("/") ? segment : `/${segment}`;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function sanitizePreviewForwardHeaders(headersInput) {
|
|
1652
|
+
if (!isObject(headersInput)) return {};
|
|
1653
|
+
const out = {};
|
|
1654
|
+
|
|
1655
|
+
for (const [nameRaw, valueRaw] of Object.entries(headersInput)) {
|
|
1656
|
+
if (Array.isArray(valueRaw)) continue;
|
|
1657
|
+
if (typeof valueRaw !== "string") continue;
|
|
1658
|
+
const name = String(nameRaw || "").trim();
|
|
1659
|
+
if (!name) continue;
|
|
1660
|
+
const lower = name.toLowerCase();
|
|
1661
|
+
if (
|
|
1662
|
+
lower === "host" ||
|
|
1663
|
+
lower === "content-length" ||
|
|
1664
|
+
lower === "connection" ||
|
|
1665
|
+
lower === "transfer-encoding" ||
|
|
1666
|
+
lower === "upgrade" ||
|
|
1667
|
+
lower === "authorization" ||
|
|
1668
|
+
lower === "x-phone-token" ||
|
|
1669
|
+
lower === "x-laptop-token"
|
|
1670
|
+
) {
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
out[name] = valueRaw;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
return out;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function normalizePreviewForwardBody(req) {
|
|
1681
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
1682
|
+
if (method === "GET" || method === "HEAD") return undefined;
|
|
1683
|
+
|
|
1684
|
+
if (req.body === undefined || req.body === null) return undefined;
|
|
1685
|
+
if (typeof req.body === "string") return req.body;
|
|
1686
|
+
if (Buffer.isBuffer(req.body)) return req.body.toString("utf8");
|
|
1687
|
+
if (typeof req.body === "object") return req.body;
|
|
1688
|
+
return String(req.body);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function withQuery(pathname, queryInput) {
|
|
1692
|
+
const params = new URLSearchParams();
|
|
1693
|
+
|
|
1694
|
+
for (const [key, value] of Object.entries(queryInput || {})) {
|
|
1695
|
+
if (Array.isArray(value)) {
|
|
1696
|
+
for (const item of value) {
|
|
1697
|
+
if (item === undefined || item === null) continue;
|
|
1698
|
+
params.append(key, String(item));
|
|
1699
|
+
}
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
if (value === undefined || value === null) continue;
|
|
1704
|
+
params.set(key, String(value));
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
const query = params.toString();
|
|
1708
|
+
if (!query) return pathname;
|
|
1709
|
+
return `${pathname}?${query}`;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function createId(prefix) {
|
|
1713
|
+
return `${prefix}_${randomBytes(8).toString("hex")}`;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function createToken(prefix) {
|
|
1717
|
+
return `${prefix}_${randomBytes(20).toString("hex")}`;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function issuePhoneToken(deviceId) {
|
|
1721
|
+
const normalizedDeviceId = safeText(deviceId, 200);
|
|
1722
|
+
if (!normalizedDeviceId || !RELAY_TOKEN_SECRET) {
|
|
1723
|
+
return createToken("ptkn");
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
return createSignedToken("ptkn1", {
|
|
1727
|
+
kind: "phone",
|
|
1728
|
+
deviceId: normalizedDeviceId
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function issueLaptopToken(laptopId, deviceId) {
|
|
1733
|
+
const normalizedLaptopId = safeText(laptopId, 200);
|
|
1734
|
+
const normalizedDeviceId = safeText(deviceId, 200);
|
|
1735
|
+
if (!normalizedLaptopId || !normalizedDeviceId || !RELAY_TOKEN_SECRET) {
|
|
1736
|
+
return createToken("ltkn");
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
return createSignedToken("ltkn1", {
|
|
1740
|
+
kind: "laptop",
|
|
1741
|
+
laptopId: normalizedLaptopId,
|
|
1742
|
+
deviceId: normalizedDeviceId
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function preferredLaptopToken(laptop) {
|
|
1747
|
+
const upgraded = issueLaptopToken(laptop?.laptopId, laptop?.deviceId);
|
|
1748
|
+
return upgraded.startsWith("ltkn1_") ? upgraded : safeText(laptop?.laptopToken, 1000);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function createSignedToken(prefix, payload) {
|
|
1752
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
1753
|
+
const signature = createHmac("sha256", RELAY_TOKEN_SECRET).update(`${prefix}.${body}`).digest("base64url");
|
|
1754
|
+
return `${prefix}_${body}.${signature}`;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function parseSignedToken(token, expectedPrefix) {
|
|
1758
|
+
if (!RELAY_TOKEN_SECRET) return null;
|
|
1759
|
+
|
|
1760
|
+
const raw = safeText(token, 4000);
|
|
1761
|
+
if (!raw.startsWith(`${expectedPrefix}_`)) return null;
|
|
1762
|
+
|
|
1763
|
+
const composite = raw.slice(expectedPrefix.length + 1);
|
|
1764
|
+
const separatorIndex = composite.lastIndexOf(".");
|
|
1765
|
+
if (separatorIndex <= 0 || separatorIndex >= composite.length - 1) return null;
|
|
1766
|
+
|
|
1767
|
+
const body = composite.slice(0, separatorIndex);
|
|
1768
|
+
const signature = composite.slice(separatorIndex + 1);
|
|
1769
|
+
|
|
1770
|
+
let actual;
|
|
1771
|
+
try {
|
|
1772
|
+
actual = Buffer.from(signature, "base64url");
|
|
1773
|
+
} catch {
|
|
1774
|
+
return null;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const expected = createHmac("sha256", RELAY_TOKEN_SECRET).update(`${expectedPrefix}.${body}`).digest();
|
|
1778
|
+
if (actual.length !== expected.length) return null;
|
|
1779
|
+
if (!timingSafeEqual(actual, expected)) return null;
|
|
1780
|
+
|
|
1781
|
+
try {
|
|
1782
|
+
const parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
1783
|
+
return isObject(parsed) ? parsed : null;
|
|
1784
|
+
} catch {
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function generatePairCode() {
|
|
1790
|
+
const alphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1791
|
+
let out = "";
|
|
1792
|
+
for (let i = 0; i < 6; i += 1) {
|
|
1793
|
+
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
1794
|
+
}
|
|
1795
|
+
return out;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function normalizePairCode(value) {
|
|
1799
|
+
const normalized = String(value || "")
|
|
1800
|
+
.trim()
|
|
1801
|
+
.toUpperCase()
|
|
1802
|
+
.replace(/[^A-Z0-9]/g, "");
|
|
1803
|
+
return normalized || "";
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function resolveRelayTokenSecret() {
|
|
1807
|
+
const explicit = safeText(process.env.RELAY_TOKEN_SECRET || process.env.AGENT_RELAY_TOKEN_SECRET || "", 1000);
|
|
1808
|
+
if (explicit) return explicit;
|
|
1809
|
+
|
|
1810
|
+
const renderServiceId = safeText(process.env.RENDER_SERVICE_ID || "", 500);
|
|
1811
|
+
if (renderServiceId) return `render:${renderServiceId}`;
|
|
1812
|
+
|
|
1813
|
+
if (/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?$/i.test(RELAY_PUBLIC_URL)) {
|
|
1814
|
+
return "agent-companion-local-dev-secret";
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
return "";
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function mutateState(mutator) {
|
|
1821
|
+
mutator();
|
|
1822
|
+
state.updatedAt = Date.now();
|
|
1823
|
+
schedulePersist();
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function schedulePersist() {
|
|
1827
|
+
if (persistTimer) return;
|
|
1828
|
+
persistTimer = setTimeout(() => {
|
|
1829
|
+
persistTimer = null;
|
|
1830
|
+
persistStateNow();
|
|
1831
|
+
}, 120);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function persistStateNow() {
|
|
1835
|
+
try {
|
|
1836
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
1837
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
console.error("[relay] failed to persist state:", error);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function loadState() {
|
|
1844
|
+
const fallback = {
|
|
1845
|
+
laptops: [],
|
|
1846
|
+
pairings: [],
|
|
1847
|
+
phones: [],
|
|
1848
|
+
previews: [],
|
|
1849
|
+
updatedAt: Date.now()
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
try {
|
|
1853
|
+
if (!fs.existsSync(STATE_FILE)) {
|
|
1854
|
+
return fallback;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
1858
|
+
return sanitizeState(parsed);
|
|
1859
|
+
} catch {
|
|
1860
|
+
return fallback;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function sanitizeState(raw) {
|
|
1865
|
+
const fallback = {
|
|
1866
|
+
laptops: [],
|
|
1867
|
+
pairings: [],
|
|
1868
|
+
phones: [],
|
|
1869
|
+
previews: [],
|
|
1870
|
+
updatedAt: Date.now()
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
if (!isObject(raw)) return fallback;
|
|
1874
|
+
|
|
1875
|
+
const laptops = Array.isArray(raw.laptops)
|
|
1876
|
+
? raw.laptops
|
|
1877
|
+
.filter((item) => isObject(item))
|
|
1878
|
+
.map((item) => ({
|
|
1879
|
+
laptopId: safeText(item.laptopId, 200),
|
|
1880
|
+
deviceId: safeText(item.deviceId, 200),
|
|
1881
|
+
laptopToken: safeText(item.laptopToken, 500),
|
|
1882
|
+
name: safeText(item.name, 120) || null,
|
|
1883
|
+
createdAt: toInt(item.createdAt, Date.now()),
|
|
1884
|
+
updatedAt: toInt(item.updatedAt, Date.now()),
|
|
1885
|
+
pairedAt: item.pairedAt ? toInt(item.pairedAt, null) : null,
|
|
1886
|
+
pairCode: safeText(item.pairCode, 40) || null,
|
|
1887
|
+
pairingExpiresAt: item.pairingExpiresAt ? toInt(item.pairingExpiresAt, null) : null,
|
|
1888
|
+
pairingUrl: safeText(item.pairingUrl, 2000) || null,
|
|
1889
|
+
pairingPayload: isObject(item.pairingPayload) ? item.pairingPayload : null,
|
|
1890
|
+
lastConnectedAt: item.lastConnectedAt ? toInt(item.lastConnectedAt, null) : null,
|
|
1891
|
+
lastDisconnectedAt: item.lastDisconnectedAt ? toInt(item.lastDisconnectedAt, null) : null,
|
|
1892
|
+
lastSnapshotAt: item.lastSnapshotAt ? toInt(item.lastSnapshotAt, null) : null,
|
|
1893
|
+
latestSnapshot: isObject(item.latestSnapshot) ? item.latestSnapshot : null,
|
|
1894
|
+
wake: {
|
|
1895
|
+
macAddress: normalizeMacAddress(item?.wake?.macAddress || item?.wakeMac || item?.macAddress) || null
|
|
1896
|
+
},
|
|
1897
|
+
lastWakeRequestedAt: item.lastWakeRequestedAt ? toInt(item.lastWakeRequestedAt, null) : null,
|
|
1898
|
+
lastWakeResult: safeText(item.lastWakeResult, 120) || null
|
|
1899
|
+
}))
|
|
1900
|
+
.filter((item) => item.laptopId && item.deviceId && item.laptopToken)
|
|
1901
|
+
: [];
|
|
1902
|
+
|
|
1903
|
+
const pairings = Array.isArray(raw.pairings)
|
|
1904
|
+
? raw.pairings
|
|
1905
|
+
.filter((item) => isObject(item))
|
|
1906
|
+
.map((item) => ({
|
|
1907
|
+
code: normalizePairCode(item.code),
|
|
1908
|
+
laptopId: safeText(item.laptopId, 200),
|
|
1909
|
+
deviceId: safeText(item.deviceId, 200),
|
|
1910
|
+
createdAt: toInt(item.createdAt, Date.now()),
|
|
1911
|
+
expiresAt: toInt(item.expiresAt, Date.now()),
|
|
1912
|
+
claimedAt: item.claimedAt ? toInt(item.claimedAt, null) : null,
|
|
1913
|
+
phoneToken: safeText(item.phoneToken, 500) || null,
|
|
1914
|
+
pairingUrl: safeText(item.pairingUrl, 2000) || null,
|
|
1915
|
+
pairingPayload: isObject(item.pairingPayload) ? item.pairingPayload : null
|
|
1916
|
+
}))
|
|
1917
|
+
.filter((item) => item.code && item.laptopId && item.deviceId)
|
|
1918
|
+
: [];
|
|
1919
|
+
|
|
1920
|
+
const phones = Array.isArray(raw.phones)
|
|
1921
|
+
? raw.phones
|
|
1922
|
+
.filter((item) => isObject(item))
|
|
1923
|
+
.map((item) => ({
|
|
1924
|
+
phoneToken: safeText(item.phoneToken, 500),
|
|
1925
|
+
deviceId: safeText(item.deviceId, 200),
|
|
1926
|
+
createdAt: toInt(item.createdAt, Date.now()),
|
|
1927
|
+
lastUsedAt: toInt(item.lastUsedAt, Date.now())
|
|
1928
|
+
}))
|
|
1929
|
+
.filter((item) => item.phoneToken && item.deviceId)
|
|
1930
|
+
: [];
|
|
1931
|
+
|
|
1932
|
+
const previews = Array.isArray(raw.previews)
|
|
1933
|
+
? raw.previews
|
|
1934
|
+
.filter((item) => isObject(item))
|
|
1935
|
+
.map((item) => ({
|
|
1936
|
+
previewId: safeText(item.previewId, 200),
|
|
1937
|
+
accessToken: safeText(item.accessToken, 500),
|
|
1938
|
+
laptopId: safeText(item.laptopId, 200),
|
|
1939
|
+
deviceId: safeText(item.deviceId, 200),
|
|
1940
|
+
label: safeText(item.label, 120) || null,
|
|
1941
|
+
target: normalizePreviewTarget(item.target, null),
|
|
1942
|
+
createdAt: toInt(item.createdAt, Date.now()),
|
|
1943
|
+
updatedAt: toInt(item.updatedAt, Date.now()),
|
|
1944
|
+
lastAccessedAt: item.lastAccessedAt ? toInt(item.lastAccessedAt, null) : null,
|
|
1945
|
+
expiresAt: toInt(item.expiresAt, Date.now()),
|
|
1946
|
+
createdByPhoneToken: safeText(item.createdByPhoneToken, 500) || null
|
|
1947
|
+
}))
|
|
1948
|
+
.filter((item) => item.previewId && item.accessToken && item.laptopId && item.deviceId && item.target)
|
|
1949
|
+
: [];
|
|
1950
|
+
|
|
1951
|
+
return {
|
|
1952
|
+
laptops,
|
|
1953
|
+
pairings,
|
|
1954
|
+
phones,
|
|
1955
|
+
previews,
|
|
1956
|
+
updatedAt: toInt(raw.updatedAt, Date.now())
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function trimStateCollections() {
|
|
1961
|
+
if (state.laptops.length > MAX_LAPTOP_RECORDS) {
|
|
1962
|
+
state.laptops = state.laptops
|
|
1963
|
+
.slice()
|
|
1964
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
1965
|
+
.slice(0, MAX_LAPTOP_RECORDS);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
if (state.phones.length > MAX_PHONE_TOKENS) {
|
|
1969
|
+
state.phones = state.phones
|
|
1970
|
+
.slice()
|
|
1971
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
1972
|
+
.slice(0, MAX_PHONE_TOKENS);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (state.previews.length > MAX_PREVIEW_RECORDS) {
|
|
1976
|
+
state.previews = state.previews
|
|
1977
|
+
.slice()
|
|
1978
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
1979
|
+
.slice(0, MAX_PREVIEW_RECORDS);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function gracefulShutdown(signal) {
|
|
1984
|
+
if (shuttingDown) return;
|
|
1985
|
+
shuttingDown = true;
|
|
1986
|
+
|
|
1987
|
+
console.log(`[relay] received ${signal}, shutting down`);
|
|
1988
|
+
|
|
1989
|
+
try {
|
|
1990
|
+
persistStateNow();
|
|
1991
|
+
} catch {
|
|
1992
|
+
// ignore
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
for (const ws of laptopSockets.values()) {
|
|
1996
|
+
try {
|
|
1997
|
+
ws.close(1001, "relay shutting down");
|
|
1998
|
+
} catch {
|
|
1999
|
+
// ignore
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
server.close(() => process.exit(0));
|
|
2004
|
+
setTimeout(() => process.exit(0), 800).unref();
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function isObject(value) {
|
|
2008
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function toInt(value, fallback = 0) {
|
|
2012
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
2013
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function clamp(value, min, max) {
|
|
2017
|
+
return Math.max(min, Math.min(max, value));
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function safeText(value, maxLength) {
|
|
2021
|
+
if (typeof value !== "string") return "";
|
|
2022
|
+
const trimmed = value.trim();
|
|
2023
|
+
if (!trimmed) return "";
|
|
2024
|
+
if (trimmed.length <= maxLength) return trimmed;
|
|
2025
|
+
return trimmed.slice(0, maxLength);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function trimTrailingSlash(value) {
|
|
2029
|
+
const trimmed = String(value || "").trim();
|
|
2030
|
+
if (!trimmed) return "";
|
|
2031
|
+
return trimmed.replace(/\/+$/, "");
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function normalizeMacAddress(value) {
|
|
2035
|
+
const raw = String(value || "")
|
|
2036
|
+
.trim()
|
|
2037
|
+
.toUpperCase()
|
|
2038
|
+
.replace(/[^0-9A-F]/g, "");
|
|
2039
|
+
if (raw.length !== 12) return "";
|
|
2040
|
+
const chunks = raw.match(/.{1,2}/g);
|
|
2041
|
+
return chunks ? chunks.join(":") : "";
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
async function safeParseJsonResponse(response) {
|
|
2045
|
+
try {
|
|
2046
|
+
return await response.json();
|
|
2047
|
+
} catch {
|
|
2048
|
+
return null;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function sleep(ms) {
|
|
2053
|
+
return new Promise((resolve) => {
|
|
2054
|
+
setTimeout(resolve, ms);
|
|
2055
|
+
});
|
|
2056
|
+
}
|