@pablopunk/tau 0.0.0-stage → 0.1.1

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.
Files changed (69) hide show
  1. package/dist/auth-storage-Dk3CUY3q.js +1814 -0
  2. package/dist/bin.js +23 -0
  3. package/dist/cli-entry-DcDpQ30L.js +13 -0
  4. package/dist/client-C91-b2CL.js +1 -0
  5. package/dist/client-y2WkNuZj.js +7977 -0
  6. package/dist/dist-BFP19NOO.js +432 -0
  7. package/dist/dist-Bh1bQHZA.js +38890 -0
  8. package/dist/dist-Cq73_r65.js +3 -0
  9. package/dist/esm-CMGh_WhC.js +9446 -0
  10. package/dist/execute-BeGXhU1O.js +1757 -0
  11. package/dist/fake-claude-connectors-DSP9UC8d.js +1 -0
  12. package/dist/fake-claude-connectors-sDLeP10T.js +107 -0
  13. package/dist/fake-claude-sdk-CvqW_1iQ.js +571 -0
  14. package/dist/fake-claude-thread-script-2iHHwUZM.js +48 -0
  15. package/dist/last-exit-write-ksHeHGrC.js +9 -0
  16. package/dist/latest-version-di9hD7cY.js +106 -0
  17. package/dist/lib-DIX7q_HJ.js +1460 -0
  18. package/dist/machines-behind-BjGgmSs_.js +257 -0
  19. package/dist/main-D-Tg9b5Y.js +1610 -0
  20. package/dist/manager-B_WHbb1U.js +589 -0
  21. package/dist/names-cTrEXB6w.js +35 -0
  22. package/dist/node-BpwblPFP.js +56269 -0
  23. package/dist/pdfjs-nTcHjDPT.js +69856 -0
  24. package/dist/photon_rs-DBr7W_rK.js +4252 -0
  25. package/dist/promise.try-CAAOYvMP.js +1426 -0
  26. package/dist/readability-DsDNO63a.js +1670 -0
  27. package/dist/real-deps-f96UmLBz.js +89 -0
  28. package/dist/rolldown-runtime-1p2KPIdQ.js +44 -0
  29. package/dist/runtime-i-oEtgAl.js +2721 -0
  30. package/dist/sdk-missing-CHbVbY1G.js +10 -0
  31. package/dist/sdk-port-BGw9ORUR.js +215 -0
  32. package/dist/serve-Cmy0rUWr.js +2932 -0
  33. package/dist/serve-Dmzn5V0l.js +1 -0
  34. package/dist/service-command-C74K0saj.js +34 -0
  35. package/dist/service-command-DqcstJ8e.js +1 -0
  36. package/dist/shell-CQa4e7rx.js +643 -0
  37. package/dist/skills/builtin/tau/SKILL.md +51 -0
  38. package/dist/src-DO9LUlhi.js +44088 -0
  39. package/dist/streamableHttp-BBaRYV9h.js +1 -0
  40. package/dist/streamableHttp-DPAoMlJd.js +1931 -0
  41. package/dist/tool-DBZK2lci.js +14335 -0
  42. package/dist/tool-server-BjP37EJg.js +40 -0
  43. package/dist/turndown.cjs-Bo1xLg2S.js +15620 -0
  44. package/dist/turndown.cjs-jpUF50AR.js +3 -0
  45. package/dist/types-C4M3CRp2.js +7592 -0
  46. package/dist/undici-8cI97lnP.js +27110 -0
  47. package/dist/wait-for-lock-B1Qx8GWg.js +1 -0
  48. package/dist/wait-for-lock-CrSl2mi1.js +25 -0
  49. package/dist/watchdog.js +47 -0
  50. package/dist/web/dist/assets/addon-fit-DIOBYJe3.js +1 -0
  51. package/dist/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  52. package/dist/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  53. package/dist/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  54. package/dist/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  55. package/dist/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  56. package/dist/web/dist/assets/index-BktLKZHR.js +108 -0
  57. package/dist/web/dist/assets/index-D_Aa3Oou.css +2 -0
  58. package/dist/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  59. package/dist/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  60. package/dist/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  61. package/dist/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  62. package/dist/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  63. package/dist/web/dist/assets/wght-DMLdVLkb.css +1 -0
  64. package/dist/web/dist/assets/xterm-BrP-ENHg.css +1 -0
  65. package/dist/web/dist/assets/xterm-CerOhKYV.js +16 -0
  66. package/dist/web/dist/favicon.svg +14 -0
  67. package/dist/web/dist/index.html +25 -0
  68. package/package.json +53 -4
  69. package/README.md +0 -3
@@ -0,0 +1,2932 @@
1
+ import { $ as routes, A as changedFrame, C as LIVE_MAX_FRAME_BYTES, D as TERMINAL_STREAM_PATH, E as TERMINAL_STREAM_MAX_INPUT_BYTES, F as ERROR_STATUS, I as isAppError, J as LIVE_PATH, N as encodeFrame, O as TerminalClientFrameSchema, P as AppError, Q as iconCandidateUrl, R as toErrorBody, S as encodeDataFrame, T as TERMINAL_STREAM_CLOSE_UNKNOWN, X as PAIR_PAGE_PATH, Y as MESSAGE_BODY_LIMIT, b as chunkBody, d as CHUNK_BYTES, et as HUB_LINK_NONE, f as CREDIT_WINDOW_BYTES, i as createExitMarker, j as decodeClientFrame, k as WS_MAX_PAYLOAD_BYTES, m as LINK_CLOSE_UPDATE_REQUIRED, n as readRunningInstance, p as LINK_CLOSE_NO_CREDIT, r as createCore, rt as RETIRED_OPS, t as publishServerUrl, tt as MACHINE_PROXY_PREFIX, u as PROTOCOL_VERSION, v as LinkFrameSchema, w as TERMINAL_STREAM_CLOSE_SLOW, x as decodeDataFrame, y as MACHINE_LINK_PATH } from "./src-DO9LUlhi.js";
2
+ import { n as waitForFreeHome } from "./wait-for-lock-CrSl2mi1.js";
3
+ import { a as cliVersion, c as writePrivateFile, i as spawnCommand, n as fetchLatestVersion, o as readOrCreateToken, s as ensurePrivateDirectory } from "./latest-version-di9hD7cY.js";
4
+ import { t as cliEntry } from "./cli-entry-DcDpQ30L.js";
5
+ import { Worker } from "node:worker_threads";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { arch, homedir, hostname, platform } from "node:os";
9
+ import { fileURLToPath } from "node:url";
10
+ import { format, parseArgs } from "node:util";
11
+ import { createHash, timingSafeEqual } from "node:crypto";
12
+ import { readFile, rm } from "node:fs/promises";
13
+ import { Value } from "typebox/value";
14
+ import { createServer } from "node:net";
15
+ import { request } from "node:http";
16
+ import fastifyCookie from "@fastify/cookie";
17
+ import fastifyWebsocket from "@fastify/websocket";
18
+ import Fastify from "fastify";
19
+ import fastifyStatic from "@fastify/static";
20
+ import WebSocket from "ws";
21
+ //#region ../protocol/src/machine-rules.ts
22
+ const NO_CHAINS = "This tau is linked to a hub, so other machines cannot link to it. Disconnect it first.";
23
+ const NO_LOOPS = "Other machines are linked to this tau, so it cannot link to a hub. Disconnect them first.";
24
+ /** No chains, no loops (two-way): a tau that is linked to a hub never accepts machine links. */
25
+ function acceptLinkRefusal(hub) {
26
+ return hub.state === "none" ? void 0 : NO_CHAINS;
27
+ }
28
+ /** `tau connect` is refused when this tau already has linked machines. */
29
+ function connectRefusal(linkedMachines) {
30
+ return linkedMachines > 0 ? NO_LOOPS : void 0;
31
+ }
32
+ /** `tau connect` is also refused when this tau already has a hub (one hub at a time). */
33
+ function alreadyLinkedRefusal(hasHubFile) {
34
+ return hasHubFile ? "This tau is already linked to a hub. Run `tau disconnect` first." : void 0;
35
+ }
36
+ //#endregion
37
+ //#region ../core/src/remote/tunnel-target.ts
38
+ function createTunnelTargetRef() {
39
+ let resolve = () => void 0;
40
+ const target = new Promise((done) => {
41
+ resolve = done;
42
+ });
43
+ return {
44
+ get: () => target,
45
+ set: (port) => resolve(`127.0.0.1:${port}`)
46
+ };
47
+ }
48
+ //#endregion
49
+ //#region ../core/src/watchdog/start-watchdog.ts
50
+ const BEAT_MS = 1e3;
51
+ /** Beats a shared counter once a second from the main thread; a worker kills the process when it stops moving. */
52
+ function startWatchdog(options) {
53
+ const counter = new SharedArrayBuffer(4);
54
+ const view = new Int32Array(counter);
55
+ const data = {
56
+ counter,
57
+ lastExitFile: options.lastExitFile,
58
+ logFile: options.logFile,
59
+ tickMs: options.tickMs,
60
+ stallTicks: options.stallTicks
61
+ };
62
+ const worker = new Worker(options.workerUrl, { workerData: data });
63
+ worker.unref();
64
+ const beat = setInterval(() => Atomics.add(view, 0, 1), BEAT_MS);
65
+ beat.unref();
66
+ return { async stop() {
67
+ clearInterval(beat);
68
+ await worker.terminate();
69
+ } };
70
+ }
71
+ //#endregion
72
+ //#region src/machines/hub-file.ts
73
+ const hubFilePath = (home) => join(home, "hub.json");
74
+ function isHubFile(value) {
75
+ const file = value;
76
+ return typeof file?.hubUrl === "string" && typeof file.machineSecret === "string" && typeof file.name === "string";
77
+ }
78
+ function createHubFileStore(home) {
79
+ const path = hubFilePath(home);
80
+ return {
81
+ async read() {
82
+ try {
83
+ const parsed = JSON.parse(await readFile(path, "utf8"));
84
+ return isHubFile(parsed) ? parsed : void 0;
85
+ } catch {
86
+ return;
87
+ }
88
+ },
89
+ write: (file) => writePrivateFile(path, `${JSON.stringify(file)}\n`),
90
+ delete: () => rm(path, { force: true })
91
+ };
92
+ }
93
+ //#endregion
94
+ //#region src/lifecycle/flags.ts
95
+ function portOf$1(raw) {
96
+ if (raw === void 0) return void 0;
97
+ const port = Number(raw);
98
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid --port: ${raw}`);
99
+ return port;
100
+ }
101
+ /** The flags the lifecycle commands read; others (`--faux`) are left alone. */
102
+ function lifecycleFlagsOf(argv, defaults) {
103
+ const { values } = parseArgs({
104
+ args: [...argv],
105
+ strict: false,
106
+ options: {
107
+ home: { type: "string" },
108
+ port: { type: "string" },
109
+ "no-open": { type: "boolean" },
110
+ json: { type: "boolean" },
111
+ follow: {
112
+ type: "boolean",
113
+ short: "f"
114
+ }
115
+ }
116
+ });
117
+ return {
118
+ home: typeof values.home === "string" ? values.home : defaults.env.TAU_HOME ?? defaults.defaultHome,
119
+ explicitPort: portOf$1(typeof values.port === "string" ? values.port : void 0),
120
+ open: values["no-open"] !== true,
121
+ json: values.json === true,
122
+ follow: values.follow === true
123
+ };
124
+ }
125
+ //#endregion
126
+ //#region src/options.ts
127
+ const DEFAULT_PORT = 4317;
128
+ const MAX_PORT$1 = 65535;
129
+ const defaultHome = () => join(homedir(), ".tau");
130
+ function parsePort(raw) {
131
+ if (raw === void 0) return DEFAULT_PORT;
132
+ const port = Number(raw);
133
+ if (!Number.isInteger(port) || port < 1 || port > MAX_PORT$1) throw new Error(`Invalid --port: ${raw}`);
134
+ return port;
135
+ }
136
+ const SERVER_FLAGS = {
137
+ port: { type: "string" },
138
+ "no-open": {
139
+ type: "boolean",
140
+ default: false
141
+ },
142
+ home: { type: "string" },
143
+ faux: {
144
+ type: "boolean",
145
+ default: false
146
+ },
147
+ service: {
148
+ type: "boolean",
149
+ default: false
150
+ }
151
+ };
152
+ /** The server flags of `serve` and `start`: `--port`, `--home`, `--faux`, `--no-open`, `--service`. */
153
+ function parseCliOptions(argv, env = process.env) {
154
+ const { values } = parseArgs({
155
+ args: [...argv],
156
+ options: SERVER_FLAGS
157
+ });
158
+ return {
159
+ port: parsePort(values.port),
160
+ open: !values["no-open"],
161
+ home: values.home ?? env.TAU_HOME ?? defaultHome(),
162
+ faux: values.faux || env.TAU_FAUX === "1",
163
+ service: values.service
164
+ };
165
+ }
166
+ /** The command word (the first positional) and the argv without it. Unknown flags do not stop the scan. */
167
+ function commandWordOf(argv) {
168
+ const { positionals } = parseArgs({
169
+ args: [...argv],
170
+ options: SERVER_FLAGS,
171
+ allowPositionals: true,
172
+ strict: false
173
+ });
174
+ const word = positionals[0];
175
+ if (word === void 0) return {
176
+ word,
177
+ rest: argv
178
+ };
179
+ const index = argv.indexOf(word);
180
+ return {
181
+ word,
182
+ rest: [...argv.slice(0, index), ...argv.slice(index + 1)]
183
+ };
184
+ }
185
+ //#endregion
186
+ //#region src/lifecycle/port-store.ts
187
+ const portFileOf = (home) => join(home, "port");
188
+ function readStoredPort(home) {
189
+ try {
190
+ const port = Number(readFileSync(portFileOf(home), "utf8").trim());
191
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : void 0;
192
+ } catch {
193
+ return;
194
+ }
195
+ }
196
+ function writeStoredPort(home, port) {
197
+ mkdirSync(home, { recursive: true });
198
+ writeFileSync(portFileOf(home), `${port}\n`);
199
+ }
200
+ /** `--port` wins and is stored; otherwise the stored port; otherwise the default. */
201
+ function resolvePort(home, explicitPort) {
202
+ if (explicitPort !== void 0) {
203
+ writeStoredPort(home, explicitPort);
204
+ return explicitPort;
205
+ }
206
+ return readStoredPort(home) ?? 4317;
207
+ }
208
+ //#endregion
209
+ //#region src/logging/rotating-log.ts
210
+ const MAX_LOG_BYTES = 10485760;
211
+ const serverLogOf = (home) => join(home, "logs", "server.log");
212
+ const sizeOf = (file) => {
213
+ try {
214
+ return statSync(file).size;
215
+ } catch {
216
+ return 0;
217
+ }
218
+ };
219
+ /** Appends to `file`; past `maxBytes` the file becomes `<file>.1` (one old file is kept). */
220
+ function createRotatingLog(file, maxBytes = MAX_LOG_BYTES) {
221
+ mkdirSync(dirname(file), { recursive: true });
222
+ return { write(text) {
223
+ if (sizeOf(file) + Buffer.byteLength(text) > maxBytes) renameSync(file, `${file}.1`);
224
+ appendFileSync(file, text);
225
+ } };
226
+ }
227
+ //#endregion
228
+ //#region ../server/src/error-handler.ts
229
+ function isClientError(error) {
230
+ return error.validation !== void 0 || error.statusCode !== void 0 && error.statusCode < 500;
231
+ }
232
+ function toAppError(error) {
233
+ if (isAppError(error)) return error;
234
+ if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") return new AppError("too_large", "The request is too large.");
235
+ if (isClientError(error)) return new AppError("invalid_request", error.message);
236
+ return new AppError("internal", "Internal error");
237
+ }
238
+ function handleError(error, request, reply) {
239
+ const appError = toAppError(error);
240
+ if (appError.code === "internal") request.log.error(error);
241
+ return reply.code(ERROR_STATUS[appError.code]).send(toErrorBody(appError));
242
+ }
243
+ function sendApiNotFound(reply) {
244
+ return reply.code(ERROR_STATUS.not_found).send(toErrorBody(new AppError("not_found", "Unknown route")));
245
+ }
246
+ //#endregion
247
+ //#region ../server/src/ws-frame-cap.ts
248
+ const MESSAGE_TOO_BIG = 1009;
249
+ function rawLength(data) {
250
+ if (Array.isArray(data)) return data.reduce((total, part) => total + part.length, 0);
251
+ return data instanceof ArrayBuffer ? data.byteLength : data.length;
252
+ }
253
+ /**
254
+ * Must be the first line of every message handler: the shared server accepts up to `WS_MAX_PAYLOAD_BYTES`,
255
+ * so each route refuses its own oversize frames, before any `toString()` or parse. Returns true when it closed.
256
+ */
257
+ function closeIfOversized(socket, data, capBytes) {
258
+ if (rawLength(data) <= capBytes) return false;
259
+ socket.close(MESSAGE_TOO_BIG, "frame too large");
260
+ return true;
261
+ }
262
+ //#endregion
263
+ //#region ../server/src/live/backpressure.ts
264
+ const HIGH_WATER_MARK_BYTES = 1048576;
265
+ const POLL_MS = 10;
266
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
267
+ /** Resolves once the socket's send buffer has drained (or the socket is no longer open). */
268
+ async function waitForDrain(socket) {
269
+ while (socket.readyState === socket.OPEN && socket.bufferedAmount > HIGH_WATER_MARK_BYTES) await sleep(POLL_MS);
270
+ }
271
+ //#endregion
272
+ //#region ../server/src/live/connection.ts
273
+ const POLICY_VIOLATION$3 = 1008;
274
+ /** One browser tab: its conversation subscriptions plus the shared `changed` broadcasts. */
275
+ var LiveConnection = class {
276
+ socket;
277
+ core;
278
+ onError;
279
+ subscriptions = /* @__PURE__ */ new Map();
280
+ stopListeningForChanges;
281
+ constructor(socket, core, onError) {
282
+ this.socket = socket;
283
+ this.core = core;
284
+ this.onError = onError;
285
+ this.stopListeningForChanges = core.onChange((change) => this.sendChange(change));
286
+ socket.on("message", (data) => {
287
+ if (closeIfOversized(socket, data, 65536)) return;
288
+ this.handleMessage(data.toString());
289
+ });
290
+ socket.on("close", () => void this.dispose());
291
+ socket.on("error", onError);
292
+ }
293
+ sendChange(change) {
294
+ this.send(changedFrame(change));
295
+ }
296
+ sendOps(conversationId, ops) {
297
+ this.send({
298
+ type: "ops",
299
+ conversationId,
300
+ ops
301
+ });
302
+ }
303
+ send(frame) {
304
+ if (this.socket.readyState === this.socket.OPEN) this.socket.send(encodeFrame(frame));
305
+ }
306
+ handleMessage(raw) {
307
+ const frame = decodeClientFrame(raw);
308
+ if (!frame) return void this.socket.close(POLICY_VIOLATION$3, "invalid frame");
309
+ if (frame.type === "subscribe") this.subscribe(frame.conversationId);
310
+ else this.unsubscribe(frame.conversationId).catch(this.onError);
311
+ }
312
+ subscribe(conversationId) {
313
+ if (this.subscriptions.has(conversationId)) return;
314
+ const subscription = {
315
+ cancelled: false,
316
+ watcher: Promise.resolve(void 0)
317
+ };
318
+ subscription.watcher = this.startWatching(conversationId, subscription);
319
+ this.subscriptions.set(conversationId, subscription);
320
+ }
321
+ async startWatching(conversationId, subscription) {
322
+ const forward = async (ops) => {
323
+ if (subscription.cancelled) return;
324
+ await waitForDrain(this.socket);
325
+ if (!subscription.cancelled) this.sendOps(conversationId, ops);
326
+ };
327
+ try {
328
+ const watcher = await this.core.conversations.watch(conversationId, forward);
329
+ if (!subscription.cancelled) return watcher;
330
+ await watcher.stop();
331
+ } catch (error) {
332
+ if (!isAppError(error)) this.onError(error);
333
+ else if (!subscription.cancelled) this.sendOps(conversationId, RETIRED_OPS);
334
+ }
335
+ }
336
+ async unsubscribe(conversationId) {
337
+ const subscription = this.subscriptions.get(conversationId);
338
+ if (!subscription) return;
339
+ this.subscriptions.delete(conversationId);
340
+ subscription.cancelled = true;
341
+ await (await subscription.watcher)?.stop();
342
+ }
343
+ async dispose() {
344
+ this.stopListeningForChanges();
345
+ await Promise.all([...this.subscriptions.keys()].map((id) => this.unsubscribe(id).catch(this.onError)));
346
+ }
347
+ };
348
+ //#endregion
349
+ //#region ../server/src/live/register-live.ts
350
+ function registerLive(app, core, sockets) {
351
+ app.get(LIVE_PATH, { websocket: true }, (socket, request) => {
352
+ if (!sockets.add(request.deviceId, socket)) return;
353
+ new LiveConnection(socket, core, (error) => request.log.error(error));
354
+ });
355
+ }
356
+ //#endregion
357
+ //#region ../server/src/machines/link-stream.ts
358
+ function newStream(id, now, handlers, ready) {
359
+ return {
360
+ id,
361
+ credit: CREDIT_WINDOW_BYTES,
362
+ pending: [],
363
+ pendingBytes: 0,
364
+ ready,
365
+ progressAt: now,
366
+ inFlight: 0,
367
+ owed: 0,
368
+ ...handlers
369
+ };
370
+ }
371
+ function enqueue(stream, frame) {
372
+ stream.pending.push(frame);
373
+ stream.pendingBytes += frame.payload.byteLength;
374
+ }
375
+ function isBlocked(stream) {
376
+ const head = stream.pending[0];
377
+ return head !== void 0 && (!stream.ready || stream.credit < head.payload.byteLength);
378
+ }
379
+ //#endregion
380
+ //#region ../server/src/machines/link-request.ts
381
+ const MAX_RESPONSE_BYTES = 268435456;
382
+ const OFFLINE = "This machine is offline.";
383
+ /** One proxied HTTP call over a link: body out under the machine's credit, response in under ours. */
384
+ function proxyRequest(session, request) {
385
+ return new Promise((resolve, reject) => {
386
+ const id = session.allocateId();
387
+ const chunks = [];
388
+ let received = 0;
389
+ let head;
390
+ const finish = (error) => {
391
+ session.removeStream(id);
392
+ if (error) reject(error);
393
+ };
394
+ const stream = newStream(id, Date.now(), {
395
+ onData(frame) {
396
+ received += frame.payload.byteLength;
397
+ if (received > MAX_RESPONSE_BYTES) {
398
+ session.sendControl({
399
+ type: "close",
400
+ id,
401
+ code: 1009,
402
+ reason: "response too large"
403
+ });
404
+ return finish(new AppError("too_large", "The machine's response is too large."));
405
+ }
406
+ chunks.push(Buffer.from(frame.payload));
407
+ stream.owed += frame.payload.byteLength;
408
+ if (!frame.end) return session.returnCredit(stream);
409
+ session.removeStream(id);
410
+ if (head) resolve({
411
+ ...head,
412
+ body: Buffer.concat(chunks)
413
+ });
414
+ else reject(new AppError("internal", "The machine answered without a response."));
415
+ },
416
+ onClose: (_code, reason) => finish(new AppError(reason === "machine went away" ? "not_found" : "internal", OFFLINE)),
417
+ onStalled() {
418
+ session.sendControl({
419
+ type: "close",
420
+ id,
421
+ code: 1013,
422
+ reason: "no credit"
423
+ });
424
+ finish(new AppError("not_found", "The machine stopped answering."));
425
+ }
426
+ }, true);
427
+ stream.onResponse = (status, headers) => {
428
+ head = {
429
+ status,
430
+ headers
431
+ };
432
+ };
433
+ const body = request.body && request.body.length > 0 ? request.body : void 0;
434
+ session.addStream(stream);
435
+ session.sendControl({
436
+ type: "request",
437
+ id,
438
+ method: request.method,
439
+ path: request.path,
440
+ query: request.query,
441
+ hubDevice: request.hubDevice.slice(0, 120),
442
+ ...request.contentType ? { contentType: request.contentType } : {},
443
+ ...body ? { bodyBytes: body.length } : {}
444
+ });
445
+ if (body) for (const frame of chunkBody(id, body)) enqueue(stream, frame);
446
+ session.pump();
447
+ });
448
+ }
449
+ //#endregion
450
+ //#region ../server/src/machines/link-session.ts
451
+ const TICK_MS$1 = 1e3;
452
+ const HEARTBEAT_EVERY_MS = 1e3;
453
+ const PUMP_RETRY_MS = 5;
454
+ const LINK_HIGH_WATER = 2 * CHUNK_BYTES;
455
+ const CREDIT_BATCH_BYTES = 65536;
456
+ const POLICY_VIOLATION$2 = 1008;
457
+ const GOING_AWAY$1 = 1001;
458
+ const INTERNAL$1 = 1011;
459
+ function toBytes$1(data) {
460
+ if (Array.isArray(data)) return Buffer.concat(data);
461
+ return data instanceof ArrayBuffer ? Buffer.from(data) : data;
462
+ }
463
+ /** One machine's link: control frames, round-robin sending under credit, 15 s pings, 45 s silence. */
464
+ var LinkSession = class {
465
+ machineId;
466
+ socket;
467
+ core;
468
+ events;
469
+ streams = /* @__PURE__ */ new Map();
470
+ registered = false;
471
+ nextId = 1;
472
+ lastFrameAt = Date.now();
473
+ lastPingAt = Date.now();
474
+ lastHeartbeatAt = 0;
475
+ pumpTimer;
476
+ ticker;
477
+ ended = false;
478
+ constructor(machineId, socket, core, events) {
479
+ this.machineId = machineId;
480
+ this.socket = socket;
481
+ this.core = core;
482
+ this.events = events;
483
+ socket.on("message", (data, isBinary) => this.receive(data, isBinary));
484
+ socket.on("close", () => this.end());
485
+ socket.on("error", () => void 0);
486
+ this.ticker = setInterval(() => this.tick(), TICK_MS$1);
487
+ this.ticker.unref();
488
+ }
489
+ allocateId() {
490
+ return this.nextId++;
491
+ }
492
+ addStream(stream) {
493
+ this.streams.set(stream.id, stream);
494
+ }
495
+ sendControl(frame) {
496
+ if (this.socket.readyState === this.socket.OPEN) this.socket.send(JSON.stringify(frame));
497
+ }
498
+ removeStream(id) {
499
+ this.streams.delete(id);
500
+ }
501
+ /** Sends credit for bytes consumed, batched; always when nothing is in flight so the sender never starves. */
502
+ returnCredit(stream) {
503
+ if (stream.owed <= 0) return;
504
+ if (stream.owed < CREDIT_BATCH_BYTES && stream.inFlight > 0) return;
505
+ this.sendControl({
506
+ type: "credit",
507
+ id: stream.id,
508
+ bytes: stream.owed
509
+ });
510
+ stream.owed = 0;
511
+ }
512
+ /** One chunk per ready stream per pass, until the link buffer is full or no stream can send. */
513
+ pump() {
514
+ if (this.ended) return;
515
+ let progressed = true;
516
+ while (progressed) {
517
+ progressed = false;
518
+ for (const stream of [...this.streams.values()]) {
519
+ if (this.socket.bufferedAmount > LINK_HIGH_WATER) {
520
+ this.schedulePump();
521
+ return;
522
+ }
523
+ progressed = this.sendOne(stream) || progressed;
524
+ }
525
+ }
526
+ }
527
+ close(code, reason) {
528
+ if (this.socket.readyState === this.socket.OPEN) this.socket.close(code, reason);
529
+ this.end();
530
+ }
531
+ sendOne(stream) {
532
+ const frame = stream.pending[0];
533
+ if (!frame || isBlocked(stream)) return false;
534
+ stream.pending.shift();
535
+ stream.pendingBytes -= frame.payload.byteLength;
536
+ stream.credit -= frame.payload.byteLength;
537
+ stream.progressAt = Date.now();
538
+ this.socket.send(encodeDataFrame(frame));
539
+ if (stream.pendingBytes < 1048576) stream.onDrained?.();
540
+ return true;
541
+ }
542
+ schedulePump() {
543
+ if (this.pumpTimer) return;
544
+ this.pumpTimer = setTimeout(() => {
545
+ this.pumpTimer = void 0;
546
+ this.pump();
547
+ }, PUMP_RETRY_MS);
548
+ }
549
+ receive(data, isBinary) {
550
+ const now = Date.now();
551
+ this.lastFrameAt = now;
552
+ if (now - this.lastHeartbeatAt >= HEARTBEAT_EVERY_MS) {
553
+ this.lastHeartbeatAt = now;
554
+ this.core.machines.heartbeat(this.machineId);
555
+ }
556
+ const bytes = toBytes$1(data);
557
+ if (isBinary) {
558
+ this.receiveData(bytes);
559
+ return;
560
+ }
561
+ let frame;
562
+ try {
563
+ frame = JSON.parse(bytes.toString("utf8"));
564
+ } catch {
565
+ this.close(POLICY_VIOLATION$2, "bad frame");
566
+ return;
567
+ }
568
+ if (!Value.Check(LinkFrameSchema, frame)) {
569
+ this.close(POLICY_VIOLATION$2, "bad frame");
570
+ return;
571
+ }
572
+ this.receiveControl(frame);
573
+ }
574
+ receiveData(bytes) {
575
+ const frame = decodeDataFrame(bytes);
576
+ const stream = frame && this.streams.get(frame.id);
577
+ if (!frame || !stream) return;
578
+ stream.ready = true;
579
+ stream.onData(frame);
580
+ }
581
+ receiveControl(frame) {
582
+ switch (frame.type) {
583
+ case "hello":
584
+ this.register(frame);
585
+ return;
586
+ case "ping":
587
+ this.sendControl({ type: "pong" });
588
+ return;
589
+ case "credit": {
590
+ const stream = this.streams.get(frame.id);
591
+ if (!stream) return;
592
+ stream.credit += frame.bytes;
593
+ stream.progressAt = Date.now();
594
+ this.pump();
595
+ return;
596
+ }
597
+ case "open": {
598
+ const stream = this.streams.get(frame.id);
599
+ if (stream) stream.ready = true;
600
+ this.pump();
601
+ return;
602
+ }
603
+ case "response":
604
+ this.streams.get(frame.id)?.onResponse?.(frame.status, frame.headers);
605
+ return;
606
+ case "close":
607
+ this.streams.get(frame.id)?.onClose(frame.code, frame.reason);
608
+ return;
609
+ default: return;
610
+ }
611
+ }
612
+ async register(frame) {
613
+ if (this.registered) return;
614
+ const { type: _type, ...hello } = frame;
615
+ try {
616
+ const machine = await this.core.machines.register(this.machineId, hello);
617
+ this.registered = true;
618
+ if (machine.status === "update-required") this.close(LINK_CLOSE_UPDATE_REQUIRED, "update required");
619
+ } catch {
620
+ this.close(INTERNAL$1, "register failed");
621
+ }
622
+ }
623
+ tick() {
624
+ const now = Date.now();
625
+ if (now - this.lastFrameAt >= 45e3) {
626
+ this.close(GOING_AWAY$1, "no frames");
627
+ return;
628
+ }
629
+ if (now - this.lastPingAt >= 15e3) {
630
+ this.lastPingAt = now;
631
+ this.sendControl({ type: "ping" });
632
+ this.core.machines.heartbeat(this.machineId);
633
+ }
634
+ for (const stream of [...this.streams.values()]) this.checkStalled(stream, now);
635
+ }
636
+ checkStalled(stream, now) {
637
+ if ((isBlocked(stream) || stream.inFlight > 0) && now - stream.progressAt >= 6e4) stream.onStalled();
638
+ }
639
+ end() {
640
+ if (this.ended) return;
641
+ this.ended = true;
642
+ clearInterval(this.ticker);
643
+ if (this.pumpTimer) clearTimeout(this.pumpTimer);
644
+ for (const stream of [...this.streams.values()]) stream.onClose(GOING_AWAY$1, "machine went away");
645
+ this.streams.clear();
646
+ this.events.onEnded();
647
+ }
648
+ };
649
+ //#endregion
650
+ //#region ../server/src/machines/link-socket.ts
651
+ const GOING_AWAY = 1001;
652
+ const INTERNAL = 1011;
653
+ /** Close codes `ws` accepts on the wire; anything else (1005, 1006, 1015, junk) becomes 1011. */
654
+ function wireCloseCode(code) {
655
+ return code >= 1e3 && code <= 1003 || code >= 1007 && code <= 1014 || code >= 3e3 && code <= 4999 ? code : INTERNAL;
656
+ }
657
+ function clip(reason) {
658
+ return Buffer.from(reason).subarray(0, 123).toString("utf8").replace(/\uFFFD+$/, "");
659
+ }
660
+ /** Pipes a browser socket to a stream on the machine. Browser frames queue until the machine confirms `open`. */
661
+ function pipeSocket(session, request, socket) {
662
+ const id = session.allocateId();
663
+ let machineClosed = false;
664
+ const closeBrowser = (code, reason) => {
665
+ if (socket.readyState === socket.OPEN || socket.readyState === socket.CONNECTING) socket.close(wireCloseCode(code), clip(reason));
666
+ };
667
+ const stream = newStream(id, Date.now(), {
668
+ onData(frame) {
669
+ if (socket.readyState !== socket.OPEN) return;
670
+ const size = frame.payload.byteLength;
671
+ if (stream.inFlight === 0) stream.progressAt = Date.now();
672
+ stream.inFlight += size;
673
+ socket.send(Buffer.from(frame.payload), { binary: !frame.text }, () => {
674
+ stream.inFlight -= size;
675
+ stream.owed += size;
676
+ stream.progressAt = Date.now();
677
+ session.returnCredit(stream);
678
+ });
679
+ },
680
+ onClose(code, reason) {
681
+ machineClosed = true;
682
+ session.removeStream(id);
683
+ closeBrowser(code === GOING_AWAY ? LINK_CLOSE_NO_CREDIT : code, reason);
684
+ },
685
+ onStalled() {
686
+ session.sendControl({
687
+ type: "close",
688
+ id,
689
+ code: LINK_CLOSE_NO_CREDIT,
690
+ reason: "no credit"
691
+ });
692
+ session.removeStream(id);
693
+ closeBrowser(LINK_CLOSE_NO_CREDIT, "stalled");
694
+ }
695
+ }, false);
696
+ stream.onDrained = () => {
697
+ if (typeof socket.resume === "function") socket.resume();
698
+ };
699
+ session.addStream(stream);
700
+ socket.on("message", (data, isBinary) => {
701
+ if (socket.readyState !== socket.OPEN) return;
702
+ const payload = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
703
+ if (payload.byteLength > 262144) return;
704
+ enqueue(stream, {
705
+ id,
706
+ end: false,
707
+ text: !isBinary,
708
+ payload
709
+ });
710
+ if (stream.pendingBytes >= 1048576 && typeof socket.pause === "function") socket.pause();
711
+ session.pump();
712
+ });
713
+ socket.on("close", (code, reason) => {
714
+ if (machineClosed) return;
715
+ machineClosed = true;
716
+ session.removeStream(id);
717
+ session.sendControl({
718
+ type: "close",
719
+ id,
720
+ code: wireCloseCode(code),
721
+ reason: clip(reason.toString())
722
+ });
723
+ });
724
+ socket.on("error", () => void 0);
725
+ session.sendControl({
726
+ type: "open",
727
+ id,
728
+ path: request.path,
729
+ query: request.query,
730
+ hubDevice: request.hubDevice.slice(0, 120)
731
+ });
732
+ }
733
+ //#endregion
734
+ //#region ../server/src/machines/create-gateway.ts
735
+ const NOT_LINKED = "This machine is offline.";
736
+ const TRY_AGAIN_LATER = 1013;
737
+ const REPLACED = 1e3;
738
+ const REVOKED = 1008;
739
+ /** The real multiplexer: one `LinkSession` per machine; a newer link replaces an older one. */
740
+ function createMachineGateway(core) {
741
+ const sessions = /* @__PURE__ */ new Map();
742
+ const online = (machineId) => {
743
+ const session = sessions.get(machineId);
744
+ return session?.registered ? session : void 0;
745
+ };
746
+ return {
747
+ acceptLink(machineId, socket) {
748
+ sessions.get(machineId)?.close(REPLACED, "replaced by a new link");
749
+ const session = new LinkSession(machineId, socket, core, { onEnded() {
750
+ if (sessions.get(machineId) !== session) return;
751
+ sessions.delete(machineId);
752
+ core.machines.markOffline(machineId);
753
+ } });
754
+ sessions.set(machineId, session);
755
+ },
756
+ isOnline: (machineId) => online(machineId) !== void 0,
757
+ request(machineId, request) {
758
+ const session = online(machineId);
759
+ if (!session) return Promise.reject(new AppError("not_found", NOT_LINKED));
760
+ return proxyRequest(session, request);
761
+ },
762
+ openStream(machineId, request, socket) {
763
+ const session = online(machineId);
764
+ if (!session) return void socket.close(TRY_AGAIN_LATER, "machine offline");
765
+ pipeSocket(session, request, socket);
766
+ },
767
+ closeMachine(machineId) {
768
+ sessions.get(machineId)?.close(REVOKED, "revoked");
769
+ }
770
+ };
771
+ }
772
+ //#endregion
773
+ //#region ../server/src/machines/device-labels.ts
774
+ const MAX_LABEL = 120;
775
+ /** Device names for the audit header `x-tau-hub-device`; the device id until the list is read or for an unknown one. */
776
+ function createDeviceLabels(core) {
777
+ const names = /* @__PURE__ */ new Map();
778
+ const refresh = () => Promise.resolve().then(() => core.access.listDevices()).then((devices) => {
779
+ names.clear();
780
+ for (const device of devices) names.set(device.id, device.name);
781
+ }).catch(() => void 0);
782
+ refresh();
783
+ return {
784
+ label: (deviceId) => (names.get(deviceId) ?? deviceId).replace(/\p{Cc}/gu, " ").slice(0, MAX_LABEL),
785
+ stop: core.onChange((change) => {
786
+ if (change.resource === "devices") refresh();
787
+ })
788
+ };
789
+ }
790
+ //#endregion
791
+ //#region ../server/src/security/allow-list.ts
792
+ const LOCAL_NAMES = ["127.0.0.1", "localhost"];
793
+ function allowListForPort(port) {
794
+ let tunnel;
795
+ return {
796
+ localHosts: new Set(LOCAL_NAMES.map((name) => `${name}:${port}`)),
797
+ localOrigins: new Set(LOCAL_NAMES.map((name) => `http://${name}:${port}`)),
798
+ tunnelHostname: () => tunnel,
799
+ setTunnelHostname: (hostname) => {
800
+ tunnel = hostname?.toLowerCase();
801
+ }
802
+ };
803
+ }
804
+ function isAllowedLocalHost(list, host) {
805
+ return host !== void 0 && list.localHosts.has(host.toLowerCase());
806
+ }
807
+ function isAllowedLocalOrigin(list, origin) {
808
+ return origin !== void 0 && list.localOrigins.has(origin.toLowerCase());
809
+ }
810
+ /** On a tagged socket the only Host is exactly the tunnel hostname. */
811
+ function isTunnelHost(list, host) {
812
+ const hostname = list.tunnelHostname();
813
+ return hostname !== void 0 && host?.toLowerCase() === hostname;
814
+ }
815
+ /** On a tagged socket the only Origin is `https://<hostname>`. */
816
+ function isTunnelOrigin(list, origin) {
817
+ const hostname = list.tunnelHostname();
818
+ return hostname !== void 0 && origin?.toLowerCase() === `https://${hostname}`;
819
+ }
820
+ /** The hostname of a tunnel URL, lowercased; `undefined` for no URL. */
821
+ function hostnameOfUrl(url) {
822
+ return url === null ? void 0 : new URL(url).hostname.toLowerCase();
823
+ }
824
+ //#endregion
825
+ //#region ../server/src/security/link-limits.ts
826
+ const FAILURE_LIMIT = 5;
827
+ const WINDOW_MS = 6e4;
828
+ const LOCK_MS = 6e5;
829
+ function createLinkLimits(now = Date.now) {
830
+ const records = /* @__PURE__ */ new Map();
831
+ return {
832
+ locked: (peer) => (records.get(peer)?.lockedUntil ?? 0) > now(),
833
+ recordFailure(peer) {
834
+ const record = records.get(peer) ?? {
835
+ at: [],
836
+ lockedUntil: 0
837
+ };
838
+ record.at = [...record.at.filter((time) => now() - time < WINDOW_MS), now()];
839
+ if (record.at.length >= FAILURE_LIMIT) record.lockedUntil = now() + LOCK_MS;
840
+ records.set(peer, record);
841
+ }
842
+ };
843
+ }
844
+ //#endregion
845
+ //#region ../server/src/security/request-info.ts
846
+ function pathOf(request) {
847
+ return request.url.split("?")[0] ?? "";
848
+ }
849
+ function isApiPath(request) {
850
+ const path = pathOf(request);
851
+ return path === "/api" || path.startsWith(`/api/`);
852
+ }
853
+ function isWebSocketUpgrade(request) {
854
+ return request.headers.upgrade?.toLowerCase() === "websocket";
855
+ }
856
+ function needsOriginCheck(request) {
857
+ return request.method !== "GET" || isWebSocketUpgrade(request);
858
+ }
859
+ function hasBody(request) {
860
+ return Number(request.headers["content-length"] ?? 0) > 0 || request.headers["transfer-encoding"] !== void 0;
861
+ }
862
+ const JSON_CONTENT_TYPE = /^application\/json\s*(;|$)/i;
863
+ function rejectApi(reply, error) {
864
+ return reply.code(ERROR_STATUS[error.code]).send(toErrorBody(error));
865
+ }
866
+ function rejectContentType(request, reply) {
867
+ if (!hasBody(request) || JSON_CONTENT_TYPE.test(request.headers["content-type"] ?? "")) return;
868
+ rejectApi(reply, new AppError("invalid_request", "Content-Type must be application/json"));
869
+ }
870
+ /** The unauthenticated surface, on every socket: the pair page, redeem and health. Nothing else. */
871
+ function isOpenSurface(request) {
872
+ const path = pathOf(request);
873
+ if (request.method === "GET") return path === "/pair" || path === routes.getHealth.path;
874
+ return request.method === "POST" && path === routes.redeemPairing.path;
875
+ }
876
+ const BEARER = /^Bearer ([^\s]+)$/i;
877
+ function bearerOf(request) {
878
+ return BEARER.exec(request.headers.authorization ?? "")?.[1];
879
+ }
880
+ /** Exactly `GET /api/machines/link` with `Upgrade: websocket`: the only place a machine secret is looked at. */
881
+ function isMachineLink(request) {
882
+ return request.method === "GET" && pathOf(request) === MACHINE_LINK_PATH && isWebSocketUpgrade(request);
883
+ }
884
+ //#endregion
885
+ //#region ../server/src/security/session-start-page.ts
886
+ /** Same-origin page that lands the cookie, then moves on. A 302 can lose a SameSite=Strict cookie when the link comes from another app. */
887
+ const SESSION_START_PAGE = `<!doctype html>
888
+ <html lang="en">
889
+ <head>
890
+ <meta charset="utf-8" />
891
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
892
+ <meta name="referrer" content="no-referrer" />
893
+ <title>tau</title>
894
+ </head>
895
+ <body>
896
+ <script>location.replace("/");<\/script>
897
+ <noscript><a href="/">Continue to tau</a></noscript>
898
+ </body>
899
+ </html>
900
+ `;
901
+ //#endregion
902
+ //#region ../server/src/security/unauthorized-page.ts
903
+ const UNAUTHORIZED_PAGE = `<!doctype html>
904
+ <html lang="en">
905
+ <head>
906
+ <meta charset="utf-8" />
907
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
908
+ <title>tau</title>
909
+ </head>
910
+ <body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 4rem auto; padding: 0 1rem">
911
+ <h1>Not signed in</h1>
912
+ <p>Open the URL printed in your terminal.</p>
913
+ </body>
914
+ </html>
915
+ `;
916
+ //#endregion
917
+ //#region ../server/src/security/guard.ts
918
+ const DEVICE_COOKIE = "tau_device";
919
+ const OWNER_DEVICE_ID = "owner";
920
+ const LOCAL_PEER$1 = "local";
921
+ function forbid(reply, what) {
922
+ reply.code(403).type("text/plain").send(`Forbidden ${what}`);
923
+ }
924
+ function sendUnauthenticated(request, reply) {
925
+ if (isApiPath(request)) return rejectApi(reply, new AppError("unauthorized", "Open the URL printed in your terminal"));
926
+ return reply.code(401).type("text/html; charset=utf-8").send(UNAUTHORIZED_PAGE);
927
+ }
928
+ async function authenticateDevice(ctx, request) {
929
+ const secret = request.cookies[DEVICE_COOKIE];
930
+ const verified = secret ? await ctx.access.verifyDevice(secret) : void 0;
931
+ if (!verified) return false;
932
+ request.deviceId = verified.deviceId;
933
+ ctx.access.touch(verified.deviceId);
934
+ return true;
935
+ }
936
+ /** Machine branch: a rate-limit check by peer BEFORE the hash lookup; only a machine secret passes, never a cookie. */
937
+ async function authenticateMachine(ctx, limits, request) {
938
+ const peer = ctx.tunnel.tagOf(request.raw.socket)?.peer ?? LOCAL_PEER$1;
939
+ if (limits.locked(peer)) return false;
940
+ const secret = bearerOf(request);
941
+ const verified = secret ? await ctx.access.verifyMachine(secret) : void 0;
942
+ if (!verified) {
943
+ limits.recordFailure(peer);
944
+ return false;
945
+ }
946
+ request.deviceId = verified.deviceId;
947
+ ctx.access.touch(verified.deviceId);
948
+ return true;
949
+ }
950
+ /** A tagged socket came through the tunnel: only the tunnel Host and Origin, only a device cookie. */
951
+ async function guardTunneled(ctx, limits, request, reply) {
952
+ const list = ctx.hostnames();
953
+ if (!isTunnelHost(list, request.headers.host)) return forbid(reply, "host");
954
+ const { origin } = request.headers;
955
+ const originOk = isTunnelOrigin(list, origin);
956
+ if (needsOriginCheck(request) && !originOk || origin !== void 0 && !originOk) return forbid(reply, "origin");
957
+ if (isOpenSurface(request)) return rejectContentType(request, reply);
958
+ if (isMachineLink(request)) {
959
+ if (!await authenticateMachine(ctx, limits, request)) return sendUnauthenticated(request, reply);
960
+ return;
961
+ }
962
+ if (!await authenticateDevice(ctx, request)) return sendUnauthenticated(request, reply);
963
+ rejectContentType(request, reply);
964
+ }
965
+ function hasForeignOrigin(list, request) {
966
+ const { origin } = request.headers;
967
+ return origin !== void 0 && !isAllowedLocalOrigin(list, origin);
968
+ }
969
+ function startSession(ctx, reply, token) {
970
+ reply.setCookie(ctx.session.cookieName, token, {
971
+ httpOnly: true,
972
+ sameSite: "strict",
973
+ path: "/"
974
+ }).header("cache-control", "no-store").type("text/html; charset=utf-8").send(SESSION_START_PAGE);
975
+ }
976
+ /** An untagged socket is loopback: the master token works, and device cookies too. */
977
+ async function guardLocal(ctx, limits, request, reply) {
978
+ const list = ctx.hostnames();
979
+ if (!isAllowedLocalHost(list, request.headers.host)) return forbid(reply, "host");
980
+ if (request.headers.authorization !== void 0) {
981
+ const master = ctx.session.isValidToken(bearerOf(request));
982
+ if (!master && !isMachineLink(request)) return sendUnauthenticated(request, reply);
983
+ if (hasForeignOrigin(list, request)) return forbid(reply, "origin");
984
+ if (!master) {
985
+ if (!await authenticateMachine(ctx, limits, request)) return sendUnauthenticated(request, reply);
986
+ return;
987
+ }
988
+ request.deviceId = OWNER_DEVICE_ID;
989
+ return rejectContentType(request, reply);
990
+ }
991
+ if (needsOriginCheck(request) && !isAllowedLocalOrigin(list, request.headers.origin)) return forbid(reply, "origin");
992
+ if (isOpenSurface(request)) return rejectContentType(request, reply);
993
+ const { token } = request.query;
994
+ if (request.method === "GET" && !isApiPath(request) && ctx.session.isValidToken(token)) return startSession(ctx, reply, token);
995
+ if (ctx.session.isValidToken(request.cookies[ctx.session.cookieName])) {
996
+ request.deviceId = OWNER_DEVICE_ID;
997
+ return rejectContentType(request, reply);
998
+ }
999
+ if (isMachineLink(request) || !await authenticateDevice(ctx, request)) return sendUnauthenticated(request, reply);
1000
+ rejectContentType(request, reply);
1001
+ }
1002
+ /** Runs before routing. Provenance is the socket (`ctx.tunnel`), never a header. */
1003
+ function createGuard(ctx, limits = createLinkLimits()) {
1004
+ return async function guard(request, reply) {
1005
+ if (ctx.tunnel.tagOf(request.raw.socket)) return guardTunneled(ctx, limits, request, reply);
1006
+ return guardLocal(ctx, limits, request, reply);
1007
+ };
1008
+ }
1009
+ //#endregion
1010
+ //#region ../server/src/machines/register-link.ts
1011
+ const POLICY_VIOLATION$1 = 1008;
1012
+ /**
1013
+ * `GET /api/machines/link`: an ordinary guarded WebSocket route on the shared server (no second WebSocketServer).
1014
+ * The guard already accepted a machine secret and set `request.deviceId` to the machine's device id; the owner
1015
+ * (master token) may reach this handler too and is refused here. Registered in `sockets` so a revoke closes the link.
1016
+ * Owner G: replace the stub gateway; the guard, caps and registration stay.
1017
+ */
1018
+ function registerMachineLink(app, { core, sockets, gateway }) {
1019
+ app.get(MACHINE_LINK_PATH, { websocket: true }, async (socket, request) => {
1020
+ if (request.deviceId === "owner") return void socket.close(POLICY_VIOLATION$1, "machines only");
1021
+ if (acceptLinkRefusal(await core.machines.hubState())) return void socket.close(POLICY_VIOLATION$1, "hub is linked");
1022
+ if (!sockets.add(request.deviceId, socket)) return;
1023
+ gateway.acceptLink(request.deviceId, socket);
1024
+ });
1025
+ }
1026
+ //#endregion
1027
+ //#region ../server/src/rest/define-route.ts
1028
+ /** Registers one frozen protocol route; schemas come from the protocol, so the handler input is fully typed. */
1029
+ function defineRoute(app, route, handler) {
1030
+ app.route({
1031
+ method: route.method,
1032
+ url: route.path,
1033
+ ...route.bodyLimit && { bodyLimit: route.bodyLimit },
1034
+ schema: {
1035
+ ...route.params && { params: route.params },
1036
+ ...route.query && { querystring: route.query },
1037
+ ...route.body && { body: route.body }
1038
+ },
1039
+ handler: async (request, reply) => {
1040
+ const output = await handler({
1041
+ params: request.params,
1042
+ query: request.query,
1043
+ body: request.body
1044
+ }, request);
1045
+ reply.code(route.status);
1046
+ if (route.status === 204) return reply.send();
1047
+ return output === null ? reply.type("application/json").send("null") : output;
1048
+ }
1049
+ });
1050
+ }
1051
+ //#endregion
1052
+ //#region ../server/src/machines/register-machine-routes.ts
1053
+ /** The hub's own machine routes (denylisted from the proxy, so they always answer about THIS tau). */
1054
+ function registerMachineRoutes(app, { core, sockets, gateway }) {
1055
+ defineRoute(app, routes.listMachines, () => core.machines.list());
1056
+ defineRoute(app, routes.renameMachine, ({ params, body }) => core.machines.rename(params.id, body.name));
1057
+ defineRoute(app, routes.getMachineSummary, async ({ params }) => {
1058
+ const summary = await core.machines.readSummary(params.id);
1059
+ if (!summary) throw new AppError("not_found", "No summary for this machine yet.");
1060
+ return summary;
1061
+ });
1062
+ defineRoute(app, routes.disconnectMachine, async ({ params }) => {
1063
+ await core.machines.disconnect(params.id);
1064
+ sockets.closeDevice(params.id);
1065
+ sockets.closeMachine(params.id);
1066
+ gateway.closeMachine(params.id);
1067
+ });
1068
+ defineRoute(app, routes.getHubLink, () => core.machines.hubState());
1069
+ defineRoute(app, routes.disconnectHub, () => core.machines.disconnectHub());
1070
+ }
1071
+ //#endregion
1072
+ //#region ../server/src/machines/path-filter.ts
1073
+ /** Routes that must never cross the hub: they would show or change the hub's own data. */
1074
+ const DENIED_FIRST_SEGMENTS = /* @__PURE__ */ new Set([
1075
+ "access",
1076
+ "remote",
1077
+ "machines",
1078
+ "update",
1079
+ "pair",
1080
+ "health",
1081
+ "m"
1082
+ ]);
1083
+ const FASTIFY_DEFAULT_BODY_LIMIT = 1048576;
1084
+ const deny = (status, reason) => ({
1085
+ allowed: false,
1086
+ status,
1087
+ reason
1088
+ });
1089
+ const segmentsOf = (path) => path.split("/").slice(1);
1090
+ function httpEntry(route) {
1091
+ return {
1092
+ method: route.method,
1093
+ segments: segmentsOf(route.path),
1094
+ target: () => ({
1095
+ kind: "http",
1096
+ bodyLimit: route.bodyLimit ?? FASTIFY_DEFAULT_BODY_LIMIT
1097
+ })
1098
+ };
1099
+ }
1100
+ const TABLE = [
1101
+ ...Object.values(routes).map(httpEntry),
1102
+ {
1103
+ method: "GET",
1104
+ segments: segmentsOf(LIVE_PATH),
1105
+ target: () => ({ kind: "live" })
1106
+ },
1107
+ {
1108
+ method: "GET",
1109
+ segments: segmentsOf(TERMINAL_STREAM_PATH),
1110
+ target: (params) => ({
1111
+ kind: "terminal-stream",
1112
+ terminalId: params.id ?? ""
1113
+ })
1114
+ }
1115
+ ];
1116
+ /** `..`, an encoded dot, slash or backslash, a doubled slash, a query, a fragment or a control character. */
1117
+ const FORBIDDEN_RAW = /\.\.|%2e|%2f|%5c|\/\/|[?#\\]/i;
1118
+ /** What decoding must not produce: a slash, a backslash, a control character or a dot segment. */
1119
+ const FORBIDDEN_DECODED = /[\\/]|^\.\.?$/;
1120
+ const hasControlCharacter$1 = (text) => [...text].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127);
1121
+ function decodeOnce(raw) {
1122
+ try {
1123
+ const decoded = segmentsOf(raw).map((segment) => decodeURIComponent(segment));
1124
+ return decoded.some((segment) => FORBIDDEN_DECODED.test(segment) || hasControlCharacter$1(segment)) ? void 0 : decoded;
1125
+ } catch {
1126
+ return;
1127
+ }
1128
+ }
1129
+ function matchEntry(entry, method, decoded) {
1130
+ if (entry.method !== method || entry.segments.length !== decoded.length) return void 0;
1131
+ const params = {};
1132
+ for (const [index, template] of entry.segments.entries()) {
1133
+ const actual = decoded[index] ?? "";
1134
+ if (template.startsWith(":")) {
1135
+ if (actual === "") return void 0;
1136
+ params[template.slice(1)] = actual;
1137
+ } else if (template !== actual) return void 0;
1138
+ }
1139
+ return params;
1140
+ }
1141
+ /**
1142
+ * The path filter of `/api/m/<machineId>/**`. `rawPath` is the part after the machine id, as received and without
1143
+ * the query string. Decodes once, then matches the exact method and path against the routes table plus the two
1144
+ * socket paths. A denylisted or unknown path is refused here and again by a direct machine (it is not a route there).
1145
+ */
1146
+ function filterProxyPath(method, rawPath) {
1147
+ if (!rawPath.startsWith(`/api/`)) return deny(404, "not an API path");
1148
+ if (FORBIDDEN_RAW.test(rawPath) || hasControlCharacter$1(rawPath)) return deny(403, "unsafe path");
1149
+ const decoded = decodeOnce(rawPath);
1150
+ if (!decoded) return deny(403, "bad encoding");
1151
+ if (DENIED_FIRST_SEGMENTS.has(decoded[1] ?? "")) return deny(403, "not available through a hub");
1152
+ for (const entry of TABLE) {
1153
+ const params = matchEntry(entry, method.toUpperCase(), decoded);
1154
+ if (params) return {
1155
+ allowed: true,
1156
+ path: rawPath,
1157
+ target: entry.target(params)
1158
+ };
1159
+ }
1160
+ return deny(404, "unknown route");
1161
+ }
1162
+ const RESPONSE_HEADERS = /* @__PURE__ */ new Set([
1163
+ "content-type",
1164
+ "cache-control",
1165
+ "content-disposition",
1166
+ "etag"
1167
+ ]);
1168
+ function pick(headers, allowed) {
1169
+ const out = {};
1170
+ for (const [name, value] of Object.entries(headers)) {
1171
+ const key = name.toLowerCase();
1172
+ if (allowed.has(key) && typeof value === "string") out[key] = value;
1173
+ }
1174
+ return out;
1175
+ }
1176
+ /** Only these come back to the browser; `set-cookie` never does, so a machine cannot plant a cookie on the hub's origin. */
1177
+ const filterResponseHeaders = (headers) => pick(headers, RESPONSE_HEADERS);
1178
+ //#endregion
1179
+ //#region ../server/src/machines/proxy-streams.ts
1180
+ function queryOf(request) {
1181
+ const index = request.url.indexOf("?");
1182
+ return index < 0 ? "" : request.url.slice(index + 1);
1183
+ }
1184
+ function hubStream(app, deps, hubPath, machinePath, capBytes) {
1185
+ app.get(hubPath, { websocket: true }, (socket, request) => {
1186
+ const { machineId } = request.params;
1187
+ if (!deps.sockets.addProxied(request.deviceId, machineId, socket)) return;
1188
+ socket.on("message", (data) => void closeIfOversized(socket, data, capBytes));
1189
+ deps.gateway.openStream(machineId, {
1190
+ path: machinePath(request.params),
1191
+ query: queryOf(request),
1192
+ hubDevice: deps.hubDeviceLabel(request.deviceId)
1193
+ }, socket);
1194
+ });
1195
+ }
1196
+ /** Hub-side `/api/m/<id>/api/live` and `/api/m/<id>/api/terminals/:id/stream`, with the 64 KB caps as the first line. */
1197
+ function registerProxyStreams(app, deps) {
1198
+ hubStream(app, deps, `${MACHINE_PROXY_PREFIX}/:machineId${LIVE_PATH}`, () => LIVE_PATH, LIVE_MAX_FRAME_BYTES);
1199
+ hubStream(app, deps, `${MACHINE_PROXY_PREFIX}/:machineId${TERMINAL_STREAM_PATH}`, (params) => TERMINAL_STREAM_PATH.replace(":id", encodeURIComponent(params.id ?? "")), TERMINAL_STREAM_MAX_INPUT_BYTES);
1200
+ }
1201
+ //#endregion
1202
+ //#region ../server/src/machines/register-proxy.ts
1203
+ const PREFIX_PATTERN = new RegExp(`^${MACHINE_PROXY_PREFIX}/([^/?]+)(/[^?]*)?`);
1204
+ function splitUrl(url) {
1205
+ const [pathPart = "", query = ""] = url.split(/\?(.*)/s);
1206
+ const match = PREFIX_PATTERN.exec(pathPart);
1207
+ if (!match) return void 0;
1208
+ return {
1209
+ machineId: decodeURIComponent(match[1] ?? ""),
1210
+ rest: match[2] ?? "",
1211
+ query
1212
+ };
1213
+ }
1214
+ async function proxyHttp(gateway, label, request, reply) {
1215
+ const parts = splitUrl(request.url);
1216
+ if (!parts) throw new AppError("not_found", "Unknown route");
1217
+ const decision = filterProxyPath(request.method, parts.rest);
1218
+ if (!decision.allowed) {
1219
+ const code = decision.status === 403 ? "unauthorized" : "not_found";
1220
+ return reply.code(decision.status).send(toErrorBody(new AppError(code, decision.reason)));
1221
+ }
1222
+ if (decision.target.kind !== "http") throw new AppError("invalid_request", "That path is a socket.");
1223
+ const body = request.body instanceof Buffer ? request.body : void 0;
1224
+ if (body && body.length > decision.target.bodyLimit) throw new AppError("too_large", "The request body is too large.");
1225
+ const response = await gateway.request(parts.machineId, {
1226
+ method: request.method,
1227
+ path: decision.path,
1228
+ query: parts.query,
1229
+ hubDevice: label(request.deviceId),
1230
+ contentType: request.headers["content-type"],
1231
+ body
1232
+ });
1233
+ return reply.code(response.status).headers(filterResponseHeaders(response.headers)).send(response.body);
1234
+ }
1235
+ /**
1236
+ * `/api/m/<machineId>/**` on the hub. Registered in an encapsulated scope so a raw-buffer parser passes bodies
1237
+ * through untouched (the machine validates). The guard has already required a device cookie. Owner G fills the gateway.
1238
+ */
1239
+ async function registerMachineProxy(app, deps) {
1240
+ await app.register(async (scope) => {
1241
+ scope.removeAllContentTypeParsers();
1242
+ scope.addContentTypeParser("*", {
1243
+ parseAs: "buffer",
1244
+ bodyLimit: MESSAGE_BODY_LIMIT
1245
+ }, (_request, body, done) => done(null, body));
1246
+ registerProxyStreams(scope, deps);
1247
+ scope.all(`${MACHINE_PROXY_PREFIX}/:machineId/*`, { bodyLimit: MESSAGE_BODY_LIMIT }, (request, reply) => proxyHttp(deps.gateway, deps.hubDeviceLabel, request, reply));
1248
+ });
1249
+ }
1250
+ //#endregion
1251
+ //#region ../server/src/machines/summary-writer.ts
1252
+ const TICK_MS = 2e3;
1253
+ const WRITE_EVERY_MS = 15e3;
1254
+ const HUB_LABEL = "hub-summary";
1255
+ async function fetchJson(gateway, machineId, path) {
1256
+ const response = await gateway.request(machineId, {
1257
+ method: "GET",
1258
+ path,
1259
+ query: "",
1260
+ hubDevice: HUB_LABEL
1261
+ });
1262
+ if (response.status !== 200) throw new Error(`${path}: ${response.status}`);
1263
+ return JSON.parse(response.body.toString("utf8"));
1264
+ }
1265
+ /** Titles and times only: what the hub shows for a machine that went offline. */
1266
+ async function summaryOf(gateway, machineId) {
1267
+ const [projects, conversations] = await Promise.all([fetchJson(gateway, machineId, "/api/projects"), fetchJson(gateway, machineId, "/api/conversations")]);
1268
+ return {
1269
+ savedAt: Date.now(),
1270
+ projects: projects.map(({ id, name }) => ({
1271
+ id,
1272
+ name
1273
+ })),
1274
+ threads: conversations.filter((thread) => thread.archivedAt === null).map(({ id, projectId, title, updatedAt }) => ({
1275
+ id,
1276
+ projectId,
1277
+ title,
1278
+ updatedAt
1279
+ }))
1280
+ };
1281
+ }
1282
+ /** While a machine is online the hub saves its summary through the link: at once, then at most every 15 s. */
1283
+ function startSummaryWriter(core, gateway) {
1284
+ const lastWrite = /* @__PURE__ */ new Map();
1285
+ let running = false;
1286
+ const writeOne = async (machineId) => {
1287
+ lastWrite.set(machineId, Date.now());
1288
+ try {
1289
+ const summary = await summaryOf(gateway, machineId);
1290
+ await core.machines.writeSummary(machineId, summary);
1291
+ core.machines.heartbeat(machineId, summary.threads.length);
1292
+ } catch {}
1293
+ };
1294
+ const tick = async () => {
1295
+ if (running) return;
1296
+ running = true;
1297
+ try {
1298
+ const due = (await core.machines.list()).filter((machine) => machine.status === "online" && gateway.isOnline(machine.id) && Date.now() - (lastWrite.get(machine.id) ?? 0) >= WRITE_EVERY_MS);
1299
+ await Promise.all(due.map((machine) => writeOne(machine.id)));
1300
+ } finally {
1301
+ running = false;
1302
+ }
1303
+ };
1304
+ const timer = setInterval(() => void tick(), TICK_MS);
1305
+ timer.unref();
1306
+ return () => clearInterval(timer);
1307
+ }
1308
+ //#endregion
1309
+ //#region ../server/src/rest/json-body.ts
1310
+ /** Like Fastify's JSON parser, but an empty body is `undefined` instead of an error (bodiless POSTs such as abort). */
1311
+ function allowEmptyJsonBodies(app) {
1312
+ app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
1313
+ if (body === "") return done(null, void 0);
1314
+ try {
1315
+ done(null, JSON.parse(body));
1316
+ } catch {
1317
+ done(Object.assign(/* @__PURE__ */ new Error("Body is not valid JSON"), { statusCode: 400 }), void 0);
1318
+ }
1319
+ });
1320
+ }
1321
+ //#endregion
1322
+ //#region ../server/src/rest/candidate-name.ts
1323
+ const MAX_NAME_LENGTH = 512;
1324
+ const hasControlCharacter = (name) => [...name].some((char) => char.charCodeAt(0) < 32);
1325
+ const escapesProject = (name) => name.startsWith("/") || name.includes("\\") || name.split("/").some((part) => part === ".." || part === "");
1326
+ /** Rejects anything but a plain relative path before it reaches core; core validates against its candidate list again. */
1327
+ function requireCandidateName(name) {
1328
+ if (name.length > MAX_NAME_LENGTH || hasControlCharacter(name) || escapesProject(name)) throw new AppError("invalid_request", "Invalid icon name.");
1329
+ return name;
1330
+ }
1331
+ //#endregion
1332
+ //#region ../server/src/rest/etag-match.ts
1333
+ const stripWeak = (tag) => tag.trim().replace(/^W\//, "");
1334
+ /** True when an If-None-Match header lists the current ETag (weak forms and `*` included). */
1335
+ function etagMatches(header, etag) {
1336
+ if (!header) return false;
1337
+ return header.split(",").some((tag) => tag.trim() === "*" || stripWeak(tag) === etag);
1338
+ }
1339
+ //#endregion
1340
+ //#region ../server/src/rest/define-image-route.ts
1341
+ const hashOf = (data) => createHash("sha1").update(data).digest("base64url");
1342
+ const etagOf = (image) => `"${image.etag ?? hashOf(image.data)}"`;
1343
+ function setImageHeaders(reply, image) {
1344
+ reply.header("x-content-type-options", "nosniff").header("cache-control", "no-cache").header("etag", etagOf(image));
1345
+ if (image.contentType === "image/svg+xml") reply.header("content-security-policy", "sandbox");
1346
+ }
1347
+ function sendImage(reply, image) {
1348
+ setImageHeaders(reply, image);
1349
+ return reply.header("content-type", image.contentType).send(Buffer.from(image.data));
1350
+ }
1351
+ /** Registers an `image` route: raw bytes, or 404 when the handler has none. Always nosniff; SVG is sandboxed. */
1352
+ function defineImageRoute(app, route, handler) {
1353
+ app.route({
1354
+ method: route.method,
1355
+ url: route.path,
1356
+ schema: {
1357
+ ...route.params && { params: route.params },
1358
+ ...route.query && { querystring: route.query }
1359
+ },
1360
+ handler: async (request, reply) => {
1361
+ const image = await handler({
1362
+ params: request.params,
1363
+ query: request.query
1364
+ });
1365
+ if (!image) return reply.code(404).send({ error: {
1366
+ code: "not_found",
1367
+ message: "No icon."
1368
+ } });
1369
+ if (etagMatches(request.headers["if-none-match"], etagOf(image))) {
1370
+ setImageHeaders(reply, image);
1371
+ return reply.code(304).send();
1372
+ }
1373
+ return sendImage(reply, image);
1374
+ }
1375
+ });
1376
+ }
1377
+ const MAX_FRAME_BYTES = 65536;
1378
+ /** Joins chunks arriving within `COALESCE_MS` (or up to `COALESCE_MAX_BYTES`) into one frame; no frame exceeds `MAX_FRAME_BYTES`. */
1379
+ function createCoalescer(send) {
1380
+ let pending = [];
1381
+ let size = 0;
1382
+ let timer;
1383
+ function flush() {
1384
+ if (timer) clearTimeout(timer);
1385
+ timer = void 0;
1386
+ if (size === 0) return;
1387
+ const frame = Buffer.concat(pending, size);
1388
+ pending = [];
1389
+ size = 0;
1390
+ send(new Uint8Array(frame.buffer, frame.byteOffset, frame.byteLength));
1391
+ }
1392
+ function add(chunk) {
1393
+ pending.push(chunk);
1394
+ size += chunk.length;
1395
+ if (size >= 16384) flush();
1396
+ else timer ??= setTimeout(flush, 8);
1397
+ }
1398
+ return {
1399
+ push(data) {
1400
+ for (let at = 0; at < data.length; at += MAX_FRAME_BYTES) {
1401
+ if (size + Math.min(65536, data.length - at) > 65536) flush();
1402
+ add(data.subarray(at, at + MAX_FRAME_BYTES));
1403
+ }
1404
+ },
1405
+ flush,
1406
+ dispose() {
1407
+ if (timer) clearTimeout(timer);
1408
+ timer = void 0;
1409
+ pending = [];
1410
+ size = 0;
1411
+ }
1412
+ };
1413
+ }
1414
+ const CHECK_MS = 250;
1415
+ /** Calls `onStalled` once the buffer has stayed above `STALL_BYTES` for `STALL_MS`. Returns the stop function. */
1416
+ function watchStall(socket, onStalled, now = Date.now) {
1417
+ let since;
1418
+ const timer = setInterval(() => {
1419
+ if (socket.bufferedAmount <= 1048576) {
1420
+ since = void 0;
1421
+ return;
1422
+ }
1423
+ since ??= now();
1424
+ if (now() - since >= 5e3) {
1425
+ clearInterval(timer);
1426
+ onStalled();
1427
+ }
1428
+ }, CHECK_MS);
1429
+ return () => clearInterval(timer);
1430
+ }
1431
+ //#endregion
1432
+ //#region ../server/src/terminals/stream-connection.ts
1433
+ const INTERNAL_ERROR = 1011;
1434
+ const SLOW_CLOSE_GRACE_MS = 2e3;
1435
+ function decodeResize(raw) {
1436
+ try {
1437
+ const value = JSON.parse(raw);
1438
+ return Value.Check(TerminalClientFrameSchema, value) ? value : void 0;
1439
+ } catch {
1440
+ return;
1441
+ }
1442
+ }
1443
+ const toBytes = (data) => {
1444
+ if (Array.isArray(data)) return Buffer.concat(data);
1445
+ return data instanceof ArrayBuffer ? new Uint8Array(data) : data;
1446
+ };
1447
+ /** One attached pane: attaches to the core, sends `ready`, coalesced output, then `exit`; detaches on close. */
1448
+ var TerminalStream = class {
1449
+ socket;
1450
+ onError;
1451
+ coalescer = createCoalescer((frame) => this.sendBinary(frame));
1452
+ stopWatching;
1453
+ attachment;
1454
+ ready = false;
1455
+ exited = false;
1456
+ queue = [];
1457
+ constructor(socket, core, id, onError) {
1458
+ this.socket = socket;
1459
+ this.onError = onError;
1460
+ this.stopWatching = watchStall(socket, () => this.dropSlowClient());
1461
+ this.attachment = this.attach(core, id);
1462
+ socket.on("message", (data, isBinary) => void this.handleMessage(data, isBinary));
1463
+ socket.on("close", () => void this.dispose());
1464
+ socket.on("error", onError);
1465
+ }
1466
+ /** A stalled client never reads the close frame queued behind its data: close with 1013, then cut the socket so the buffer is freed. */
1467
+ dropSlowClient() {
1468
+ this.socket.close(TERMINAL_STREAM_CLOSE_SLOW, "too slow");
1469
+ const cut = setTimeout(() => this.socket.terminate(), SLOW_CLOSE_GRACE_MS);
1470
+ cut.unref();
1471
+ this.socket.once("close", () => clearTimeout(cut));
1472
+ }
1473
+ async attach(core, id) {
1474
+ try {
1475
+ const attachment = await core.terminals.attach(id, {
1476
+ onData: (data) => this.afterReady(() => this.coalescer.push(data)),
1477
+ onExit: (code) => this.afterReady(() => this.sendExit(code))
1478
+ });
1479
+ if (this.socket.readyState !== this.socket.OPEN) {
1480
+ attachment.detach();
1481
+ return;
1482
+ }
1483
+ this.sendText({
1484
+ type: "ready",
1485
+ status: attachment.status,
1486
+ exitCode: attachment.exitCode
1487
+ });
1488
+ this.ready = true;
1489
+ for (const step of this.queue.splice(0)) step();
1490
+ if (attachment.status === "exited") this.finishExited(attachment.exitCode);
1491
+ return attachment;
1492
+ } catch (error) {
1493
+ this.failAttach(error);
1494
+ return;
1495
+ }
1496
+ }
1497
+ /** Attaching to an ended terminal never calls `onExit`: send the replay, then `exit`, then close. */
1498
+ finishExited(exitCode) {
1499
+ this.afterReady(() => {
1500
+ this.sendExit(exitCode);
1501
+ this.socket.close(1e3, "exited");
1502
+ });
1503
+ }
1504
+ failAttach(error) {
1505
+ if (error instanceof AppError && error.code === "not_found") {
1506
+ this.socket.close(TERMINAL_STREAM_CLOSE_UNKNOWN, "not_found");
1507
+ return;
1508
+ }
1509
+ this.onError(error);
1510
+ this.socket.close(INTERNAL_ERROR, "internal_error");
1511
+ }
1512
+ afterReady(step) {
1513
+ if (this.ready) step();
1514
+ else this.queue.push(step);
1515
+ }
1516
+ sendExit(exitCode) {
1517
+ if (this.exited) return;
1518
+ this.exited = true;
1519
+ this.coalescer.flush();
1520
+ this.sendText({
1521
+ type: "exit",
1522
+ exitCode
1523
+ });
1524
+ }
1525
+ sendBinary(frame) {
1526
+ if (this.socket.readyState === this.socket.OPEN) this.socket.send(frame, { binary: true });
1527
+ }
1528
+ sendText(frame) {
1529
+ if (this.socket.readyState === this.socket.OPEN) this.socket.send(JSON.stringify(frame));
1530
+ }
1531
+ async handleMessage(data, isBinary) {
1532
+ if (closeIfOversized(this.socket, data, 65536)) return;
1533
+ const attachment = await this.attachment;
1534
+ if (!attachment) return;
1535
+ if (isBinary) return attachment.write(toBytes(data));
1536
+ const frame = decodeResize(data.toString());
1537
+ if (!frame) return this.socket.close(TERMINAL_STREAM_CLOSE_UNKNOWN, "invalid frame");
1538
+ attachment.resize(frame.cols, frame.rows);
1539
+ }
1540
+ async dispose() {
1541
+ this.stopWatching();
1542
+ this.coalescer.dispose();
1543
+ this.queue = [];
1544
+ (await this.attachment)?.detach();
1545
+ }
1546
+ };
1547
+ //#endregion
1548
+ //#region ../server/src/terminals/register-stream.ts
1549
+ function registerTerminalStream(app, core, sockets) {
1550
+ app.get(TERMINAL_STREAM_PATH, { websocket: true }, (socket, request) => {
1551
+ if (!sockets.add(request.deviceId, socket)) return;
1552
+ new TerminalStream(socket, core, request.params.id, (error) => request.log.error(error));
1553
+ });
1554
+ }
1555
+ //#endregion
1556
+ //#region ../server/src/rest/register-terminal-routes.ts
1557
+ /**
1558
+ * REST half of terminals: no business logic, every route forwards to `core.terminals`.
1559
+ * Owner B: the stream WebSocket (`TERMINAL_STREAM_PATH`) lives in `server/src/terminals/**`.
1560
+ * `getTerminalsAvailable` answers `{ available }`; the list and open routes answer `not_found` when unavailable.
1561
+ */
1562
+ function registerTerminalRoutes(app, core, sockets) {
1563
+ const { terminals } = core;
1564
+ defineRoute(app, routes.getTerminalsAvailable, async () => ({ available: await terminals.available() }));
1565
+ defineRoute(app, routes.listTerminals, ({ params }) => terminals.list(params.id));
1566
+ defineRoute(app, routes.openTerminal, ({ params, body }) => terminals.open(params.id, body));
1567
+ defineRoute(app, routes.closeTerminal, ({ params }) => terminals.close(params.id));
1568
+ registerTerminalStream(app, core, sockets);
1569
+ }
1570
+ //#endregion
1571
+ //#region ../server/src/rest/register-connector-routes.ts
1572
+ function registerConnectorRoutes(app, core) {
1573
+ const { approvals, settings } = core;
1574
+ defineRoute(app, routes.setClaudeConnectors, ({ body }) => settings.setClaudeConnectors(body));
1575
+ defineRoute(app, routes.listApprovals, ({ params }) => approvals.list(params.id));
1576
+ defineRoute(app, routes.decideApproval, ({ params, body }) => approvals.decide(params.id, params.toolUseId, body.decision));
1577
+ }
1578
+ //#endregion
1579
+ //#region ../server/src/rest/register-workspace-routes.ts
1580
+ function registerThreadRoutes(app, core) {
1581
+ const { conversations, workspaces } = core;
1582
+ defineRoute(app, routes.forkConversation, ({ params, body }) => conversations.fork(params.id, body.entryId));
1583
+ defineRoute(app, routes.rewindPreview, ({ params, body }) => conversations.rewindPreview(params.id, body.entryId));
1584
+ defineRoute(app, routes.rewindConversation, ({ params, body }) => conversations.rewind(params.id, body.entryId));
1585
+ defineRoute(app, routes.stopAgent, ({ params }) => conversations.stopAgent(params.id));
1586
+ defineRoute(app, routes.cancelQueued, ({ params }) => conversations.cancelQueued(params.id, params.itemId));
1587
+ defineRoute(app, routes.listCheckpoints, ({ params }) => conversations.checkpoints(params.id));
1588
+ defineRoute(app, routes.getCheckpointDiff, async ({ params, query }) => ({ diff: await conversations.checkpointDiff(params.id, params.cid, query.path) }));
1589
+ defineRoute(app, routes.getWorkspace, ({ params }) => workspaces.status(params.id));
1590
+ defineRoute(app, routes.mergeWorkspace, ({ params }) => workspaces.merge(params.id));
1591
+ defineRoute(app, routes.createPullRequest, ({ params }) => workspaces.createPullRequest(params.id));
1592
+ defineRoute(app, routes.removeWorkspace, ({ params, query }) => workspaces.remove(params.id, query.force === "true"));
1593
+ }
1594
+ function registerToolRoutes(app, core) {
1595
+ const { processes, mcp, settings, timers, goals } = core;
1596
+ defineRoute(app, routes.listProcesses, ({ params }) => processes.list(params.id));
1597
+ defineRoute(app, routes.getProcessLogs, async ({ params, query }) => ({ logs: await processes.logs(params.id, query.tail ? Number(query.tail) : 200) }));
1598
+ defineRoute(app, routes.stopProcess, ({ params }) => processes.stop(params.id));
1599
+ defineRoute(app, routes.listTimers, ({ params }) => timers.list(params.id));
1600
+ defineRoute(app, routes.deleteTimer, ({ params }) => timers.delete(params.id));
1601
+ defineRoute(app, routes.startLoop, ({ params, body }) => timers.loop(params.id, body));
1602
+ defineRoute(app, routes.stopLoop, ({ params }) => timers.stopLoop(params.id));
1603
+ defineRoute(app, routes.getGoal, ({ params }) => goals.get(params.id));
1604
+ defineRoute(app, routes.setGoal, ({ params, body }) => goals.set(params.id, body.condition));
1605
+ defineRoute(app, routes.clearGoal, ({ params }) => goals.clear(params.id));
1606
+ defineRoute(app, routes.listMcpServers, ({ query }) => mcp.servers(query.projectId ?? null));
1607
+ defineRoute(app, routes.saveMcpServer, ({ params, body }) => mcp.save({
1608
+ ...body,
1609
+ name: params.name
1610
+ }));
1611
+ defineRoute(app, routes.removeMcpServer, ({ params }) => mcp.remove(params.name));
1612
+ defineRoute(app, routes.setMessageMode, ({ body }) => settings.setMessageMode(body.mode));
1613
+ defineRoute(app, routes.setWorkspaceDefault, ({ body }) => settings.setWorkspaceDefault(body.mode));
1614
+ }
1615
+ function registerWorkspaceRoutes(app, core) {
1616
+ registerThreadRoutes(app, core);
1617
+ registerToolRoutes(app, core);
1618
+ registerConnectorRoutes(app, core);
1619
+ }
1620
+ //#endregion
1621
+ //#region ../server/src/rest/register-routes.ts
1622
+ function startLoginFor(core, id) {
1623
+ if (id !== "openai") throw new AppError("invalid_request", "This provider has no sign-in.");
1624
+ return core.providers.startLogin(id);
1625
+ }
1626
+ function registerRoutes(app, core, sockets) {
1627
+ defineRoute(app, routes.suggestDirectories, ({ body }) => core.fs.suggestDirectories(body));
1628
+ defineRoute(app, routes.getUpdateStatus, () => core.updates.status());
1629
+ defineRoute(app, routes.refreshUpdateStatus, () => core.updates.refresh());
1630
+ defineRoute(app, routes.getSettings, () => core.settings.get());
1631
+ defineRoute(app, routes.setDefaultModel, ({ body }) => core.settings.setDefaultModel(body));
1632
+ defineRoute(app, routes.setDefaultThinking, ({ body }) => core.settings.setDefaultThinkingLevel(body.thinkingLevel));
1633
+ defineRoute(app, routes.setFastThinking, ({ body }) => core.settings.setFastThinkingLevel(body.thinkingLevel));
1634
+ defineRoute(app, routes.setFastModel, ({ body }) => core.settings.setFastModel(body.model));
1635
+ defineRoute(app, routes.setProjectsFolder, ({ body }) => core.settings.setProjectsFolder(body.path));
1636
+ defineRoute(app, routes.getSuggestedCodeFolder, () => core.settings.suggestedCodeFolder());
1637
+ defineRoute(app, routes.getGithubStatus, () => core.github.status());
1638
+ defineRoute(app, routes.listGithubRepos, () => core.github.repos());
1639
+ defineRoute(app, routes.refreshGithubRepos, () => core.github.refreshRepos());
1640
+ defineRoute(app, routes.useGithubRepo, ({ body }) => core.github.use(body));
1641
+ defineRoute(app, routes.listProviders, () => core.providers.list());
1642
+ defineRoute(app, routes.setProviderKey, ({ params, body }) => core.providers.setApiKey(params.id, body.key));
1643
+ defineRoute(app, routes.disconnectProvider, ({ params }) => core.providers.disconnect(params.id));
1644
+ defineRoute(app, routes.acknowledgeClaudeCli, () => core.providers.acknowledgeClaudeCli());
1645
+ defineRoute(app, routes.refreshClaudeCli, () => core.providers.refreshClaudeCli());
1646
+ defineRoute(app, routes.installClaudeCliSdk, () => core.providers.installClaudeCliSdk());
1647
+ defineRoute(app, routes.startLogin, ({ params }) => startLoginFor(core, params.id));
1648
+ defineRoute(app, routes.getLogin, ({ params }) => core.providers.login(params.sid));
1649
+ defineRoute(app, routes.submitLoginUrl, ({ params, body }) => core.providers.submitLoginUrl(params.sid, body.url));
1650
+ defineRoute(app, routes.cancelLogin, ({ params }) => core.providers.cancelLogin(params.sid));
1651
+ defineRoute(app, routes.listModels, () => core.models.list());
1652
+ defineRoute(app, routes.listSkills, ({ query }) => core.skills.list(query.projectId ?? null));
1653
+ defineRoute(app, routes.suggestProjectFiles, ({ params, body }) => core.projects.suggestFiles(params.id, body.query));
1654
+ defineRoute(app, routes.listProjects, () => core.projects.list());
1655
+ defineRoute(app, routes.addProject, ({ body }) => core.projects.add(body));
1656
+ defineRoute(app, routes.cloneProject, ({ body }) => core.projects.clone(body));
1657
+ defineRoute(app, routes.retryClone, ({ params }) => core.projects.retryClone(params.id));
1658
+ defineRoute(app, routes.setProjectFolder, ({ params, body }) => core.projects.setFolder(params.id, body.path));
1659
+ defineRoute(app, routes.renameProject, ({ params, body }) => core.projects.rename(params.id, body.name));
1660
+ defineRoute(app, routes.removeProject, ({ params }) => core.projects.remove(params.id));
1661
+ defineImageRoute(app, routes.getProjectIcon, ({ params }) => core.projects.icon(params.id));
1662
+ defineRoute(app, routes.setProjectIcon, ({ params, body }) => core.projects.setIcon(params.id, new Uint8Array(Buffer.from(body.png, "base64"))));
1663
+ defineRoute(app, routes.resetProjectIcon, ({ params }) => core.projects.setIcon(params.id, null));
1664
+ defineRoute(app, routes.listIconCandidates, async ({ params }) => (await core.projects.iconCandidates(params.id)).map(({ name }) => ({
1665
+ name,
1666
+ url: iconCandidateUrl(params.id, name)
1667
+ })));
1668
+ defineImageRoute(app, routes.getIconCandidate, ({ params, query }) => core.projects.iconCandidate(params.id, requireCandidateName(query.name)));
1669
+ defineRoute(app, routes.listConversations, ({ params }) => core.conversations.list(params.id));
1670
+ defineRoute(app, routes.listAllConversations, () => core.conversations.listAll());
1671
+ defineRoute(app, routes.deleteArchivedConversations, ({ body }) => core.conversations.deleteArchived(body));
1672
+ defineRoute(app, routes.startConversation, ({ body }) => core.conversations.start(body));
1673
+ defineRoute(app, routes.renameConversation, ({ params, body }) => core.conversations.rename(params.id, body.title));
1674
+ defineRoute(app, routes.sendMessage, ({ params, body }) => core.conversations.send(params.id, body));
1675
+ defineRoute(app, routes.abortConversation, ({ params }) => core.conversations.abort(params.id));
1676
+ defineRoute(app, routes.archiveConversation, ({ params, body }) => core.conversations.archive(params.id, body.archived));
1677
+ defineRoute(app, routes.removeConversation, ({ params, query }) => core.conversations.remove(params.id, { force: query.force === "true" }));
1678
+ defineRoute(app, routes.configureConversation, ({ params, body }) => core.conversations.configure(params.id, body));
1679
+ defineRoute(app, routes.compactConversation, ({ params, body }) => core.conversations.compact(params.id, body));
1680
+ defineRoute(app, routes.retryConversation, ({ params, body }) => core.conversations.retry(params.id, body));
1681
+ registerWorkspaceRoutes(app, core);
1682
+ registerTerminalRoutes(app, core, sockets);
1683
+ }
1684
+ //#endregion
1685
+ //#region ../server/src/security/device-sockets.ts
1686
+ const POLICY_VIOLATION = 1008;
1687
+ const OWNER = "owner";
1688
+ function createDeviceSockets() {
1689
+ const byDevice = /* @__PURE__ */ new Map();
1690
+ const byMachine = /* @__PURE__ */ new Map();
1691
+ const closeAll = (sockets) => {
1692
+ for (const socket of sockets) socket.close(POLICY_VIOLATION, "revoked");
1693
+ };
1694
+ const self = {
1695
+ addProxied(deviceId, machineId, socket) {
1696
+ if (!self.add(deviceId, socket)) return false;
1697
+ const set = byMachine.get(machineId) ?? /* @__PURE__ */ new Set();
1698
+ set.add(socket);
1699
+ byMachine.set(machineId, set);
1700
+ socket.once("close", () => {
1701
+ set.delete(socket);
1702
+ if (set.size === 0) byMachine.delete(machineId);
1703
+ });
1704
+ return true;
1705
+ },
1706
+ closeMachine(machineId) {
1707
+ closeAll([...byMachine.get(machineId) ?? []]);
1708
+ },
1709
+ add(deviceId, socket) {
1710
+ if (deviceId === "") {
1711
+ socket.close(POLICY_VIOLATION, "unauthorized");
1712
+ return false;
1713
+ }
1714
+ const set = byDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
1715
+ set.add(socket);
1716
+ byDevice.set(deviceId, set);
1717
+ socket.once("close", () => {
1718
+ set.delete(socket);
1719
+ if (set.size === 0) byDevice.delete(deviceId);
1720
+ });
1721
+ return true;
1722
+ },
1723
+ closeDevice(deviceId) {
1724
+ closeAll([...byDevice.get(deviceId) ?? []]);
1725
+ },
1726
+ closeExcept(liveIds) {
1727
+ for (const [deviceId, sockets] of [...byDevice]) if (deviceId !== OWNER && !liveIds.has(deviceId)) closeAll([...sockets]);
1728
+ },
1729
+ count: (deviceId) => byDevice.get(deviceId)?.size ?? 0
1730
+ };
1731
+ return self;
1732
+ }
1733
+ /** After any `devices` change: cut the sockets of devices that no longer exist (revoke from the CLI, a new URL). */
1734
+ async function closeSocketsOfGoneDevices(core, sockets) {
1735
+ const devices = await core.access.listDevices();
1736
+ sockets.closeExcept(new Set(devices.map((device) => device.id)));
1737
+ }
1738
+ //#endregion
1739
+ //#region ../server/src/security/pair-page.ts
1740
+ /** The one UI outside the design system, on purpose: the React app needs a session first. Colours by value. */
1741
+ const SCRIPT = `
1742
+ const $ = (id) => document.getElementById(id);
1743
+ const REDEEM_PATH = ${JSON.stringify(routes.redeemPairing.path)};
1744
+ const MESSAGES = {
1745
+ 401: "That code did not work. Check it, or ask for a new one.",
1746
+ 429: "Too many tries. Wait a little, then try again.",
1747
+ };
1748
+ const guessName = () => {
1749
+ const agent = navigator.userAgent;
1750
+ const os = /iPhone|iPad/.test(agent) ? "iOS" : /Android/.test(agent) ? "Android" : /Mac/.test(agent) ? "Mac" : /Windows/.test(agent) ? "Windows" : /Linux/.test(agent) ? "Linux" : "";
1751
+ const browser = /Edg\\//.test(agent) ? "Edge" : /Firefox\\//.test(agent) ? "Firefox" : /Chrome\\//.test(agent) ? "Chrome" : /Safari\\//.test(agent) ? "Safari" : "Browser";
1752
+ return os ? browser + " on " + os : browser;
1753
+ };
1754
+ const show = (text) => { $("error").textContent = text; };
1755
+ const busy = (on) => { $("link").disabled = on; };
1756
+ const redeem = async (code, deviceName) => {
1757
+ busy(true);
1758
+ show("");
1759
+ try {
1760
+ const response = await fetch(REDEEM_PATH, {
1761
+ method: "POST",
1762
+ headers: { "content-type": "application/json" },
1763
+ body: JSON.stringify({ code, deviceName: deviceName.slice(0, 120) || guessName() }),
1764
+ });
1765
+ if (response.ok) return location.replace("/");
1766
+ show(MESSAGES[response.status] || "Something went wrong. Try again.");
1767
+ } catch {
1768
+ show("Could not reach tau. Check your connection and try again.");
1769
+ }
1770
+ busy(false);
1771
+ };
1772
+ const groups = (text) => text.replace(/[^a-z0-9]/gi, "").toUpperCase().slice(0, 8).replace(/(.{4})(?=.)/g, "$1 ");
1773
+ const fromLink = () => {
1774
+ const [code, name] = location.hash.slice(1).split(".");
1775
+ try { return { code, name: name ? decodeURIComponent(name) : "" }; } catch { return { code, name: "" }; }
1776
+ };
1777
+ $("name").value = guessName();
1778
+ const linked = fromLink();
1779
+ if (linked.code) {
1780
+ history.replaceState(null, "", location.pathname + location.search);
1781
+ if (linked.name) $("name").value = linked.name;
1782
+ redeem(linked.code, $("name").value);
1783
+ }
1784
+ $("code").addEventListener("input", (event) => { event.target.value = groups(event.target.value); });
1785
+ $("form").addEventListener("submit", (event) => {
1786
+ event.preventDefault();
1787
+ const typed = $("code").value.replace(/\\s/g, "");
1788
+ if (typed.length !== 8) return show("The code has 8 characters.");
1789
+ redeem(typed, $("name").value.trim());
1790
+ });
1791
+ `;
1792
+ const PAIR_PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Link this device</title><style>
1793
+ :root{color-scheme:light dark;--bg:#ffffff;--fg:#0a0a0a;--muted:#737373;--border:#e5e5e5;--primary:#171717;--primary-fg:#fafafa;--danger:#e7000b}
1794
+ @media(prefers-color-scheme:dark){:root{--bg:#0a0a0a;--fg:#fafafa;--muted:#b5b5b5;--border:#ffffff26;--primary:#e5e5e5;--primary-fg:#171717;--danger:#ff6467}}
1795
+ *{box-sizing:border-box}
1796
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif}
1797
+ main{width:100%;max-width:24rem;padding:1.5rem}
1798
+ h1{margin:0 0 .25rem;font-size:1.25rem;font-weight:600}
1799
+ p{margin:0 0 1.25rem;color:var(--muted);font-size:.875rem}
1800
+ label{display:block;margin:0 0 .375rem;font-size:.875rem;font-weight:500}
1801
+ input,button{font:inherit;width:100%;height:2.5rem;padding:0 .75rem;border-radius:.5rem;border:1px solid var(--border)}
1802
+ input{background:transparent;color:var(--fg);margin-bottom:1rem}
1803
+ #code{font-family:ui-monospace,monospace;letter-spacing:.2em;text-transform:uppercase}
1804
+ input:focus-visible,button:focus-visible{outline:2px solid var(--fg);outline-offset:2px}
1805
+ button{background:var(--primary);color:var(--primary-fg);border-color:transparent;font-weight:500;cursor:pointer}
1806
+ button:disabled{opacity:.5;cursor:default}
1807
+ #error{min-height:1.5rem;margin:1rem 0 0;color:var(--danger)}
1808
+ </style></head><body><main><h1>Link this device</h1><p>Type the 8-character code shown on your tau, or open the link it gave you.</p>
1809
+ <form id="form" novalidate><label for="code">Code</label><input id="code" inputmode="text" autocomplete="off" autocapitalize="characters" spellcheck="false" maxlength="9" placeholder="ABCD EFGH" aria-describedby="error">
1810
+ <label for="name">Device name</label><input id="name" autocomplete="off" maxlength="120"><button id="link" type="submit">Link this device</button></form>
1811
+ <p id="error" role="alert" aria-live="polite"></p></main><script>${SCRIPT}<\/script></body></html>`;
1812
+ const PAIR_PAGE_HEADERS = {
1813
+ "content-security-policy": `default-src 'none'; script-src '${`sha256-${createHash("sha256").update(SCRIPT).digest("base64")}`}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
1814
+ "cache-control": "no-store"
1815
+ };
1816
+ //#endregion
1817
+ //#region ../server/src/security/register-access-routes.ts
1818
+ const COOKIE_MAX_AGE_SECONDS = 3456e4;
1819
+ const LOCAL_PEER = "local";
1820
+ function setDeviceCookie(reply, secret, secure) {
1821
+ reply.setCookie(DEVICE_COOKIE, secret, {
1822
+ httpOnly: true,
1823
+ sameSite: "strict",
1824
+ secure,
1825
+ path: "/",
1826
+ maxAge: COOKIE_MAX_AGE_SECONDS
1827
+ });
1828
+ }
1829
+ function registerRedeem({ core, tunnel }, app) {
1830
+ app.post(routes.redeemPairing.path, { schema: { body: routes.redeemPairing.body } }, async (request, reply) => {
1831
+ const tag = tunnel.tagOf(request.raw.socket);
1832
+ const redeemed = await core.access.redeemPairing({
1833
+ ...request.body,
1834
+ peer: tag?.peer ?? LOCAL_PEER,
1835
+ userAgent: request.headers["user-agent"]
1836
+ });
1837
+ if (redeemed.kind === "machine") return {
1838
+ deviceId: redeemed.deviceId,
1839
+ secret: redeemed.secret
1840
+ };
1841
+ setDeviceCookie(reply, redeemed.secret, tag !== void 0);
1842
+ return { deviceId: redeemed.deviceId };
1843
+ });
1844
+ }
1845
+ function registerDevices({ core, sockets }, app) {
1846
+ defineRoute(app, routes.listDevices, (_input, request) => core.access.listDevices(request.deviceId));
1847
+ defineRoute(app, routes.renameDevice, ({ params, body }) => core.access.renameDevice(params.id, body.name));
1848
+ defineRoute(app, routes.revokeDevice, async ({ params }) => {
1849
+ await core.access.revokeDevice(params.id);
1850
+ sockets.closeDevice(params.id);
1851
+ });
1852
+ defineRoute(app, routes.signOutAllDevices, async () => {
1853
+ const before = await core.access.listDevices();
1854
+ await core.access.signOutAll();
1855
+ for (const device of before) if (device.kind !== "machine") sockets.closeDevice(device.id);
1856
+ });
1857
+ defineRoute(app, routes.mintPairing, async ({ query }) => {
1858
+ const refusal = query.kind === "machine" ? acceptLinkRefusal(await core.machines.hubState()) : void 0;
1859
+ if (refusal) throw new AppError("invalid_request", refusal);
1860
+ return core.access.mintPairing(query);
1861
+ });
1862
+ }
1863
+ function registerAccessRoutes(app, deps) {
1864
+ const { core } = deps;
1865
+ defineRoute(app, routes.getHealth, async () => ({ ok: true }));
1866
+ app.get(PAIR_PAGE_PATH, async (_request, reply) => reply.headers(PAIR_PAGE_HEADERS).type("text/html; charset=utf-8").send(PAIR_PAGE));
1867
+ registerRedeem(deps, app);
1868
+ registerDevices(deps, app);
1869
+ defineRoute(app, routes.getRemoteStatus, () => core.remote.status());
1870
+ defineRoute(app, routes.enableRemote, () => core.remote.enable());
1871
+ defineRoute(app, routes.disableRemote, () => core.remote.disable());
1872
+ defineRoute(app, routes.recreateRemote, () => core.remote.recreate());
1873
+ }
1874
+ //#endregion
1875
+ //#region ../server/src/security/session.ts
1876
+ function digest(value) {
1877
+ return createHash("sha256").update(value).digest();
1878
+ }
1879
+ function createSession(token, port) {
1880
+ const expected = digest(token);
1881
+ return {
1882
+ cookieName: `tau_${port}`,
1883
+ isValidToken: (candidate) => typeof candidate === "string" && timingSafeEqual(digest(candidate), expected)
1884
+ };
1885
+ }
1886
+ //#endregion
1887
+ //#region ../server/src/security/proxy-v2.ts
1888
+ const SIGNATURE = Buffer.from("0d0a0d0a000d0a515549540a", "hex");
1889
+ const HEADER_BYTES = 16;
1890
+ const COMMAND_LOCAL = 32;
1891
+ const COMMAND_PROXY = 33;
1892
+ const FAMILY_UNSPEC = 0;
1893
+ const FAMILY_TCP4 = 17;
1894
+ const FAMILY_TCP6 = 33;
1895
+ const TLV_AUTHORITY = 2;
1896
+ const TCP4_ADDRESS_BYTES = 12;
1897
+ const TCP6_ADDRESS_BYTES = 36;
1898
+ function peerOf(command, family, body) {
1899
+ if (command === COMMAND_LOCAL) return {
1900
+ peer: "local",
1901
+ skip: 0
1902
+ };
1903
+ if (family === FAMILY_TCP4) return {
1904
+ peer: [...body.subarray(0, 4)].join("."),
1905
+ skip: TCP4_ADDRESS_BYTES
1906
+ };
1907
+ if (family === FAMILY_TCP6) return {
1908
+ peer: body.subarray(0, 16).toString("hex"),
1909
+ skip: TCP6_ADDRESS_BYTES
1910
+ };
1911
+ return {
1912
+ peer: "local",
1913
+ skip: 0
1914
+ };
1915
+ }
1916
+ function authorityOf(tlvs) {
1917
+ let offset = 0;
1918
+ while (offset + 3 <= tlvs.length) {
1919
+ const length = tlvs.readUInt16BE(offset + 1);
1920
+ if (tlvs[offset] === TLV_AUTHORITY) return tlvs.subarray(offset + 3, offset + 3 + length).toString();
1921
+ offset += 3 + length;
1922
+ }
1923
+ }
1924
+ function addressBytesNeeded(command, family) {
1925
+ if (command === COMMAND_LOCAL) return 0;
1926
+ if (family === FAMILY_TCP4) return TCP4_ADDRESS_BYTES;
1927
+ return family === FAMILY_TCP6 ? TCP6_ADDRESS_BYTES : 0;
1928
+ }
1929
+ /** Parses a PROXY protocol v2 header at the start of `buffer`. */
1930
+ function parseProxyV2(buffer) {
1931
+ const seen = buffer.subarray(0, Math.min(buffer.length, SIGNATURE.length));
1932
+ if (!seen.equals(SIGNATURE.subarray(0, seen.length))) return {
1933
+ kind: "malformed",
1934
+ reason: "signature"
1935
+ };
1936
+ if (buffer.length < HEADER_BYTES) return { kind: "need-more" };
1937
+ const command = buffer[12];
1938
+ const family = buffer[13];
1939
+ if (command !== COMMAND_PROXY && command !== COMMAND_LOCAL) return {
1940
+ kind: "malformed",
1941
+ reason: "command"
1942
+ };
1943
+ if (![
1944
+ FAMILY_TCP4,
1945
+ FAMILY_TCP6,
1946
+ FAMILY_UNSPEC
1947
+ ].includes(family)) return {
1948
+ kind: "malformed",
1949
+ reason: "family"
1950
+ };
1951
+ const length = buffer.readUInt16BE(14);
1952
+ if (length < addressBytesNeeded(command, family)) return {
1953
+ kind: "malformed",
1954
+ reason: "length"
1955
+ };
1956
+ if (buffer.length < HEADER_BYTES + length) return { kind: "need-more" };
1957
+ const body = buffer.subarray(HEADER_BYTES, HEADER_BYTES + length);
1958
+ const { peer, skip } = peerOf(command, family, body);
1959
+ return {
1960
+ kind: "ok",
1961
+ peer,
1962
+ sni: authorityOf(body.subarray(skip)),
1963
+ rest: buffer.subarray(HEADER_BYTES + length)
1964
+ };
1965
+ }
1966
+ const MAX_HEADER_BYTES = 65551;
1967
+ /** Tag first, then hand the socket to the HTTP server: unshift the rest, emit `connection`, resume. */
1968
+ function admit(options, socket, peer, sni, rest) {
1969
+ options.tags.tag(socket, {
1970
+ peer,
1971
+ sni
1972
+ });
1973
+ if (rest.length > 0) socket.unshift(rest);
1974
+ options.app.server.emit("connection", socket);
1975
+ socket.resume();
1976
+ }
1977
+ function handleConnection(options, socket) {
1978
+ let buffered = Buffer.alloc(0);
1979
+ const timer = setTimeout(() => socket.destroy(), options.headerTimeoutMs ?? 5e3);
1980
+ const onData = (chunk) => {
1981
+ buffered = Buffer.concat([buffered, chunk]);
1982
+ const parsed = parseProxyV2(buffered);
1983
+ if (parsed.kind === "need-more" && buffered.length <= MAX_HEADER_BYTES) return;
1984
+ clearTimeout(timer);
1985
+ socket.off("data", onData);
1986
+ socket.pause();
1987
+ const hostname = options.hostname();
1988
+ if (parsed.kind !== "ok" || hostname === void 0 || parsed.sni?.toLowerCase() !== hostname) {
1989
+ socket.destroy();
1990
+ return;
1991
+ }
1992
+ admit(options, socket, parsed.peer, hostname, parsed.rest);
1993
+ };
1994
+ socket.on("data", onData);
1995
+ socket.on("error", () => socket.destroy());
1996
+ socket.on("close", () => clearTimeout(timer));
1997
+ }
1998
+ function createTunnelListener(options) {
1999
+ const sockets = /* @__PURE__ */ new Set();
2000
+ const server = createServer((socket) => {
2001
+ sockets.add(socket);
2002
+ socket.on("close", () => sockets.delete(socket));
2003
+ handleConnection(options, socket);
2004
+ });
2005
+ return {
2006
+ start: () => new Promise((resolve, reject) => {
2007
+ server.once("error", reject);
2008
+ server.listen(0, "127.0.0.1", () => {
2009
+ server.off("error", reject);
2010
+ const address = server.address();
2011
+ resolve({ port: typeof address === "object" && address ? address.port : 0 });
2012
+ });
2013
+ }),
2014
+ close: () => new Promise((resolve) => {
2015
+ for (const socket of sockets) socket.destroy();
2016
+ server.close(() => resolve());
2017
+ })
2018
+ };
2019
+ }
2020
+ //#endregion
2021
+ //#region ../server/src/security/tunnel-tags.ts
2022
+ function createTunnelTags() {
2023
+ const tags = /* @__PURE__ */ new WeakMap();
2024
+ return {
2025
+ tagOf: (socket) => tags.get(socket),
2026
+ tag: (socket, tag) => void tags.set(socket, tag)
2027
+ };
2028
+ }
2029
+ //#endregion
2030
+ //#region ../server/src/static-assets.ts
2031
+ function isApiRequest(request) {
2032
+ return request.url === "/api" || request.url.startsWith(`/api/`);
2033
+ }
2034
+ function acceptsHtml(request) {
2035
+ return request.method === "GET" && (request.headers.accept ?? "").includes("text/html");
2036
+ }
2037
+ /** Serves the built web app; unknown HTML navigations fall back to `index.html` (client-side routing). */
2038
+ async function registerStaticAssets(app, staticDir) {
2039
+ if (staticDir) await app.register(fastifyStatic, { root: staticDir });
2040
+ app.setNotFoundHandler((request, reply) => {
2041
+ if (isApiRequest(request)) return sendApiNotFound(reply);
2042
+ if (staticDir && acceptsHtml(request)) return reply.sendFile("index.html");
2043
+ return reply.code(404).type("text/plain").send("Not found");
2044
+ });
2045
+ }
2046
+ //#endregion
2047
+ //#region ../server/src/create-server.ts
2048
+ const DEFAULT_HOST = "127.0.0.1";
2049
+ /** Keeps the allow-list's tunnel hostname equal to the core's remote URL, now and after every `remote` change. */
2050
+ function followTunnelHostname(core, list) {
2051
+ const sync = () => void core.remote.status().then((status) => list()?.setTunnelHostname(hostnameOfUrl(status.url)), () => void 0);
2052
+ sync();
2053
+ return core.onChange((change) => {
2054
+ if (change.resource === "remote") sync();
2055
+ });
2056
+ }
2057
+ const createServer$2 = async (options) => {
2058
+ const app = Fastify({
2059
+ logger: options.logger ?? false,
2060
+ ajv: { customOptions: {
2061
+ coerceTypes: false,
2062
+ removeAdditional: false
2063
+ } }
2064
+ });
2065
+ let boundPort = options.port;
2066
+ let allowList;
2067
+ const tags = createTunnelTags();
2068
+ const sockets = createDeviceSockets();
2069
+ const session = () => createSession(options.token, boundPort);
2070
+ const guard = (() => {
2071
+ let built;
2072
+ return (...args) => {
2073
+ allowList ??= allowListForPort(boundPort);
2074
+ built ??= createGuard({
2075
+ session: session(),
2076
+ access: options.core.access,
2077
+ tunnel: tags,
2078
+ hostnames: () => allowList
2079
+ });
2080
+ return built(...args);
2081
+ };
2082
+ })();
2083
+ await app.register(fastifyCookie);
2084
+ await app.register(fastifyWebsocket, { options: { maxPayload: WS_MAX_PAYLOAD_BYTES } });
2085
+ allowEmptyJsonBodies(app);
2086
+ app.setErrorHandler(handleError);
2087
+ app.decorateRequest("deviceId", "");
2088
+ app.addHook("onRequest", guard);
2089
+ registerRoutes(app, options.core, sockets);
2090
+ registerAccessRoutes(app, {
2091
+ core: options.core,
2092
+ tunnel: tags,
2093
+ sockets
2094
+ });
2095
+ registerLive(app, options.core, sockets);
2096
+ const labels = createDeviceLabels(options.core);
2097
+ const gateway = options.gateway ?? createMachineGateway(options.core);
2098
+ registerMachineRoutes(app, {
2099
+ core: options.core,
2100
+ sockets,
2101
+ gateway
2102
+ });
2103
+ registerMachineLink(app, {
2104
+ core: options.core,
2105
+ sockets,
2106
+ gateway
2107
+ });
2108
+ await registerMachineProxy(app, {
2109
+ sockets,
2110
+ gateway,
2111
+ hubDeviceLabel: labels.label
2112
+ });
2113
+ await registerStaticAssets(app, options.staticDir);
2114
+ const tunnel = createTunnelListener({
2115
+ app,
2116
+ tags,
2117
+ hostname: () => allowList?.tunnelHostname()
2118
+ });
2119
+ let stopFollowing = () => {};
2120
+ let stopReaping = () => {};
2121
+ let stopSummaries = () => {};
2122
+ try {
2123
+ await app.listen({
2124
+ port: options.port,
2125
+ host: options.host ?? DEFAULT_HOST
2126
+ });
2127
+ boundPort = portOf(app.server.address(), options.port);
2128
+ allowList = allowListForPort(boundPort);
2129
+ stopSummaries = startSummaryWriter(options.core, gateway);
2130
+ stopFollowing = followTunnelHostname(options.core, () => allowList);
2131
+ stopReaping = options.core.onChange((change) => {
2132
+ if (change.resource === "devices") closeSocketsOfGoneDevices(options.core, sockets);
2133
+ });
2134
+ } catch (error) {
2135
+ await app.close();
2136
+ throw error;
2137
+ }
2138
+ const { port: tunnelPort } = await tunnel.start();
2139
+ const origin = `http://${DEFAULT_HOST}:${boundPort}`;
2140
+ return {
2141
+ app,
2142
+ port: boundPort,
2143
+ tunnelPort,
2144
+ origin,
2145
+ url: `${origin}/?token=${encodeURIComponent(options.token)}`,
2146
+ close: async () => {
2147
+ labels.stop();
2148
+ stopFollowing();
2149
+ stopReaping();
2150
+ stopSummaries();
2151
+ await tunnel.close();
2152
+ await app.close();
2153
+ }
2154
+ };
2155
+ };
2156
+ function portOf(address, fallback) {
2157
+ return typeof address === "object" && address ? address.port : fallback;
2158
+ }
2159
+ //#endregion
2160
+ //#region src/logging/file-logging.ts
2161
+ const METHODS = [
2162
+ "log",
2163
+ "info",
2164
+ "warn",
2165
+ "error"
2166
+ ];
2167
+ /** Mirrors `console.*` into `<home>/logs/server.log` with timestamps. Returns a function that undoes it. */
2168
+ function mirrorConsoleToLogFile(home, target = console) {
2169
+ const log = createRotatingLog(serverLogOf(home));
2170
+ const originals = /* @__PURE__ */ new Map();
2171
+ for (const method of METHODS) {
2172
+ const original = target[method].bind(target);
2173
+ originals.set(method, target[method]);
2174
+ target[method] = (...args) => {
2175
+ original(...args);
2176
+ log.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${method} ${format(...args)}\n`);
2177
+ };
2178
+ }
2179
+ return () => {
2180
+ for (const [method, original] of originals) target[method] = original;
2181
+ };
2182
+ }
2183
+ //#endregion
2184
+ //#region src/machines/link-endpoint.ts
2185
+ /** `https://host` dials `wss://host/api/machines/link`; a loopback hub `http://127.0.0.1:<port>` dials `ws://`. */
2186
+ function linkUrlOf(hubUrl) {
2187
+ const url = new URL(hubUrl);
2188
+ return `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}${MACHINE_LINK_PATH}`;
2189
+ }
2190
+ const originOf = (hubUrl) => new URL(hubUrl).origin;
2191
+ /** The frame's `query` has no leading `?` (the hub splits the URL there); both forms are accepted. */
2192
+ function targetOf(path, query) {
2193
+ const bare = query.startsWith("?") ? query.slice(1) : query;
2194
+ return bare === "" ? path : `${path}?${bare}`;
2195
+ }
2196
+ //#endregion
2197
+ //#region src/machines/link-context.ts
2198
+ const loopbackHeaders = (context, hubDevice) => ({
2199
+ host: `127.0.0.1:${context.port}`,
2200
+ authorization: `Bearer ${context.token}`,
2201
+ "x-tau-hub-device": hubDevice
2202
+ });
2203
+ //#endregion
2204
+ //#region src/machines/link-request.ts
2205
+ function flatten(headers) {
2206
+ const out = {};
2207
+ for (const [name, value] of Object.entries(headers)) if (value !== void 0) out[name] = Array.isArray(value) ? value.join(", ") : value;
2208
+ return out;
2209
+ }
2210
+ function requestHeaders(context, frame) {
2211
+ return {
2212
+ ...loopbackHeaders(context, frame.hubDevice),
2213
+ ...frame.contentType !== void 0 && { "content-type": frame.contentType },
2214
+ ...frame.bodyBytes !== void 0 && frame.bodyBytes > 0 && { "content-length": String(frame.bodyBytes) }
2215
+ };
2216
+ }
2217
+ /** Calls this machine's own server on loopback and streams the answer back as `response` + data frames. */
2218
+ function startRequest(context, frame, finished) {
2219
+ const { id } = frame;
2220
+ const hasBody = (frame.bodyBytes ?? 0) > 0;
2221
+ let responded = false;
2222
+ let done = false;
2223
+ const finish = () => {
2224
+ if (done) return;
2225
+ done = true;
2226
+ finished();
2227
+ };
2228
+ const req = request({
2229
+ host: "127.0.0.1",
2230
+ port: context.port,
2231
+ method: frame.method,
2232
+ path: targetOf(frame.path, frame.query),
2233
+ headers: requestHeaders(context, frame)
2234
+ }, (res) => {
2235
+ responded = true;
2236
+ context.sendControl({
2237
+ type: "response",
2238
+ id,
2239
+ status: res.statusCode ?? 502,
2240
+ headers: flatten(res.headers)
2241
+ });
2242
+ pipeResponse(res);
2243
+ });
2244
+ async function pipeResponse(res) {
2245
+ try {
2246
+ for await (const chunk of res) for (const part of chunkBody(id, chunk)) if (!await context.sender.push({
2247
+ ...part,
2248
+ end: false
2249
+ })) {
2250
+ req.destroy();
2251
+ return;
2252
+ }
2253
+ await context.sender.push({
2254
+ id,
2255
+ end: true,
2256
+ text: false,
2257
+ payload: /* @__PURE__ */ new Uint8Array(0)
2258
+ });
2259
+ } catch {
2260
+ context.sendControl({
2261
+ type: "close",
2262
+ id,
2263
+ code: 1011,
2264
+ reason: "response failed"
2265
+ });
2266
+ } finally {
2267
+ context.sender.drop(id);
2268
+ finish();
2269
+ }
2270
+ }
2271
+ req.on("error", () => {
2272
+ if (done) return;
2273
+ if (!responded) {
2274
+ context.sendControl({
2275
+ type: "response",
2276
+ id,
2277
+ status: 502,
2278
+ headers: {}
2279
+ });
2280
+ context.sender.push({
2281
+ id,
2282
+ end: true,
2283
+ text: false,
2284
+ payload: /* @__PURE__ */ new Uint8Array(0)
2285
+ }).then(() => {
2286
+ context.sender.drop(id);
2287
+ finish();
2288
+ });
2289
+ } else {
2290
+ context.sendControl({
2291
+ type: "close",
2292
+ id,
2293
+ code: 1011,
2294
+ reason: "request failed"
2295
+ });
2296
+ context.sender.drop(id);
2297
+ finish();
2298
+ }
2299
+ });
2300
+ if (!hasBody) req.end();
2301
+ return {
2302
+ onData(payload, end) {
2303
+ if (!hasBody || req.writableEnded) return;
2304
+ const sent = payload.byteLength;
2305
+ const flushed = () => sent > 0 && context.sendControl({
2306
+ type: "credit",
2307
+ id,
2308
+ bytes: sent
2309
+ });
2310
+ if (sent > 0) req.write(payload, flushed);
2311
+ if (end) req.end();
2312
+ },
2313
+ onClose() {
2314
+ req.destroy();
2315
+ context.sender.drop(id);
2316
+ finish();
2317
+ },
2318
+ abort() {
2319
+ req.destroy();
2320
+ context.sender.drop(id);
2321
+ }
2322
+ };
2323
+ }
2324
+ //#endregion
2325
+ //#region src/machines/link-sender.ts
2326
+ const BUFFER_LIMIT = 1048576;
2327
+ const BUFFER_POLL_MS = 5;
2328
+ function createLinkSender(socket, onDrained = () => void 0) {
2329
+ const lanes = /* @__PURE__ */ new Map();
2330
+ const pendingBytes = /* @__PURE__ */ new Map();
2331
+ let pumping = false;
2332
+ let closed = false;
2333
+ const laneOf = (id) => {
2334
+ const existing = lanes.get(id);
2335
+ if (existing) return existing;
2336
+ const lane = {
2337
+ credit: CREDIT_WINDOW_BYTES,
2338
+ queue: []
2339
+ };
2340
+ lanes.set(id, lane);
2341
+ return lane;
2342
+ };
2343
+ const sendable = (lane) => {
2344
+ const head = lane.queue[0];
2345
+ return head !== void 0 && lane.credit >= head.frame.payload.byteLength;
2346
+ };
2347
+ function sendOne(id, lane) {
2348
+ const item = lane.queue.shift();
2349
+ lane.credit -= item.frame.payload.byteLength;
2350
+ pendingBytes.set(id, (pendingBytes.get(id) ?? 0) - item.frame.payload.byteLength);
2351
+ socket.send(encodeDataFrame(item.frame), { binary: true }, () => void 0);
2352
+ item.done(true);
2353
+ onDrained(id);
2354
+ }
2355
+ async function pump() {
2356
+ if (pumping) return;
2357
+ pumping = true;
2358
+ try {
2359
+ while (!closed) {
2360
+ const ready = [...lanes].filter(([, lane]) => sendable(lane));
2361
+ if (ready.length === 0) return;
2362
+ if (socket.bufferedAmount > BUFFER_LIMIT) {
2363
+ await new Promise((resolve) => setTimeout(resolve, BUFFER_POLL_MS));
2364
+ continue;
2365
+ }
2366
+ for (const [id, lane] of ready) sendOne(id, lane);
2367
+ await new Promise((resolve) => setImmediate(resolve));
2368
+ }
2369
+ } finally {
2370
+ pumping = false;
2371
+ }
2372
+ }
2373
+ return {
2374
+ push(frame) {
2375
+ if (closed) return Promise.resolve(false);
2376
+ return new Promise((resolve) => {
2377
+ laneOf(frame.id).queue.push({
2378
+ frame,
2379
+ done: resolve
2380
+ });
2381
+ pendingBytes.set(frame.id, (pendingBytes.get(frame.id) ?? 0) + frame.payload.byteLength);
2382
+ pump();
2383
+ });
2384
+ },
2385
+ addCredit(id, bytes) {
2386
+ const lane = lanes.get(id);
2387
+ if (!lane) return;
2388
+ lane.credit += bytes;
2389
+ pump();
2390
+ },
2391
+ pending: (id) => pendingBytes.get(id) ?? 0,
2392
+ drop(id) {
2393
+ for (const item of lanes.get(id)?.queue ?? []) item.done(false);
2394
+ lanes.delete(id);
2395
+ pendingBytes.delete(id);
2396
+ },
2397
+ closeAll() {
2398
+ closed = true;
2399
+ for (const id of [...lanes.keys()]) this.drop(id);
2400
+ }
2401
+ };
2402
+ }
2403
+ //#endregion
2404
+ //#region src/machines/link-socket.ts
2405
+ const MAX_INPUT_BYTES = 65536;
2406
+ const NOT_SENDABLE = /* @__PURE__ */ new Set([
2407
+ 1004,
2408
+ 1005,
2409
+ 1006,
2410
+ 1015
2411
+ ]);
2412
+ const closeCodeOf = (code) => code >= 1e3 && code < 5e3 && !NOT_SENDABLE.has(code) ? code : 1011;
2413
+ /** Relays one hub stream to a loopback WebSocket of this machine's own server (`/api/live`, terminal stream). */
2414
+ function startSocketStream(context, frame, finished) {
2415
+ const { id } = frame;
2416
+ const socket = new WebSocket(`ws://127.0.0.1:${context.port}${targetOf(frame.path, frame.query)}`, {
2417
+ headers: loopbackHeaders(context, frame.hubDevice),
2418
+ maxPayload: CHUNK_BYTES * 4
2419
+ });
2420
+ const queued = [];
2421
+ let open = false;
2422
+ let ended = false;
2423
+ const end = (code, reason, tell) => {
2424
+ if (ended) return;
2425
+ ended = true;
2426
+ if (!tell) {
2427
+ context.sender.drop(id);
2428
+ return finished();
2429
+ }
2430
+ const close = {
2431
+ type: "close",
2432
+ id,
2433
+ code: closeCodeOf(code),
2434
+ reason: reason.slice(0, 120)
2435
+ };
2436
+ context.sender.push({
2437
+ id,
2438
+ end: true,
2439
+ text: false,
2440
+ payload: /* @__PURE__ */ new Uint8Array(0)
2441
+ }).then((sent) => {
2442
+ if (sent) context.sendControl(close);
2443
+ context.sender.drop(id);
2444
+ finished();
2445
+ });
2446
+ };
2447
+ const credit = (bytes) => bytes > 0 && context.sendControl({
2448
+ type: "credit",
2449
+ id,
2450
+ bytes
2451
+ });
2452
+ function deliver({ payload, text }) {
2453
+ const bytes = payload.byteLength;
2454
+ if (text) return void socket.send(Buffer.from(payload).toString("utf8"), () => credit(bytes));
2455
+ for (let offset = 0; offset < bytes; offset += MAX_INPUT_BYTES) {
2456
+ const part = payload.subarray(offset, offset + MAX_INPUT_BYTES);
2457
+ socket.send(part, { binary: true }, () => credit(part.byteLength));
2458
+ }
2459
+ }
2460
+ socket.on("open", () => {
2461
+ open = true;
2462
+ context.sendControl({
2463
+ type: "open",
2464
+ id,
2465
+ path: frame.path,
2466
+ query: frame.query,
2467
+ hubDevice: frame.hubDevice
2468
+ });
2469
+ for (const held of queued.splice(0)) deliver(held);
2470
+ });
2471
+ socket.on("message", (data, isBinary) => {
2472
+ const bytes = Buffer.isBuffer(data) ? data : Buffer.concat(data);
2473
+ if (!isBinary && bytes.byteLength > 262144) {
2474
+ socket.close(1009, "message too big");
2475
+ return end(1009, "message too big", true);
2476
+ }
2477
+ for (const part of chunkBody(id, bytes, !isBinary)) context.sender.push({
2478
+ ...part,
2479
+ end: false
2480
+ }).then(relieve);
2481
+ if (context.sender.pending(id) >= 1048576) socket.pause();
2482
+ });
2483
+ const relieve = () => {
2484
+ if (context.sender.pending(id) < 524288) socket.resume();
2485
+ };
2486
+ socket.on("close", (code, reason) => end(code, reason.toString(), true));
2487
+ socket.on("error", () => end(1011, "loopback socket failed", true));
2488
+ return {
2489
+ onData(payload, _end, text) {
2490
+ if (ended) return;
2491
+ if (text && payload.byteLength > MAX_INPUT_BYTES) {
2492
+ socket.close(1009, "message too big");
2493
+ return end(1009, "message too big", true);
2494
+ }
2495
+ const held = {
2496
+ payload: Uint8Array.from(payload),
2497
+ text
2498
+ };
2499
+ if (open) deliver(held);
2500
+ else queued.push(held);
2501
+ },
2502
+ onClose() {
2503
+ end(1e3, "", false);
2504
+ socket.close();
2505
+ },
2506
+ abort() {
2507
+ ended = true;
2508
+ context.sender.drop(id);
2509
+ socket.terminate();
2510
+ }
2511
+ };
2512
+ }
2513
+ //#endregion
2514
+ //#region src/machines/link-session.ts
2515
+ function parseFrame(data) {
2516
+ try {
2517
+ const value = JSON.parse(data.toString());
2518
+ return Value.Check(LinkFrameSchema, value) ? value : void 0;
2519
+ } catch {
2520
+ return;
2521
+ }
2522
+ }
2523
+ /** Serves one open link socket: `hello`, pings, `request` and `open` frames, data and credit. */
2524
+ function runLinkSession(socket, options) {
2525
+ const streams = /* @__PURE__ */ new Map();
2526
+ const sender = createLinkSender(socket);
2527
+ const send = (frame) => {
2528
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(frame));
2529
+ };
2530
+ const context = {
2531
+ port: options.port,
2532
+ token: options.token,
2533
+ sender,
2534
+ sendControl: send,
2535
+ log: options.log
2536
+ };
2537
+ const forget = (id) => () => void streams.delete(id);
2538
+ const serve = (frame) => {
2539
+ if (frame.type === "request") streams.set(frame.id, startRequest(context, frame, forget(frame.id)));
2540
+ else if (frame.type === "open") streams.set(frame.id, startSocketStream(context, frame, forget(frame.id)));
2541
+ else if (frame.type === "credit") sender.addCredit(frame.id, frame.bytes);
2542
+ else if (frame.type === "close") streams.get(frame.id)?.onClose();
2543
+ else if (frame.type === "ping") send({ type: "pong" });
2544
+ };
2545
+ const logged = (frame) => {
2546
+ if (frame.type === "request" || frame.type === "open") options.log(`hub device ${JSON.stringify(frame.hubDevice)}: ${frame.type === "request" ? frame.method : "open"} ${frame.path}`);
2547
+ };
2548
+ let lastFrameAt = Date.now();
2549
+ socket.on("message", (data, isBinary) => {
2550
+ lastFrameAt = Date.now();
2551
+ if (isBinary) {
2552
+ const bytes = Buffer.isBuffer(data) ? data : Buffer.concat(data);
2553
+ const decoded = decodeDataFrame(bytes);
2554
+ if (decoded) streams.get(decoded.id)?.onData(decoded.payload, decoded.end, decoded.text);
2555
+ return;
2556
+ }
2557
+ const frame = parseFrame(data);
2558
+ if (!frame) return;
2559
+ logged(frame);
2560
+ serve(frame);
2561
+ });
2562
+ const ping = setInterval(() => {
2563
+ if (Date.now() - lastFrameAt > (options.idleMs ?? 45e3)) return socket.terminate();
2564
+ send({ type: "ping" });
2565
+ }, options.pingMs ?? 15e3);
2566
+ const ended = new Promise((resolve) => {
2567
+ socket.once("close", (code, reason) => {
2568
+ clearInterval(ping);
2569
+ sender.closeAll();
2570
+ for (const stream of streams.values()) stream.abort();
2571
+ streams.clear();
2572
+ resolve({
2573
+ code,
2574
+ reason: reason.toString()
2575
+ });
2576
+ });
2577
+ });
2578
+ send({
2579
+ type: "hello",
2580
+ ...options.hello
2581
+ });
2582
+ return {
2583
+ ended,
2584
+ close: () => socket.close(1e3, "stopping")
2585
+ };
2586
+ }
2587
+ //#endregion
2588
+ //#region src/machines/link-state.ts
2589
+ const MESSAGES = {
2590
+ reconnecting: "Reconnecting to the hub",
2591
+ unreachable: "Hub unreachable",
2592
+ "update-required": "Update tau on the hub or on this machine",
2593
+ refused: "The hub refused this machine. Run `tau disconnect`, then link again."
2594
+ };
2595
+ function hubLinkOf(state, file, since) {
2596
+ if (state === "none" || !file) return HUB_LINK_NONE;
2597
+ return {
2598
+ state,
2599
+ hubName: file.name,
2600
+ hubUrl: file.hubUrl,
2601
+ since,
2602
+ message: MESSAGES[state] ?? null
2603
+ };
2604
+ }
2605
+ const fileKey = (file) => file ? `${file.hubUrl}\n${file.machineSecret}` : "";
2606
+ //#endregion
2607
+ //#region src/machines/link-client.ts
2608
+ const DEFAULT_TUNING = {
2609
+ pollMs: 1e3,
2610
+ backoffBaseMs: 1e3,
2611
+ backoffMaxMs: 3e4,
2612
+ unreachableAfter: 3,
2613
+ log: (message) => console.log(`[link] ${message}`)
2614
+ };
2615
+ const REFUSED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
2616
+ const REFUSED_CLOSE = 1008;
2617
+ async function helloOf(file, core) {
2618
+ const threadCount = await core.conversations.listAll().then((all) => all.length, () => 0);
2619
+ return {
2620
+ name: file.machineName ?? hostname(),
2621
+ os: `${platform()} ${arch()}`,
2622
+ tauVersion: safeVersion(),
2623
+ protocolVersion: PROTOCOL_VERSION,
2624
+ threadCount
2625
+ };
2626
+ }
2627
+ function safeVersion() {
2628
+ try {
2629
+ return cliVersion();
2630
+ } catch {
2631
+ return "unknown";
2632
+ }
2633
+ }
2634
+ /** Reads `hub.json` (and watches it by polling, so `tau connect` and `tau disconnect` need no restart) and keeps the hub link up. */
2635
+ function startLinkClientWith(options, tuning) {
2636
+ const { core, hubFile } = options;
2637
+ let stopped = false;
2638
+ let wakeSleeper;
2639
+ /** A pause that `stop()` cuts short, so shutting down never waits for a poll tick. */
2640
+ const sleep = (ms) => new Promise((resolve) => {
2641
+ const timer = setTimeout(resolve, ms);
2642
+ wakeSleeper = () => {
2643
+ clearTimeout(timer);
2644
+ resolve();
2645
+ };
2646
+ });
2647
+ let active;
2648
+ let lastState = "none";
2649
+ let since = Date.now();
2650
+ const report = (state, file) => {
2651
+ if (state !== lastState) since = Date.now();
2652
+ lastState = state;
2653
+ core.machines.reportLink(hubLinkOf(state, file, since));
2654
+ };
2655
+ async function waitFor(ms, key) {
2656
+ const deadline = Date.now() + ms;
2657
+ while (!stopped && Date.now() < deadline) {
2658
+ await sleep(Math.min(tuning.pollMs, Math.max(1, deadline - Date.now())));
2659
+ if (fileKey(await hubFile.read()) !== key) return;
2660
+ }
2661
+ }
2662
+ function dial(file) {
2663
+ return new Promise((resolve) => {
2664
+ const key = fileKey(file);
2665
+ let linked = false;
2666
+ const socket = new WebSocket(linkUrlOf(file.hubUrl), {
2667
+ headers: {
2668
+ authorization: `Bearer ${file.machineSecret}`,
2669
+ origin: originOf(file.hubUrl)
2670
+ },
2671
+ handshakeTimeout: 15e3
2672
+ });
2673
+ active = socket;
2674
+ socket.once("unexpected-response", (_request, response) => {
2675
+ response.resume();
2676
+ resolve({
2677
+ outcome: REFUSED_STATUSES.has(response.statusCode ?? 0) ? "refused" : "failed",
2678
+ linked
2679
+ });
2680
+ });
2681
+ socket.once("error", () => resolve({
2682
+ outcome: "failed",
2683
+ linked
2684
+ }));
2685
+ socket.once("open", () => {
2686
+ helloOf(file, core).then(async (hello) => {
2687
+ if (socket.readyState !== socket.OPEN) return resolve({
2688
+ outcome: "failed",
2689
+ linked
2690
+ });
2691
+ linked = true;
2692
+ report("linked", file);
2693
+ const session = runLinkSession(socket, {
2694
+ port: options.port,
2695
+ token: options.token,
2696
+ hello,
2697
+ ...tuning
2698
+ });
2699
+ const watch = setInterval(async () => {
2700
+ if (fileKey(await hubFile.read()) !== key) session.close();
2701
+ }, tuning.pollMs);
2702
+ const end = await session.ended;
2703
+ clearInterval(watch);
2704
+ resolve({
2705
+ outcome: outcomeOf(end),
2706
+ linked
2707
+ });
2708
+ });
2709
+ });
2710
+ });
2711
+ }
2712
+ const outcomeOf = ({ code }) => {
2713
+ if (code === REFUSED_CLOSE) return "refused";
2714
+ return code === 4001 ? "update-required" : "ended";
2715
+ };
2716
+ async function loop() {
2717
+ let attempts = 0;
2718
+ let wasLinked = false;
2719
+ while (!stopped) {
2720
+ const file = await hubFile.read();
2721
+ if (!file) {
2722
+ if (lastState !== "none") report("none", void 0);
2723
+ attempts = 0;
2724
+ wasLinked = false;
2725
+ await sleep(tuning.pollMs);
2726
+ continue;
2727
+ }
2728
+ if (lastState === "none") report("connecting", file);
2729
+ const { outcome, linked } = await dial(file);
2730
+ active = void 0;
2731
+ if (stopped) return;
2732
+ wasLinked ||= linked;
2733
+ attempts = linked ? 1 : attempts + 1;
2734
+ const key = fileKey(file);
2735
+ if (outcome === "refused") {
2736
+ report("refused", file);
2737
+ await waitFor(Number.POSITIVE_INFINITY, key);
2738
+ attempts = 0;
2739
+ continue;
2740
+ }
2741
+ if (outcome === "update-required") report("update-required", file);
2742
+ else if (attempts >= tuning.unreachableAfter) report("unreachable", file);
2743
+ else report(wasLinked ? "reconnecting" : "connecting", file);
2744
+ await waitFor(Math.min(tuning.backoffMaxMs, tuning.backoffBaseMs * 2 ** Math.max(0, attempts - 1)), key);
2745
+ }
2746
+ }
2747
+ const running = loop().catch((error) => {
2748
+ tuning.log(`link client stopped: ${error instanceof Error ? error.message : String(error)}`);
2749
+ });
2750
+ return { async stop() {
2751
+ stopped = true;
2752
+ wakeSleeper?.();
2753
+ const socket = active;
2754
+ if (socket) {
2755
+ socket.close(1e3, "stopping");
2756
+ setTimeout(() => socket.terminate(), 1e3).unref();
2757
+ }
2758
+ await running;
2759
+ } };
2760
+ }
2761
+ const startLinkClient = (options) => startLinkClientWith(options, DEFAULT_TUNING);
2762
+ //#endregion
2763
+ //#region src/listen-on-free-port.ts
2764
+ const MAX_PORT_ATTEMPTS = 100;
2765
+ const MAX_PORT = 65535;
2766
+ function isAddressInUse(error) {
2767
+ return error?.code === "EADDRINUSE";
2768
+ }
2769
+ /** Tries `preferred`, then the next ports, until `start` succeeds. */
2770
+ async function listenOnFreePort(preferred, start) {
2771
+ const last = Math.min(preferred + MAX_PORT_ATTEMPTS - 1, MAX_PORT);
2772
+ for (let port = preferred; port <= last; port++) try {
2773
+ return await start(port);
2774
+ } catch (error) {
2775
+ if (!isAddressInUse(error)) throw error;
2776
+ }
2777
+ throw new Error(`No free port between ${preferred} and ${last}`);
2778
+ }
2779
+ //#endregion
2780
+ //#region src/update/latest-source.ts
2781
+ /**
2782
+ * Where core learns the latest published version. `TAU_FAKE_LATEST` is a test hook (e2e): its value is the
2783
+ * answer, so no npm runs.
2784
+ */
2785
+ function latestVersionSource(env = process.env) {
2786
+ const fake = env.TAU_FAKE_LATEST;
2787
+ if (fake) return async () => fake;
2788
+ return () => fetchLatestVersion(spawnCommand, process.execPath);
2789
+ }
2790
+ //#endregion
2791
+ //#region src/run.ts
2792
+ const withToken = (origin, token) => `${origin}/?token=${encodeURIComponent(token)}`;
2793
+ async function reportExistingInstance(options, dependencies, token) {
2794
+ const running = await readRunningInstance(options.home);
2795
+ if (!running?.url) return void 0;
2796
+ const url = withToken(running.url, token);
2797
+ dependencies.log(`tau is already running: ${url}`);
2798
+ return url;
2799
+ }
2800
+ async function openBrowserSafely(url, { openBrowser, log }) {
2801
+ try {
2802
+ await openBrowser(url);
2803
+ } catch (error) {
2804
+ log(`Could not open the browser (${error instanceof Error ? error.message : error}). Open the URL above.`);
2805
+ }
2806
+ }
2807
+ /** Resolves to the running app, or `undefined` when another instance already serves this home. */
2808
+ async function run(options, dependencies) {
2809
+ await ensurePrivateDirectory(options.home);
2810
+ const token = await readOrCreateToken(options.home);
2811
+ if (await reportExistingInstance(options, dependencies, token)) return void 0;
2812
+ const tunnelTarget = createTunnelTargetRef();
2813
+ const hubFile = createHubFileStore(options.home);
2814
+ const core = await dependencies.createCore({
2815
+ home: options.home,
2816
+ faux: options.faux,
2817
+ cliEntry: dependencies.cliEntry,
2818
+ tunnelTarget: tunnelTarget.get,
2819
+ forgetHub: () => hubFile.delete(),
2820
+ currentVersion: dependencies.version ?? "",
2821
+ latestVersion: dependencies.latestVersion ?? latestVersionSource()
2822
+ });
2823
+ try {
2824
+ const server = await listenOnFreePort(options.port, (port) => dependencies.createServer({
2825
+ core,
2826
+ port,
2827
+ token,
2828
+ staticDir: dependencies.staticDir
2829
+ }));
2830
+ tunnelTarget.set(server.tunnelPort);
2831
+ await publishServerUrl(options.home, server.origin, dependencies.version);
2832
+ dependencies.log(`tau is running: ${server.url}`);
2833
+ if (options.open) await openBrowserSafely(server.url, dependencies);
2834
+ const link = dependencies.startLinkClient?.({
2835
+ port: server.port,
2836
+ token,
2837
+ hubFile,
2838
+ core
2839
+ });
2840
+ return {
2841
+ url: server.url,
2842
+ close: async () => {
2843
+ await link?.stop();
2844
+ await server.close();
2845
+ await core.close();
2846
+ }
2847
+ };
2848
+ } catch (error) {
2849
+ await core.close();
2850
+ throw error;
2851
+ }
2852
+ }
2853
+ //#endregion
2854
+ //#region src/static-dir.ts
2855
+ const here = (relative) => fileURLToPath(new URL(relative, import.meta.url));
2856
+ /** Bundled (`dist/web` next to `dist/bin.js`) or, when run from source, the sibling web package build. */
2857
+ const CANDIDATES = [here("./web"), here("../../web/dist")];
2858
+ function findStaticDir(candidates = CANDIDATES) {
2859
+ return candidates.find((directory) => existsSync(join(directory, "index.html")));
2860
+ }
2861
+ //#endregion
2862
+ //#region src/serve.ts
2863
+ async function openBrowser(url) {
2864
+ const { default: open } = await import("open");
2865
+ await open(url);
2866
+ }
2867
+ /** Only the service-managed run is watched: a hang is killed and the OS service restarts it. */
2868
+ function startServiceWatchdog(home) {
2869
+ return startWatchdog({
2870
+ lastExitFile: join(home, "last-exit.json"),
2871
+ logFile: join(home, "logs", "server.log"),
2872
+ workerUrl: new URL("./watchdog.js", import.meta.url)
2873
+ });
2874
+ }
2875
+ function closeOnSignals(close, markExit) {
2876
+ let closing = false;
2877
+ const shutDown = () => {
2878
+ if (closing) return;
2879
+ closing = true;
2880
+ markExit("signal");
2881
+ close().then(() => process.exit(0), (error) => {
2882
+ console.error(error);
2883
+ process.exit(1);
2884
+ });
2885
+ };
2886
+ process.on("SIGINT", shutDown);
2887
+ process.on("SIGTERM", shutDown);
2888
+ }
2889
+ const portOfUrl = (url) => Number(new URL(url).port);
2890
+ async function serveOnce(argv) {
2891
+ const parsed = parseCliOptions(argv);
2892
+ const { explicitPort } = lifecycleFlagsOf(argv, {
2893
+ defaultHome: defaultHome(),
2894
+ env: process.env
2895
+ });
2896
+ mirrorConsoleToLogFile(parsed.home);
2897
+ if (parsed.service) await waitForFreeHome(parsed.home, { log: (message) => console.log(message) });
2898
+ const options = {
2899
+ ...parsed,
2900
+ port: resolvePort(parsed.home, explicitPort)
2901
+ };
2902
+ const app = await run(options, {
2903
+ createCore,
2904
+ createServer: createServer$2,
2905
+ openBrowser,
2906
+ startLinkClient,
2907
+ log: (message) => console.log(message),
2908
+ staticDir: findStaticDir(),
2909
+ cliEntry: cliEntry().entry,
2910
+ version: cliVersion()
2911
+ });
2912
+ if (!app) return options.service ? process.exit(1) : void 0;
2913
+ writeStoredPort(options.home, portOfUrl(app.url));
2914
+ const watchdog = options.service ? startServiceWatchdog(options.home) : void 0;
2915
+ const markExit = createExitMarker(join(options.home, "last-exit.json"));
2916
+ process.on("exit", (code) => code === 0 && markExit("clean"));
2917
+ closeOnSignals(async () => {
2918
+ await watchdog?.stop();
2919
+ await app.close();
2920
+ }, markExit);
2921
+ }
2922
+ /** `tau serve`: the server in the foreground. `argv` holds only the server flags. */
2923
+ async function serveInForeground(argv) {
2924
+ try {
2925
+ await serveOnce(argv);
2926
+ } catch (error) {
2927
+ console.error(error instanceof Error ? error.message : error);
2928
+ process.exit(1);
2929
+ }
2930
+ }
2931
+ //#endregion
2932
+ export { lifecycleFlagsOf as a, connectRefusal as c, commandWordOf as i, serverLogOf as n, createHubFileStore as o, writeStoredPort as r, alreadyLinkedRefusal as s, serveInForeground as t };