@runuai/host 0.9.77 → 0.9.78

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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * ADR-121: the MCP gateway reverse tunnel for machine tasks.
3
+ *
4
+ * Containers reach the host's MCP gateway via container topology
5
+ * (host.docker.internal, the vmnet gateway IP). A machine — an EC2
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).
12
+ *
13
+ * Supervision: the tunnel respawns with a short backoff while ensured —
14
+ * re-resolving the machine address each spawn, since it changes across
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.
19
+ */
20
+
21
+ import { spawn, type ChildProcess } from "node:child_process";
22
+
23
+ import { MCP_GATEWAY_PORT } from "./mcp-gateway";
24
+
25
+ const RESPAWN_DELAY_MS = 3_000;
26
+ /** After this many consecutive sub-5s deaths, slow the respawn cadence so a
27
+ * permanently unreachable machine does not spin ssh forever. */
28
+ const FAST_DEATH_THRESHOLD = 5;
29
+ const SLOW_RESPAWN_DELAY_MS = 60_000;
30
+
31
+ export interface MachineTunnelSource {
32
+ gatewayTunnelSshArgs(
33
+ machinePort: number,
34
+ hostPort: number,
35
+ ): Promise<string[]>;
36
+ }
37
+
38
+ interface TunnelState {
39
+ generation: number;
40
+ child: ChildProcess | null;
41
+ timer: ReturnType<typeof setTimeout> | null;
42
+ fastDeaths: number;
43
+ spawnedAt: number;
44
+ }
45
+
46
+ const tunnels = new Map<string, TunnelState>();
47
+
48
+ /** Idempotently keep a gateway tunnel alive for this machine task. */
49
+ export function ensureMachineGatewayTunnel(
50
+ taskId: string,
51
+ source: MachineTunnelSource,
52
+ ): void {
53
+ const existing = tunnels.get(taskId);
54
+ if (existing && (existing.child !== null || existing.timer !== null)) {
55
+ return; // live or already scheduled
56
+ }
57
+ const state: TunnelState = existing ?? {
58
+ generation: 0,
59
+ child: null,
60
+ timer: null,
61
+ fastDeaths: 0,
62
+ spawnedAt: 0,
63
+ };
64
+ state.generation += 1;
65
+ tunnels.set(taskId, state);
66
+ if (existing === undefined) {
67
+ console.log(`[machine-tunnel] ${taskId}: ensuring gateway tunnel`);
68
+ }
69
+ void spawnTunnel(taskId, source, state, state.generation);
70
+ }
71
+
72
+ async function spawnTunnel(
73
+ taskId: string,
74
+ source: MachineTunnelSource,
75
+ state: TunnelState,
76
+ generation: number,
77
+ ): Promise<void> {
78
+ if (tunnels.get(taskId) !== state || state.generation !== generation) return;
79
+ let args: string[];
80
+ try {
81
+ args = await source.gatewayTunnelSshArgs(
82
+ MCP_GATEWAY_PORT,
83
+ MCP_GATEWAY_PORT,
84
+ );
85
+ } catch (err) {
86
+ // Machine not reachable right now (stopped, address settling) — retry
87
+ // on the slow cadence; a stop/teardown clears us before it matters.
88
+ scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
89
+ if (state.fastDeaths === 0) {
90
+ console.warn(
91
+ `[machine-tunnel] ${taskId}: target unavailable (${err instanceof Error ? err.message : err}) — retrying`,
92
+ );
93
+ }
94
+ state.fastDeaths += 1;
95
+ return;
96
+ }
97
+ state.spawnedAt = Date.now();
98
+ const child = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
99
+ state.child = child;
100
+ let stderrTail = "";
101
+ child.stderr?.setEncoding("utf8");
102
+ child.stderr?.on("data", (chunk: string) => {
103
+ stderrTail = (stderrTail + chunk).slice(-2048);
104
+ });
105
+ child.once("exit", (code) => {
106
+ if (tunnels.get(taskId) !== state || state.generation !== generation) {
107
+ return;
108
+ }
109
+ state.child = null;
110
+ const lifetimeMs = Date.now() - state.spawnedAt;
111
+ if (lifetimeMs < 5_000) state.fastDeaths += 1;
112
+ else state.fastDeaths = 0;
113
+ const slow = state.fastDeaths >= FAST_DEATH_THRESHOLD;
114
+ if (state.fastDeaths === FAST_DEATH_THRESHOLD) {
115
+ console.warn(
116
+ `[machine-tunnel] ${taskId}: tunnel keeps dying (exit ${code ?? "signal"}${
117
+ stderrTail.trim() ? `: ${stderrTail.trim().slice(0, 200)}` : ""
118
+ }) — slowing retries`,
119
+ );
120
+ }
121
+ scheduleRespawn(
122
+ taskId,
123
+ source,
124
+ state,
125
+ generation,
126
+ slow ? SLOW_RESPAWN_DELAY_MS : RESPAWN_DELAY_MS,
127
+ );
128
+ });
129
+ child.once("error", () => {
130
+ // exit fires too; the handler above owns respawn.
131
+ });
132
+ }
133
+
134
+ function scheduleRespawn(
135
+ taskId: string,
136
+ source: MachineTunnelSource,
137
+ state: TunnelState,
138
+ generation: number,
139
+ delayMs: number,
140
+ ): void {
141
+ if (tunnels.get(taskId) !== state || state.generation !== generation) return;
142
+ state.timer = setTimeout(() => {
143
+ state.timer = null;
144
+ void spawnTunnel(taskId, source, state, generation);
145
+ }, delayMs);
146
+ state.timer.unref?.();
147
+ }
148
+
149
+ /** Tear the tunnel down (task stop/teardown/GC). Idempotent. */
150
+ export function stopMachineGatewayTunnel(taskId: string): void {
151
+ const state = tunnels.get(taskId);
152
+ if (!state) return;
153
+ tunnels.delete(taskId);
154
+ state.generation += 1; // invalidate in-flight spawns
155
+ if (state.timer) clearTimeout(state.timer);
156
+ if (state.child) {
157
+ state.child.kill("SIGTERM");
158
+ state.child = null;
159
+ }
160
+ }
161
+
162
+ /** Test-only view. */
163
+ export function machineGatewayTunnelActive(taskId: string): boolean {
164
+ const state = tunnels.get(taskId);
165
+ return Boolean(state && (state.child !== null || state.timer !== null));
166
+ }
@@ -854,11 +854,14 @@ function gatewayIdentity(value, slug) {
854
854
  }
855
855
 
856
856
  function sameTaskGateway(oldUrl, desiredUrl, slug) {
857
+ // Ownership is proven by the /t/<taskId>.<token>/<slug> path, not the
858
+ // origin: the gateway host is topology-dependent (container gateway IP,
859
+ // host.docker.internal, machine loopback behind a reverse tunnel) and
860
+ // migrating a task across topologies is exactly when the rewrite runs.
857
861
  const oldIdentity = gatewayIdentity(oldUrl, slug);
858
862
  const desiredIdentity = gatewayIdentity(desiredUrl, slug);
859
863
  return oldIdentity !== null &&
860
864
  desiredIdentity !== null &&
861
- oldIdentity.origin === desiredIdentity.origin &&
862
865
  oldIdentity.taskId === desiredIdentity.taskId;
863
866
  }
864
867
 
@@ -1063,7 +1066,8 @@ export async function setupMcpTaskConfig(
1063
1066
  connections: TaskMcpConnection[],
1064
1067
  engineKinds: string[],
1065
1068
  codexHomes: readonly string[] = ["/home/node/.codex"],
1066
- environment?: Pick<TaskEnvironmentHandle, "exec">,
1069
+ environment?: Pick<TaskEnvironmentHandle, "exec"> &
1070
+ Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
1067
1071
  ): Promise<boolean> {
1068
1072
  // No early return on empty: the claude adapter passes
1069
1073
  // `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
@@ -1084,7 +1088,14 @@ export async function setupMcpTaskConfig(
1084
1088
  throw new Error("invalid Codex MCP connection slug");
1085
1089
  }
1086
1090
  const acl = ensureTaskGatewayAcl(taskId, connections);
1087
- const gatewayHost = await taskGatewayHost();
1091
+ // ADR-121: a machine dials the gateway through its ssh REVERSE tunnel —
1092
+ // the host's loopback appears at the machine's own 127.0.0.1:PORT
1093
+ // (machine-gateway-tunnel.ts). host.docker.internal / vmnet addresses
1094
+ // are container topology a machine can never route.
1095
+ const gatewayHost =
1096
+ environment?.descriptor?.locator.provider === "machine"
1097
+ ? "127.0.0.1"
1098
+ : await taskGatewayHost();
1088
1099
  const urlFor = (slug: string): string =>
1089
1100
  `http://${gatewayHost}:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
1090
1101
 
@@ -90,6 +90,7 @@ import {
90
90
  type SkillExec,
91
91
  writeAgentSkills,
92
92
  } from "./skills";
93
+ import { ensureMachineGatewayTunnel } from "./machine-gateway-tunnel";
93
94
  import {
94
95
  CONTAINER_CLI_PATH,
95
96
  agentCliEnv,
@@ -1655,7 +1656,14 @@ export class Orchestrator {
1655
1656
  return environment;
1656
1657
  })();
1657
1658
  const tracked = created.then((environment) => {
1658
- if (this.isActiveChannel(channel)) channel.agentHandle = environment;
1659
+ if (this.isActiveChannel(channel)) {
1660
+ channel.agentHandle = environment;
1661
+ // Every path that touches a machine workspace settles the handle
1662
+ // here — including durable-session reattach after a host restart,
1663
+ // which spawns no sessions and runs no config writers. Ensuring the
1664
+ // gateway tunnel at the funnel keeps it alive across all of them.
1665
+ ensureMachineGatewayTunnelFor(channel.taskId, environment);
1666
+ }
1659
1667
  return environment;
1660
1668
  });
1661
1669
  channel.agentEnvironment = tracked;
@@ -5641,6 +5649,20 @@ async function quarantineWritableAppleRuntimeContainers(options: {
5641
5649
  * machine tasks (their workspace is not host-reachable). Best-effort like the
5642
5650
  * host-side writer.
5643
5651
  */
5652
+ /** Keep the MCP gateway reverse tunnel alive for a machine channel. The
5653
+ * handle exposes tunnel argv only on the machine provider; anything else is
5654
+ * a quiet no-op. */
5655
+ function ensureMachineGatewayTunnelFor(
5656
+ taskId: string,
5657
+ handle: TaskEnvironmentHandle<unknown> | null,
5658
+ ): void {
5659
+ const argv = handle?.gatewayTunnelSshArgs;
5660
+ if (!argv) return;
5661
+ ensureMachineGatewayTunnel(taskId, {
5662
+ gatewayTunnelSshArgs: argv.bind(handle),
5663
+ });
5664
+ }
5665
+
5644
5666
  async function materializeAgentCli(
5645
5667
  task: typeof schema.hostTasks.$inferSelect,
5646
5668
  roster: RosterAgent[],
@@ -448,6 +448,12 @@ function withTaskDownResult(
448
448
  ...(handle.inspectRoute
449
449
  ? { inspectRoute: () => handle.inspectRoute!() }
450
450
  : {}),
451
+ ...(handle.gatewayTunnelSshArgs
452
+ ? {
453
+ gatewayTunnelSshArgs: (machinePort: number, hostPort: number) =>
454
+ handle.gatewayTunnelSshArgs!(machinePort, hostPort),
455
+ }
456
+ : {}),
451
457
  };
452
458
  }
453
459
 
@@ -504,6 +504,31 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
504
504
  };
505
505
  }
506
506
 
507
+ /** Full ssh argv for the persistent REVERSE gateway tunnel: the host's
508
+ * loopback gateway appears at the machine's own 127.0.0.1:<machinePort>.
509
+ * Resolved per call — cloud machines change address across stop/start, so
510
+ * each (re)spawn of the tunnel re-resolves the dial target. */
511
+ async gatewayTunnelSshArgs(
512
+ machinePort: number,
513
+ hostPort: number,
514
+ ): Promise<string[]> {
515
+ const target = await this.#target();
516
+ return [
517
+ "-o", "BatchMode=yes",
518
+ "-o", "StrictHostKeyChecking=accept-new",
519
+ "-o", "ConnectTimeout=10",
520
+ "-o", "ExitOnForwardFailure=yes",
521
+ "-o", "ServerAliveInterval=15",
522
+ "-o", "ServerAliveCountMax=3",
523
+ "-i", target.keyPath,
524
+ "-p", String(target.port ?? 22),
525
+ "-l", this.#value.sshUser,
526
+ target.address,
527
+ "-N",
528
+ "-R", `127.0.0.1:${machinePort}:127.0.0.1:${hostPort}`,
529
+ ];
530
+ }
531
+
507
532
  async status(): Promise<TaskEnvironmentStatus> {
508
533
  const info = await this.#describeOwn();
509
534
  switch (info.state) {
@@ -220,6 +220,14 @@ export interface TaskEnvironmentHandle<TTeardownResult = void>
220
220
  | { kind: "unavailable" }
221
221
  | { kind: "unknown"; detail: string }
222
222
  >;
223
+ /** ADR-121, machine provider only: full ssh argv for the persistent
224
+ * REVERSE gateway tunnel that makes the host's MCP gateway answer on the
225
+ * environment's own loopback. Container providers omit it — their tasks
226
+ * route to the gateway via container topology instead. */
227
+ gatewayTunnelSshArgs?(
228
+ machinePort: number,
229
+ hostPort: number,
230
+ ): Promise<string[]>;
223
231
  }
224
232
 
225
233
  export interface TaskEnvironmentProvisionRequest<TInput, TCredentials> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.77",
3
+ "version": "0.9.78",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  removeTaskCliSecretStrict,
18
18
  storeTaskCliSecret,
19
19
  } from "../lib/agent-cli";
20
+ import { stopMachineGatewayTunnel } from "../lib/machine-gateway-tunnel";
20
21
  import {
21
22
  clearRefresh,
22
23
  injectIntoContainer,
@@ -966,9 +967,13 @@ export const hostCommands: HostCommands = {
966
967
  !orphanGc &&
967
968
  environment.descriptor.locator.provider === "machine"
968
969
  ) {
970
+ stopMachineGatewayTunnel(input.taskId);
969
971
  await environment.stop();
970
972
  return taskDownResultForInput(input, { status: "stopped" });
971
973
  }
974
+ if (environment.descriptor.locator.provider === "machine") {
975
+ stopMachineGatewayTunnel(input.taskId);
976
+ }
972
977
  return taskDownResultForInput(input, await environment.teardown());
973
978
  });
974
979
  if (result.ok) {