@runuai/host 0.9.98 → 0.9.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.ts CHANGED
@@ -116,6 +116,8 @@ import {
116
116
  invalidateMachineTunnelRoute,
117
117
  resolveMachineTunnelRoute,
118
118
  } from "./machine-tunnel-route";
119
+ import { armConnectTimeout } from "./tunnel-dial";
120
+ import { scheduleActivationRespawn } from "./activation-respawn";
119
121
  import {
120
122
  configureBundledContainerRuntime,
121
123
  selectedRuntimeMatchesPersistedTaskEnvironments,
@@ -148,7 +150,7 @@ import {
148
150
  patchHostConfig,
149
151
  type HostConfigResult,
150
152
  } from "../lib/host-config";
151
- import { listEngineAccounts } from "../lib/engine-accounts";
153
+ import { listEngineAccountsAllScopes } from "../lib/engine-accounts";
152
154
  import { createEngineConnectManager } from "../lib/engine-connect";
153
155
  import { createEngineLoginManager } from "../lib/engine-login";
154
156
  import {
@@ -180,7 +182,9 @@ import {
180
182
  HOST_TASK_STOP_PROTOCOL_FEATURE,
181
183
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
182
184
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
185
+ HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
183
186
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
187
+ isEngineAccountScope,
184
188
  HOST_LOGS_PROTOCOL_FEATURE,
185
189
  HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
186
190
  MAX_ENGINE_ACCOUNT_LABEL_CHARS,
@@ -599,6 +603,7 @@ function buildCapabilities(): HostCapabilities {
599
603
  HOST_TASK_STOP_PROTOCOL_FEATURE,
600
604
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
601
605
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
606
+ HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
602
607
  // The echo adapter cannot execute the in-task CLI. Advertising typed
603
608
  // dispatch in mock mode would let the composer create a Secretary that
604
609
  // has no way to wake crew.
@@ -694,13 +699,15 @@ function engineAccountSummaries(): EngineAccountSummary[] {
694
699
  const summaries: EngineAccountSummary[] = [];
695
700
  for (const kind of ["claude", "codex", "opencode"] as const) {
696
701
  try {
697
- for (const account of listEngineAccounts(kind)) {
702
+ // ADR-131 D3: every scope — the cloud filters per caller.
703
+ for (const account of listEngineAccountsAllScopes(kind)) {
698
704
  summaries.push({
699
705
  id: account.id,
700
706
  kind,
701
707
  label: account.label.slice(0, MAX_ENGINE_ACCOUNT_LABEL_CHARS),
702
708
  authKind: account.authKind,
703
709
  isDefault: account.isDefault,
710
+ scope: account.scope,
704
711
  });
705
712
  }
706
713
  } catch (error) {
@@ -1225,6 +1232,7 @@ async function connect(): Promise<void> {
1225
1232
  frame.engine,
1226
1233
  emitEngineLoginEvent,
1227
1234
  frame.label,
1235
+ frame.scope,
1228
1236
  );
1229
1237
  break;
1230
1238
  case "engine.login.input":
@@ -1245,6 +1253,7 @@ async function connect(): Promise<void> {
1245
1253
  frame.secret,
1246
1254
  frame.label,
1247
1255
  (event) => sendRemoteOperationFrame(event),
1256
+ frame.scope,
1248
1257
  );
1249
1258
  break;
1250
1259
  case "engine.connect.stop":
@@ -1863,6 +1872,18 @@ function handleHttpTunnelOpen(
1863
1872
  },
1864
1873
  );
1865
1874
 
1875
+ // ADR-121: a memoised machine address may black-hole (instance stopped
1876
+ // while memoised), and a black-holed dial neither errors nor answers for
1877
+ // the kernel's SYN budget. Bound the connect phase only — never `timeout`
1878
+ // on the request, which is idle semantics and would cut quiet streams. A
1879
+ // socket the agent reuses arrives connected and is left alone.
1880
+ if (target.connectTimeoutMs !== undefined && !target.socket) {
1881
+ const connectTimeoutMs = target.connectTimeoutMs;
1882
+ upstream.once("socket", (socket: Socket) => {
1883
+ armConnectTimeout(socket, target, connectTimeoutMs);
1884
+ });
1885
+ }
1886
+
1866
1887
  // Registering replays anything that arrived while the target resolved — for a
1867
1888
  // bodyless GET that is the `requestEnd` this request cannot reply without.
1868
1889
  tunnels.register(frame.tunnelId, upstream);
@@ -1872,7 +1893,8 @@ function handleHttpTunnelOpen(
1872
1893
  // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
1873
1894
  // the next request recreates it (no-op for published / container-IP targets).
1874
1895
  invalidatePreviewSidecar(target.port);
1875
- // ADR-121: a memoised machine address that no longer answers is stale.
1896
+ // ADR-121: a memoised machine address that no longer answers is stale —
1897
+ // a refused dial and a connect timeout (tunnel-dial.ts) both land here.
1876
1898
  invalidateMachineTunnelRoute(frame.taskId);
1877
1899
  if (!acked) {
1878
1900
  send(wsSocket, {
@@ -1969,7 +1991,16 @@ function handleRawTunnelOpen(
1969
1991
  });
1970
1992
  });
1971
1993
 
1972
- if (!target.socket) upstream.connect(target.port, target.host);
1994
+ if (!target.socket) {
1995
+ upstream.connect(target.port, target.host);
1996
+ // ADR-121: a memoised machine address may black-hole; bound the connect
1997
+ // phase (tunnel-dial.ts) so the 'error' handler above runs and drops the
1998
+ // memo instead of the pane hanging on the kernel's SYN budget. Armed
1999
+ // after connect() so the socket is `connecting`; disarmed on 'connect'.
2000
+ if (target.connectTimeoutMs !== undefined) {
2001
+ armConnectTimeout(upstream, target, target.connectTimeoutMs);
2002
+ }
2003
+ }
1973
2004
  // AFTER `connect`, not before: replaying a queued write onto a socket that
1974
2005
  // has not started connecting errors, while node buffers writes made once it
1975
2006
  // is connecting. An upgrade's own request goes out from the `connect`
@@ -2003,6 +2034,12 @@ interface UpstreamAddr {
2003
2034
  * (ADR-106 apple routing). An established TCP peer cannot be retargeted by
2004
2035
  * later IP reuse, so consumers must use this socket instead of redialing. */
2005
2036
  socket?: Socket;
2037
+ /** Connect-phase bound for a dial to this address, set only by the ADR-121
2038
+ * machine branch: its address is memoised and may black-hole after an
2039
+ * external stop, and a black-holed dial neither errors nor answers for the
2040
+ * kernel's SYN budget. Loopback, sidecar and pre-established (`socket`)
2041
+ * targets carry none — their dials fail fast or are already connected. */
2042
+ connectTimeoutMs?: number;
2006
2043
  }
2007
2044
 
2008
2045
  async function resolveTunnelTarget(
@@ -2404,7 +2441,7 @@ async function dispatchCommand(
2404
2441
  function expectEngineAccountRemoveInput(
2405
2442
  args: unknown[],
2406
2443
  index: number,
2407
- ): { id: string } {
2444
+ ): { id: string; scope?: string } {
2408
2445
  const value = args[index];
2409
2446
  if (
2410
2447
  !value ||
@@ -2415,7 +2452,14 @@ function expectEngineAccountRemoveInput(
2415
2452
  ) {
2416
2453
  throw new Error(`invalid command args: expected account id at ${index}`);
2417
2454
  }
2418
- return { id: (value as { id: string }).id };
2455
+ const scope = (value as { scope?: unknown }).scope;
2456
+ if (scope !== undefined && !isEngineAccountScope(scope)) {
2457
+ throw new Error(`invalid command args: bad account scope at ${index}`);
2458
+ }
2459
+ return {
2460
+ id: (value as { id: string }).id,
2461
+ ...(scope !== undefined ? { scope } : {}),
2462
+ };
2419
2463
  }
2420
2464
 
2421
2465
  function expectNumberArg(args: unknown[], index: number): number {
@@ -3369,13 +3413,27 @@ function maybeExit(): void {
3369
3413
  }
3370
3414
  if (ws.readyState === WebSocket.CLOSING) return;
3371
3415
  }
3416
+ if (shutdownExitCode === MANAGED_UPDATE_RESTART_EXIT_CODE) {
3417
+ // The drain is complete: this is the last thing the old generation does.
3418
+ // On a launchd host the respawn is scheduled from here rather than left
3419
+ // to KeepAlive — see activation-respawn.ts for the two 2026-09-06 stalls.
3420
+ const plan = scheduleActivationRespawn();
3421
+ console.log(
3422
+ plan.scheduled
3423
+ ? `[host-agent] exiting ${shutdownExitCode} for the supervisor to relaunch runtime/current; launchctl kickstart ${plan.target} is scheduled in ${plan.delaySeconds}s in case KeepAlive does not respawn the job`
3424
+ : `[host-agent] exiting ${shutdownExitCode} for the supervisor to relaunch runtime/current (no launchctl kickstart scheduled: ${plan.reason})`,
3425
+ );
3426
+ }
3372
3427
  process.exit(shutdownExitCode);
3373
3428
  }
3374
3429
 
3375
3430
  /**
3376
3431
  * Finish any in-flight bridge command, then let the service supervisor exec
3377
3432
  * the newly activated `runtime/current`. The non-zero status is intentional:
3378
- * Linux uses Restart=on-failure, while launchd KeepAlive also respawns it.
3433
+ * Linux uses Restart=on-failure and the desktop supervisor restarts on any
3434
+ * exit. launchd's KeepAlive is supposed to respawn it too but has been seen
3435
+ * not to (2026-09-06, twice), so maybeExit() also schedules an explicit
3436
+ * `launchctl kickstart` on macOS before the process exits.
3379
3437
  */
3380
3438
  export function requestManagedHostRestart(): void {
3381
3439
  shutdownExitCode = MANAGED_UPDATE_RESTART_EXIT_CODE;
package/src/protocol.ts CHANGED
@@ -102,6 +102,25 @@ export const MAX_ENGINE_ACCOUNTS_ADVERTISED = 48;
102
102
  /** ADR-117: one-shot engine credentials over the bridge + the full engine
103
103
  * catalog in capabilities, so every engine renders on the cloud pane. */
104
104
  export const HOST_ENGINE_CONNECT_PROTOCOL_FEATURE = "host-engine-connect-v1";
105
+ /** ADR-131 Decision 3: engine accounts carry a scope — `host` (the owner's)
106
+ * or `org:<orgId>` (a workspace's account on a shared host). A host that
107
+ * advertises this accepts `scope` on engine.login.start / engine.connect.start
108
+ * / engineAccountRemove, `engineAccountScope` on channelEnsure and task-up,
109
+ * and stamps `scope` on every EngineAccountSummary it advertises. The cloud
110
+ * sends none of those to a host without the feature (the v1 exact-shape
111
+ * guards would reject the frames). */
112
+ export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
113
+ "host-engine-account-scopes-v1";
114
+ /** The owner-level account scope; every pre-scope account lives here. */
115
+ export const HOST_ACCOUNT_SCOPE = "host";
116
+ const ORG_ACCOUNT_SCOPE_RE = /^org:[A-Za-z0-9_-]{1,128}$/;
117
+ /** `host` or `org:<orgId>` — bounded, single-token, safe in every surface. */
118
+ export function isEngineAccountScope(value: unknown): value is string {
119
+ return (
120
+ value === HOST_ACCOUNT_SCOPE ||
121
+ (typeof value === "string" && ORG_ACCOUNT_SCOPE_RE.test(value))
122
+ );
123
+ }
105
124
  export const MAX_ENGINE_CONNECT_SECRET_CHARS = 8_192;
106
125
  export const MAX_ENGINE_CONNECT_LINE_CHARS = 512;
107
126
  export const MAX_ENGINE_CATALOG_ENTRIES = 8;
@@ -307,6 +326,10 @@ export interface EngineLoginStartFrame {
307
326
  * slot. Only sent to hosts advertising `host-engine-accounts-v1`; the v1
308
327
  * exact-shape guard on older hosts rejects frames carrying it. */
309
328
  label?: string;
329
+ /** ADR-131 D3: capture into this account scope (`org:<orgId>` on a shared
330
+ * host). Only sent to hosts advertising `host-engine-account-scopes-v1`;
331
+ * absent = the host scope. An org scope requires a label. */
332
+ scope?: string;
310
333
  }
311
334
 
312
335
  /** The only generic one-time response channel. Long-lived tokens must never
@@ -379,6 +402,9 @@ export interface EngineAccountSummary {
379
402
  label: string;
380
403
  authKind: "env" | "config-dir";
381
404
  isDefault: boolean;
405
+ /** ADR-131 D3: `host` or `org:<orgId>`. Absent from hosts that predate
406
+ * scopes — read as `host`. */
407
+ scope?: string;
382
408
  }
383
409
 
384
410
  /** ADR-117 engine kinds — every engine the host's descriptor table knows. */
@@ -416,6 +442,8 @@ export interface EngineConnectStartFrame {
416
442
  engine: EngineConnectKind;
417
443
  secret?: string;
418
444
  label?: string;
445
+ /** ADR-131 D3: see EngineLoginStartFrame.scope. */
446
+ scope?: string;
419
447
  }
420
448
 
421
449
  export interface EngineConnectStopFrame {
@@ -454,13 +482,14 @@ export function isEngineConnectStartFrame(
454
482
  const frame = exactWireObject(
455
483
  value,
456
484
  ["kind", "opId", "engine"],
457
- ["secret", "label"],
485
+ ["secret", "label", "scope"],
458
486
  );
459
487
  if (
460
488
  !frame ||
461
489
  frame.kind !== "engine.connect.start" ||
462
490
  !isHostOpId(frame.opId) ||
463
- !isEngineConnectKind(frame.engine)
491
+ !isEngineConnectKind(frame.engine) ||
492
+ (frame.scope !== undefined && !isEngineAccountScope(frame.scope))
464
493
  ) {
465
494
  return false;
466
495
  }
@@ -705,13 +734,18 @@ export function isHostLogsEndFrame(value: unknown): value is HostLogsEndFrame {
705
734
  export function isEngineLoginStartFrame(
706
735
  value: unknown,
707
736
  ): value is EngineLoginStartFrame {
708
- const frame = exactWireObject(value, ["kind", "opId", "engine"], ["label"]);
737
+ const frame = exactWireObject(
738
+ value,
739
+ ["kind", "opId", "engine"],
740
+ ["label", "scope"],
741
+ );
709
742
  return Boolean(
710
743
  frame &&
711
744
  frame.kind === "engine.login.start" &&
712
745
  isHostOpId(frame.opId) &&
713
746
  isEngineLoginKind(frame.engine) &&
714
- (frame.label === undefined || isEngineAccountLabel(frame.label)),
747
+ (frame.label === undefined || isEngineAccountLabel(frame.label)) &&
748
+ (frame.scope === undefined || isEngineAccountScope(frame.scope)),
715
749
  );
716
750
  }
717
751
 
@@ -1087,6 +1121,9 @@ export interface TaskCommandTask {
1087
1121
  previewEnv?: Record<string, string>;
1088
1122
  /** ADR-062: owning org — locates the org shared-files root on the host. */
1089
1123
  ownerOrgId?: string;
1124
+ /** ADR-131 D3: the engine-account scope the task's agents resolve
1125
+ * credentials from (`org:<orgId>` on a shared host). Absent = `host`. */
1126
+ engineAccountScope?: string;
1090
1127
  /** ADR-062: shared-files mount mode ("off" | "ro" | "rw"; default "ro"). */
1091
1128
  sharedFiles?: string;
1092
1129
  }
@@ -1188,6 +1225,10 @@ export interface ChannelEnsureInput {
1188
1225
  /** ADR-053: any joined project opted into browser testing — the host wires
1189
1226
  * the Playwright MCP browser at session start. */
1190
1227
  browserTesting?: boolean;
1228
+ /** ADR-131 D3: see TaskCommandTask.engineAccountScope — the channel's
1229
+ * agents pick accounts from this scope. Absent = the task's persisted
1230
+ * scope, else `host`. */
1231
+ engineAccountScope?: string;
1191
1232
  /** ADR-057: the owner's usable MCP connections (policy "on", connected,
1192
1233
  * remote). The host writes gateway-URL MCP configs at session start. */
1193
1234
  mcpConnections?: Array<{ id: string; slug: string }>;
@@ -1358,7 +1399,7 @@ export interface HostCommands {
1358
1399
  * exactly like the local API; re-advertises capabilities on success. */
1359
1400
  engineAccountRemove(
1360
1401
  ctx: CommandContext,
1361
- input: { id: string },
1402
+ input: { id: string; scope?: string },
1362
1403
  ): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
1363
1404
  cloneRepo(
1364
1405
  ctx: CommandContext,
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Connect-phase timeout for tunnel upstream dials to a memoised address.
3
+ *
4
+ * An ADR-121 machine task's `{host, port}` comes from a 45 s memo
5
+ * (`machine-tunnel-route.ts`) that is dropped when a dial fails. A machine
6
+ * stopped or terminated while its address is memoised does not refuse the
7
+ * dial — it black-holes it: no SYN answer, so neither `connect` nor `error`
8
+ * fires until the kernel gives up on retransmits (~75 s macOS, ~127 s Linux).
9
+ * The memo's failure-driven invalidation never runs in that window and the
10
+ * pane or editor hangs on every open. An in-region machine answers a SYN in
11
+ * milliseconds, the cloud waits 30 s for the ack (`TUNNEL_ACK_TIMEOUT_MS`)
12
+ * and the forward hop 45 s for the head, so a 10 s connect bound is generous
13
+ * to a live machine and well inside what the caller will still accept.
14
+ *
15
+ * Only the connect phase is timed: the deadline is armed while the socket is
16
+ * `connecting` and disarmed by `connect` (or by the dial failing on its own).
17
+ * It is NOT an idle timeout — a quiet VNC/SSE/code-server stream after the
18
+ * handshake must live forever (see the tunnel rules in docs/agent-notes.md).
19
+ * A timeout destroys the socket with an error carrying `code = "ETIMEDOUT"`
20
+ * and `syscall = "connect"`, so the dial path's existing `'error'` handler
21
+ * runs unchanged: it acks the cloud with the same 502 shape as any other
22
+ * dial failure and invalidates the machine route memo.
23
+ */
24
+
25
+ export interface DialTarget {
26
+ readonly host: string;
27
+ readonly port: number;
28
+ }
29
+
30
+ /** The slice of `net.Socket` the timeout needs; tests pass a fake. */
31
+ export interface DialSocket {
32
+ /** True from `connect()` until the handshake completes. False on a socket
33
+ * handed over already connected (an agent's reused keep-alive socket, an
34
+ * ADR-106 pre-established apple route) — those are never armed. */
35
+ readonly connecting: boolean;
36
+ once(event: "connect" | "error" | "close", listener: () => void): unknown;
37
+ destroy(error?: Error): unknown;
38
+ }
39
+
40
+ export interface DialTimers {
41
+ setTimeout(callback: () => void, delayMs: number): NodeJS.Timeout;
42
+ clearTimeout(timer: NodeJS.Timeout): void;
43
+ }
44
+
45
+ export const CONNECT_TIMEOUT_ERROR_CODE = "ETIMEDOUT" as const;
46
+
47
+ export interface ConnectTimeoutError extends Error {
48
+ readonly code: typeof CONNECT_TIMEOUT_ERROR_CODE;
49
+ readonly syscall: "connect";
50
+ readonly address: string;
51
+ readonly port: number;
52
+ }
53
+
54
+ export function connectTimeoutError(
55
+ target: DialTarget,
56
+ timeoutMs: number,
57
+ ): ConnectTimeoutError {
58
+ return Object.assign(
59
+ new Error(
60
+ `connect ETIMEDOUT ${target.host}:${target.port} (no answer within ${timeoutMs} ms)`,
61
+ ),
62
+ {
63
+ code: CONNECT_TIMEOUT_ERROR_CODE,
64
+ syscall: "connect" as const,
65
+ address: target.host,
66
+ port: target.port,
67
+ },
68
+ );
69
+ }
70
+
71
+ export function isConnectTimeoutError(error: unknown): error is ConnectTimeoutError {
72
+ return (
73
+ error instanceof Error &&
74
+ (error as Partial<ConnectTimeoutError>).code === CONNECT_TIMEOUT_ERROR_CODE &&
75
+ (error as Partial<ConnectTimeoutError>).syscall === "connect"
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Arm a connect deadline on a socket that has started (or is about to be
81
+ * handed a started) connect. Returns whether a deadline was armed — false for
82
+ * a socket that is already connected, which has nothing to time.
83
+ */
84
+ export function armConnectTimeout(
85
+ socket: DialSocket,
86
+ target: DialTarget,
87
+ timeoutMs: number,
88
+ timers: DialTimers = { setTimeout, clearTimeout },
89
+ ): boolean {
90
+ if (!socket.connecting) return false;
91
+ const deadline = timers.setTimeout(() => {
92
+ socket.destroy(connectTimeoutError(target, timeoutMs));
93
+ }, timeoutMs);
94
+ deadline.unref?.();
95
+ const disarm = (): void => {
96
+ timers.clearTimeout(deadline);
97
+ };
98
+ socket.once("connect", disarm);
99
+ socket.once("error", disarm);
100
+ socket.once("close", disarm);
101
+ return true;
102
+ }