@runuai/host 0.9.13 → 0.9.42

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +69 -4
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3070 -223
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1463 -109
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +871 -50
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -17,6 +17,16 @@ import { eq } from "drizzle-orm";
17
17
 
18
18
  import { getDb, schema } from "./db";
19
19
  import { dockerCli } from "./docker-exec";
20
+ import {
21
+ removeTaskAppContainer,
22
+ taskAppContainerName,
23
+ taskAppContainerState,
24
+ taskContainerCli,
25
+ } from "./task-container-cli";
26
+ import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
27
+ import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
28
+ import { assertSafeHostTaskId } from "./task-identity";
29
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
20
30
  import {
21
31
  deleteKey,
22
32
  getPublicKey,
@@ -33,22 +43,38 @@ export type DockerExec = (
33
43
  ) => Promise<{ status: number | null; stdout?: string; stderr: string }>;
34
44
 
35
45
  const defaultExec: DockerExec = async (args) => {
36
- const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
46
+ const res = await taskContainerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
37
47
  return { status: res.status, stdout: res.stdout, stderr: res.stderr };
38
48
  };
39
49
 
50
+ function managedTaskExecArgs(
51
+ user: "node" | "root",
52
+ container: string,
53
+ command: string[],
54
+ ): string[] {
55
+ return [
56
+ "exec",
57
+ "-u",
58
+ user,
59
+ ...runtimeAuthorityDockerExecArgs(),
60
+ container,
61
+ ...command,
62
+ ];
63
+ }
64
+
40
65
  export async function setupTaskGitIdentity(
41
66
  taskId: string,
42
67
  name: string | null,
43
68
  email: string | null,
44
69
  exec: DockerExec = defaultExec,
70
+ environment?: Pick<TaskEnvironmentHandle, "exec">,
45
71
  ): Promise<boolean> {
46
72
  if (!email) {
47
73
  console.log(`[git] task ${taskId}: no owner email — leaving git identity`);
48
74
  return false;
49
75
  }
50
76
  const displayName = name && name.trim() ? name.trim() : email;
51
- const container = `task-${taskId}-app-1`;
77
+ const container = taskAppContainerName(taskId);
52
78
  // Values are passed as argv (never interpolated into a shell string), so a
53
79
  // name/email can't shell-inject.
54
80
  const entries: ReadonlyArray<readonly [string, string]> = [
@@ -56,17 +82,22 @@ export async function setupTaskGitIdentity(
56
82
  ["user.email", email],
57
83
  ];
58
84
  for (const [key, value] of entries) {
59
- const res = await exec([
60
- "exec",
61
- "-u",
62
- "node",
63
- container,
64
- "/usr/bin/git",
65
- "config",
66
- "--global",
67
- key,
68
- value,
69
- ]);
85
+ const command = ["/usr/bin/git", "config", "--global", key, value] as [
86
+ string,
87
+ ...string[],
88
+ ];
89
+ const res = environment
90
+ ? await environment.exec({
91
+ argv: command,
92
+ user: "node",
93
+ env: RUNTIME_AUTHORITY_ENV,
94
+ timeoutMs: EXEC_TIMEOUT_MS,
95
+ maxOutputBytes: 64 * 1024,
96
+ }).then((result) => ({
97
+ status: result.exitCode,
98
+ stderr: Buffer.from(result.stderr).toString("utf8"),
99
+ }))
100
+ : await exec(managedTaskExecArgs("node", container, command));
70
101
  if (res.status !== 0) {
71
102
  console.warn(
72
103
  `[git] task ${taskId}: git config ${key} failed: ${res.stderr.trim()}`,
@@ -97,15 +128,11 @@ export async function configureTaskSshTransport(
97
128
  taskId: string,
98
129
  exec: DockerExec = defaultExec,
99
130
  ): Promise<boolean> {
100
- const res = await exec([
101
- "exec",
102
- "-u",
103
- "node",
104
- `task-${taskId}-app-1`,
131
+ const res = await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), [
105
132
  "/bin/sh",
106
133
  "-c",
107
134
  SSH_TRANSPORT_CONFIG,
108
- ]);
135
+ ]));
109
136
  if (res.status !== 0) {
110
137
  console.warn(
111
138
  `[ssh] task ${taskId}: SSH transport fallback setup failed: ${res.stderr.trim()}`,
@@ -127,19 +154,33 @@ export async function injectSshIdentity(
127
154
  keyPath: string,
128
155
  exec: DockerExec = defaultExec,
129
156
  ): Promise<boolean> {
130
- const container = `task-${taskId}-app-1`;
157
+ const container = taskAppContainerName(taskId);
131
158
  const steps: ReadonlyArray<readonly [string, string[]]> = [
132
- ["mkdir ~/.ssh", ["exec", "-u", "root", container, "/bin/mkdir", "-p", "/home/node/.ssh"]],
159
+ [
160
+ "mkdir ~/.ssh",
161
+ managedTaskExecArgs("root", container, [
162
+ "/bin/mkdir",
163
+ "-p",
164
+ "/home/node/.ssh",
165
+ ]),
166
+ ],
133
167
  ["copy key", ["cp", keyPath, `${container}:/home/node/.ssh/id_ed25519`]],
134
168
  ["copy pubkey", ["cp", `${keyPath}.pub`, `${container}:/home/node/.ssh/id_ed25519.pub`]],
135
169
  [
136
170
  "own + chmod",
137
- [
138
- "exec", "-u", "root", container, "/bin/sh", "-c",
171
+ managedTaskExecArgs("root", container, [
172
+ "/bin/sh", "-c",
139
173
  "/bin/chown -R node:node /home/node/.ssh && /bin/chmod 700 /home/node/.ssh && /bin/chmod 600 /home/node/.ssh/id_ed25519",
140
- ],
174
+ ]),
175
+ ],
176
+ [
177
+ "SSH signing config",
178
+ managedTaskExecArgs("node", container, [
179
+ "/bin/sh",
180
+ "-c",
181
+ SSH_SIGNING_CONFIG,
182
+ ]),
141
183
  ],
142
- ["SSH signing config", ["exec", "-u", "node", container, "/bin/sh", "-c", SSH_SIGNING_CONFIG]],
143
184
  ];
144
185
  for (const [label, args] of steps) {
145
186
  const res = await exec(args);
@@ -167,6 +208,34 @@ interface SshEnsureInFlight {
167
208
  }
168
209
 
169
210
  const sshEnsureInFlight = new Map<string, SshEnsureInFlight>();
211
+ const sshDeleteInFlight = new Map<string, Promise<void>>();
212
+ /** Durable for this host process: an orphan id is immutable/non-reused, and
213
+ * no later channel/recovery continuation may materialize its private key. */
214
+ const retiredTaskSshEnsures = new Set<string>();
215
+
216
+ /** Fence future task SSH ensures and join the exact older materialization, if
217
+ * any. This is stronger than deleting the directory once: channel startup and
218
+ * runtime recovery intentionally launch SSH reconciliation in the background,
219
+ * and an older health probe can otherwise resume by writing the key after GC
220
+ * has already checked absence. Never rejects; strict removal is the final
221
+ * filesystem authority and will surface any cleanup failure to the cloud. */
222
+ export async function retireTaskSshIdentityEnsures(
223
+ taskId: string,
224
+ ): Promise<void> {
225
+ assertSafeHostTaskId(taskId);
226
+ retiredTaskSshEnsures.add(taskId);
227
+ for (;;) {
228
+ const current = sshEnsureInFlight.get(taskId);
229
+ if (!current) return;
230
+ await current.promise.catch(() => false);
231
+ // The tracked promise includes write/copy/finally-remove. Its outer caller
232
+ // performs only authority bookkeeping after this point, so clearing the
233
+ // matching entry is safe and avoids depending on microtask ordering.
234
+ if (sshEnsureInFlight.get(taskId) === current) {
235
+ sshEnsureInFlight.delete(taskId);
236
+ }
237
+ }
238
+ }
170
239
 
171
240
  function sshPublicKeyFingerprint(publicKey: string): string | null {
172
241
  const [, encoded] = publicKey.trim().split(/\s+/, 3);
@@ -190,15 +259,11 @@ async function sshKeyHealthy(
190
259
  expectedPublicKey: string,
191
260
  exec: DockerExec,
192
261
  ): Promise<boolean> {
193
- const res = await exec([
194
- "exec",
195
- "-u",
196
- "node",
197
- `task-${taskId}-app-1`,
262
+ const res = await exec(managedTaskExecArgs("node", taskAppContainerName(taskId), [
198
263
  "/bin/sh",
199
264
  "-c",
200
265
  "test -r /home/node/.ssh/id_ed25519 && /bin/cat /home/node/.ssh/id_ed25519.pub",
201
- ]);
266
+ ]));
202
267
  const expectedFingerprint = sshPublicKeyFingerprint(expectedPublicKey);
203
268
  const actualFingerprint = sshPublicKeyFingerprint(res.stdout ?? "");
204
269
  return (
@@ -226,6 +291,14 @@ export async function ensureTaskSshIdentity(
226
291
  exec: DockerExec = defaultExec,
227
292
  deps: EnsureTaskSshIdentityDeps = {},
228
293
  ): Promise<boolean> {
294
+ if (retiredTaskSshEnsures.has(taskId)) return false;
295
+ if (ownerUserId) {
296
+ const deleting = sshDeleteInFlight.get(ownerUserId);
297
+ if (deleting) {
298
+ await deleting.catch(() => {});
299
+ return ensureTaskSshIdentity(taskId, ownerUserId, exec, deps);
300
+ }
301
+ }
229
302
  const publicKeyForUser = deps.publicKeyForUser ?? getPublicKey;
230
303
  const expectedPublicKey = ownerUserId
231
304
  ? publicKeyForUser(ownerUserId)
@@ -285,15 +358,15 @@ async function ensureTaskSshIdentityOnce(
285
358
  // Fast path: the DB-authoritative key is already good → re-assert only the
286
359
  // cheap signing config and skip docker cp churn.
287
360
  if (await sshKeyHealthy(taskId, expectedPublicKey, exec)) {
288
- const configured = await exec([
289
- "exec",
290
- "-u",
291
- "node",
292
- `task-${taskId}-app-1`,
361
+ const configured = await exec(managedTaskExecArgs(
362
+ "node",
363
+ taskAppContainerName(taskId),
364
+ [
293
365
  "/bin/sh",
294
366
  "-c",
295
367
  SSH_SIGNING_CONFIG,
296
- ]);
368
+ ],
369
+ ));
297
370
  return configured.status === 0;
298
371
  }
299
372
 
@@ -378,43 +451,64 @@ export async function removeTaskSshIdentityFromContainer(
378
451
  taskId: string,
379
452
  exec: DockerExec = defaultExec,
380
453
  ): Promise<void> {
381
- const container = `task-${taskId}-app-1`;
382
- const inspectArgs = [
383
- "ps",
384
- "--all",
385
- "--filter",
386
- `name=^/${container}$`,
387
- "--format",
388
- "{{.State}}",
389
- ];
390
- const listed = await checkedSshDocker(
391
- exec,
392
- inspectArgs,
393
- `inspect ${container}`,
394
- );
395
- const states = (listed.stdout ?? "")
396
- .split(/\r?\n/)
397
- .map((state) => state.trim())
398
- .filter(Boolean);
399
- if (states.length === 0) return;
400
- if (states.length !== 1) {
401
- throw new Error(`inspect ${container} returned ${states.length} containers`);
402
- }
403
- if (states[0] !== "running") {
404
- await checkedSshDocker(
405
- exec,
406
- ["rm", "--force", container],
407
- `remove SSH credential-bearing ${container}`,
408
- );
409
- const remaining = await checkedSshDocker(
454
+ const container = taskAppContainerName(taskId);
455
+ if (exec === defaultExec) {
456
+ // Backend-aware state/removal: the Apple CLI has no `ps`/`rm` verbs
457
+ // (review 2026-08-18 round 3, verified live: "Plugin 'container-ps' not
458
+ // found"), so the shared helpers own the per-backend translation and
459
+ // the whole-line absence proofs. The injected-exec seam below keeps
460
+ // scripting docker-shaped answers for tests.
461
+ const state = await taskAppContainerState(container);
462
+ if (state.kind === "absent") return;
463
+ if (state.kind === "unknown") {
464
+ throw new Error(`inspect ${container} failed: ${state.detail}`);
465
+ }
466
+ if (state.kind === "stopped") {
467
+ // Non-running credential-bearing containers are removed without
468
+ // executing their filesystem; removal is absence-verified inside.
469
+ await removeTaskAppContainer(container);
470
+ return;
471
+ }
472
+ } else {
473
+ const inspectArgs = [
474
+ "ps",
475
+ "--all",
476
+ "--filter",
477
+ `name=^/${container}$`,
478
+ "--format",
479
+ "{{.State}}",
480
+ ];
481
+ const listed = await checkedSshDocker(
410
482
  exec,
411
483
  inspectArgs,
412
- `verify removal of ${container}`,
484
+ `inspect ${container}`,
413
485
  );
414
- if ((remaining.stdout ?? "").trim()) {
415
- throw new Error(`remove ${container} was not verified`);
486
+ const states = (listed.stdout ?? "")
487
+ .split(/\r?\n/)
488
+ .map((state) => state.trim())
489
+ .filter(Boolean);
490
+ if (states.length === 0) return;
491
+ if (states.length !== 1) {
492
+ throw new Error(
493
+ `inspect ${container} returned ${states.length} containers`,
494
+ );
495
+ }
496
+ if (states[0] !== "running") {
497
+ await checkedSshDocker(
498
+ exec,
499
+ ["rm", "--force", container],
500
+ `remove SSH credential-bearing ${container}`,
501
+ );
502
+ const remaining = await checkedSshDocker(
503
+ exec,
504
+ inspectArgs,
505
+ `verify removal of ${container}`,
506
+ );
507
+ if ((remaining.stdout ?? "").trim()) {
508
+ throw new Error(`remove ${container} was not verified`);
509
+ }
510
+ return;
416
511
  }
417
- return;
418
512
  }
419
513
 
420
514
  await checkedSshDocker(
@@ -424,6 +518,7 @@ export async function removeTaskSshIdentityFromContainer(
424
518
  "-u",
425
519
  "node",
426
520
  ...SANITIZED_SSH_EXEC_ENV,
521
+ ...runtimeAuthorityDockerExecArgs(),
427
522
  container,
428
523
  "/usr/bin/env",
429
524
  "-i",
@@ -458,30 +553,47 @@ function sshCredentialTaskIdsForUser(userId: string): string[] {
458
553
  }
459
554
 
460
555
  export interface DeleteUserSshIdentityDeps {
556
+ /** Synchronous fail-closed check that must pass before authority changes. */
557
+ preflight?: () => void;
461
558
  deleteStored?: (userId: string) => void;
462
559
  taskIds?: (userId: string) => string[];
463
560
  removeFromContainer?: (taskId: string) => Promise<void>;
464
561
  }
465
562
 
466
- /** Delete authority first, then drain any older ensure and remove all copied
467
- * material before acknowledging ssh.key.delete. */
563
+ /** Serialize deletion against new ensures, drain every older ensure, scrub all
564
+ * copied material, and only then delete authority. A failed scrub leaves the
565
+ * key authoritative and retryable instead of creating an unrecoverable split
566
+ * where host authority is gone but a valid private key remains in a container. */
468
567
  export async function deleteUserSshIdentity(
469
568
  userId: string,
470
569
  deps: DeleteUserSshIdentityDeps = {},
471
570
  ): Promise<void> {
472
- (deps.deleteStored ?? deleteKey)(userId);
473
- const taskIds = (deps.taskIds ?? sshCredentialTaskIdsForUser)(userId);
474
- const removeFromContainer =
475
- deps.removeFromContainer ?? removeTaskSshIdentityFromContainer;
476
- const settled = await Promise.allSettled(
477
- taskIds.map(async (taskId) => {
478
- await sshEnsureInFlight.get(taskId)?.promise.catch(() => {});
479
- removeTaskIdentity(taskId);
480
- await removeFromContainer(taskId);
481
- }),
482
- );
483
- const failed = settled.find(
484
- (result): result is PromiseRejectedResult => result.status === "rejected",
485
- );
486
- if (failed) throw failed.reason;
571
+ const existing = sshDeleteInFlight.get(userId);
572
+ if (existing) return existing;
573
+ const run = async (): Promise<void> => {
574
+ deps.preflight?.();
575
+ const taskIds = (deps.taskIds ?? sshCredentialTaskIdsForUser)(userId);
576
+ const removeFromContainer =
577
+ deps.removeFromContainer ?? removeTaskSshIdentityFromContainer;
578
+ const settled = await Promise.allSettled(
579
+ taskIds.map(async (taskId) => {
580
+ await sshEnsureInFlight.get(taskId)?.promise.catch(() => {});
581
+ removeTaskIdentity(taskId);
582
+ await removeFromContainer(taskId);
583
+ }),
584
+ );
585
+ const failed = settled.find(
586
+ (result): result is PromiseRejectedResult => result.status === "rejected",
587
+ );
588
+ if (failed) throw failed.reason;
589
+ (deps.deleteStored ?? deleteKey)(userId);
590
+ };
591
+ let tracked!: Promise<void>;
592
+ tracked = run().finally(() => {
593
+ if (sshDeleteInFlight.get(userId) === tracked) {
594
+ sshDeleteInFlight.delete(userId);
595
+ }
596
+ });
597
+ sshDeleteInFlight.set(userId, tracked);
598
+ return tracked;
487
599
  }