@runuai/host 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/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
package/src/main.ts ADDED
@@ -0,0 +1,1156 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import "./load-env";
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import {
7
+ request as httpRequest,
8
+ type ClientRequest,
9
+ type IncomingMessage,
10
+ type OutgoingHttpHeaders,
11
+ } from "node:http";
12
+ import { Socket } from "node:net";
13
+ import { join } from "node:path";
14
+
15
+ import WebSocket, { type RawData } from "ws";
16
+
17
+ import { env } from "../lib/env";
18
+ import { getDb, migrateHostDb } from "../lib/db";
19
+ import {
20
+ markCloudError,
21
+ markConnected,
22
+ markDisconnected,
23
+ markReconnecting,
24
+ } from "../lib/cloud-state";
25
+ import { getHostTask } from "../lib/runtime-state";
26
+ import { getOrchestrator } from "../lib/orchestrator";
27
+ import {
28
+ onConnectClear,
29
+ onConnectSet,
30
+ setAuthExpiredHandler,
31
+ } from "../lib/github-tokens";
32
+ import {
33
+ deleteKey as deleteSshKey,
34
+ ensureKeyForUser as ensureSshKeyForUser,
35
+ getPublicKey as getSshPublicKey,
36
+ } from "../lib/ssh";
37
+ import {
38
+ packageVersion,
39
+ serviceLogPath,
40
+ uiAssetsDir,
41
+ uiPortFilePath,
42
+ } from "./paths";
43
+ import { dockerMemoryBytes, startUiServer } from "./ui/server";
44
+ import { parsePreviewPortRuntimes } from "../lib/preview-ports";
45
+ import { newId } from "../lib/ulid";
46
+ import {
47
+ capabilities as agentKindCapabilities,
48
+ onChange as onRegistryChange,
49
+ } from "../lib/agents/registry";
50
+ // Importing the real factory triggers the built-in adapters' register()
51
+ // calls (claude, codex), so the registry is populated before we advertise.
52
+ import "../lib/agents/factory";
53
+ import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
54
+ import { hostCommands, hostEvents } from "./index";
55
+ import {
56
+ HostErrorCode,
57
+ type CloudToHost,
58
+ type CommandContext,
59
+ type ChannelEnsureInput,
60
+ type HostCapabilities,
61
+ type HostCommandResult,
62
+ type HostCommands,
63
+ type HostToCloud,
64
+ type PermissionDecision,
65
+ type TaskAgent,
66
+ type TaskCommandProject,
67
+ type TaskCommandTask,
68
+ type TaskDiffInput,
69
+ type TaskDownInput,
70
+ type TaskLaunchInput,
71
+ } from "./protocol";
72
+
73
+ const PING_INTERVAL_MS = 15_000;
74
+ const DEAD_AFTER_MS = 45_000;
75
+ // Reconnect backoff, capped low (~15s) on purpose: cloud deploys drop the
76
+ // bridge for ~30-40s, and an unbounded backoff (the old 60s tail) left the host
77
+ // "disconnected" for up to a minute after the cloud was back, needing a manual
78
+ // restart. Capping at 15s means the host self-heals within ~15s of recovery;
79
+ // the extra retries during a longer outage are one cheap WS connect each.
80
+ const BACKOFF_STEPS_MS = [1_000, 2_000, 4_000, 8_000, 15_000];
81
+ const MAX_WS_BUFFERED_BYTES = 16 * 1024 * 1024;
82
+
83
+ // Per-message deflate on the bridge WS. Tunnelled payloads are mostly text
84
+ // (JS/HTML/JSON) and compress 3-4x, which matters because every preview/editor
85
+ // stream is multiplexed over this one socket and bounded by the host's upload.
86
+ // `threshold` skips the tiny tunnel.data envelopes (compressing them is pure
87
+ // overhead); context takeover (ws default) keeps a shared dictionary for the
88
+ // big JS chunks.
89
+ const WS_DEFLATE = { threshold: 1024 } as const;
90
+
91
+ const token = requireEnv("UAI_HOST_TOKEN");
92
+
93
+ const bridgePort = parsePort(process.env.UAI_HOST_BRIDGE_PORT, 8789);
94
+ const hostId = process.env.UAI_HOST_ID ?? ensureHostId();
95
+ const bridgeUrl =
96
+ process.env.UAI_CLOUD_URL ?? `ws://127.0.0.1:${bridgePort}/host`;
97
+
98
+ let ws: WebSocket | null = null;
99
+ let stopping = false;
100
+ let fatal = false;
101
+ let reconnectAttempt = 0;
102
+ let inFlight = 0;
103
+ let shutdownRequested = false;
104
+ let pendingBinaryTunnelId: string | null = null;
105
+ const tunnels = new Map<string, TunnelUpstream>();
106
+
107
+ interface TunnelUpstream {
108
+ write(chunk: Buffer): boolean;
109
+ end(): void;
110
+ destroy(): void;
111
+ }
112
+
113
+ interface PausableSource {
114
+ pause(): unknown;
115
+ resume(): unknown;
116
+ destroy(): void;
117
+ }
118
+
119
+ console.log(`[host-agent] starting host ${hostId}`);
120
+ migrateHostDb();
121
+ // When a task's GitHub token can't be refreshed (revoked, disconnected,
122
+ // expired), surface it in the task channel so the user can re-grant (ADR-027).
123
+ setAuthExpiredHandler((taskId, _userId, reason) => {
124
+ getOrchestrator().emitSystemNote(
125
+ taskId,
126
+ `gh authentication expired (reason: ${reason}). Reconnect GitHub on ` +
127
+ `Account, then run /retry-gh in this task to restore.`,
128
+ );
129
+ });
130
+ // Best-effort: build the standard image + asdf volume if missing. Logs and
131
+ // continues on failure (e.g. docker unavailable) so the host still boots.
132
+ void ensureStandardImage();
133
+ connect();
134
+ // Local browser UI (ADR-028) — same single process, alongside the WSS client.
135
+ // Best-effort: a UI bind failure must not take the host service down.
136
+ void startLocalUi();
137
+
138
+ async function startLocalUi(): Promise<void> {
139
+ try {
140
+ const handle = await startUiServer({
141
+ db: getDb(),
142
+ uiDir: uiAssetsDir(),
143
+ portFilePath: uiPortFilePath(),
144
+ startPort: parsePort(process.env.UAI_HOST_UI_PORT, 5876),
145
+ version: packageVersion(),
146
+ cloudUrl: bridgeUrl,
147
+ hostId,
148
+ logPath: serviceLogPath(),
149
+ taskMemory: dockerMemoryBytes,
150
+ });
151
+ console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
152
+ } catch (err) {
153
+ console.warn(
154
+ `[host-agent] local UI failed to start: ${err instanceof Error ? err.message : String(err)}`,
155
+ );
156
+ }
157
+ }
158
+
159
+ /** Build the current host capability advertisement (ADR-021). */
160
+ function buildCapabilities(): HostCapabilities {
161
+ return {
162
+ agentKinds: agentKindCapabilities(),
163
+ runtimes: standardRuntimes(),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Re-advertise capabilities on the active connection. Safe to call any time;
169
+ * a no-op when no socket is connected/open (the next `connect()` re-sends on
170
+ * auth). Wired to registry changes below — manual trigger for now.
171
+ */
172
+ function sendCapabilities(): void {
173
+ if (ws && ws.readyState === WebSocket.OPEN) {
174
+ send(ws, { kind: "host.capabilities", capabilities: buildCapabilities() });
175
+ }
176
+ }
177
+
178
+ onRegistryChange(() => sendCapabilities());
179
+
180
+ function connect(): void {
181
+ if (stopping || fatal) return;
182
+
183
+ console.log(`[host-agent] connecting ${bridgeUrl}`);
184
+ const socket = new WebSocket(bridgeUrl, { perMessageDeflate: WS_DEFLATE });
185
+ ws = socket;
186
+
187
+ let lastTraffic = Date.now();
188
+ let ready = false;
189
+ let unsubscribe: (() => void) | null = null;
190
+ let pingTimer: NodeJS.Timeout | null = null;
191
+ let deadTimer: NodeJS.Timeout | null = null;
192
+
193
+ const cleanup = (): void => {
194
+ if (pingTimer) clearInterval(pingTimer);
195
+ if (deadTimer) clearInterval(deadTimer);
196
+ if (unsubscribe) unsubscribe();
197
+ if (ws === socket) ws = null;
198
+ };
199
+
200
+ socket.on("open", () => {
201
+ send(socket, { kind: "auth", token, hostId });
202
+ // Advertise capabilities immediately after auth (ADR-021). The bridge
203
+ // rejects with close-code 4001 if auth fails, so sending here is harmless
204
+ // on a bad token and saves a round-trip on a good one. Re-sent on every
205
+ // reconnect (this handler) and on registry change (onRegistryChange).
206
+ send(socket, {
207
+ kind: "host.capabilities",
208
+ capabilities: buildCapabilities(),
209
+ });
210
+ ready = true;
211
+ reconnectAttempt = 0;
212
+ markConnected();
213
+ console.log(`[host-agent] connected as ${hostId}`);
214
+
215
+ unsubscribe = hostEvents.subscribe((event) => {
216
+ if (socket.readyState !== WebSocket.OPEN) return;
217
+ send(socket, { kind: "event", event });
218
+ });
219
+
220
+ pingTimer = setInterval(() => {
221
+ if (socket.readyState === WebSocket.OPEN) {
222
+ send(socket, { kind: "ping", ts: Date.now() });
223
+ }
224
+ }, PING_INTERVAL_MS);
225
+
226
+ deadTimer = setInterval(() => {
227
+ if (Date.now() - lastTraffic > DEAD_AFTER_MS) {
228
+ console.warn("[host-agent] bridge heartbeat timed out");
229
+ socket.close(4000, "heartbeat timeout");
230
+ }
231
+ }, 5_000);
232
+ });
233
+
234
+ socket.on("message", (data, isBinary) => {
235
+ lastTraffic = Date.now();
236
+ if (isBinary) {
237
+ const tunnelId = pendingBinaryTunnelId;
238
+ pendingBinaryTunnelId = null;
239
+ if (!tunnelId) return;
240
+ tunnels.get(tunnelId)?.write(rawDataToBuffer(data));
241
+ return;
242
+ }
243
+
244
+ const frame = parseCloudFrame(data);
245
+ if (!frame) return;
246
+
247
+ switch (frame.kind) {
248
+ case "pong":
249
+ break;
250
+ case "command":
251
+ void handleCommand(socket, frame);
252
+ break;
253
+ case "tunnel.open":
254
+ handleTunnelOpen(socket, frame);
255
+ break;
256
+ case "tunnel.data":
257
+ pendingBinaryTunnelId = frame.tunnelId;
258
+ break;
259
+ case "tunnel.requestEnd":
260
+ // Request body complete → finish the upstream request so it can reply.
261
+ tunnels.get(frame.tunnelId)?.end();
262
+ break;
263
+ case "tunnel.close":
264
+ closeTunnel(socket, frame.tunnelId, frame.reason);
265
+ break;
266
+ case "gh.connect.set": {
267
+ const result = onConnectSet(frame);
268
+ send(
269
+ socket,
270
+ result.ok
271
+ ? { kind: "gh.connect.ack", userId: frame.userId, ok: true }
272
+ : {
273
+ kind: "gh.connect.ack",
274
+ userId: frame.userId,
275
+ ok: false,
276
+ error: result.error ?? "store failed",
277
+ },
278
+ );
279
+ break;
280
+ }
281
+ case "gh.connect.clear":
282
+ onConnectClear(frame.userId);
283
+ send(socket, { kind: "gh.connect.ack", userId: frame.userId, ok: true });
284
+ break;
285
+ case "ssh.key.get":
286
+ case "ssh.key.ensure":
287
+ case "ssh.key.delete": {
288
+ try {
289
+ let publicKey: string | null;
290
+ if (frame.kind === "ssh.key.delete") {
291
+ deleteSshKey(frame.userId);
292
+ publicKey = null;
293
+ } else if (frame.kind === "ssh.key.ensure") {
294
+ publicKey = ensureSshKeyForUser(frame.userId);
295
+ } else {
296
+ publicKey = getSshPublicKey(frame.userId);
297
+ }
298
+ send(socket, { kind: "ssh.key.ack", userId: frame.userId, ok: true, publicKey });
299
+ } catch (err) {
300
+ send(socket, {
301
+ kind: "ssh.key.ack",
302
+ userId: frame.userId,
303
+ ok: false,
304
+ error: err instanceof Error ? err.message : "ssh key op failed",
305
+ });
306
+ }
307
+ break;
308
+ }
309
+ }
310
+ });
311
+
312
+ socket.on("error", (err) => {
313
+ markCloudError(err.message);
314
+ if (!stopping) {
315
+ console.warn(`[host-agent] bridge error: ${err.message}`);
316
+ }
317
+ });
318
+
319
+ socket.on("close", (code, reason) => {
320
+ cleanup();
321
+ const text = reason.toString("utf8");
322
+ if (code === 4001) {
323
+ fatal = true;
324
+ markDisconnected(text || "auth rejected by bridge");
325
+ console.error(
326
+ `[host-agent] auth rejected by bridge${text ? `: ${text}` : ""}`,
327
+ );
328
+ process.exitCode = 1;
329
+ return;
330
+ }
331
+ if (stopping) {
332
+ markDisconnected();
333
+ maybeExit();
334
+ return;
335
+ }
336
+ const delay = reconnectDelay();
337
+ markReconnecting(text || `disconnected${ready ? "" : " before auth"}`);
338
+ console.warn(
339
+ `[host-agent] disconnected${ready ? "" : " before auth"}; reconnecting in ${Math.round(delay)}ms`,
340
+ );
341
+ setTimeout(connect, delay);
342
+ });
343
+ }
344
+
345
+ async function handleCommand(
346
+ socket: WebSocket,
347
+ frame: Extract<CloudToHost, { kind: "command" }>,
348
+ ): Promise<void> {
349
+ inFlight += 1;
350
+ const ctx: CommandContext = { commandId: frame.commandId };
351
+ try {
352
+ const result = await dispatchCommand(ctx, frame.command, frame.args);
353
+ if (socket.readyState === WebSocket.OPEN) {
354
+ send(socket, { kind: "result", commandId: frame.commandId, result });
355
+ }
356
+ } catch (err) {
357
+ if (socket.readyState === WebSocket.OPEN) {
358
+ send(socket, {
359
+ kind: "result",
360
+ commandId: frame.commandId,
361
+ result: {
362
+ ok: false,
363
+ code: HostErrorCode.Internal,
364
+ message: err instanceof Error ? err.message : String(err),
365
+ },
366
+ });
367
+ }
368
+ } finally {
369
+ inFlight -= 1;
370
+ maybeExit();
371
+ }
372
+ }
373
+
374
+ function handleTunnelOpen(
375
+ wsSocket: WebSocket,
376
+ frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
377
+ ): void {
378
+ const port = resolveTunnelPort(frame);
379
+ if (!port) {
380
+ send(wsSocket, {
381
+ kind: "tunnel.ack",
382
+ tunnelId: frame.tunnelId,
383
+ ok: false,
384
+ status: 503,
385
+ message: "target port is not available on this host",
386
+ });
387
+ return;
388
+ }
389
+
390
+ if (!frame.upgrade) {
391
+ handleHttpTunnelOpen(wsSocket, frame, port);
392
+ return;
393
+ }
394
+
395
+ handleRawTunnelOpen(wsSocket, frame, port);
396
+ }
397
+
398
+ function handleHttpTunnelOpen(
399
+ wsSocket: WebSocket,
400
+ frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
401
+ port: number,
402
+ ): void {
403
+ let acked = false;
404
+ const upstream = httpRequest(
405
+ {
406
+ host: "127.0.0.1",
407
+ port,
408
+ method: frame.reqLine.method,
409
+ path: frame.reqLine.url,
410
+ headers: requestHeaders(frame.reqLine.headers),
411
+ },
412
+ (response) => {
413
+ const headers = responseHeaders(response);
414
+ send(wsSocket, {
415
+ kind: "tunnel.ack",
416
+ tunnelId: frame.tunnelId,
417
+ ok: true,
418
+ status: response.statusCode ?? 502,
419
+ statusText: response.statusMessage || "OK",
420
+ headers,
421
+ });
422
+ acked = true;
423
+
424
+ response.on("data", (chunk: Buffer) => {
425
+ sendTunnelData(wsSocket, frame.tunnelId, chunk, response);
426
+ });
427
+ response.on("end", () => {
428
+ tunnels.delete(frame.tunnelId);
429
+ send(wsSocket, {
430
+ kind: "tunnel.close",
431
+ tunnelId: frame.tunnelId,
432
+ reason: "upstream ended",
433
+ });
434
+ });
435
+ },
436
+ );
437
+
438
+ tunnels.set(frame.tunnelId, upstream);
439
+
440
+ upstream.on("error", (err) => {
441
+ tunnels.delete(frame.tunnelId);
442
+ if (!acked) {
443
+ send(wsSocket, {
444
+ kind: "tunnel.ack",
445
+ tunnelId: frame.tunnelId,
446
+ ok: false,
447
+ status: 502,
448
+ message: err.message,
449
+ });
450
+ return;
451
+ }
452
+ send(wsSocket, {
453
+ kind: "tunnel.close",
454
+ tunnelId: frame.tunnelId,
455
+ reason: err.message,
456
+ });
457
+ });
458
+
459
+ // NB: do NOT end the request here. The body (if any) arrives as tunnel.data
460
+ // frames; the cloud sends tunnel.requestEnd once the body is complete, which
461
+ // ends `upstream` (see the message loop). Ending now would drop request
462
+ // bodies and deadlock body-reading upstreams.
463
+ }
464
+
465
+ function handleRawTunnelOpen(
466
+ wsSocket: WebSocket,
467
+ frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
468
+ port: number,
469
+ ): void {
470
+ const upstream = new Socket();
471
+ tunnels.set(frame.tunnelId, upstream);
472
+
473
+ let acked = false;
474
+ let responseBuffer = Buffer.alloc(0);
475
+
476
+ upstream.on("connect", () => {
477
+ upstream.write(serializeRequest(frame.reqLine));
478
+ });
479
+
480
+ upstream.on("data", (chunk) => {
481
+ if (!acked) {
482
+ responseBuffer = Buffer.concat([responseBuffer, chunk]);
483
+ const headerEnd = responseBuffer.indexOf("\r\n\r\n");
484
+ if (headerEnd === -1) return;
485
+ const head = responseBuffer.subarray(0, headerEnd).toString("latin1");
486
+ const rest = responseBuffer.subarray(headerEnd + 4);
487
+ const ack = parseResponseHead(head);
488
+ send(wsSocket, {
489
+ kind: "tunnel.ack",
490
+ tunnelId: frame.tunnelId,
491
+ ok: true,
492
+ status: ack.status,
493
+ statusText: ack.statusText,
494
+ headers: ack.headers,
495
+ });
496
+ acked = true;
497
+ if (rest.length > 0) sendTunnelData(wsSocket, frame.tunnelId, rest, upstream);
498
+ return;
499
+ }
500
+ sendTunnelData(wsSocket, frame.tunnelId, chunk, upstream);
501
+ });
502
+
503
+ upstream.on("error", (err) => {
504
+ if (!acked) {
505
+ send(wsSocket, {
506
+ kind: "tunnel.ack",
507
+ tunnelId: frame.tunnelId,
508
+ ok: false,
509
+ status: 502,
510
+ message: err.message,
511
+ });
512
+ } else {
513
+ send(wsSocket, {
514
+ kind: "tunnel.close",
515
+ tunnelId: frame.tunnelId,
516
+ reason: err.message,
517
+ });
518
+ }
519
+ tunnels.delete(frame.tunnelId);
520
+ });
521
+
522
+ upstream.on("close", () => {
523
+ tunnels.delete(frame.tunnelId);
524
+ send(wsSocket, {
525
+ kind: "tunnel.close",
526
+ tunnelId: frame.tunnelId,
527
+ reason: "upstream closed",
528
+ });
529
+ });
530
+
531
+ upstream.connect(port, "127.0.0.1");
532
+ }
533
+
534
+ function closeTunnel(
535
+ wsSocket: WebSocket,
536
+ tunnelId: string,
537
+ reason: string | undefined,
538
+ ): void {
539
+ const upstream = tunnels.get(tunnelId);
540
+ tunnels.delete(tunnelId);
541
+ upstream?.destroy();
542
+ send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
543
+ }
544
+
545
+ function resolveTunnelPort(
546
+ frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
547
+ ): number | null {
548
+ const task = getHostTask(frame.taskId);
549
+ if (!task) return null;
550
+ if (frame.target === "editor") return task.codeServerPort ?? null;
551
+ if (!frame.name) return null;
552
+ const preview = parsePreviewPortRuntimes(task.previewPorts).find(
553
+ (port) => port.name === frame.name,
554
+ );
555
+ return preview?.hostPort ?? null;
556
+ }
557
+
558
+ function serializeRequest(reqLine: {
559
+ method: string;
560
+ url: string;
561
+ headers: [string, string][];
562
+ }): Buffer {
563
+ const lines = [`${reqLine.method} ${reqLine.url} HTTP/1.1`];
564
+ for (const [name, value] of reqLine.headers) {
565
+ if (name.toLowerCase() === "connection") {
566
+ lines.push(`${name}: ${value}`);
567
+ } else {
568
+ lines.push(`${name}: ${value}`);
569
+ }
570
+ }
571
+ return Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1");
572
+ }
573
+
574
+ function requestHeaders(headers: [string, string][]): OutgoingHttpHeaders {
575
+ const out: OutgoingHttpHeaders = {};
576
+ for (const [name, value] of headers) {
577
+ const lower = name.toLowerCase();
578
+ if (isHopByHopHeader(lower)) continue;
579
+ if (lower === "host") {
580
+ out.host = "127.0.0.1";
581
+ continue;
582
+ }
583
+
584
+ const existing = out[lower];
585
+ if (existing === undefined) {
586
+ out[lower] = value;
587
+ } else if (Array.isArray(existing)) {
588
+ existing.push(value);
589
+ } else {
590
+ out[lower] = [String(existing), value];
591
+ }
592
+ }
593
+ return out;
594
+ }
595
+
596
+ function responseHeaders(response: IncomingMessage): [string, string][] {
597
+ const headers: [string, string][] = [];
598
+ for (let i = 0; i < response.rawHeaders.length; i += 2) {
599
+ const name = response.rawHeaders[i];
600
+ const value = response.rawHeaders[i + 1];
601
+ if (!name || value === undefined) continue;
602
+ if (isHopByHopHeader(name.toLowerCase())) continue;
603
+ headers.push([name, value]);
604
+ }
605
+ return headers;
606
+ }
607
+
608
+ function isHopByHopHeader(name: string): boolean {
609
+ return (
610
+ name === "connection" ||
611
+ name === "keep-alive" ||
612
+ name === "proxy-authenticate" ||
613
+ name === "proxy-authorization" ||
614
+ name === "te" ||
615
+ name === "trailer" ||
616
+ name === "transfer-encoding" ||
617
+ name === "upgrade"
618
+ );
619
+ }
620
+
621
+ function parseResponseHead(head: string): {
622
+ status: number;
623
+ statusText: string;
624
+ headers: [string, string][];
625
+ } {
626
+ const lines = head.split("\r\n");
627
+ const statusLine = lines.shift() ?? "HTTP/1.1 502 Bad Gateway";
628
+ const match = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/.exec(statusLine);
629
+ const headers: [string, string][] = [];
630
+ for (const line of lines) {
631
+ const idx = line.indexOf(":");
632
+ if (idx === -1) continue;
633
+ headers.push([line.slice(0, idx), line.slice(idx + 1).trimStart()]);
634
+ }
635
+ return {
636
+ status: match ? Number(match[1]) : 502,
637
+ statusText: match?.[2] || "Bad Gateway",
638
+ headers,
639
+ };
640
+ }
641
+
642
+ function sendTunnelData(
643
+ wsSocket: WebSocket,
644
+ tunnelId: string,
645
+ chunk: Buffer,
646
+ upstream: PausableSource,
647
+ ): void {
648
+ if (wsSocket.readyState !== WebSocket.OPEN) {
649
+ upstream.destroy();
650
+ return;
651
+ }
652
+ send(wsSocket, { kind: "tunnel.data", tunnelId });
653
+ wsSocket.send(chunk);
654
+ // Backpressure: always send the current chunk (make progress), then if the
655
+ // socket buffer is high, pause this upstream and resume only once it drains
656
+ // below the low-water mark — a tight poll instead of a blind fixed sleep, so
657
+ // throughput tracks the real drain rate. Once paused, no more data events
658
+ // fire for this upstream, so the loop can't stack.
659
+ if (wsSocket.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
660
+ upstream.pause();
661
+ const resume = (): void => {
662
+ if (wsSocket.readyState !== WebSocket.OPEN) {
663
+ upstream.destroy();
664
+ return;
665
+ }
666
+ if (wsSocket.bufferedAmount <= MAX_WS_BUFFERED_BYTES / 2) {
667
+ upstream.resume();
668
+ } else {
669
+ setTimeout(resume, 5);
670
+ }
671
+ };
672
+ setTimeout(resume, 5);
673
+ }
674
+ }
675
+
676
+ function dispatchCommand(
677
+ ctx: CommandContext,
678
+ command: keyof HostCommands,
679
+ args: unknown[],
680
+ ): Promise<HostCommandResult<unknown>> {
681
+ switch (command) {
682
+ case "taskUp":
683
+ return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
684
+ case "taskDown":
685
+ return hostCommands.taskDown(ctx, expectTaskDownInput(args, 0));
686
+ case "taskStatus":
687
+ return hostCommands.taskStatus(ctx, expectString(args, 0));
688
+ case "channelEnsure":
689
+ return hostCommands.channelEnsure(ctx, expectChannelEnsureInput(args, 0));
690
+ case "channelTeardown":
691
+ return hostCommands.channelTeardown(ctx, expectString(args, 0));
692
+ case "channelDeliver":
693
+ return hostCommands.channelDeliver(
694
+ ctx,
695
+ expectString(args, 0),
696
+ expectString(args, 1),
697
+ expectString(args, 2),
698
+ );
699
+ case "channelResolvePermission":
700
+ return hostCommands.channelResolvePermission(
701
+ ctx,
702
+ expectString(args, 0),
703
+ expectString(args, 1),
704
+ expectString(args, 2),
705
+ expectPermissionDecision(args, 3),
706
+ );
707
+ case "cloneRepo":
708
+ return hostCommands.cloneRepo(ctx, expectCloneRepoInput(args, 0));
709
+ case "taskDiff":
710
+ return hostCommands.taskDiff(ctx, expectTaskDiffInput(args, 0));
711
+ case "attachmentWrite":
712
+ return hostCommands.attachmentWrite(
713
+ ctx,
714
+ expectAttachmentWriteInput(args, 0),
715
+ );
716
+ case "attachmentRead":
717
+ return hostCommands.attachmentRead(
718
+ ctx,
719
+ expectAttachmentReadInput(args, 0),
720
+ );
721
+ case "channelInterrupt":
722
+ return hostCommands.channelInterrupt(
723
+ ctx,
724
+ expectString(args, 0),
725
+ expectString(args, 1),
726
+ );
727
+ case "appendTranscript":
728
+ return hostCommands.appendTranscript(
729
+ ctx,
730
+ expectString(args, 0),
731
+ expectString(args, 1),
732
+ expectString(args, 2),
733
+ );
734
+ }
735
+ }
736
+
737
+ function parseCloudFrame(data: RawData): CloudToHost | null {
738
+ let parsed: unknown;
739
+ try {
740
+ parsed = JSON.parse(data.toString());
741
+ } catch {
742
+ console.warn("[host-agent] dropping invalid JSON frame");
743
+ return null;
744
+ }
745
+ if (!parsed || typeof parsed !== "object") return null;
746
+ const frame = parsed as Record<string, unknown>;
747
+ if (frame.kind === "pong" && typeof frame.ts === "number") {
748
+ return { kind: "pong", ts: frame.ts };
749
+ }
750
+ if (
751
+ frame.kind === "command" &&
752
+ typeof frame.commandId === "string" &&
753
+ typeof frame.command === "string" &&
754
+ Array.isArray(frame.args) &&
755
+ isHostCommand(frame.command)
756
+ ) {
757
+ return {
758
+ kind: "command",
759
+ commandId: frame.commandId,
760
+ command: frame.command,
761
+ args: frame.args,
762
+ };
763
+ }
764
+ if (
765
+ frame.kind === "tunnel.open" &&
766
+ typeof frame.tunnelId === "string" &&
767
+ (frame.target === "editor" || frame.target === "preview") &&
768
+ typeof frame.taskId === "string" &&
769
+ isReqLine(frame.reqLine) &&
770
+ typeof frame.upgrade === "boolean"
771
+ ) {
772
+ return {
773
+ kind: "tunnel.open",
774
+ tunnelId: frame.tunnelId,
775
+ target: frame.target,
776
+ taskId: frame.taskId,
777
+ name: typeof frame.name === "string" ? frame.name : undefined,
778
+ reqLine: frame.reqLine,
779
+ upgrade: frame.upgrade,
780
+ };
781
+ }
782
+ if (frame.kind === "tunnel.data" && typeof frame.tunnelId === "string") {
783
+ return { kind: "tunnel.data", tunnelId: frame.tunnelId };
784
+ }
785
+ if (frame.kind === "tunnel.requestEnd" && typeof frame.tunnelId === "string") {
786
+ return { kind: "tunnel.requestEnd", tunnelId: frame.tunnelId };
787
+ }
788
+ if (frame.kind === "tunnel.close" && typeof frame.tunnelId === "string") {
789
+ return {
790
+ kind: "tunnel.close",
791
+ tunnelId: frame.tunnelId,
792
+ reason: typeof frame.reason === "string" ? frame.reason : undefined,
793
+ };
794
+ }
795
+ if (
796
+ frame.kind === "gh.connect.set" &&
797
+ typeof frame.userId === "string" &&
798
+ typeof frame.installationId === "number" &&
799
+ typeof frame.githubLogin === "string" &&
800
+ (frame.targetType === "User" || frame.targetType === "Organization") &&
801
+ typeof frame.refreshToken === "string"
802
+ ) {
803
+ return {
804
+ kind: "gh.connect.set",
805
+ userId: frame.userId,
806
+ installationId: frame.installationId,
807
+ githubLogin: frame.githubLogin,
808
+ targetType: frame.targetType,
809
+ refreshToken: frame.refreshToken,
810
+ refreshTokenExpiresAt:
811
+ typeof frame.refreshTokenExpiresAt === "number"
812
+ ? frame.refreshTokenExpiresAt
813
+ : undefined,
814
+ };
815
+ }
816
+ if (frame.kind === "gh.connect.clear" && typeof frame.userId === "string") {
817
+ return { kind: "gh.connect.clear", userId: frame.userId };
818
+ }
819
+ if (
820
+ (frame.kind === "ssh.key.get" ||
821
+ frame.kind === "ssh.key.ensure" ||
822
+ frame.kind === "ssh.key.delete") &&
823
+ typeof frame.userId === "string"
824
+ ) {
825
+ return { kind: frame.kind, userId: frame.userId };
826
+ }
827
+ console.warn("[host-agent] dropping unknown frame");
828
+ return null;
829
+ }
830
+
831
+ function isReqLine(value: unknown): value is {
832
+ method: string;
833
+ url: string;
834
+ headers: [string, string][];
835
+ } {
836
+ if (!value || typeof value !== "object") return false;
837
+ const row = value as Record<string, unknown>;
838
+ return (
839
+ typeof row.method === "string" &&
840
+ typeof row.url === "string" &&
841
+ Array.isArray(row.headers) &&
842
+ row.headers.every(
843
+ (item) =>
844
+ Array.isArray(item) &&
845
+ item.length === 2 &&
846
+ typeof item[0] === "string" &&
847
+ typeof item[1] === "string",
848
+ )
849
+ );
850
+ }
851
+
852
+ function send(socket: WebSocket, frame: HostToCloud): void {
853
+ socket.send(JSON.stringify(frame));
854
+ }
855
+
856
+ function rawDataToBuffer(data: RawData): Buffer {
857
+ if (Buffer.isBuffer(data)) return data;
858
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
859
+ if (Array.isArray(data)) return Buffer.concat(data);
860
+ return Buffer.from(data);
861
+ }
862
+
863
+ function isHostCommand(command: string): command is keyof HostCommands {
864
+ // ⚠️ Keep in sync with the HostCommands interface + dispatchCommand. This is a
865
+ // manual allow-list (TS can't derive it from the type), so a missing entry
866
+ // silently drops the frame and hangs the cloud command.
867
+ return [
868
+ "taskUp",
869
+ "taskDown",
870
+ "taskStatus",
871
+ "channelEnsure",
872
+ "channelTeardown",
873
+ "channelDeliver",
874
+ "channelResolvePermission",
875
+ "cloneRepo",
876
+ "taskDiff",
877
+ "attachmentWrite",
878
+ "attachmentRead",
879
+ "channelInterrupt",
880
+ "appendTranscript",
881
+ ].includes(command);
882
+ }
883
+
884
+ function expectString(args: unknown[], index: number): string {
885
+ const value = args[index];
886
+ if (typeof value !== "string") {
887
+ throw new Error(`invalid command args: expected string at ${index}`);
888
+ }
889
+ return value;
890
+ }
891
+
892
+ function expectTaskAgents(value: unknown): TaskAgent[] {
893
+ if (!Array.isArray(value)) {
894
+ throw new Error("invalid command args: expected agents array");
895
+ }
896
+ return value.map((agent) => {
897
+ const row = expectRecord(agent, "task agent");
898
+ const out: TaskAgent = {
899
+ id: expectStringValue(row.id, "agent.id"),
900
+ label: expectStringValue(row.label, "agent.label"),
901
+ kind: expectStringValue(row.kind, "agent.kind"),
902
+ };
903
+ if (typeof row.model === "string") out.model = row.model;
904
+ if (typeof row.defaultPrompt === "string") {
905
+ out.defaultPrompt = row.defaultPrompt;
906
+ }
907
+ if (typeof row.initialPrompt === "string") {
908
+ out.initialPrompt = row.initialPrompt;
909
+ }
910
+ return out;
911
+ });
912
+ }
913
+
914
+ function expectPermissionDecision(
915
+ args: unknown[],
916
+ index: number,
917
+ ): PermissionDecision {
918
+ const value = args[index];
919
+ if (!value || typeof value !== "object") {
920
+ throw new Error(`invalid command args: expected decision at ${index}`);
921
+ }
922
+ const decision = value as Record<string, unknown>;
923
+ if (decision.kind === "accept" || decision.kind === "decline") {
924
+ return { kind: decision.kind };
925
+ }
926
+ throw new Error("invalid permission decision");
927
+ }
928
+
929
+ function expectCloneRepoInput(
930
+ args: unknown[],
931
+ index: number,
932
+ ): { url: string; projectId: string } {
933
+ const value = args[index];
934
+ if (!value || typeof value !== "object") {
935
+ throw new Error(`invalid command args: expected clone input at ${index}`);
936
+ }
937
+ const input = value as Record<string, unknown>;
938
+ if (typeof input.url !== "string" || typeof input.projectId !== "string") {
939
+ throw new Error("invalid clone input");
940
+ }
941
+ return {
942
+ url: input.url,
943
+ projectId: input.projectId,
944
+ };
945
+ }
946
+
947
+ function expectTaskLaunchInput(
948
+ args: unknown[],
949
+ index: number,
950
+ ): TaskLaunchInput {
951
+ const input = expectRecord(args[index], "task launch input");
952
+ const out: TaskLaunchInput = {
953
+ task: expectTaskCommandTask(input.task),
954
+ projects: expectTaskCommandProjects(input.projects),
955
+ };
956
+ if (typeof input.ownerEmail === "string") out.ownerEmail = input.ownerEmail;
957
+ if (typeof input.ownerName === "string") out.ownerName = input.ownerName;
958
+ return out;
959
+ }
960
+
961
+ function expectTaskDownInput(args: unknown[], index: number): TaskDownInput {
962
+ const input = expectRecord(args[index], "task down input");
963
+ return {
964
+ taskId: expectStringValue(input.taskId, "taskId"),
965
+ task: expectTaskCommandTask(input.task),
966
+ projects: expectTaskCommandProjects(input.projects),
967
+ };
968
+ }
969
+
970
+ function expectTaskDiffInput(args: unknown[], index: number): TaskDiffInput {
971
+ const input = expectRecord(args[index], "task diff input");
972
+ const projectsValue = input.projects;
973
+ if (!Array.isArray(projectsValue)) throw new Error("invalid diff projects");
974
+ return {
975
+ taskId: expectStringValue(input.taskId, "taskId"),
976
+ projects: projectsValue.map((project) => {
977
+ const row = expectRecord(project, "diff project");
978
+ return {
979
+ id: expectStringValue(row.id, "project.id"),
980
+ slug: expectStringValue(row.slug, "project.slug"),
981
+ };
982
+ }),
983
+ };
984
+ }
985
+
986
+ function expectAttachmentWriteInput(
987
+ args: unknown[],
988
+ index: number,
989
+ ): { taskId: string; filename: string; dataBase64: string } {
990
+ const input = expectRecord(args[index], "attachment write input");
991
+ return {
992
+ taskId: expectStringValue(input.taskId, "taskId"),
993
+ filename: expectStringValue(input.filename, "filename"),
994
+ dataBase64: expectStringValue(input.dataBase64, "dataBase64"),
995
+ };
996
+ }
997
+
998
+ function expectAttachmentReadInput(
999
+ args: unknown[],
1000
+ index: number,
1001
+ ): { taskId: string; filename: string } {
1002
+ const input = expectRecord(args[index], "attachment read input");
1003
+ return {
1004
+ taskId: expectStringValue(input.taskId, "taskId"),
1005
+ filename: expectStringValue(input.filename, "filename"),
1006
+ };
1007
+ }
1008
+
1009
+ function expectChannelEnsureInput(
1010
+ args: unknown[],
1011
+ index: number,
1012
+ ): ChannelEnsureInput {
1013
+ const input = expectRecord(args[index], "channel ensure input");
1014
+ const projectsValue = input.projects;
1015
+ if (!Array.isArray(projectsValue)) throw new Error("invalid channel projects");
1016
+ const out: ChannelEnsureInput = {
1017
+ taskId: expectStringValue(input.taskId, "taskId"),
1018
+ agents: expectTaskAgents(input.agents),
1019
+ projects: projectsValue.map((project) => {
1020
+ const row = expectRecord(project, "channel project");
1021
+ return {
1022
+ slug: expectStringValue(row.slug, "slug"),
1023
+ defaultPrompt: expectStringValue(row.defaultPrompt, "defaultPrompt"),
1024
+ };
1025
+ }),
1026
+ branch: expectStringValue(input.branch, "branch"),
1027
+ workspacePath: expectStringValue(input.workspacePath, "workspacePath"),
1028
+ status: expectStringValue(input.status, "status"),
1029
+ };
1030
+ if (typeof input.globalContext === "string") {
1031
+ out.globalContext = input.globalContext;
1032
+ }
1033
+ return out;
1034
+ }
1035
+
1036
+ function expectTaskCommandTask(value: unknown): TaskCommandTask {
1037
+ const row = expectRecord(value, "task");
1038
+ const out: TaskCommandTask = {
1039
+ id: expectStringValue(row.id, "task.id"),
1040
+ ownerUserId: expectStringValue(row.ownerUserId, "task.ownerUserId"),
1041
+ hostId: expectStringValue(row.hostId, "task.hostId"),
1042
+ name: expectStringValue(row.name, "task.name"),
1043
+ slug: expectStringValue(row.slug, "task.slug"),
1044
+ branch: expectStringValue(row.branch, "task.branch"),
1045
+ status: expectStringValue(row.status, "task.status"),
1046
+ agents: expectTaskAgents(row.agents),
1047
+ };
1048
+ if (typeof row.globalContext === "string") {
1049
+ out.globalContext = row.globalContext;
1050
+ }
1051
+ if (Array.isArray(row.reviewerOrder)) {
1052
+ out.reviewerOrder = row.reviewerOrder.map((id, i) =>
1053
+ expectStringValue(id, `task.reviewerOrder[${i}]`),
1054
+ );
1055
+ }
1056
+ return out;
1057
+ }
1058
+
1059
+ function expectTaskCommandProjects(value: unknown): TaskCommandProject[] {
1060
+ if (!Array.isArray(value)) throw new Error("invalid projects");
1061
+ return value.map(expectTaskCommandProject);
1062
+ }
1063
+
1064
+ function expectTaskCommandProject(value: unknown): TaskCommandProject {
1065
+ const row = expectRecord(value, "project");
1066
+ const out: TaskCommandProject = {
1067
+ id: expectStringValue(row.id, "project.id"),
1068
+ ownerUserId: expectStringValue(row.ownerUserId, "project.ownerUserId"),
1069
+ name: expectStringValue(row.name, "project.name"),
1070
+ slug: expectStringValue(row.slug, "project.slug"),
1071
+ repoUrl: expectStringValue(row.repoUrl, "project.repoUrl"),
1072
+ defaultPrompt: expectStringValue(row.defaultPrompt, "project.defaultPrompt"),
1073
+ previewPorts: expectStringValue(row.previewPorts, "project.previewPorts"),
1074
+ env: expectStringValue(row.env, "project.env"),
1075
+ position:
1076
+ typeof row.position === "number" && Number.isInteger(row.position)
1077
+ ? row.position
1078
+ : 0,
1079
+ };
1080
+ if (typeof row.toolVersions === "string") {
1081
+ out.toolVersions = row.toolVersions;
1082
+ }
1083
+ if (typeof row.extra === "string") out.extra = row.extra;
1084
+ return out;
1085
+ }
1086
+
1087
+ function expectRecord(value: unknown, label: string): Record<string, unknown> {
1088
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1089
+ throw new Error(`invalid command args: expected ${label}`);
1090
+ }
1091
+ return value as Record<string, unknown>;
1092
+ }
1093
+
1094
+ function expectStringValue(value: unknown, label: string): string {
1095
+ if (typeof value !== "string") {
1096
+ throw new Error(`invalid command args: expected string ${label}`);
1097
+ }
1098
+ return value;
1099
+ }
1100
+
1101
+ function ensureHostId(): string {
1102
+ mkdirSync(env.dataDir, { recursive: true, mode: 0o700 });
1103
+ const path = join(env.dataDir, "host-id");
1104
+ if (existsSync(path)) {
1105
+ const existing = readFileSync(path, "utf8").trim();
1106
+ if (existing) return existing;
1107
+ }
1108
+ const id = newId();
1109
+ writeFileSync(path, `${id}\n`, { mode: 0o600 });
1110
+ return id;
1111
+ }
1112
+
1113
+ function parsePort(raw: string | undefined, fallback: number): number {
1114
+ if (!raw) return fallback;
1115
+ const port = Number(raw);
1116
+ return Number.isInteger(port) && port > 0 && port <= 65535
1117
+ ? port
1118
+ : fallback;
1119
+ }
1120
+
1121
+ function reconnectDelay(): number {
1122
+ const base =
1123
+ BACKOFF_STEPS_MS[
1124
+ Math.min(reconnectAttempt, BACKOFF_STEPS_MS.length - 1)
1125
+ ] ?? 30_000;
1126
+ reconnectAttempt += 1;
1127
+ const jitter = 0.8 + Math.random() * 0.4;
1128
+ return base * jitter;
1129
+ }
1130
+
1131
+ function requestShutdown(signal: NodeJS.Signals): void {
1132
+ console.log(`[host-agent] ${signal} received; shutting down`);
1133
+ stopping = true;
1134
+ shutdownRequested = true;
1135
+ if (ws && ws.readyState === WebSocket.OPEN) {
1136
+ ws.close(1001, "host shutting down");
1137
+ }
1138
+ maybeExit();
1139
+ }
1140
+
1141
+ function maybeExit(): void {
1142
+ if (!shutdownRequested || inFlight > 0) return;
1143
+ process.exit(0);
1144
+ }
1145
+
1146
+ function requireEnv(name: string): string {
1147
+ const value = process.env[name];
1148
+ if (!value) {
1149
+ console.error(`[host-agent] ${name} is required`);
1150
+ process.exit(1);
1151
+ }
1152
+ return value;
1153
+ }
1154
+
1155
+ process.on("SIGINT", requestShutdown);
1156
+ process.on("SIGTERM", requestShutdown);