@runuai/host 0.9.72 → 0.9.74

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.
@@ -171,14 +171,16 @@ export class MachineDurableProcess implements LineTransport {
171
171
  (result) => {
172
172
  const stderr = Buffer.from(result.stderr).toString("utf8");
173
173
  if (stderr) this.#stderrBuf = (this.#stderrBuf + stderr).slice(-8192);
174
- if (result.exitCode !== 0) this.#finish(null);
174
+ if (result.exitCode !== 0) {
175
+ this.#finish(null, `launch exited ${result.exitCode}`);
176
+ }
175
177
  },
176
178
  (error: unknown) => {
177
179
  this.#stderrBuf = (
178
180
  this.#stderrBuf +
179
181
  (error instanceof Error ? error.message : String(error))
180
182
  ).slice(-8192);
181
- this.#finish(null);
183
+ this.#finish(null, "launch threw");
182
184
  },
183
185
  );
184
186
  } else {
@@ -306,7 +308,10 @@ export class MachineDurableProcess implements LineTransport {
306
308
  this.#stderrBuf = meta.stderrTail.slice(-8192);
307
309
  }
308
310
  if (this.#debug) this.#log(`exit meta: code ${meta.code ?? null}`);
309
- this.#finish(typeof meta.code === "number" ? meta.code : null);
311
+ this.#finish(
312
+ typeof meta.code === "number" ? meta.code : null,
313
+ "runner exit meta",
314
+ );
310
315
  }
311
316
  }
312
317
 
@@ -317,7 +322,9 @@ export class MachineDurableProcess implements LineTransport {
317
322
  this.#probing = true;
318
323
  try {
319
324
  if (!this.#sawSpawnMeta) {
320
- if (Date.now() - this.#startedAt > HEARTBEAT_STALE_MS) this.#finish(null);
325
+ if (Date.now() - this.#startedAt > HEARTBEAT_STALE_MS) {
326
+ this.#finish(null, "no spawn meta within grace");
327
+ }
321
328
  return;
322
329
  }
323
330
  const fresh = await machineHeartbeatFresh(
@@ -326,14 +333,19 @@ export class MachineDurableProcess implements LineTransport {
326
333
  HEARTBEAT_STALE_MS,
327
334
  );
328
335
  if (this.#closed || this.#detached) return;
329
- if (!fresh) this.#finish(null);
336
+ if (!fresh) this.#finish(null, "heartbeat stale");
330
337
  } finally {
331
338
  this.#probing = false;
332
339
  }
333
340
  }
334
341
 
335
- #finish(code: number | null): void {
342
+ #finish(code: number | null, reason = "unspecified"): void {
336
343
  if (this.#closed) return;
344
+ // Session deaths are load-bearing and were undiagnosable without this
345
+ // (live 2026-08-27: a 65s recycle loop with no trace). Always log.
346
+ console.warn(
347
+ `[machine-durable] session ${this.#sessionDir} finished (code ${code ?? "null"}): ${reason}`,
348
+ );
337
349
  this.#closed = true;
338
350
  this.#stopTimers();
339
351
  const tail = this.#tail;
@@ -421,7 +433,10 @@ export class MachineDurableProcess implements LineTransport {
421
433
  } catch {
422
434
  // Inbox unwritable — fall through to the grace timer.
423
435
  }
424
- this.#closeTimer = setTimeout(() => this.#finish(null), CLOSE_GRACE_MS);
436
+ this.#closeTimer = setTimeout(
437
+ () => this.#finish(null, "close grace elapsed"),
438
+ CLOSE_GRACE_MS,
439
+ );
425
440
  }
426
441
  }
427
442
 
@@ -84,12 +84,20 @@ export function buildRemoteCommand(request: {
84
84
  const command = request.argv.map(shellQuote).join(" ");
85
85
  if (request.detached) {
86
86
  // Detached durable sessions manage their own transcript IO (runner.mjs);
87
- // the launch just needs the process to survive this ssh connection:
88
- // setsid detaches the controlling terminal, streams are severed, and the
89
- // remote shell exits immediately with the launch verdict.
90
- parts.push(`${envPrefix}setsid ${command} </dev/null >/dev/null 2>&1 &`);
91
- parts.push("exit 0");
92
- return parts.join(" ");
87
+ // the launch just needs the process to survive this ssh connection AND
88
+ // the connection to close immediately. The redirects must cover the
89
+ // WHOLE background job and the job must `exec` into the payload: with
90
+ // redirects on the inner command only, the waiting shell kept the ssh
91
+ // channel's stdout/stderr open until the runner died, so sshd never saw
92
+ // EOF and every launch rode its timeout into a false spawn failure
93
+ // (live 2026-08-27: a 65s session recycle loop on the second machine
94
+ // task; the first machine's stable session was an accident of its host
95
+ // dying seconds before the timeout could mark the row closed).
96
+ const body = [
97
+ ...(request.cwd ? [`cd ${shellQuote(request.cwd)} &&`] : []),
98
+ `exec ${envPrefix}setsid ${command}`,
99
+ ].join(" ");
100
+ return `{ ${body}; } </dev/null >/dev/null 2>&1 & exit 0`;
93
101
  }
94
102
  parts.push(`exec ${envPrefix}${command}`);
95
103
  return parts.join(" ");
@@ -15,12 +15,19 @@
15
15
  * directory that really does hold this task's host-side state — its machine
16
16
  * key). Consumers that need the workspace go through the environment handle.
17
17
  */
18
+ import { execFile } from "node:child_process";
19
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { promisify } from "node:util";
23
+
18
24
  import type {
19
25
  TaskDownResult,
20
26
  TaskLaunchInput,
21
27
  TaskUpCredentials,
22
28
  TaskUpResult,
23
29
  } from "../agent";
30
+ import { sharedRoot } from "../shared-files";
24
31
  import {
25
32
  MACHINE_TASK_ENVIRONMENT_PROVIDER,
26
33
  parseMachineTaskEnvironmentLocator,
@@ -36,6 +43,9 @@ import type {
36
43
 
37
44
  /** In-machine clone budget per project. */
38
45
  const CLONE_TIMEOUT_MS = 10 * 60_000;
46
+ /** The machine port code-server listens on (dialed directly by the editor
47
+ * tunnel via inspectRoute; never published). */
48
+ export const MACHINE_CODE_SERVER_PORT = 8080;
39
49
  const GIT_STEP_TIMEOUT_MS = 60_000;
40
50
  const STEP_OUTPUT_BYTES = 256 * 1024;
41
51
 
@@ -223,6 +233,191 @@ async function cloneProject(
223
233
  return null;
224
234
  }
225
235
 
236
+ /** The curated code-server defaults (slim chrome, telemetry off, trust
237
+ * off) — the SAME seed the standard image bakes; resolved from this package
238
+ * so machine and container editors can never diverge. Machines adjust two
239
+ * keys host-side: the task's ADR-091 theme, and bash for the terminal (the
240
+ * machine payload carries no zsh). */
241
+ function editorSettingsSeed(editorTheme: string | undefined): string {
242
+ const seedPath = resolve(
243
+ dirname(fileURLToPath(import.meta.url)),
244
+ "..",
245
+ "..",
246
+ "images",
247
+ "standard",
248
+ "container",
249
+ "code-server-settings.json",
250
+ );
251
+ const seed = JSON.parse(readFileSync(seedPath, "utf8")) as Record<
252
+ string,
253
+ unknown
254
+ >;
255
+ if (editorTheme) seed["workbench.colorTheme"] = editorTheme;
256
+ seed["terminal.integrated.defaultProfile.linux"] = "bash";
257
+ return `${JSON.stringify(seed, null, 2)}\n`;
258
+ }
259
+
260
+ /** Seed the editor settings exactly once (an existing settings.json is the
261
+ * user's own state and is never touched — same contract as uai-init). */
262
+ async function seedEditorSettings(
263
+ handle: TaskEnvironmentHandle<unknown>,
264
+ editorTheme: string | undefined,
265
+ ): Promise<void> {
266
+ const destination =
267
+ "/home/node/.local/share/code-server/User/settings.json";
268
+ await handle.exec({
269
+ argv: [
270
+ "/bin/sh",
271
+ "-c",
272
+ '[ -e "$1" ] && exit 0; mkdir -p "$(dirname "$1")" && cat > "$1"',
273
+ "seed",
274
+ destination,
275
+ ],
276
+ stdin: Buffer.from(editorSettingsSeed(editorTheme)),
277
+ timeoutMs: GIT_STEP_TIMEOUT_MS,
278
+ maxOutputBytes: STEP_OUTPUT_BYTES,
279
+ });
280
+ }
281
+
282
+ const execFileAsync = promisify(execFile);
283
+ /** Shared-files snapshots are reference docs, not repos; a tree past this
284
+ * is skipped with a warning rather than ballooning host memory. */
285
+ const SHARED_FILES_TAR_MAX_BYTES = 256 * 1024 * 1024;
286
+
287
+ /**
288
+ * ADR-062 shared files on machines: containers get live bind mounts at
289
+ * /workspace/files/{org,me}; a machine shares no filesystem, so it gets a
290
+ * POINT-IN-TIME COPY at provision (tar streamed over the transport). The
291
+ * managed-hosting answer for live semantics is EFS (ADR-121); until then a
292
+ * snapshot with an honest note beats silently absent files.
293
+ * Returns a warning string when rw mode was requested (writes cannot sync
294
+ * back) or a scope was skipped; null when clean.
295
+ */
296
+ async function copySharedFiles(
297
+ handle: TaskEnvironmentHandle<unknown>,
298
+ workspacePath: string,
299
+ task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
300
+ ): Promise<string | null> {
301
+ const mode = task.sharedFiles ?? "ro";
302
+ if (mode === "off") return null;
303
+ const scopes: Array<{ host: string; destination: string }> = [];
304
+ if (task.ownerOrgId) {
305
+ scopes.push({
306
+ host: sharedRoot("org", task.ownerOrgId, task.ownerUserId),
307
+ destination: `${workspacePath}/files/org`,
308
+ });
309
+ }
310
+ scopes.push({
311
+ host: sharedRoot("me", task.ownerOrgId ?? "", task.ownerUserId),
312
+ destination: `${workspacePath}/files/me`,
313
+ });
314
+ const warnings: string[] = [];
315
+ let copiedAny = false;
316
+ for (const scope of scopes) {
317
+ if (!existsSync(scope.host) || readdirSync(scope.host).length === 0) {
318
+ continue;
319
+ }
320
+ try {
321
+ const tarball = await execFileAsync(
322
+ "tar",
323
+ ["-C", scope.host, "-cf", "-", "."],
324
+ { encoding: "buffer", maxBuffer: SHARED_FILES_TAR_MAX_BYTES },
325
+ );
326
+ const result = await handle.exec({
327
+ argv: [
328
+ "/bin/sh",
329
+ "-c",
330
+ 'mkdir -p "$1" && tar -C "$1" -xf -',
331
+ "shared",
332
+ scope.destination,
333
+ ],
334
+ stdin: tarball.stdout,
335
+ timeoutMs: 5 * 60_000,
336
+ maxOutputBytes: STEP_OUTPUT_BYTES,
337
+ });
338
+ if (result.exitCode !== 0) {
339
+ throw new Error(
340
+ `extract exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
341
+ );
342
+ }
343
+ copiedAny = true;
344
+ } catch (error) {
345
+ warnings.push(
346
+ `shared files: could not copy ${scope.destination}: ${
347
+ error instanceof Error ? error.message : String(error)
348
+ }`,
349
+ );
350
+ }
351
+ }
352
+ if (copiedAny || warnings.length === 0) {
353
+ // Marker gates the "## Shared files" preamble briefing, mirroring the
354
+ // container path's task-up marker.
355
+ await handle
356
+ .writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(""))
357
+ .catch(() => {});
358
+ }
359
+ if (mode === "rw") {
360
+ warnings.push(
361
+ "shared files on this machine task are a point-in-time copy — " +
362
+ "changes under /workspace/files will NOT sync back to the host yet",
363
+ );
364
+ }
365
+ return warnings.length > 0 ? warnings.join("\n") : null;
366
+ }
367
+
368
+ /** Launch code-server for the Editor pane, exactly once. The pgrep guard
369
+ * makes re-provision and resume idempotent; the exec chain leaves only
370
+ * code-server holding the detached job. */
371
+ async function ensureCodeServer(
372
+ handle: TaskEnvironmentHandle<unknown>,
373
+ workspacePath: string,
374
+ editorTheme: string | undefined,
375
+ ): Promise<void> {
376
+ // Settings before process: code-server reads them at startup. A failed
377
+ // seed still launches — a themed editor is worth less than one that starts.
378
+ await seedEditorSettings(handle, editorTheme).catch((error: unknown) => {
379
+ console.warn(
380
+ `[machine] editor settings seed failed: ${
381
+ error instanceof Error ? error.message : String(error)
382
+ }`,
383
+ );
384
+ });
385
+ // The guard and the launch are SEPARATE execs on purpose: any guard that
386
+ // shares a cmdline with the launch text matches itself — the bracketed
387
+ // pattern alone did not save the first combined script, because the plain
388
+ // launch line later in the same argv still contained code-server +
389
+ // --bind-addr (live 2026-08-27, second silent skip).
390
+ const running = await handle.exec({
391
+ argv: [
392
+ "/bin/sh",
393
+ "-c",
394
+ 'pgrep -f "[c]ode-server.*--bind-addr" >/dev/null 2>&1',
395
+ ],
396
+ timeoutMs: GIT_STEP_TIMEOUT_MS,
397
+ maxOutputBytes: STEP_OUTPUT_BYTES,
398
+ });
399
+ if (running.exitCode === 0) return;
400
+ const script =
401
+ "mkdir -p /home/node/.local/share/code-server && " +
402
+ "exec /home/node/.local/bin/code-server " +
403
+ "--user-data-dir /home/node/.local/share/code-server " +
404
+ "--disable-workspace-trust --auth none --disable-telemetry " +
405
+ `--disable-update-check --bind-addr 0.0.0.0:${MACHINE_CODE_SERVER_PORT} ` +
406
+ '"$1" >/tmp/code-server.log 2>&1';
407
+ const result = await handle.launchDetachedSession({
408
+ argv: ["/bin/sh", "-c", script, "code-server", workspacePath],
409
+ inheritEnv: [],
410
+ env: {},
411
+ launchTimeoutMs: 30_000,
412
+ maxOutputBytes: 64 * 1024,
413
+ });
414
+ if (result.exitCode !== 0) {
415
+ throw new Error(
416
+ `code-server launch exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
417
+ );
418
+ }
419
+ }
420
+
226
421
  /** Wrap the void-teardown machine handle so registry consumers get the
227
422
  * TaskDownResult contract the compose providers speak. */
228
423
  function withTaskDownResult(
@@ -249,6 +444,9 @@ function withTaskDownResult(
249
444
  await handle.teardown();
250
445
  return { status: "terminated" };
251
446
  },
447
+ ...(handle.inspectRoute
448
+ ? { inspectRoute: () => handle.inspectRoute!() }
449
+ : {}),
252
450
  };
253
451
  }
254
452
 
@@ -311,6 +509,36 @@ export function createMachineHostTaskEnvironmentProvider(
311
509
  throw error;
312
510
  }
313
511
 
512
+ // ADR-062 shared files: point-in-time copy (see copySharedFiles).
513
+ const sharedWarning = await copySharedFiles(handle, workspacePath, {
514
+ ...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
515
+ ownerUserId: input.task.ownerUserId,
516
+ ...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
517
+ }).catch((error: unknown) => {
518
+ console.warn(
519
+ `[machine] task ${request.taskId}: shared files copy failed: ${
520
+ error instanceof Error ? error.message : String(error)
521
+ }`,
522
+ );
523
+ return null;
524
+ });
525
+ if (sharedWarning) warnings.push(sharedWarning);
526
+
527
+ // Editor pane: code-server on the machine (same flags as uai-init's
528
+ // container launch), idempotent across provision re-runs and resume.
529
+ // Best-effort — a missing binary degrades the Editor tab, never the task.
530
+ await ensureCodeServer(
531
+ handle,
532
+ workspacePath,
533
+ input.task.previewEnv?.UAI_EDITOR_THEME,
534
+ ).catch((error: unknown) => {
535
+ console.warn(
536
+ `[machine] task ${request.taskId}: code-server launch failed: ${
537
+ error instanceof Error ? error.message : String(error)
538
+ }`,
539
+ );
540
+ });
541
+
314
542
  const machineId = parseMachineTaskEnvironmentLocator(
315
543
  handle.descriptor.locator,
316
544
  ).machineId;
@@ -319,6 +547,7 @@ export function createMachineHostTaskEnvironmentProvider(
319
547
  result: {
320
548
  composeProject: machineId,
321
549
  worktreePath: deps.taskControlDir(request.taskId),
550
+ codeServerPort: MACHINE_CODE_SERVER_PORT,
322
551
  ...(warnings.length > 0
323
552
  ? { initWarning: warnings.join("\n") }
324
553
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.72",
3
+ "version": "0.9.74",
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
@@ -475,6 +475,7 @@ export const hostCommands: HostCommands = {
475
475
  value: {
476
476
  composeProject: existingTask.composeProject ?? "",
477
477
  worktreePath: existingTask.worktreePath ?? "",
478
+ codeServerPort: existingTask.codeServerPort ?? undefined,
478
479
  },
479
480
  };
480
481
  }
package/src/main.ts CHANGED
@@ -252,6 +252,12 @@ const bridgeUrl =
252
252
  let ws: WebSocket | null = null;
253
253
  let stopping = false;
254
254
  let fatal = false;
255
+ /** Auth-rejected (4001) retry cadence. A genuinely revoked credential costs
256
+ * one connection attempt per interval; a transient rejection (the bridge's
257
+ * own DB down during a cloud restart, an auth frame lost on a slow link)
258
+ * heals without an operator visit — the 2026-08-27 outage stranded every
259
+ * connected host overnight because 4001 was treated as terminal. */
260
+ const AUTH_REJECTED_RETRY_MS = 5 * 60_000;
255
261
  let reconnectAttempt = 0;
256
262
  let connectionRequestGeneration = 0;
257
263
  let inFlight = 0;
@@ -1642,12 +1648,16 @@ async function connect(): Promise<void> {
1642
1648
  return;
1643
1649
  }
1644
1650
  if (code === 4001) {
1645
- fatal = true;
1646
1651
  markDisconnected(text || "auth rejected by bridge");
1647
1652
  console.error(
1648
- `[host-agent] auth rejected by bridge${text ? `: ${text}` : ""}`,
1653
+ `[host-agent] auth rejected by bridge${text ? `: ${text}` : ""} — retrying in ${Math.round(AUTH_REJECTED_RETRY_MS / 60_000)} minutes`,
1649
1654
  );
1650
- process.exitCode = 1;
1655
+ addHostBreadcrumb("bridge", "auth-rejected", {
1656
+ retryInMs: AUTH_REJECTED_RETRY_MS,
1657
+ });
1658
+ setTimeout(() => {
1659
+ void connect();
1660
+ }, AUTH_REJECTED_RETRY_MS);
1651
1661
  return;
1652
1662
  }
1653
1663
  const delay = reconnectDelay();
@@ -1966,6 +1976,26 @@ async function resolveTunnelTarget(
1966
1976
  // Persisted ports are meaningful only while the selected runtime is known
1967
1977
  // operational. After a daemon restart an unrelated local process could bind
1968
1978
  // a stale port before recovery refreshes the task's mappings.
1979
+ const machineTask = getHostTask(frame.taskId);
1980
+ if (machineTask?.environmentProvider === "machine") {
1981
+ // ADR-121: a machine's address is orchestrator-routable (bridge IP on
1982
+ // the Linux guinea pig, VPC-private on EC2) — editor and previews dial
1983
+ // it straight via the handle's ownership-proven route, independent of
1984
+ // the container runtime's health. macOS-local machines need a published
1985
+ // fallback (bridge IPs are not host-routable there) — recorded ADR-121
1986
+ // follow-up, not silently misrouted: the connect simply fails.
1987
+ const environment = await reconstructPersistedTaskEnvironment(machineTask);
1988
+ const route = environment?.inspectRoute
1989
+ ? await environment.inspectRoute()
1990
+ : null;
1991
+ if (!route || route.kind !== "running") return null;
1992
+ const port =
1993
+ frame.target === "editor"
1994
+ ? (machineTask.codeServerPort ?? null)
1995
+ : (frame.containerPort ?? null);
1996
+ if (!port) return null;
1997
+ return { host: route.ipv4Address, port };
1998
+ }
1969
1999
  if (containerRuntimeProblem()) return null;
1970
2000
  const task = getHostTask(frame.taskId);
1971
2001
  if (!task) return null;