@runuai/host 0.9.150 → 0.9.152

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 (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1837 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +650 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -11,6 +11,7 @@ import { and, eq } from "drizzle-orm";
11
11
 
12
12
  import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
13
13
  import { getDb, schema } from "./db";
14
+ import { githubApiOrigin } from "./github-origin";
14
15
  import { sealAesGcm, openAesGcm } from "./secrets";
15
16
  import { dockerCli } from "./docker-exec";
16
17
  import {
@@ -33,6 +34,7 @@ import {
33
34
  ensureTaskSshIdentity,
34
35
  } from "./git-identity";
35
36
  import type { TaskEnvironmentHandle } from "./task-environment/types";
37
+ import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
36
38
  import { sidecarMode } from "./sidecar";
37
39
  import { signInBoxMode } from "./signin-box";
38
40
  import {
@@ -95,7 +97,7 @@ const SANITIZED_DOCKER_EXEC_ENV = [
95
97
  "SSH_ASKPASS=",
96
98
  ] as const;
97
99
 
98
- const CLEAN_CONTAINER_ENV = [
100
+ export const CLEAN_CONTAINER_ENV = [
99
101
  "/usr/bin/env",
100
102
  "-i",
101
103
  "HOME=/home/node",
@@ -105,7 +107,7 @@ const CLEAN_CONTAINER_ENV = [
105
107
 
106
108
  /** Provider-exec form of {@link SANITIZED_DOCKER_EXEC_ENV}: same overrides,
107
109
  * carried as a bounded env record instead of docker `-e` flags. */
108
- const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
110
+ export const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
109
111
  Object.freeze({
110
112
  HOME: "/home/node",
111
113
  GH_CONFIG_DIR: MANAGED_GH_CONFIG_DIR,
@@ -1043,29 +1045,23 @@ interface GitHubListDeps {
1043
1045
  requestAccessToken?: typeof requestAccessToken;
1044
1046
  fetch?: typeof fetch;
1045
1047
  credentialGenerationMatches?: typeof githubCredentialGenerationMatches;
1048
+ /** When the whole operation must end (the token's read included). Absent,
1049
+ * the listing alone gets its own bound once a token is in hand. */
1050
+ deadlineAt?: number;
1046
1051
  }
1047
1052
 
1048
1053
  /**
1049
- * GitHub REST origin. Mirrors the cloud's lib/github/origins.ts contract,
1050
- * including its most important property: production ignores the override
1051
- * entirely, so a staged URL can never be pointed at a real user's token.
1054
+ * The token could not be read where it is kept (the sign-in box's store):
1055
+ * a fixed message, and a transient code, never the store's own words.
1052
1056
  */
1053
- function githubApiOrigin(): string {
1054
- const fallback = "https://api.github.com";
1055
- if (process.env.NODE_ENV === "production") return fallback;
1056
- const configured = process.env.UAI_GITHUB_API_URL?.trim();
1057
- if (!configured) return fallback;
1058
- const url = new URL(configured);
1059
- if (
1060
- (url.protocol !== "http:" && url.protocol !== "https:") ||
1061
- url.username ||
1062
- url.password
1063
- ) {
1064
- throw new Error("UAI_GITHUB_API_URL must be an HTTP(S) origin");
1057
+ export class GitHubTokenUnreadableError extends Error {
1058
+ constructor() {
1059
+ super("the GitHub sign-in could not be read");
1060
+ this.name = "GitHubTokenUnreadableError";
1065
1061
  }
1066
- return url.origin;
1067
1062
  }
1068
1063
 
1064
+
1069
1065
  /**
1070
1066
  * Every installation GitHub currently lists for this user, read with the token
1071
1067
  * this host already holds — the host-side half of `gh.installations.list`.
@@ -1099,6 +1095,9 @@ export async function listUserInstallationIds(
1099
1095
  try {
1100
1096
  tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
1101
1097
  } catch (err) {
1098
+ if (err instanceof GitHubTokenUnreadableError) {
1099
+ throw new GitHubRepositoryAccessError("github_unavailable", err.message);
1100
+ }
1102
1101
  const reason = err instanceof Error ? err.message : String(err);
1103
1102
  throw new GitHubRepositoryAccessError(
1104
1103
  isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
@@ -1112,7 +1111,7 @@ export async function listUserInstallationIds(
1112
1111
  );
1113
1112
  }
1114
1113
  const ids = new Set<number>();
1115
- const deadline = Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
1114
+ const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
1116
1115
  let res: Response;
1117
1116
  try {
1118
1117
  res = await (deps.fetch ?? fetch)(
@@ -1226,6 +1225,9 @@ export async function listUserInstallationRepositoryIds(
1226
1225
  try {
1227
1226
  tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
1228
1227
  } catch (err) {
1228
+ if (err instanceof GitHubTokenUnreadableError) {
1229
+ throw new GitHubRepositoryAccessError("github_unavailable", err.message);
1230
+ }
1229
1231
  const reason = err instanceof Error ? err.message : String(err);
1230
1232
  throw new GitHubRepositoryAccessError(
1231
1233
  isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
@@ -1240,7 +1242,7 @@ export async function listUserInstallationRepositoryIds(
1240
1242
  }
1241
1243
  const repositoryIds = new Set<number>();
1242
1244
  let truncated = false;
1243
- const deadline = Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
1245
+ const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
1244
1246
  for (let page = 1; page <= REPOSITORY_PAGE_CAP; page++) {
1245
1247
  let res: Response;
1246
1248
  try {
@@ -1337,6 +1339,21 @@ export function clearAllAccessCache(): void {
1337
1339
  githubAuthGenerations.clear();
1338
1340
  }
1339
1341
 
1342
+ /**
1343
+ * ADR-154 §13, the cutover purge, once the token rows are gone: no GitHub
1344
+ * token stays in this process. Each user with a cached access token or an
1345
+ * exchange under way moves to a new generation, as at a sign-out, which
1346
+ * drops both; an exchange that ends later then caches and stores nothing.
1347
+ * The sign-in box's cache goes too. Unlike the test hook above, no
1348
+ * generation goes back, so nothing fenced off before is let in again.
1349
+ */
1350
+ export function forgetGithubTokensInMemory(): void {
1351
+ for (const userId of new Set([...accessCache.keys(), ...inflightExchanges.keys()])) {
1352
+ advanceGithubAuthGeneration(userId);
1353
+ }
1354
+ boxTokens.clear();
1355
+ }
1356
+
1340
1357
  export async function requestAccessToken(
1341
1358
  userId: string,
1342
1359
  ): Promise<{ accessToken: string; expiresAt: number | null } | null> {
@@ -2288,11 +2305,29 @@ export function reconcileTaskGitAuth(
2288
2305
  });
2289
2306
  }
2290
2307
 
2308
+ /**
2309
+ * ADR-154: the runner's own GitHub sign-in (or its SSH key) reaches a task
2310
+ * only while the runner is on its own credentials. Leaving them waits for a
2311
+ * reconciliation already under way, and each of its steps, the last write
2312
+ * included, is fenced on them: none starts once leaving has begun.
2313
+ */
2291
2314
  function enqueueTaskGitAuth(
2292
2315
  taskId: string,
2293
2316
  userId: string,
2294
2317
  deps: ReconcileTaskGitAuthDeps,
2295
2318
  invocation: ReconcileInvocation,
2319
+ ): Promise<boolean> {
2320
+ return (
2321
+ whileOnOwnCredentials(() => enqueueOwnTaskGitAuth(taskId, userId, deps, invocation)) ??
2322
+ Promise.resolve(false)
2323
+ );
2324
+ }
2325
+
2326
+ function enqueueOwnTaskGitAuth(
2327
+ taskId: string,
2328
+ userId: string,
2329
+ deps: ReconcileTaskGitAuthDeps,
2330
+ invocation: ReconcileInvocation,
2296
2331
  ): Promise<boolean> {
2297
2332
  const previous = gitAuthReconcileQueues.get(taskId) ?? Promise.resolve(false);
2298
2333
  let queued: Promise<boolean>;
@@ -2300,7 +2335,7 @@ function enqueueTaskGitAuth(
2300
2335
  .catch(() => false)
2301
2336
  .then(async () => {
2302
2337
  const isCurrent = () =>
2303
- invocation.getGeneration() === invocation.generation;
2338
+ invocation.getGeneration() === invocation.generation && onOwnCredentials();
2304
2339
  const admitContainerWork =
2305
2340
  deps.admitContainerWork ?? requireContainerRuntimeOperational;
2306
2341
  if (!isCurrent()) return false;
package/lib/host-env.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  queueStoreWrite,
24
24
  type CredentialStore,
25
25
  } from "./credential-store";
26
+ import { ENGINE_ENV_CREDENTIAL_KEYS } from "../src/cloud-credentials";
26
27
  import { getDb, schema } from "./db";
27
28
  import { sealAesGcm, openAesGcm } from "./secrets";
28
29
  import { signInBoxMode } from "./signin-box";
@@ -83,6 +84,14 @@ export function noteProjectEnvAuthorityChange(): void {
83
84
  emitProjectEnvChange();
84
85
  }
85
86
 
87
+ /** A task's project env changed outside this runner's store (ADR-154: the
88
+ * cloud's task replica, or the composer starting or ending serving it):
89
+ * whoever follows changes (the proxy's trust delivery) looks again. The
90
+ * store's own revisions do not move; nothing caches a view of the cloud's. */
91
+ export function noteProjectEnvViewChange(): void {
92
+ emitProjectEnvChange();
93
+ }
94
+
86
95
  /** Current process-local revision of the store's records and gate. */
87
96
  export function projectEnvAuthorityRevision(): number {
88
97
  return projectEnvAuthorityRevisionCount;
@@ -402,15 +411,19 @@ const RESERVED_TASK_ENV_EXACT = new Set([
402
411
  "NODE_PATH", "SHELL", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
403
412
  "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE",
404
413
  "__CURSOR_SANDBOX_ENV_RESTORE", "GROK_LOGIN_ENV",
405
- "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN",
406
- "XAI_API_KEY", "PLAYWRIGHT_BROWSERS_PATH",
414
+ // Every name an env-token engine's account may set (ADR-154's contract):
415
+ // a project value never stands in for an engine's credential.
416
+ ...Object.values(ENGINE_ENV_CREDENTIAL_KEYS).flat(),
417
+ "PLAYWRIGHT_BROWSERS_PATH",
407
418
  ]);
408
419
  const RESERVED_TASK_ENV_NPM = /^npm_config_(prefix|cache|logs[_-]dir|umask)$/i;
420
+ /** Toolchain prefixes, in any case, as task-up.sh matches them. */
421
+ const RESERVED_TASK_ENV_TOOLCHAIN = /^(asdf|corepack)_/i;
409
422
 
410
423
  export function isReservedTaskEnvName(name: string): boolean {
411
424
  if (RESERVED_TASK_ENV_EXACT.has(name)) return true;
412
- if (name.startsWith("GIT_CONFIG") || name.startsWith("ASDF_")) return true;
413
- if (name.startsWith("COREPACK_") || name.startsWith("UAI_")) return true;
425
+ if (name.startsWith("GIT_CONFIG") || RESERVED_TASK_ENV_TOOLCHAIN.test(name)) return true;
426
+ if (name.startsWith("UAI_")) return true;
414
427
  if (name.startsWith("__UAI_ENV_")) return true;
415
428
  return RESERVED_TASK_ENV_NPM.test(name);
416
429
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Whether this host was launched for development or tests, or is an
3
+ * installed product. An installed service leaves NODE_ENV unset, so "not
4
+ * production" is the wrong test; these markers are the positive signals of a
5
+ * development launch. Telemetry's default DSN (lib/obs) and any development
6
+ * override of where credentials are sent (lib/github-origin) both read this.
7
+ */
8
+
9
+ export type LaunchEnv = {
10
+ NODE_ENV?: string;
11
+ UAI_AGENT_FROM_REPO?: string;
12
+ UAI_LAUNCHED_FROM_REPO?: string;
13
+ };
14
+
15
+ /** A development or test launch: an explicit dev/test NODE_ENV, or a host
16
+ * started from a repository checkout (src/load-env.ts stamps that). */
17
+ export function isDevelopmentLaunch(env: LaunchEnv = process.env): boolean {
18
+ return (
19
+ env.NODE_ENV === "development" ||
20
+ env.NODE_ENV === "test" ||
21
+ env.UAI_AGENT_FROM_REPO === "1" ||
22
+ env.UAI_AGENT_FROM_REPO === "true" ||
23
+ env.UAI_LAUNCHED_FROM_REPO === "1"
24
+ );
25
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * ADR-154 §13: pre-v1 task runtimes are retired as the runner moves to
3
+ * cloud credentials. Every runtime it has was created on its own
4
+ * credentials (the mode was `legacy` for each one's whole life), and holds
5
+ * them: Compose interpolated the default slot's engine tokens into the
6
+ * container env, and legacy copies of Codex, Grok, OpenCode, `gh` and SSH
7
+ * credentials sit in its home, recorded nowhere.
8
+ *
9
+ * Two halves, because removing a runtime cannot be undone:
10
+ *
11
+ * - `planLegacyTaskRuntimeRetirement` changes nothing. From one snapshot
12
+ * (the task rows, the identity fence on those same rows, the pinned
13
+ * engine) it finds every blocker known in advance, and rejects with all of
14
+ * them, so a runner that cannot finish stays on v0 exactly as it was: the
15
+ * container runtime not operational; a task row of another backend or
16
+ * engine than the one pinned (the persisted identity fence); a machine
17
+ * (ADR-121) or self runtime, which cannot be removed with its worktree
18
+ * kept; a row naming a Compose project that is not its own; a container at
19
+ * a task's name that cannot be proven that task's; a `task-*` Compose
20
+ * project no row with a runtime names, which nothing proves Uai's
21
+ * (`task-*` alone is no ownership, see orchestrator.ts's runtime
22
+ * isolation), and which the operator removes or renames. A row with no
23
+ * runtime (a task that went down keeps its row, its runtime fields
24
+ * cleared) neither blocks nor vouches for anything. The plan records the
25
+ * engine it was made against.
26
+ * - `retireLegacyTaskRuntimes` carries a plan out, only after the runner has
27
+ * committed to the switch. Each task's check, Stop and removal share its
28
+ * lifecycle slot: before its assignment closes and again before anything
29
+ * is removed, the live engine is re-probed and the task's row checked
30
+ * against the plan's engine, and so again before each command of the
31
+ * removal after its first and once more before the task counts as
32
+ * retired, so an absence answered by another engine never counts. A daemon
33
+ * replaced since (even at the same socket), or a row gone or cleared since
34
+ * (its task went down), is a problem, never a removal: the next plan
35
+ * decides. Each task stops, and its
36
+ * container goes with its worktree kept, exactly as task-up's step 2
37
+ * removes a previous container before a Resume (Docker by the Compose
38
+ * project its task id names, proven by an empty label query; Apple by
39
+ * name, its exact identity proven before it is deleted, and its absence
40
+ * by the CLI's not-found answer). An Apple task container no row names
41
+ * goes too when its label names it exactly (apple-uninstall-teardown.ts's
42
+ * proof). It is idempotent: a runtime already gone counts as removed, so
43
+ * a later attempt finishes what one left. Every removal is attempted; what
44
+ * could not be proven is in `problems`, so the caller can still report the
45
+ * tasks that did go.
46
+ */
47
+
48
+ import type { DockerResult } from "./docker-exec";
49
+ import { SAFE_HOST_TASK_ID, isSafeHostTaskId } from "./task-identity";
50
+
51
+ /** A task row, as far as its retirement goes. */
52
+ export interface RetirementTask {
53
+ taskId: string;
54
+ /** `uai_host_tasks.environment_provider`; null on rows that predate it
55
+ * (Docker Compose). */
56
+ provider: string | null;
57
+ composeProject: string | null;
58
+ /** The task directory (task-up's `$task_dir`). */
59
+ worktreePath: string | null;
60
+ /** It names a runtime (a locator, or a legacy Compose project). */
61
+ hasRuntime: boolean;
62
+ }
63
+
64
+ /** The container engine the runner has pinned. */
65
+ export interface RetirementMachine {
66
+ backend: string;
67
+ endpoint: string;
68
+ engineId: string;
69
+ }
70
+
71
+ /** What one plan reads, all at once. */
72
+ export interface RetirementSnapshot {
73
+ tasks: RetirementTask[];
74
+ /** Why the container runtime cannot be used now; null when it can. */
75
+ runtimeProblem: string | null;
76
+ /** The persisted identity fence over these same rows: every row's runtime
77
+ * is on `machine`. */
78
+ fence: { ok: boolean; detail?: string };
79
+ machine: RetirementMachine | null;
80
+ }
81
+
82
+ type Cli = (args: string[], opts?: { timeoutMs?: number; maxOutputBytes?: number }) => Promise<DockerResult>;
83
+
84
+ export interface RetirementPlanDeps {
85
+ snapshot: () => RetirementSnapshot;
86
+ /** The pinned container backend. */
87
+ apple: boolean;
88
+ docker: Cli;
89
+ /** The Apple container CLI (only called when `apple`). */
90
+ appleCli: Cli;
91
+ fileExists: (path: string) => boolean;
92
+ }
93
+
94
+ export type PlannedRemoval =
95
+ | { taskId: string; kind: "docker"; project: string; composeFile: string | null }
96
+ | { taskId: string; kind: "apple"; container: string };
97
+
98
+ export interface RetirementPlan {
99
+ /** The engine the plan was made against. */
100
+ machine: RetirementMachine;
101
+ /** Every runtime a task row names, whether or not anything is left of it. */
102
+ removals: PlannedRemoval[];
103
+ /** Apple task containers no row names, each labelled with the task its
104
+ * name names. */
105
+ appleOrphans: string[];
106
+ }
107
+
108
+ export interface RetirementDeps extends RetirementPlanDeps {
109
+ /** Why the live engine, or this task's row (null: the engine alone), no
110
+ * longer matches the plan; null while it does. Called inside the task's
111
+ * lifecycle slot, right before its Stop and right before its removal. */
112
+ current: (taskId: string | null) => Promise<string | null>;
113
+ /** The task's Stop, for a caller holding its lifecycle slot. */
114
+ stop: (taskId: string) => Promise<{ ok: true } | { ok: false; error: string }>;
115
+ /** `work` in the task's lifecycle slot. */
116
+ inLifecycle: <T>(taskId: string, work: () => Promise<T>) => Promise<T>;
117
+ log: (line: string) => void;
118
+ }
119
+
120
+ export interface RetirementReport {
121
+ /** Tasks whose runtime is gone (or was already). */
122
+ retired: string[];
123
+ /** Apple task containers no row named, removed. */
124
+ orphans: string[];
125
+ /** What could not be proven removed: the retirement is not done. */
126
+ problems: string[];
127
+ }
128
+
129
+ const DOCKER_PROVIDER = "docker-compose";
130
+ const APPLE_PROVIDER = "apple-container";
131
+ const TASK_ID = SAFE_HOST_TASK_ID.source.slice(1, -1);
132
+ const TASK_PROJECT = new RegExp(`^task-${TASK_ID}$`);
133
+ const APPLE_TASK_CONTAINER = new RegExp(`^task-(${TASK_ID})-app$`);
134
+
135
+ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps): Promise<RetirementPlan> {
136
+ const blockers: string[] = [];
137
+ const snapshot = deps.snapshot();
138
+ if (snapshot.runtimeProblem !== null || snapshot.machine === null) {
139
+ throw new Error(
140
+ `task runtimes cannot be retired now: the container runtime is not ready (${snapshot.runtimeProblem ?? "no engine is pinned"})`,
141
+ );
142
+ }
143
+ if (!snapshot.fence.ok) blockers.push(snapshot.fence.detail ?? "the task rows do not match the pinned container runtime");
144
+
145
+ const removals: PlannedRemoval[] = [];
146
+ // A row with no runtime takes no part: it blocks nothing, and vouches for
147
+ // no Compose project or container of its task's name.
148
+ const rows = snapshot.tasks.filter((task) => task.hasRuntime);
149
+ for (const task of rows) {
150
+ if (!isSafeHostTaskId(task.taskId)) {
151
+ blockers.push(`a task row has an unsafe id ${JSON.stringify(task.taskId)}`);
152
+ continue;
153
+ }
154
+ const provider = task.provider ?? DOCKER_PROVIDER;
155
+ if (provider !== DOCKER_PROVIDER && provider !== APPLE_PROVIDER) {
156
+ blockers.push(`task ${task.taskId}: its ${provider} runtime cannot be removed with its worktree kept; close the task first`);
157
+ continue;
158
+ }
159
+ if ((provider === APPLE_PROVIDER) !== deps.apple) {
160
+ blockers.push(`task ${task.taskId}: its runtime belongs to another container backend`);
161
+ continue;
162
+ }
163
+ if (provider === DOCKER_PROVIDER) {
164
+ const project = `task-${task.taskId}`;
165
+ if (task.composeProject !== null && task.composeProject !== project) {
166
+ blockers.push(`task ${task.taskId}: its row names Compose project ${JSON.stringify(task.composeProject)}, not its own`);
167
+ continue;
168
+ }
169
+ removals.push({ taskId: task.taskId, kind: "docker", project, composeFile: composeFile(deps, task) });
170
+ } else {
171
+ const container = `task-${task.taskId}-app`;
172
+ try {
173
+ if ((await appleIdentity(deps.appleCli, container)) === "foreign") {
174
+ blockers.push(`task ${task.taskId}: a container named ${container} exists but cannot be proven this task's; remove or rename it`);
175
+ continue;
176
+ }
177
+ } catch (error) {
178
+ blockers.push(`task ${task.taskId}: ${message(error)}`);
179
+ continue;
180
+ }
181
+ removals.push({ taskId: task.taskId, kind: "apple", container });
182
+ }
183
+ }
184
+
185
+ const appleOrphans: string[] = [];
186
+ try {
187
+ if (deps.apple) {
188
+ const named = new Set(rows.map((task) => `task-${task.taskId}-app`));
189
+ for (const container of await listOwnedAppleTaskContainers(deps.appleCli)) {
190
+ if (!named.has(container)) appleOrphans.push(container);
191
+ }
192
+ } else {
193
+ const named = new Set(rows.map((task) => `task-${task.taskId}`));
194
+ for (const project of await listTaskProjects(deps.docker)) {
195
+ if (named.has(project)) continue;
196
+ blockers.push(
197
+ `Compose project ${project} has containers but no task on this runner: nothing proves it Uai's, so it is not removed. ` +
198
+ `If it was a task's, remove it (docker compose -p ${project} down); otherwise rename it`,
199
+ );
200
+ }
201
+ }
202
+ } catch (error) {
203
+ blockers.push(`the task runtime inventory failed: ${message(error)}`);
204
+ }
205
+
206
+ if (blockers.length > 0) {
207
+ throw new Error(`task runtimes cannot all be retired: ${blockers.join("; ").slice(0, 1500)}`);
208
+ }
209
+ return { machine: snapshot.machine, removals, appleOrphans };
210
+ }
211
+
212
+ export async function retireLegacyTaskRuntimes(plan: RetirementPlan, deps: RetirementDeps): Promise<RetirementReport> {
213
+ const problems: string[] = [];
214
+ const retired: string[] = [];
215
+ for (const removal of plan.removals) {
216
+ try {
217
+ await deps.inLifecycle(removal.taskId, async () => {
218
+ const staleBeforeStop = await deps.current(removal.taskId);
219
+ if (staleBeforeStop !== null) throw new Error(`not stopped: ${staleBeforeStop}`);
220
+ const stopped = await deps.stop(removal.taskId);
221
+ if (!stopped.ok && stopped.error !== "task has no running stack") {
222
+ // Its removal below proves what matters: the runtime gone.
223
+ deps.log(`[credentials] task ${removal.taskId}: stop before retirement failed (${stopped.error}); removing its runtime`);
224
+ }
225
+ const fence = async (): Promise<void> => {
226
+ const stale = await deps.current(removal.taskId);
227
+ if (stale !== null) throw new Error(`not removed: ${stale}`);
228
+ };
229
+ await (removal.kind === "apple"
230
+ ? removeAppleContainer(deps.appleCli, removal.container, fence)
231
+ : removeComposeProject(deps.docker, removal.project, removal.composeFile, fence));
232
+ });
233
+ retired.push(removal.taskId);
234
+ } catch (error) {
235
+ problems.push(`task ${removal.taskId}: ${message(error)}`);
236
+ }
237
+ }
238
+ const orphans: string[] = [];
239
+ for (const container of plan.appleOrphans) {
240
+ try {
241
+ const fence = async (): Promise<void> => {
242
+ const stale = await deps.current(null);
243
+ if (stale !== null) throw new Error(`${container} not removed: ${stale}`);
244
+ };
245
+ await removeAppleContainer(deps.appleCli, container, fence);
246
+ orphans.push(container);
247
+ } catch (error) {
248
+ problems.push(message(error));
249
+ }
250
+ }
251
+ return { retired, orphans, problems };
252
+ }
253
+
254
+ function composeFile(deps: RetirementPlanDeps, task: RetirementTask): string | null {
255
+ if (task.worktreePath === null) return null;
256
+ const file = `${task.worktreePath}/.uai/docker-compose.yml`;
257
+ return deps.fileExists(file) ? file : null;
258
+ }
259
+
260
+ /** task-up's step 2 for Docker, and then whatever Compose left, by label.
261
+ * `fence` re-proves the engine before every command and once more after
262
+ * the last, so each command and the absence proof are the planned
263
+ * engine's. */
264
+ async function removeComposeProject(
265
+ docker: Cli,
266
+ project: string,
267
+ file: string | null,
268
+ fence: () => Promise<void>,
269
+ ): Promise<void> {
270
+ if (!TASK_PROJECT.test(project)) throw new Error(`refusing to remove Compose project ${JSON.stringify(project)}`);
271
+ await fence();
272
+ await docker(["compose", "-p", project, ...(file === null ? [] : ["-f", file]), "down", "--remove-orphans"], {
273
+ timeoutMs: 180_000,
274
+ maxOutputBytes: 1024 * 1024,
275
+ });
276
+ await fence();
277
+ let left = await projectContainers(docker, project);
278
+ if (left.length > 0) {
279
+ await fence();
280
+ await docker(["rm", "--force", ...left], { timeoutMs: 120_000, maxOutputBytes: 256 * 1024 });
281
+ await fence();
282
+ left = await projectContainers(docker, project);
283
+ }
284
+ await fence();
285
+ if (left.length > 0) throw new Error(`${left.length} container(s) of ${project} are still present`);
286
+ }
287
+
288
+ async function projectContainers(docker: Cli, project: string): Promise<string[]> {
289
+ const listed = await docker(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`], {
290
+ timeoutMs: 30_000,
291
+ maxOutputBytes: 256 * 1024,
292
+ });
293
+ if (listed.status !== 0) {
294
+ throw new Error(`could not list ${project}'s containers: ${listed.stderr.trim().slice(0, 200) || "docker ps failed"}`);
295
+ }
296
+ return lines(listed.stdout);
297
+ }
298
+
299
+ /** Every Compose project of a task-shaped name, whatever its containers' state. */
300
+ async function listTaskProjects(docker: Cli): Promise<string[]> {
301
+ const listed = await docker(
302
+ ["ps", "-a", "--filter", "label=com.docker.compose.project", "--format", '{{.Label "com.docker.compose.project"}}'],
303
+ { timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 },
304
+ );
305
+ if (listed.status !== 0) throw new Error(listed.stderr.trim().slice(0, 200) || "docker ps failed");
306
+ return [...new Set(lines(listed.stdout).filter((project) => TASK_PROJECT.test(project)))];
307
+ }
308
+
309
+ /**
310
+ * Whether the container at a task's name is that task's: absent, or exactly
311
+ * one record whose id is the name, labelled `com.uai.task` with the task id
312
+ * the name derives from (task-down.sh's proof). Anything else is foreign.
313
+ * Throws when the CLI gives no answer that settles it.
314
+ */
315
+ async function appleIdentity(cli: Cli, name: string): Promise<"absent" | "owned" | "foreign"> {
316
+ const taskId = APPLE_TASK_CONTAINER.exec(name)?.[1];
317
+ if (taskId === undefined) return "foreign";
318
+ const inspected = await cli(["inspect", name], { timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 });
319
+ if (inspected.status !== 0) {
320
+ if (
321
+ inspected.stdout.trim() === "" &&
322
+ inspected.stderr.split("\n").some((line) => line.trim() === `Error: container not found: ${name}`)
323
+ ) {
324
+ return "absent";
325
+ }
326
+ throw new Error(`could not inspect ${name}: ${inspected.stderr.trim().slice(0, 200) || "inspect failed"}`);
327
+ }
328
+ let records: unknown;
329
+ try {
330
+ records = JSON.parse(inspected.stdout);
331
+ } catch {
332
+ return "foreign";
333
+ }
334
+ if (!Array.isArray(records) || records.length !== 1) return "foreign";
335
+ const record = records[0] as { id?: unknown; configuration?: { labels?: Record<string, unknown> } } | null;
336
+ return record?.id === name && record.configuration?.labels?.["com.uai.task"] === taskId ? "owned" : "foreign";
337
+ }
338
+
339
+ /** task-up's step 2 for Apple: its identity proven, then stop, delete, and
340
+ * the not-found answer, `fence` re-proving the engine before every command
341
+ * and once more after the last. */
342
+ async function removeAppleContainer(cli: Cli, name: string, fence: () => Promise<void>): Promise<void> {
343
+ await fence();
344
+ const identity = await appleIdentity(cli, name);
345
+ await fence();
346
+ if (identity === "absent") return;
347
+ if (identity === "foreign") throw new Error(`container ${name} cannot be proven Uai's; it was not removed`);
348
+ await cli(["stop", "--time", "10", name], { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 });
349
+ await fence();
350
+ await cli(["delete", "--force", name], { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 });
351
+ await fence();
352
+ const after = await appleIdentity(cli, name).catch(() => "unknown");
353
+ await fence();
354
+ if (after !== "absent") throw new Error(`container ${name} could not be proven removed`);
355
+ }
356
+
357
+ /** Uai's task containers no matter their state: labelled `com.uai.task`
358
+ * with exactly the task their name names. */
359
+ async function listOwnedAppleTaskContainers(cli: Cli): Promise<string[]> {
360
+ const listed = await cli(["list", "--all", "--format", "json"], { timeoutMs: 30_000, maxOutputBytes: 8 * 1024 * 1024 });
361
+ if (listed.status !== 0) throw new Error(listed.stderr.trim().slice(0, 200) || "container list failed");
362
+ let rows: unknown;
363
+ try {
364
+ rows = JSON.parse(listed.stdout);
365
+ } catch {
366
+ throw new Error("the container list is not JSON");
367
+ }
368
+ if (!Array.isArray(rows)) throw new Error("the container list is not an array");
369
+ const names: string[] = [];
370
+ for (const row of rows as Array<{ configuration?: { id?: unknown; labels?: Record<string, unknown> } }>) {
371
+ const id = row?.configuration?.id;
372
+ if (typeof id !== "string") continue;
373
+ const taskId = APPLE_TASK_CONTAINER.exec(id)?.[1];
374
+ if (taskId !== undefined && row.configuration?.labels?.["com.uai.task"] === taskId) names.push(id);
375
+ }
376
+ return names;
377
+ }
378
+
379
+ function lines(text: string): string[] {
380
+ return text
381
+ .split("\n")
382
+ .map((line) => line.trim())
383
+ .filter(Boolean);
384
+ }
385
+
386
+ function message(error: unknown): string {
387
+ return (error instanceof Error ? error.message : String(error)).slice(0, 300);
388
+ }
@@ -56,7 +56,12 @@ export function shellQuote(value: string): string {
56
56
  * instead (`-l`). Env values therefore appear in the machine-side process
57
57
  * table; the machine is a single-task trust domain, and hardening to sshd
58
58
  * SetEnv acceptlists is recorded ADR-121 follow-up work, not silently
59
- * skipped.
59
+ * skipped. They also sit on the runner's own `ssh` argv for as long as the
60
+ * connection lives, so a leased engine's credentials never ride here: they
61
+ * travel in its launch's single-use admission (ADR-154 §6,
62
+ * credential-consumers/engine-anchor.ts). With `clean`, the command starts
63
+ * from an empty environment, not the session's (whatever sshd, PAM and the
64
+ * login gave it): only what the request names reaches it.
60
65
  */
61
66
  export function buildRemoteCommand(request: {
62
67
  argv: readonly string[];
@@ -64,6 +69,7 @@ export function buildRemoteCommand(request: {
64
69
  inheritEnv?: readonly string[];
65
70
  env?: Readonly<Record<string, string>>;
66
71
  detached?: boolean;
72
+ clean?: boolean;
67
73
  }): string {
68
74
  const parts: string[] = [];
69
75
  if (request.cwd) {
@@ -79,8 +85,12 @@ export function buildRemoteCommand(request: {
79
85
  )) {
80
86
  env.push(`${name}=${value}`);
81
87
  }
82
- const envPrefix =
83
- env.length > 0 ? `env ${env.map(shellQuote).join(" ")} ` : "";
88
+ // A clean start names env absolutely: the session's PATH never picks it.
89
+ const envPrefix = request.clean
90
+ ? `/usr/bin/env -i ${env.map((entry) => `${shellQuote(entry)} `).join("")}`
91
+ : env.length > 0
92
+ ? `env ${env.map(shellQuote).join(" ")} `
93
+ : "";
84
94
  const command = request.argv.map(shellQuote).join(" ");
85
95
  if (request.detached) {
86
96
  // Detached durable sessions manage their own transcript IO (runner.mjs);
@@ -146,6 +156,7 @@ export function machineEnvironmentSshArgs(
146
156
  user?: string;
147
157
  inheritEnv?: readonly string[];
148
158
  env?: Readonly<Record<string, string>>;
159
+ clean?: boolean;
149
160
  },
150
161
  mode: "interactive" | "detached",
151
162
  ): string[] {