@runuai/host 0.9.148 → 0.9.149

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 (93) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +391 -69
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -15
package/lib/git-diff.ts CHANGED
@@ -196,6 +196,7 @@ async function runEnvironmentGitCommand(
196
196
  maxOutputBytes: number,
197
197
  ): Promise<string> {
198
198
  const result = await environment.exec({
199
+ projectEnvProfile: "maintenance",
199
200
  argv: ["/usr/bin/git", ...args],
200
201
  cwd,
201
202
  env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
@@ -73,6 +73,7 @@ async function environmentStep(
73
73
  stdin?: Uint8Array,
74
74
  ): Promise<{ status: number | null; stdout: string; stderr: string }> {
75
75
  const result = await environment.exec({
76
+ projectEnvProfile: "maintenance",
76
77
  argv,
77
78
  user: "node",
78
79
  env: RUNTIME_AUTHORITY_ENV,
@@ -113,6 +114,7 @@ export async function setupTaskGitIdentity(
113
114
  ];
114
115
  const res = environment
115
116
  ? await environment.exec({
117
+ projectEnvProfile: "maintenance",
116
118
  argv: command,
117
119
  user: "node",
118
120
  env: RUNTIME_AUTHORITY_ENV,
@@ -1736,6 +1736,7 @@ export async function injectIntoContainer(
1736
1736
  ? async (command, input) => {
1737
1737
  const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...command];
1738
1738
  const result = await environment.exec({
1739
+ projectEnvProfile: "maintenance",
1739
1740
  argv: [first!, ...rest],
1740
1741
  user: "node",
1741
1742
  env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
package/lib/host-env.ts CHANGED
@@ -27,14 +27,75 @@ import { getDb, schema } from "./db";
27
27
  import { sealAesGcm, openAesGcm } from "./secrets";
28
28
  import { signInBoxMode } from "./signin-box";
29
29
 
30
+ // Process-local epochs are enough for loader snapshots: every value mutation
31
+ // also happens in this process, and a host restart rebuilds both the epoch map
32
+ // and every task resolver. Keeping the counter per project lets a task ignore
33
+ // unrelated projects without retaining decrypted values in a shared cache.
34
+ const projectEnvRevisions = new Map<string, number>();
35
+
36
+ // ADR-153: what a task's view decides moves with more than values: a
37
+ // binding, and the store's gate opening or closing (a value write outside
38
+ // the records closes it too). One process-local counter covers them all.
39
+ let projectEnvAuthorityRevisionCount = 0;
40
+
41
+ export function noteProjectEnvValueMutation(projectId: string): void {
42
+ projectEnvRevisions.set(
43
+ projectId,
44
+ (projectEnvRevisions.get(projectId) ?? 0) + 1,
45
+ );
46
+ // On an activated store, a value written outside its records (an old
47
+ // frame, the credential store) closes the gate by trigger, which every
48
+ // task's view depends on, not only this project's.
49
+ projectEnvAuthorityRevisionCount += 1;
50
+ emitProjectEnvChange();
51
+ }
52
+
53
+ const projectEnvChangeListeners = new Set<() => void>();
54
+
55
+ /** Calls `listener` after any change to project env values, records,
56
+ * bindings or the store's gate; returns its removal. A listener that throws
57
+ * is skipped, never the change. */
58
+ export function onProjectEnvChange(listener: () => void): () => void {
59
+ projectEnvChangeListeners.add(listener);
60
+ return () => {
61
+ projectEnvChangeListeners.delete(listener);
62
+ };
63
+ }
64
+
65
+ function emitProjectEnvChange(): void {
66
+ for (const listener of projectEnvChangeListeners) {
67
+ try {
68
+ listener();
69
+ } catch {
70
+ // A listener's failure is its own.
71
+ }
72
+ }
73
+ }
74
+
75
+ /** Current process-local value revision for one project's env. */
76
+ export function projectEnvRevision(projectId: string): number {
77
+ return projectEnvRevisions.get(projectId) ?? 0;
78
+ }
79
+
80
+
81
+ export function noteProjectEnvAuthorityChange(): void {
82
+ projectEnvAuthorityRevisionCount += 1;
83
+ emitProjectEnvChange();
84
+ }
85
+
86
+ /** Current process-local revision of the store's records and gate. */
87
+ export function projectEnvAuthorityRevision(): number {
88
+ return projectEnvAuthorityRevisionCount;
89
+ }
90
+
30
91
  /** Pack a sealed value (ct + nonce) into the single TEXT column. */
31
- function packValue(value: string): string {
92
+ export function sealProjectEnvValueForStorage(value: string): string {
32
93
  const sealed = sealAesGcm(value);
33
94
  return `${sealed.ct.toString("base64")}.${sealed.nonce.toString("base64")}`;
34
95
  }
35
96
 
36
- /** Reverse {@link packValue}; throws if the column is malformed. */
37
- function unpackValue(valueEnc: string): string {
97
+ /** Reverse {@link sealProjectEnvValueForStorage}; throws if malformed. */
98
+ export function openProjectEnvValueFromStorage(valueEnc: string): string {
38
99
  const dot = valueEnc.indexOf(".");
39
100
  if (dot === -1) throw new Error("malformed value_enc");
40
101
  const ct = Buffer.from(valueEnc.slice(0, dot), "base64");
@@ -56,15 +117,16 @@ export function setProjectEnvVar(
56
117
  .values({
57
118
  projectId,
58
119
  key,
59
- valueEnc: packValue(value),
120
+ valueEnc: sealProjectEnvValueForStorage(value),
60
121
  updatedAt: now,
61
122
  ...(orgId ? { orgId } : {}),
62
123
  })
63
124
  .onConflictDoUpdate({
64
125
  target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key],
65
- set: { valueEnc: packValue(value), updatedAt: now, ...(orgId ? { orgId } : {}) },
126
+ set: { valueEnc: sealProjectEnvValueForStorage(value), updatedAt: now, ...(orgId ? { orgId } : {}) },
66
127
  })
67
128
  .run();
129
+ noteProjectEnvValueMutation(projectId);
68
130
  if (orgId) noteProjectOrg(projectId, orgId, { except: key });
69
131
  mirrorProjectEnvToStore(projectId, key);
70
132
  }
@@ -159,8 +221,21 @@ export function deleteProjectEnvVar(projectId: string, key: string): void {
159
221
  )
160
222
  .run();
161
223
  if (result.changes > 0) {
162
- const name = projectEnvParameterName(projectId, key);
163
- if (name) queueStoreWrite(`delete project env ${projectId}/${key}`, (store) => store.delete(name));
224
+ noteProjectEnvValueMutation(projectId);
225
+ mirrorProjectEnvDeletionToStore(projectId, key);
226
+ }
227
+ }
228
+
229
+ /** Queue only the credential-store side of a committed local deletion. */
230
+ export function mirrorProjectEnvDeletionToStore(
231
+ projectId: string,
232
+ key: string,
233
+ ): void {
234
+ const name = projectEnvParameterName(projectId, key);
235
+ if (name) {
236
+ queueStoreWrite(`delete project env ${projectId}/${key}`, (store) =>
237
+ store.delete(name),
238
+ );
164
239
  }
165
240
  }
166
241
 
@@ -202,7 +277,7 @@ export function projectEnvStoreValue(projectId: string, key: string): StoredProj
202
277
  kind: "project-env",
203
278
  projectId,
204
279
  key,
205
- value: unpackValue(row.valueEnc),
280
+ value: openProjectEnvValueFromStorage(row.valueEnc),
206
281
  updatedAt: row.updatedAt,
207
282
  ...(row.orgId ? { orgId: row.orgId } : {}),
208
283
  };
@@ -249,22 +324,24 @@ export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: str
249
324
  .values({
250
325
  projectId: value.projectId,
251
326
  key: value.key,
252
- valueEnc: packValue(value.value),
327
+ valueEnc: sealProjectEnvValueForStorage(value.value),
253
328
  updatedAt: value.updatedAt,
254
329
  storeRef,
255
330
  ...(value.orgId ? { orgId: value.orgId } : {}),
256
331
  })
257
332
  .onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
258
333
  .run();
334
+ if (result.changes > 0) noteProjectEnvValueMutation(value.projectId);
259
335
  return result.changes > 0;
260
336
  }
261
337
 
262
338
  /** Reconcile: the row's parameter vanished elsewhere; drop the row only. */
263
339
  export function deleteProjectEnvLocal(projectId: string, key: string): void {
264
- getDb()
340
+ const result = getDb()
265
341
  .delete(schema.hostProjectEnv)
266
342
  .where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
267
343
  .run();
344
+ if (result.changes > 0) noteProjectEnvValueMutation(projectId);
268
345
  }
269
346
 
270
347
  /** Sorted list of a project's env var KEY NAMES (never the values). */
@@ -297,7 +374,7 @@ export function getDecryptedForProject(projectId: string): Record<string, string
297
374
  const out: Record<string, string> = {};
298
375
  for (const row of rows) {
299
376
  try {
300
- out[row.key] = unpackValue(row.valueEnc);
377
+ out[row.key] = openProjectEnvValueFromStorage(row.valueEnc);
301
378
  } catch (err) {
302
379
  console.warn(
303
380
  `[host-env] skipping ${projectId}/${row.key}: decrypt failed (${
@@ -322,7 +399,9 @@ const RESERVED_TASK_ENV_EXACT = new Set([
322
399
  "GIT_ASKPASS", "SSH_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND",
323
400
  "HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
324
401
  "PATH", "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "NODE_OPTIONS",
325
- "NODE_PATH", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
402
+ "NODE_PATH", "SHELL", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
403
+ "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE",
404
+ "__CURSOR_SANDBOX_ENV_RESTORE", "GROK_LOGIN_ENV",
326
405
  "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN",
327
406
  "XAI_API_KEY", "PLAYWRIGHT_BROWSERS_PATH",
328
407
  ]);
@@ -332,16 +411,17 @@ export function isReservedTaskEnvName(name: string): boolean {
332
411
  if (RESERVED_TASK_ENV_EXACT.has(name)) return true;
333
412
  if (name.startsWith("GIT_CONFIG") || name.startsWith("ASDF_")) return true;
334
413
  if (name.startsWith("COREPACK_") || name.startsWith("UAI_")) return true;
414
+ if (name.startsWith("__UAI_ENV_")) return true;
335
415
  return RESERVED_TASK_ENV_NPM.test(name);
336
416
  }
337
417
 
338
418
  /**
339
- * Project env for a task that has no container env to carry it (ADR-121
340
- * machine tasks): every project's decrypted values, lead project winning a
341
- * key collision, minus the reserved names above — exactly what task-up hands
342
- * Compose for a Docker task. Live 2026-09-09: a machine task on Uai Cloud
343
- * had the Infisical identity in the store and the host, and the agent never
344
- * saw it, because only the compose path injected project env.
419
+ * Rollout-only project env for an ADR-121 machine task whose persisted mode
420
+ * is still `baked`: every project's decrypted values, lead project winning a
421
+ * key collision, minus the reserved names above. Loader tasks must use the
422
+ * declaration-pinned gateway instead. Live 2026-09-09: a machine task on Uai
423
+ * Cloud had the Infisical identity in the store and the host, and the agent
424
+ * never saw it, because only the compose path injected project env.
345
425
  */
346
426
  export function projectEnvForTask(projectIds: readonly string[]): Record<string, string> {
347
427
  const merged: Record<string, string> = {};
package/lib/lease.ts CHANGED
@@ -110,13 +110,21 @@ export function leaseAwsConfig(lease: Pick<TaskLease, "region">): string {
110
110
  return `[default]\nregion = ${lease.region}\ncredential_process = ${LEASE_CREDENTIAL_PROCESS}\n`;
111
111
  }
112
112
 
113
- /** The org and user a lease was issued for — the credential store paths a
113
+ /** The org and user a current, unexpired lease was issued for — the credential store paths a
114
114
  * sidecar may list (`/uai/orgs/<org>`, `/uai/users/<user>`; the task-machine
115
115
  * policy grants nothing above them). Null on an owned host or before the
116
116
  * first grant; `orgId` is null for a lease with no org scope. Absent from
117
117
  * the lease when the cloud predates the field. */
118
- export function leaseScope(): { orgId: string | null; userId: string } | null {
119
- if (!current || typeof current.userId !== "string") return null;
118
+ export function leaseScope(
119
+ now: number = Date.now(),
120
+ ): { orgId: string | null; userId: string } | null {
121
+ if (
122
+ !current ||
123
+ current.expiresAt <= now ||
124
+ typeof current.userId !== "string"
125
+ ) {
126
+ return null;
127
+ }
120
128
  return { orgId: current.orgId ?? null, userId: current.userId };
121
129
  }
122
130
 
@@ -4,18 +4,18 @@
4
4
  * Containers reach the host's MCP gateway via container topology
5
5
  * (host.docker.internal, the vmnet gateway IP). A machine — an EC2
6
6
  * instance behind its own network — can never route to the host, so the
7
- * host pushes the gateway TO the machine instead: one persistent
8
- * `ssh -N -R 127.0.0.1:<port>:127.0.0.1:<port>` per machine task, using
9
- * the same per-task key every other machine operation rides. Task configs
10
- * then point MCP servers at the machine's own loopback
11
- * (setupMcpTaskConfig's machine branch).
7
+ * host pushes the gateway TO the machine instead: one persistent reverse
8
+ * tunnel publishes the runner's 5877 listener as machine-loopback 5878.
9
+ * The task-local authenticated forwarder owns 5877 and relays there only
10
+ * after proving the task generation. Task configs keep pointing MCP servers
11
+ * at the machine's own loopback (setupMcpTaskConfig's machine branch).
12
12
  *
13
13
  * Supervision: the tunnel respawns with a short backoff while ensured —
14
14
  * re-resolving the machine address each spawn, since it changes across
15
15
  * stop/start — and stops when the task stops, tears down, or the process
16
- * exits. Failure is quiet-but-logged: MCP servers failing to connect is
17
- * the visible symptom, and the agent-facing degradation is already
18
- * per-server.
16
+ * exits. Initial provisioning can await an OpenSSH forwarding acknowledgement
17
+ * before starting consumers; background recovery keeps the original
18
+ * quiet-but-logged behavior.
19
19
  */
20
20
 
21
21
  import { spawn, type ChildProcess } from "node:child_process";
@@ -23,10 +23,20 @@ import { spawn, type ChildProcess } from "node:child_process";
23
23
  import { MCP_GATEWAY_PORT } from "./mcp-gateway";
24
24
 
25
25
  const RESPAWN_DELAY_MS = 3_000;
26
+ /** Long enough for the provider's 10s SSH connect deadline plus remote
27
+ * forwarding setup, but bounded so task-up fails closed rather than starting
28
+ * consumers with an unusable env loader. */
29
+ export const MACHINE_GATEWAY_TUNNEL_READY_TIMEOUT_MS = 20_000;
30
+ /** Task-local 5877 belongs to the authenticated forwarder. SSH publishes the
31
+ * runner's real gateway one hop away so unproved traffic can never dial it. */
32
+ export const MACHINE_GATEWAY_TUNNEL_PORT = 5_878;
26
33
  /** After this many consecutive sub-5s deaths, slow the respawn cadence so a
27
34
  * permanently unreachable machine does not spin ssh forever. */
28
35
  const FAST_DEATH_THRESHOLD = 5;
29
36
  const SLOW_RESPAWN_DELAY_MS = 60_000;
37
+ const READY_MARKER = "__UAI_MACHINE_GATEWAY_TUNNEL_READY__";
38
+ const READY_REMOTE_COMMAND =
39
+ `/bin/echo '${READY_MARKER}'; while :; do sleep 3600; done`;
30
40
 
31
41
  export interface MachineTunnelSource {
32
42
  gatewayTunnelSshArgs(
@@ -37,36 +47,125 @@ export interface MachineTunnelSource {
37
47
 
38
48
  interface TunnelState {
39
49
  generation: number;
50
+ machinePort: number;
40
51
  child: ChildProcess | null;
41
52
  timer: ReturnType<typeof setTimeout> | null;
53
+ spawning: boolean;
54
+ ready: boolean;
55
+ readinessWaiters: Set<ReadinessWaiter>;
42
56
  fastDeaths: number;
43
57
  spawnedAt: number;
44
58
  }
45
59
 
60
+ interface ReadinessWaiter {
61
+ resolve(): void;
62
+ reject(error: Error): void;
63
+ }
64
+
46
65
  const tunnels = new Map<string, TunnelState>();
47
66
 
48
- /** Idempotently keep a gateway tunnel alive for this machine task. */
67
+ /** Idempotently keep a gateway tunnel alive for this machine task. Legacy
68
+ * baked tasks retain direct 5877→5877 forwarding; loader tasks explicitly
69
+ * request 5878 so their authenticated task-local forwarder owns 5877. */
49
70
  export function ensureMachineGatewayTunnel(
50
71
  taskId: string,
51
72
  source: MachineTunnelSource,
73
+ machinePort = MCP_GATEWAY_PORT,
52
74
  ): void {
53
- const existing = tunnels.get(taskId);
54
- if (existing && (existing.child !== null || existing.timer !== null)) {
55
- return; // live or already scheduled
75
+ armMachineGatewayTunnel(taskId, source, machinePort);
76
+ }
77
+
78
+ /** Keep the tunnel supervised and wait until OpenSSH has acknowledged the
79
+ * remote listener. `ExitOnForwardFailure=yes` delays the remote command until
80
+ * every requested forwarding listener is installed; its stdout marker is
81
+ * therefore stronger than the local child `spawn` event. A timeout rejects
82
+ * (fail closed) but deliberately leaves the supervisor armed for recovery. */
83
+ export function ensureMachineGatewayTunnelReady(
84
+ taskId: string,
85
+ source: MachineTunnelSource,
86
+ machinePort = MCP_GATEWAY_PORT,
87
+ timeoutMs = MACHINE_GATEWAY_TUNNEL_READY_TIMEOUT_MS,
88
+ ): Promise<void> {
89
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
90
+ return Promise.reject(
91
+ new Error("machine gateway tunnel readiness timeout must be positive"),
92
+ );
93
+ }
94
+ const state = armMachineGatewayTunnel(taskId, source, machinePort);
95
+ if (state.ready) return Promise.resolve();
96
+
97
+ return new Promise<void>((resolve, reject) => {
98
+ let settled = false;
99
+ let timer: ReturnType<typeof setTimeout> | null = null;
100
+ const waiter: ReadinessWaiter = {
101
+ resolve: () => {
102
+ if (settled) return;
103
+ settled = true;
104
+ if (timer) clearTimeout(timer);
105
+ state.readinessWaiters.delete(waiter);
106
+ resolve();
107
+ },
108
+ reject: (error) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ if (timer) clearTimeout(timer);
112
+ state.readinessWaiters.delete(waiter);
113
+ reject(error);
114
+ },
115
+ };
116
+ state.readinessWaiters.add(waiter);
117
+ // Readiness can race the waiter registration when an already-running ssh
118
+ // child emits its marker in the same turn.
119
+ if (state.ready) {
120
+ waiter.resolve();
121
+ return;
122
+ }
123
+ timer = setTimeout(() => {
124
+ waiter.reject(
125
+ new Error(
126
+ `machine gateway tunnel ${taskId} did not become ready within ${timeoutMs}ms`,
127
+ ),
128
+ );
129
+ }, timeoutMs);
130
+ timer.unref?.();
131
+ });
132
+ }
133
+
134
+ function armMachineGatewayTunnel(
135
+ taskId: string,
136
+ source: MachineTunnelSource,
137
+ machinePort: number,
138
+ ): TunnelState {
139
+ let existing = tunnels.get(taskId);
140
+ if (
141
+ existing &&
142
+ (existing.child !== null || existing.timer !== null || existing.spawning)
143
+ ) {
144
+ if (existing.machinePort === machinePort) return existing;
145
+ // The persisted env mode is attach-incompatible, so this should only be
146
+ // a recovery-order correction. Replace the wrong topology immediately.
147
+ stopMachineGatewayTunnel(taskId);
148
+ existing = undefined;
56
149
  }
57
150
  const state: TunnelState = existing ?? {
58
151
  generation: 0,
152
+ machinePort,
59
153
  child: null,
60
154
  timer: null,
155
+ spawning: false,
156
+ ready: false,
157
+ readinessWaiters: new Set(),
61
158
  fastDeaths: 0,
62
159
  spawnedAt: 0,
63
160
  };
161
+ state.machinePort = machinePort;
64
162
  state.generation += 1;
65
163
  tunnels.set(taskId, state);
66
164
  if (existing === undefined) {
67
165
  console.log(`[machine-tunnel] ${taskId}: ensuring gateway tunnel`);
68
166
  }
69
167
  void spawnTunnel(taskId, source, state, state.generation);
168
+ return state;
70
169
  }
71
170
 
72
171
  async function spawnTunnel(
@@ -76,13 +175,15 @@ async function spawnTunnel(
76
175
  generation: number,
77
176
  ): Promise<void> {
78
177
  if (tunnels.get(taskId) !== state || state.generation !== generation) return;
178
+ state.spawning = true;
79
179
  let args: string[];
80
180
  try {
81
181
  args = await source.gatewayTunnelSshArgs(
82
- MCP_GATEWAY_PORT,
182
+ state.machinePort,
83
183
  MCP_GATEWAY_PORT,
84
184
  );
85
185
  } catch (err) {
186
+ state.spawning = false;
86
187
  // Machine not reachable right now (stopped, address settling) — retry
87
188
  // on the slow cadence; a stop/teardown clears us before it matters.
88
189
  scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
@@ -94,19 +195,52 @@ async function spawnTunnel(
94
195
  state.fastDeaths += 1;
95
196
  return;
96
197
  }
198
+ if (tunnels.get(taskId) !== state || state.generation !== generation) return;
199
+ let sshArgs: string[];
200
+ try {
201
+ sshArgs = readinessAwareSshArgs(
202
+ args,
203
+ state.machinePort,
204
+ MCP_GATEWAY_PORT,
205
+ );
206
+ } catch (err) {
207
+ state.spawning = false;
208
+ scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
209
+ console.warn(
210
+ `[machine-tunnel] ${taskId}: invalid ssh arguments (${err instanceof Error ? err.message : err}) — retrying`,
211
+ );
212
+ return;
213
+ }
214
+ state.spawning = false;
215
+ state.ready = false;
97
216
  state.spawnedAt = Date.now();
98
- const child = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
217
+ const child = spawn("ssh", sshArgs, {
218
+ stdio: ["ignore", "pipe", "pipe"],
219
+ });
99
220
  state.child = child;
221
+ let stdoutTail = "";
100
222
  let stderrTail = "";
223
+ child.stdout?.setEncoding("utf8");
224
+ child.stdout?.on("data", (chunk: string) => {
225
+ if (tunnels.get(taskId) !== state || state.generation !== generation) {
226
+ return;
227
+ }
228
+ stdoutTail = (stdoutTail + chunk).slice(-2048);
229
+ if (!state.ready && stdoutTail.includes(`${READY_MARKER}\n`)) {
230
+ state.ready = true;
231
+ for (const waiter of [...state.readinessWaiters]) waiter.resolve();
232
+ }
233
+ });
101
234
  child.stderr?.setEncoding("utf8");
102
235
  child.stderr?.on("data", (chunk: string) => {
103
236
  stderrTail = (stderrTail + chunk).slice(-2048);
104
237
  });
105
- child.once("exit", (code) => {
238
+ child.once("close", (code) => {
106
239
  if (tunnels.get(taskId) !== state || state.generation !== generation) {
107
240
  return;
108
241
  }
109
242
  state.child = null;
243
+ state.ready = false;
110
244
  const lifetimeMs = Date.now() - state.spawnedAt;
111
245
  if (lifetimeMs < 5_000) state.fastDeaths += 1;
112
246
  else state.fastDeaths = 0;
@@ -127,10 +261,48 @@ async function spawnTunnel(
127
261
  );
128
262
  });
129
263
  child.once("error", () => {
130
- // exit fires too; the handler above owns respawn.
264
+ // close fires too; the handler above owns respawn.
131
265
  });
132
266
  }
133
267
 
268
+ /** Replace `-N` (no remote command) with a fixed remote command. With the
269
+ * provider's `ExitOnForwardFailure=yes`, OpenSSH runs this command only after
270
+ * the server has acknowledged the `-R` listener. The loop keeps the session
271
+ * alive exactly as `-N` did. */
272
+ function readinessAwareSshArgs(
273
+ args: readonly string[],
274
+ machinePort: number,
275
+ hostPort: number,
276
+ ): string[] {
277
+ const noCommandIndex = args.indexOf("-N");
278
+ if (noCommandIndex === -1) {
279
+ throw new Error("machine gateway tunnel ssh args must include -N");
280
+ }
281
+ const exitOnForwardFailure = args.some(
282
+ (value, index) =>
283
+ value === "ExitOnForwardFailure=yes" && args[index - 1] === "-o",
284
+ );
285
+ if (!exitOnForwardFailure) {
286
+ throw new Error(
287
+ "machine gateway tunnel ssh args must enable ExitOnForwardFailure",
288
+ );
289
+ }
290
+ const expectedForward =
291
+ `127.0.0.1:${machinePort}:127.0.0.1:${hostPort}`;
292
+ const remoteForward = args.some(
293
+ (value, index) => value === expectedForward && args[index - 1] === "-R",
294
+ );
295
+ if (!remoteForward) {
296
+ throw new Error(
297
+ `machine gateway tunnel ssh args must publish ${expectedForward}`,
298
+ );
299
+ }
300
+ const readyArgs = [...args];
301
+ readyArgs.splice(noCommandIndex, 1);
302
+ readyArgs.unshift("-T", "-o", `RemoteCommand=${READY_REMOTE_COMMAND}`);
303
+ return readyArgs;
304
+ }
305
+
134
306
  function scheduleRespawn(
135
307
  taskId: string,
136
308
  source: MachineTunnelSource,
@@ -153,6 +325,9 @@ export function stopMachineGatewayTunnel(taskId: string): void {
153
325
  tunnels.delete(taskId);
154
326
  state.generation += 1; // invalidate in-flight spawns
155
327
  if (state.timer) clearTimeout(state.timer);
328
+ for (const waiter of [...state.readinessWaiters]) {
329
+ waiter.reject(new Error(`machine gateway tunnel ${taskId} was stopped`));
330
+ }
156
331
  if (state.child) {
157
332
  state.child.kill("SIGTERM");
158
333
  state.child = null;
@@ -162,5 +337,8 @@ export function stopMachineGatewayTunnel(taskId: string): void {
162
337
  /** Test-only view. */
163
338
  export function machineGatewayTunnelActive(taskId: string): boolean {
164
339
  const state = tunnels.get(taskId);
165
- return Boolean(state && (state.child !== null || state.timer !== null));
340
+ return Boolean(
341
+ state &&
342
+ (state.child !== null || state.timer !== null || state.spawning),
343
+ );
166
344
  }