@runuai/host 0.9.14 → 0.9.43

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 +33 -2
  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 +3051 -200
  53. package/lib/preview-sidecar.ts +68 -14
  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 +956 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +459 -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 +1405 -107
  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 +766 -42
  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
@@ -12,10 +12,26 @@ import { and, eq } from "drizzle-orm";
12
12
  import { getDb, schema } from "./db";
13
13
  import { sealAesGcm, openAesGcm } from "./secrets";
14
14
  import { dockerCli } from "./docker-exec";
15
+ import {
16
+ removeTaskAppContainer,
17
+ taskAppContainerName,
18
+ taskAppContainerState,
19
+ taskContainerCli,
20
+ } from "./task-container-cli";
21
+ import {
22
+ RUNTIME_AUTHORITY_ENV,
23
+ runtimeAuthorityDockerExecArgs,
24
+ } from "./runtime-authority";
25
+ import {
26
+ backgroundContainerWorkAllowed,
27
+ ContainerRuntimeUnavailableError,
28
+ requireContainerRuntimeOperational,
29
+ } from "./runtime-guard";
15
30
  import {
16
31
  configureTaskSshTransport,
17
32
  ensureTaskSshIdentity,
18
33
  } from "./git-identity";
34
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
19
35
  import {
20
36
  MAX_GITHUB_INSTALLATIONS,
21
37
  MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
@@ -84,6 +100,30 @@ const CLEAN_CONTAINER_ENV = [
84
100
  `PATH=${SAFE_CONTAINER_PATH}`,
85
101
  ] as const;
86
102
 
103
+ /** Provider-exec form of {@link SANITIZED_DOCKER_EXEC_ENV}: same overrides,
104
+ * carried as a bounded env record instead of docker `-e` flags. */
105
+ const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
106
+ Object.freeze({
107
+ HOME: "/home/node",
108
+ GH_CONFIG_DIR: MANAGED_GH_CONFIG_DIR,
109
+ PATH: SAFE_CONTAINER_PATH,
110
+ XDG_CONFIG_HOME: "",
111
+ LD_PRELOAD: "",
112
+ LD_LIBRARY_PATH: "",
113
+ DYLD_INSERT_LIBRARIES: "",
114
+ DYLD_LIBRARY_PATH: "",
115
+ GIT_CONFIG: "",
116
+ GIT_CONFIG_GLOBAL: "",
117
+ GIT_CONFIG_SYSTEM: "",
118
+ GIT_CONFIG_NOSYSTEM: "",
119
+ GIT_CONFIG_COUNT: "0",
120
+ GIT_EXEC_PATH: "",
121
+ GIT_SSH: "",
122
+ GIT_SSH_COMMAND: "",
123
+ GIT_ASKPASS: "",
124
+ SSH_ASKPASS: "",
125
+ });
126
+
87
127
  function managedContainerExecArgs(
88
128
  container: string,
89
129
  command: string[],
@@ -95,6 +135,7 @@ function managedContainerExecArgs(
95
135
  "-u",
96
136
  "node",
97
137
  ...SANITIZED_DOCKER_EXEC_ENV,
138
+ ...runtimeAuthorityDockerExecArgs(),
98
139
  container,
99
140
  ...CLEAN_CONTAINER_ENV,
100
141
  ...command,
@@ -129,6 +170,7 @@ function advanceGithubAuthGeneration(userId: string): number {
129
170
  export function runGithubConnectionTransition<T>(
130
171
  userId: string,
131
172
  transition: () => Promise<T>,
173
+ admit?: () => void,
132
174
  ): Promise<T> {
133
175
  const previous =
134
176
  githubConnectionTransitionQueues.get(userId) ?? Promise.resolve();
@@ -136,7 +178,10 @@ export function runGithubConnectionTransition<T>(
136
178
  let tail!: Promise<void>;
137
179
  result = previous
138
180
  .catch(() => {})
139
- .then(transition)
181
+ .then(() => {
182
+ admit?.();
183
+ return transition();
184
+ })
140
185
  .finally(() => {
141
186
  if (githubConnectionTransitionQueues.get(userId) === tail) {
142
187
  githubConnectionTransitionQueues.delete(userId);
@@ -458,12 +503,12 @@ async function bestEffortRevoke(
458
503
  }
459
504
 
460
505
  /**
461
- * Disconnect GitHub on this host (gh.connect.clear handler). Deletes the token
462
- * locally FIRST (so capability readers stop trusting it immediately), waits
463
- * for best-effort revocation, then waits for every live container to drop the
464
- * credential and select its disconnected transport. The surrounding
465
- * generation transition is serialized so a delayed revoke cannot target a
466
- * subsequently stored replacement.
506
+ * Disconnect GitHub on this host (gh.connect.clear handler). The surrounding
507
+ * claimed `clear` generation makes the stored row inert immediately. Keep that
508
+ * row recoverable until every live container has dropped the credential and
509
+ * selected its disconnected transport; only then delete it and best-effort
510
+ * revoke remotely. A runtime loss can therefore reject without turning a
511
+ * retryable scrub into an irreversible half-transition.
467
512
  *
468
513
  * ADR-033: only a non-expiring ACCESS token can be revoked by token
469
514
  * (`DELETE /applications/{client_id}/token` matches access tokens only — a
@@ -500,25 +545,6 @@ export async function onConnectClear(
500
545
  );
501
546
  }
502
547
 
503
- // Local capability removal is synchronous and authoritative. It must happen
504
- // even when token decryption or GitHub's revoke endpoint is unavailable.
505
- (deps.deleteLocal ?? deleteToken)(userId);
506
-
507
- // Await best-effort remote revocation while the caller still owns the
508
- // per-user transition lock. revokeAtGitHub catches its own failures; the
509
- // wrapper also contains failures from injected test/alternate implementations.
510
- if (stored?.kind === "access") {
511
- await bestEffortRevoke(
512
- userId,
513
- stored.token,
514
- deps.revoke ?? revokeAtGitHub,
515
- );
516
- } else if (stored) {
517
- console.warn(
518
- `[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
519
- );
520
- }
521
-
522
548
  // Incrementing the host-Git generation prevents any not-yet-started use of
523
549
  // an old prepared handle. Await already-started task-up operations before
524
550
  // discovering and scrubbing containers: a task may create its app container
@@ -539,6 +565,27 @@ export async function onConnectClear(
539
565
  (result): result is PromiseRejectedResult => result.status === "rejected",
540
566
  );
541
567
  if (failed) throw failed.reason;
568
+
569
+ // The claimed clear already removed this generation from every authority
570
+ // read. Delete the recoverable ciphertext only after all container scrubs
571
+ // crossed their durable boundary, so the same-generation operation can be
572
+ // retried if Docker disappeared mid-transition.
573
+ (deps.deleteLocal ?? deleteToken)(userId);
574
+
575
+ // Await best-effort remote revocation while the caller still owns the
576
+ // per-user transition lock. revokeAtGitHub catches its own failures; the
577
+ // wrapper also contains failures from injected test/alternate implementations.
578
+ if (stored?.kind === "access") {
579
+ await bestEffortRevoke(
580
+ userId,
581
+ stored.token,
582
+ deps.revoke ?? revokeAtGitHub,
583
+ );
584
+ } else if (stored) {
585
+ console.warn(
586
+ `[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
587
+ );
588
+ }
542
589
  }
543
590
 
544
591
  /** Fail-closed rollback for a generation that was claimed for set but could
@@ -1279,7 +1326,10 @@ export type DockerExec = (
1279
1326
  ) => Promise<{ status: number | null; stderr: string }>;
1280
1327
 
1281
1328
  const defaultExec: DockerExec = async (args, input) => {
1282
- const res = await dockerCli(args, { input, timeoutMs: EXEC_TIMEOUT_MS });
1329
+ const res = await taskContainerCli(args, {
1330
+ input,
1331
+ timeoutMs: EXEC_TIMEOUT_MS,
1332
+ });
1283
1333
  return { status: res.status, stderr: res.stderr };
1284
1334
  };
1285
1335
 
@@ -1292,10 +1342,38 @@ export async function injectIntoContainer(
1292
1342
  taskId: string,
1293
1343
  accessToken: string,
1294
1344
  exec: DockerExec = defaultExec,
1345
+ admitContainerWork: () => void = requireContainerRuntimeOperational,
1346
+ environment?: Pick<TaskEnvironmentHandle, "exec">,
1295
1347
  ): Promise<void> {
1296
- const container = `task-${taskId}-app-1`;
1297
- const args = managedContainerExecArgs(
1298
- container,
1348
+ const container = taskAppContainerName(taskId);
1349
+ // Provider-neutral path: same in-container `/usr/bin/env -i` command, same
1350
+ // sanitized-plus-authority environment, argv/stdin separation preserved.
1351
+ const run: (
1352
+ command: string[],
1353
+ input: string,
1354
+ ) => Promise<{ status: number | null; stderr: string }> = environment
1355
+ ? async (command, input) => {
1356
+ const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...command];
1357
+ const result = await environment.exec({
1358
+ argv: [first!, ...rest],
1359
+ user: "node",
1360
+ env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
1361
+ ...(input ? { stdin: Buffer.from(input) } : {}),
1362
+ timeoutMs: EXEC_TIMEOUT_MS,
1363
+ maxOutputBytes: 256 * 1024,
1364
+ });
1365
+ return {
1366
+ status: result.exitCode,
1367
+ stderr: Buffer.from(result.stderr).toString("utf8"),
1368
+ };
1369
+ }
1370
+ : (command, input) =>
1371
+ exec(
1372
+ managedContainerExecArgs(container, command, input.length > 0),
1373
+ input,
1374
+ );
1375
+ admitContainerWork();
1376
+ const res = await run(
1299
1377
  [
1300
1378
  "/usr/bin/gh",
1301
1379
  "auth",
@@ -1306,40 +1384,38 @@ export async function injectIntoContainer(
1306
1384
  "--git-protocol",
1307
1385
  "https",
1308
1386
  ],
1309
- true,
1387
+ `${accessToken}\n`,
1310
1388
  );
1311
- const res = await exec(args, `${accessToken}\n`);
1389
+ admitContainerWork();
1312
1390
  if (res.status !== 0) {
1313
1391
  throw new Error(`gh auth login failed in ${container}: ${res.stderr.trim()}`);
1314
1392
  }
1315
1393
 
1316
- const setup = await exec(
1317
- managedContainerExecArgs(container, [
1318
- "/usr/bin/gh",
1319
- "auth",
1320
- "setup-git",
1321
- "--hostname",
1322
- "github.com",
1323
- ]),
1394
+ admitContainerWork();
1395
+ const setup = await run(
1396
+ ["/usr/bin/gh", "auth", "setup-git", "--hostname", "github.com"],
1324
1397
  "",
1325
1398
  );
1399
+ admitContainerWork();
1326
1400
  if (setup.status !== 0) {
1327
1401
  throw new Error(
1328
1402
  `gh git credential setup failed in ${container}: ${setup.stderr.trim()}`,
1329
1403
  );
1330
1404
  }
1331
1405
 
1332
- const transport = await exec(
1333
- managedContainerExecArgs(container, [
1406
+ admitContainerWork();
1407
+ const transport = await run(
1408
+ [
1334
1409
  "/bin/sh",
1335
1410
  "-c",
1336
1411
  "/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true; " +
1337
1412
  "/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true; " +
1338
1413
  "/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'; " +
1339
1414
  "/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
1340
- ]),
1415
+ ],
1341
1416
  "",
1342
1417
  );
1418
+ admitContainerWork();
1343
1419
  if (transport.status !== 0) {
1344
1420
  throw new Error(
1345
1421
  `Git HTTPS transport setup failed in ${container}: ${transport.stderr.trim()}`,
@@ -1370,6 +1446,10 @@ export function scheduleRefresh(
1370
1446
  const timer = setTimeout(() => {
1371
1447
  timers.delete(taskId);
1372
1448
  if (githubAuthGeneration(userId) !== generation) return;
1449
+ if (!backgroundContainerWorkAllowed()) {
1450
+ scheduleGithubRetry(taskId, userId, 0, {}, generation);
1451
+ return;
1452
+ }
1373
1453
  void enqueueTaskGitAuth(taskId, userId, {}, {
1374
1454
  generation,
1375
1455
  getGeneration: () => githubAuthGeneration(userId),
@@ -1496,6 +1576,13 @@ function scheduleGithubRetry(
1496
1576
  if (githubAuthGeneration(userId) !== generation) return;
1497
1577
  const isActive = deps.taskIsActive ?? taskIsActive;
1498
1578
  if (!isActive(taskId)) return; // task ended while we backed off
1579
+ if (!backgroundContainerWorkAllowed()) {
1580
+ // Runtime downtime is not a failed GitHub attempt. Preserve the same
1581
+ // retry budget and wait for another backoff window instead of spawning
1582
+ // Docker while the host is deliberately fail-closed.
1583
+ scheduleGithubRetry(taskId, userId, attempt, deps, generation);
1584
+ return;
1585
+ }
1499
1586
  void enqueueTaskGitAuth(taskId, userId, {}, {
1500
1587
  generation,
1501
1588
  getGeneration: () => githubAuthGeneration(userId),
@@ -1534,6 +1621,9 @@ export interface SetupTaskDeps {
1534
1621
  deleteToken?: (userId: string) => void;
1535
1622
  taskIsActive?: (taskId: string) => boolean;
1536
1623
  scrub?: (taskId: string) => Promise<boolean | void>;
1624
+ /** Late-bound provider handle for the default injection path. Absent (or
1625
+ * null at call time) keeps the legacy Docker fallback. */
1626
+ environment?: () => Pick<TaskEnvironmentHandle, "exec"> | null;
1537
1627
  }
1538
1628
 
1539
1629
  /**
@@ -1546,6 +1636,7 @@ export interface SetupTaskDeps {
1546
1636
  interface SetupExecutionGuard {
1547
1637
  generation: number;
1548
1638
  isCurrent: () => boolean;
1639
+ admitContainerWork: () => void;
1549
1640
  }
1550
1641
 
1551
1642
  export async function setupTaskGithub(
@@ -1563,13 +1654,23 @@ export async function setupTaskGithub(
1563
1654
  const _hasToken = deps.hasToken ?? hasToken;
1564
1655
  const _request = deps.requestAccessToken ?? requestAccessToken;
1565
1656
  const _inject =
1566
- deps.inject ?? ((t: string, tok: string) => injectIntoContainer(t, tok));
1657
+ deps.inject ??
1658
+ ((t: string, tok: string) =>
1659
+ injectIntoContainer(
1660
+ t,
1661
+ tok,
1662
+ defaultExec,
1663
+ guard?.admitContainerWork ?? requireContainerRuntimeOperational,
1664
+ deps.environment?.() ?? undefined,
1665
+ ));
1567
1666
  if (!isCurrent()) return false;
1568
1667
  try {
1569
1668
  if (_hasToken(userId)) {
1570
1669
  const tok = await _request(userId);
1571
1670
  if (tok && isCurrent()) {
1671
+ guard?.admitContainerWork();
1572
1672
  await _inject(taskId, tok.accessToken);
1673
+ guard?.admitContainerWork();
1573
1674
  // The mutation itself cannot be interrupted, but a newer transition is
1574
1675
  // already queued behind us. Do not let this stale run arm more work.
1575
1676
  if (!isCurrent()) return false;
@@ -1618,7 +1719,9 @@ export async function setupTaskGithub(
1618
1719
  `not task owner ${userId} — skipping.`,
1619
1720
  );
1620
1721
  } else {
1722
+ guard?.admitContainerWork();
1621
1723
  await _inject(taskId, pat);
1724
+ guard?.admitContainerWork();
1622
1725
  clearGithubRetry(taskId);
1623
1726
  console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
1624
1727
  return true;
@@ -1630,6 +1733,10 @@ export async function setupTaskGithub(
1630
1733
  // A newer connect/disconnect owns the final state and is queued behind this
1631
1734
  // operation. The superseded run must neither delete its grant nor retry.
1632
1735
  if (!isCurrent()) return false;
1736
+ // Runtime unavailability is not an ordinary "GitHub not configured" or
1737
+ // retryable credential verdict. Reconciliation callers must reject so a
1738
+ // destructive set/clear transition cannot be acknowledged as complete.
1739
+ if (err instanceof ContainerRuntimeUnavailableError) throw err;
1633
1740
  const reason = err instanceof Error ? err.message : String(err);
1634
1741
  console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
1635
1742
  // A revoked refresh token can't recover by retrying — drop it so the user
@@ -1687,8 +1794,11 @@ async function checkedDockerCommand(
1687
1794
  args: string[],
1688
1795
  label: string,
1689
1796
  timeoutMs = EXEC_TIMEOUT_MS,
1797
+ admitContainerWork: () => void = requireContainerRuntimeOperational,
1690
1798
  ): Promise<Awaited<ReturnType<DockerCommand>>> {
1799
+ admitContainerWork();
1691
1800
  const result = await run(args, { timeoutMs });
1801
+ admitContainerWork();
1692
1802
  if (result.status !== 0) {
1693
1803
  throw new Error(
1694
1804
  `${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
@@ -1699,52 +1809,27 @@ async function checkedDockerCommand(
1699
1809
 
1700
1810
  export async function removeGithubFromContainer(
1701
1811
  taskId: string,
1702
- run: DockerCommand = dockerCli,
1812
+ run: DockerCommand = taskContainerCli,
1813
+ admitContainerWork: () => void = requireContainerRuntimeOperational,
1703
1814
  ): Promise<boolean> {
1704
- const container = `task-${taskId}-app-1`;
1705
- const listed = await checkedDockerCommand(
1706
- run,
1707
- [
1708
- "ps",
1709
- "--all",
1710
- "--filter",
1711
- `name=^/${container}$`,
1712
- "--format",
1713
- "{{.State}}",
1714
- ],
1715
- `inspect ${container}`,
1716
- );
1717
- const states = listed.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1718
- if (states.length === 0) return false; // no container means no credential to retain
1719
- if (states.length !== 1) {
1720
- throw new Error(`inspect ${container} returned ${states.length} containers`);
1815
+ const container = taskAppContainerName(taskId);
1816
+ admitContainerWork();
1817
+ const observed = await taskAppContainerState(container, run as never);
1818
+ admitContainerWork();
1819
+ if (observed.kind === "unknown") {
1820
+ throw new Error(`inspect ${container} failed: ${observed.detail}`);
1821
+ }
1822
+ if (observed.kind === "absent") {
1823
+ return false; // no container means no credential to retain
1721
1824
  }
1722
1825
 
1723
- const originalState = states[0];
1724
- if (originalState !== "running") {
1826
+ if (observed.kind !== "running") {
1725
1827
  // Never start or unpause untrusted task code while its old credential is
1726
1828
  // still on disk. The app container is disposable; workspace/repository
1727
- // state lives outside it and Compose recreates it on Resume.
1728
- await checkedDockerCommand(
1729
- run,
1730
- ["rm", "--force", container],
1731
- `remove credential-bearing ${container}`,
1732
- );
1733
- const remaining = await checkedDockerCommand(
1734
- run,
1735
- [
1736
- "ps",
1737
- "--all",
1738
- "--filter",
1739
- `name=^/${container}$`,
1740
- "--format",
1741
- "{{.State}}",
1742
- ],
1743
- `verify removal of ${container}`,
1744
- );
1745
- if (remaining.stdout.trim()) {
1746
- throw new Error(`remove ${container} was not verified`);
1747
- }
1829
+ // state lives outside it and provisioning recreates it on Resume.
1830
+ admitContainerWork();
1831
+ await removeTaskAppContainer(container, run as never);
1832
+ admitContainerWork();
1748
1833
  return false;
1749
1834
  }
1750
1835
 
@@ -1756,6 +1841,8 @@ export async function removeGithubFromContainer(
1756
1841
  REMOVE_MANAGED_GITHUB_AUTH,
1757
1842
  ]),
1758
1843
  `remove managed GitHub auth from ${container}`,
1844
+ EXEC_TIMEOUT_MS,
1845
+ admitContainerWork,
1759
1846
  );
1760
1847
  return true;
1761
1848
  }
@@ -1768,6 +1855,9 @@ export interface ReconcileTaskGitAuthDeps {
1768
1855
  logoutContainer?: (taskId: string) => Promise<boolean | void>;
1769
1856
  ensureSsh?: (taskId: string, userId: string) => Promise<boolean>;
1770
1857
  selectSsh?: (taskId: string) => Promise<boolean>;
1858
+ /** Last-hop Docker admission. Recovery supplies an epoch-scoped predicate
1859
+ * while the public runtime deliberately remains `checking`. */
1860
+ admitContainerWork?: () => void;
1771
1861
  }
1772
1862
 
1773
1863
  interface ReconcileInvocation {
@@ -1810,7 +1900,10 @@ function enqueueTaskGitAuth(
1810
1900
  .then(async () => {
1811
1901
  const isCurrent = () =>
1812
1902
  invocation.getGeneration() === invocation.generation;
1903
+ const admitContainerWork =
1904
+ deps.admitContainerWork ?? requireContainerRuntimeOperational;
1813
1905
  if (!isCurrent()) return false;
1906
+ admitContainerWork();
1814
1907
 
1815
1908
  const connected =
1816
1909
  deps.hasToken ?? invocation.setupDeps?.hasToken ?? hasToken;
@@ -1821,12 +1914,14 @@ function enqueueTaskGitAuth(
1821
1914
  // remove every managed account/helper first, in the same per-task queue,
1822
1915
  // then install only the current user's credential. Non-running containers
1823
1916
  // are removed rather than executed with the old credential on disk.
1824
- const canConfigureLiveTransport = await (
1917
+ const logoutContainer =
1825
1918
  deps.logoutContainer ??
1826
1919
  invocation.setupDeps?.scrub ??
1827
- removeGithubFromContainer
1828
- )(taskId);
1920
+ ((id: string) =>
1921
+ removeGithubFromContainer(id, taskContainerCli, admitContainerWork));
1922
+ const canConfigureLiveTransport = await logoutContainer(taskId);
1829
1923
  if (!isCurrent()) return false;
1924
+ admitContainerWork();
1830
1925
  if (canConfigureLiveTransport === false) {
1831
1926
  // No credential-bearing container remains. A connected credential will
1832
1927
  // be installed when Compose recreates it; disconnected state is already
@@ -1836,29 +1931,45 @@ function enqueueTaskGitAuth(
1836
1931
 
1837
1932
  if (shouldConnect) {
1838
1933
  if (deps.setupGithub) {
1934
+ admitContainerWork();
1839
1935
  const result = await deps.setupGithub(taskId, userId);
1840
- return isCurrent() ? result : false;
1936
+ if (!isCurrent()) return false;
1937
+ admitContainerWork();
1938
+ return result;
1841
1939
  }
1842
- return setupTaskGithub(
1940
+ admitContainerWork();
1941
+ const result = await setupTaskGithub(
1843
1942
  taskId,
1844
1943
  userId,
1845
1944
  invocation.setupDeps,
1846
1945
  invocation.attempt,
1847
- { generation: invocation.generation, isCurrent },
1946
+ {
1947
+ generation: invocation.generation,
1948
+ isCurrent,
1949
+ admitContainerWork,
1950
+ },
1848
1951
  );
1952
+ if (!isCurrent()) return false;
1953
+ admitContainerWork();
1954
+ return result;
1849
1955
  }
1850
1956
 
1851
1957
  // SSH remains an opt-in fallback for users who did not connect GitHub;
1852
1958
  // ensuring the identity also keeps commit/tag signing available. Never
1853
1959
  // select SSH transport unless a usable identity was actually asserted.
1960
+ admitContainerWork();
1854
1961
  const hasSshIdentity = await (
1855
1962
  deps.ensureSsh ?? ensureTaskSshIdentity
1856
1963
  )(taskId, userId);
1857
1964
  if (!isCurrent()) return false;
1965
+ admitContainerWork();
1858
1966
  if (hasSshIdentity) {
1967
+ admitContainerWork();
1859
1968
  const selected = await (
1860
1969
  deps.selectSsh ?? configureTaskSshTransport
1861
1970
  )(taskId);
1971
+ if (!isCurrent()) return false;
1972
+ admitContainerWork();
1862
1973
  if (!selected) {
1863
1974
  throw new Error(
1864
1975
  `task ${taskId}: SSH fallback transport could not be configured`,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Canonical cloud endpoint parsing shared by first enrollment, later org
3
+ * attachment, and the crash-recovery journal.
4
+ *
5
+ * The HTTP enrollment call and the persistent WebSocket connection must name
6
+ * the same authority. Accepting an arbitrary path/query for one while sending
7
+ * the other to a fixed API origin can successfully spend a one-time token and
8
+ * then leave a host that can never connect.
9
+ */
10
+
11
+ const ALLOWED_PROTOCOLS = new Set(["ws:", "wss:", "http:", "https:"]);
12
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
13
+
14
+ export interface HostCloudEndpoint {
15
+ /** Canonical value persisted as UAI_CLOUD_URL. */
16
+ readonly bridgeUrl: string;
17
+ /** Matching HTTPS/HTTP authority used by enrollment API calls. */
18
+ readonly apiOrigin: string;
19
+ readonly displayHost: string;
20
+ readonly secure: boolean;
21
+ }
22
+
23
+ export function parseHostCloudEndpoint(raw: string): HostCloudEndpoint {
24
+ if (raw.length === 0 || raw.length > 2_048) {
25
+ throw new Error("cloud URL is invalid");
26
+ }
27
+
28
+ let parsed: URL;
29
+ try {
30
+ parsed = new URL(raw);
31
+ } catch {
32
+ throw new Error("cloud URL is invalid");
33
+ }
34
+ if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
35
+ throw new Error(`unsupported cloud URL protocol: ${parsed.protocol}`);
36
+ }
37
+ if (parsed.username || parsed.password) {
38
+ throw new Error("cloud URL must not contain credentials");
39
+ }
40
+ if (parsed.pathname !== "/host") {
41
+ throw new Error("cloud URL path must be exactly /host");
42
+ }
43
+ if (parsed.search || parsed.hash) {
44
+ throw new Error("cloud URL must not contain a query or fragment");
45
+ }
46
+
47
+ const secure = parsed.protocol === "wss:" || parsed.protocol === "https:";
48
+ if (!secure && !LOOPBACK_HOSTS.has(parsed.hostname)) {
49
+ throw new Error("refusing to use a plaintext cloud URL outside loopback");
50
+ }
51
+
52
+ parsed.protocol = secure ? "wss:" : "ws:";
53
+ const bridgeUrl = parsed.toString();
54
+ const apiOrigin = `${secure ? "https:" : "http:"}//${parsed.host}`;
55
+ return Object.freeze({
56
+ bridgeUrl,
57
+ apiOrigin,
58
+ displayHost: parsed.host,
59
+ secure,
60
+ });
61
+ }
62
+