@pablopunk/tau 0.0.0-stage → 0.1.0

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