@runuai/host 0.9.14 → 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 +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 +3060 -218
  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 +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
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Self-recycle for wedged Apple `container` runtime services (ADR-106).
3
+ *
4
+ * The wedge, observed live three times over 2026-08-16/17: a
5
+ * created-never-started container (usually minted by a `run --rm` the
6
+ * runtime refused to start) leaves the apiserver in a state where `run` and
7
+ * `delete` hang for minutes while `list`/`inspect` keep answering. Every
8
+ * occurrence previously required a human to run the same shell ritual —
9
+ * stop the system, boot out every `com.apple.container.*` launchd unit
10
+ * (including the per-container `container-runtime-linux.<uuid>` units that
11
+ * hold the wedge), start the system again, and delete the corpses. The
12
+ * owner's verdict on that ritual: "this needs to be automatic."
13
+ *
14
+ * This module IS that ritual. It is invoked by the maintenance-run wedge
15
+ * sensor in `standard-image.ts` when a named run cannot reach `running`
16
+ * while the control plane still answers. Recycling restarts runtime
17
+ * services only — task containers stop with the VMs, and host recovery
18
+ * restarts them afterwards exactly as it does after a host reboot.
19
+ *
20
+ * A cooldown serializes and rate-limits recycles: a second wedge verdict
21
+ * inside the window means recycling did not cure the underlying cause, and
22
+ * looping stop/start forever would only churn tasks. That failure surfaces
23
+ * to the operator instead.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+
27
+ interface CliResult {
28
+ code: number | null;
29
+ stdout: string;
30
+ stderr: string;
31
+ }
32
+
33
+ export interface AppleRecycleDeps {
34
+ /** Run the bundled `container` CLI. */
35
+ cli: (args: string[], timeoutMs: number) => Promise<CliResult>;
36
+ /** Run `launchctl`. Injectable for tests. */
37
+ launchctl?: (args: string[], timeoutMs: number) => Promise<CliResult>;
38
+ uid?: number;
39
+ now?: () => number;
40
+ }
41
+
42
+ const RECYCLE_COOLDOWN_MS = 10 * 60_000;
43
+ const SYSTEM_STOP_TIMEOUT_MS = 60_000;
44
+ const SYSTEM_START_TIMEOUT_MS = 120_000;
45
+
46
+ let lastRecycleAt = 0;
47
+ let inFlight: Promise<AppleRecycleOutcome> | null = null;
48
+
49
+ export type AppleRecycleOutcome =
50
+ | { recycled: true; sweptCorpses: string[] }
51
+ | { recycled: false; reason: string };
52
+
53
+ /** Test hook: clear the cooldown/serialization latches. */
54
+ export function resetAppleRecycleStateForTest(): void {
55
+ lastRecycleAt = 0;
56
+ inFlight = null;
57
+ }
58
+
59
+ function defaultExec(
60
+ command: string,
61
+ ): (args: string[], timeoutMs: number) => Promise<CliResult> {
62
+ return (args, timeoutMs) =>
63
+ new Promise<CliResult>((resolve) => {
64
+ let stdout = "";
65
+ let stderr = "";
66
+ let settled = false;
67
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
68
+ const settle = (code: number | null): void => {
69
+ if (settled) return;
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ resolve({ code, stdout, stderr });
73
+ };
74
+ const timer = setTimeout(() => {
75
+ child.kill("SIGKILL");
76
+ const backstop = setTimeout(() => settle(null), 1_000);
77
+ backstop.unref();
78
+ }, timeoutMs);
79
+ timer.unref();
80
+ child.stdout?.setEncoding("utf8");
81
+ child.stderr?.setEncoding("utf8");
82
+ child.stdout?.on("data", (chunk: string) => {
83
+ stdout += chunk;
84
+ });
85
+ child.stderr?.on("data", (chunk: string) => {
86
+ stderr += chunk;
87
+ });
88
+ child.on("error", () => settle(null));
89
+ child.on("close", (code) => settle(code));
90
+ });
91
+ }
92
+
93
+ /** `launchctl list` third column, filtered to the Apple container family. */
94
+ export function parseAppleContainerServiceLabels(listing: string): string[] {
95
+ return listing
96
+ .split("\n")
97
+ .map((line) => line.trim().split(/\s+/).pop() ?? "")
98
+ .filter((label) => label.startsWith("com.apple.container"));
99
+ }
100
+
101
+ /** The exact ownership label every Uai maintenance container carries. A name
102
+ * alone — even a `uai-maint-*` one — is forgeable/collidable; deletion
103
+ * requires the label AND the closed generated grammar together. */
104
+ export const MAINTENANCE_CONTAINER_LABEL = "com.uai.maintenance";
105
+
106
+ /** The closed grammar `runMaintenanceContainer` generates:
107
+ * uai-maint-<purpose>-<pid>-<seq>. */
108
+ export const MAINTENANCE_CONTAINER_NAME_RE = /^uai-maint-[a-z-]+-\d+-\d+$/;
109
+
110
+ /**
111
+ * The sweep deletes ONLY containers Uai provably owns: the exact generated
112
+ * maintenance-name grammar PLUS the ownership label our runner stamps
113
+ * (review 2026-08-18 round 2: a name prefix alone can collide with a foreign
114
+ * or another-process container). A bare-UUID name is the Apple CLI's
115
+ * identity for ANY anonymous container — including a user's own — so it is
116
+ * never a deletion target; UUID rows are surfaced as wedge suspects instead.
117
+ */
118
+ export function isCorpseContainer(
119
+ id: string,
120
+ state: string | undefined,
121
+ labels: Record<string, unknown> | undefined,
122
+ ): boolean {
123
+ return (
124
+ MAINTENANCE_CONTAINER_NAME_RE.test(id) &&
125
+ labels?.[MAINTENANCE_CONTAINER_LABEL] === "1" &&
126
+ state !== "running"
127
+ );
128
+ }
129
+
130
+ /** Foreign anonymous containers worth telling the operator about after a
131
+ * wedge: not deletable by us, but the likeliest remaining wedge fuel. */
132
+ export function isWedgeSuspect(id: string, state: string | undefined): boolean {
133
+ const uuid =
134
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
135
+ return uuid.test(id) && state !== "running";
136
+ }
137
+
138
+ async function sweepCorpses(deps: AppleRecycleDeps): Promise<string[]> {
139
+ const listed = await deps.cli(["list", "--all", "--format", "json"], 30_000);
140
+ if (listed.code !== 0) return [];
141
+ let rows: Array<{
142
+ configuration?: { id?: unknown; labels?: Record<string, unknown> };
143
+ status?: { state?: unknown };
144
+ }>;
145
+ try {
146
+ const parsed = JSON.parse(listed.stdout) as typeof rows;
147
+ if (!Array.isArray(parsed)) return [];
148
+ rows = parsed;
149
+ } catch {
150
+ return [];
151
+ }
152
+ const swept: string[] = [];
153
+ const suspects: string[] = [];
154
+ for (const row of rows) {
155
+ const id = row?.configuration?.id;
156
+ const rawState = row?.status?.state;
157
+ const state = typeof rawState === "string" ? rawState : undefined;
158
+ if (typeof id !== "string") continue;
159
+ if (isWedgeSuspect(id, state)) {
160
+ suspects.push(id);
161
+ continue;
162
+ }
163
+ if (!isCorpseContainer(id, state, row?.configuration?.labels)) continue;
164
+ const removed = await deps.cli(["delete", "--force", id], 30_000);
165
+ if (removed.code === 0) swept.push(id);
166
+ else {
167
+ console.warn(
168
+ `[host-agent] could not sweep corpse container ${id}: ${removed.stderr.trim().slice(0, 200)}`,
169
+ );
170
+ }
171
+ }
172
+ if (suspects.length > 0) {
173
+ console.warn(
174
+ `[host-agent] ${suspects.length} anonymous non-running container(s) present after the recycle — not Uai's to delete, but the likeliest remaining wedge fuel: ${suspects.join(", ")} (remove with: container delete --force <id>)`,
175
+ );
176
+ }
177
+ return swept;
178
+ }
179
+
180
+ /**
181
+ * The ritual, encoded. Serialized (concurrent verdicts join the in-flight
182
+ * recycle) and cooldown-limited (a wedge inside the window reports failure
183
+ * instead of churning stop/start forever).
184
+ */
185
+ export function recycleAppleRuntimeServices(
186
+ deps: AppleRecycleDeps,
187
+ ): Promise<AppleRecycleOutcome> {
188
+ if (inFlight) return inFlight;
189
+ const now = deps.now ?? Date.now;
190
+ if (now() - lastRecycleAt < RECYCLE_COOLDOWN_MS) {
191
+ return Promise.resolve({
192
+ recycled: false,
193
+ reason:
194
+ "the runtime wedged again within minutes of the last recycle; " +
195
+ "restarting services is not curing it — inspect the host " +
196
+ "(container list --all) or reboot the machine",
197
+ });
198
+ }
199
+ lastRecycleAt = now();
200
+ const launchctl = deps.launchctl ?? defaultExec("launchctl");
201
+ const uid = deps.uid ?? process.getuid?.() ?? 501;
202
+ inFlight = (async (): Promise<AppleRecycleOutcome> => {
203
+ console.warn(
204
+ "[host-agent] apple container runtime is wedged (runs cannot start while the control plane answers); recycling runtime services",
205
+ );
206
+ // 1. Polite stop. Known to hang exactly when we need it most — bounded,
207
+ // and its failure changes nothing about the steps that actually work.
208
+ await deps.cli(["system", "stop"], SYSTEM_STOP_TIMEOUT_MS);
209
+ // 2. Boot out every com.apple.container.* unit. The per-container
210
+ // runtime-linux units are the ones that hold a wedge through a plain
211
+ // system restart.
212
+ const listing = await launchctl(["list"], 15_000);
213
+ const labels =
214
+ listing.code === 0 ? parseAppleContainerServiceLabels(listing.stdout) : [];
215
+ for (const label of labels) {
216
+ await launchctl(["bootout", `gui/${uid}/${label}`], 15_000);
217
+ }
218
+ // 3. Fresh start.
219
+ const started = await deps.cli(["system", "start"], SYSTEM_START_TIMEOUT_MS);
220
+ if (started.code !== 0) {
221
+ return {
222
+ recycled: false,
223
+ reason: `container system start failed after the recycle (exit ${started.code ?? "killed"}): ${started.stderr.trim().slice(0, 200)}`,
224
+ };
225
+ }
226
+ // 4. Sweep the debris that caused this, while the apiserver is fresh.
227
+ const sweptCorpses = await sweepCorpses(deps);
228
+ console.warn(
229
+ `[host-agent] apple runtime services recycled (${labels.length} unit(s) booted out, ${sweptCorpses.length} corpse container(s) swept)`,
230
+ );
231
+ return { recycled: true, sweptCorpses };
232
+ })().finally(() => {
233
+ inFlight = null;
234
+ });
235
+ return inFlight;
236
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Uninstall-time teardown of Apple-container state Uai provably owns
3
+ * (ADR-106/108). Runs from the managed-removal transaction AFTER the service
4
+ * stops and BEFORE the signed runtime is removed — the last moment the
5
+ * bundled CLI exists to clean up what Uai provisioned through it.
6
+ *
7
+ * Two review-hardened rules (2026-08-18 round 2):
8
+ *
9
+ * 1. OWNERSHIP IS PROVEN, never inferred from a name. A container is Uai's
10
+ * only when its `com.uai.task` label derives its exact name, or when it
11
+ * carries the maintenance ownership label under the closed generated
12
+ * grammar. Images are matched by closed exact grammar over Uai's own
13
+ * naming (`uai-standard`, `uai-task-<safe-id>`). Nothing else is touched
14
+ * — the runtime's state root and launchd domain may be shared with a
15
+ * user's own Apple container installation, so the shared system is NEVER
16
+ * stopped here.
17
+ *
18
+ * 2. UNCERTAINTY FAILS THE TEARDOWN. Every deletion proves absence
19
+ * afterwards; an unreadable inventory or a surviving resource returns
20
+ * problems, and the caller must THROW so the managed-removal transaction
21
+ * keeps its teardown marker and the signed runtime — leaving a retryable
22
+ * uninstall instead of removing the escape route while Uai state remains.
23
+ */
24
+ import { isSafeHostTaskId } from "./task-identity";
25
+ import { appleTaskContainerName } from "./task-environment/apple-container";
26
+ import {
27
+ MAINTENANCE_CONTAINER_LABEL,
28
+ MAINTENANCE_CONTAINER_NAME_RE,
29
+ } from "./apple-runtime-recycle";
30
+
31
+ interface CliResult {
32
+ code: number | null;
33
+ stdout: string;
34
+ stderr: string;
35
+ }
36
+
37
+ export interface AppleUninstallTeardownDeps {
38
+ cli: (args: string[], timeoutMs: number) => Promise<CliResult>;
39
+ purge: boolean;
40
+ }
41
+
42
+ export interface AppleUninstallTeardownResult {
43
+ removedContainers: string[];
44
+ removedImages: string[];
45
+ /** Non-empty means the teardown is INCOMPLETE and the uninstall must not
46
+ * proceed to remove the signed runtime. */
47
+ problems: string[];
48
+ }
49
+
50
+ const TASK_CONTAINER_NAME_RE = /^task-[a-z0-9][a-z0-9_-]*-app$/;
51
+ const DERIVED_IMAGE_NAME_RE = /^uai-task-[0-9a-z]+$/;
52
+
53
+ function ownedContainer(
54
+ id: string,
55
+ labels: Record<string, unknown> | undefined,
56
+ ): boolean {
57
+ if (TASK_CONTAINER_NAME_RE.test(id)) {
58
+ const label = labels?.["com.uai.task"];
59
+ return (
60
+ typeof label === "string" &&
61
+ isSafeHostTaskId(label) &&
62
+ appleTaskContainerName(label) === id
63
+ );
64
+ }
65
+ if (MAINTENANCE_CONTAINER_NAME_RE.test(id)) {
66
+ return labels?.[MAINTENANCE_CONTAINER_LABEL] === "1";
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Ownership over a full image REFERENCE as the CLI lists it. The Apple CLI
73
+ * normalizes unqualified local names to `docker.io/library/<name>:<tag>`
74
+ * (verified live on 1.2.2), so exactly that prefix — and no other registry
75
+ * path — is accepted around Uai's closed naming. A foreign registry's
76
+ * `uai-standard` is not ours.
77
+ */
78
+ function ownedImageReference(reference: string): boolean {
79
+ const withoutTag = reference.replace(/(@sha256:[0-9a-f]{64}|:[^/:]+)$/, "");
80
+ const base = withoutTag.startsWith("docker.io/library/")
81
+ ? withoutTag.slice("docker.io/library/".length)
82
+ : withoutTag;
83
+ if (base.includes("/")) return false;
84
+ if (base === "uai-standard") return true;
85
+ if (!DERIVED_IMAGE_NAME_RE.test(base)) return false;
86
+ return isSafeHostTaskId(base.slice("uai-task-".length));
87
+ }
88
+
89
+ function containerProvenAbsent(name: string, inspected: CliResult): boolean {
90
+ return (
91
+ inspected.code !== 0 &&
92
+ inspected.stdout.trim() === "" &&
93
+ inspected.stderr
94
+ .split("\n")
95
+ .some((line) => line.trim() === `Error: container not found: ${name}`)
96
+ );
97
+ }
98
+
99
+ export async function teardownAppleRuntimeStateForUninstall(
100
+ deps: AppleUninstallTeardownDeps,
101
+ ): Promise<AppleUninstallTeardownResult> {
102
+ const problems: string[] = [];
103
+ const removedContainers: string[] = [];
104
+ const removedImages: string[] = [];
105
+
106
+ // 1. Containers: label-proven Uai ownership only, absence proven after.
107
+ const listed = await deps.cli(["list", "--all", "--format", "json"], 30_000);
108
+ if (listed.code !== 0) {
109
+ problems.push(
110
+ `container inventory failed (exit ${listed.code ?? "killed"}): ${listed.stderr.trim().slice(0, 160)}`,
111
+ );
112
+ } else {
113
+ let rows: Array<{
114
+ configuration?: { id?: unknown; labels?: Record<string, unknown> };
115
+ }>;
116
+ try {
117
+ const parsed = JSON.parse(listed.stdout) as typeof rows;
118
+ if (!Array.isArray(parsed)) throw new Error("not an array");
119
+ rows = parsed;
120
+ } catch {
121
+ problems.push("container inventory was unparseable");
122
+ rows = [];
123
+ }
124
+ for (const row of rows) {
125
+ const id = row?.configuration?.id;
126
+ if (typeof id !== "string") continue;
127
+ if (!ownedContainer(id, row?.configuration?.labels)) continue;
128
+ await deps.cli(["stop", "--time", "10", id], 30_000);
129
+ await deps.cli(["delete", "--force", id], 60_000);
130
+ const after = await deps.cli(["inspect", id], 30_000);
131
+ if (containerProvenAbsent(id, after)) {
132
+ removedContainers.push(id);
133
+ } else {
134
+ problems.push(`container ${id} could not be proven removed`);
135
+ }
136
+ }
137
+ }
138
+
139
+ // 2. Images: Uai's closed naming only, read from the schema the CLI
140
+ // actually emits — full references at `.configuration.name` (verified
141
+ // live on 1.2.2; `.name` is accepted defensively). An unreadable
142
+ // inventory or an unidentifiable row is a problem, not silence, and
143
+ // every deletion is absence-proven afterwards.
144
+ const images = await deps.cli(["image", "list", "--format", "json"], 30_000);
145
+ if (images.code !== 0) {
146
+ problems.push(
147
+ `image inventory failed (exit ${images.code ?? "killed"}): ${images.stderr.trim().slice(0, 160)}`,
148
+ );
149
+ } else {
150
+ let references: string[] = [];
151
+ try {
152
+ const parsed = JSON.parse(images.stdout) as Array<{
153
+ name?: unknown;
154
+ configuration?: { name?: unknown };
155
+ }>;
156
+ if (!Array.isArray(parsed)) throw new Error("not an array");
157
+ const seen = new Set<string>();
158
+ for (const row of parsed) {
159
+ const reference =
160
+ typeof row?.configuration?.name === "string"
161
+ ? row.configuration.name
162
+ : typeof row?.name === "string"
163
+ ? row.name
164
+ : null;
165
+ if (reference === null) {
166
+ problems.push("image inventory contains an unidentifiable row");
167
+ continue;
168
+ }
169
+ if (!seen.has(reference)) {
170
+ seen.add(reference);
171
+ references.push(reference);
172
+ }
173
+ }
174
+ } catch {
175
+ problems.push("image inventory was unparseable");
176
+ references = [];
177
+ }
178
+ for (const reference of references) {
179
+ if (!ownedImageReference(reference)) continue;
180
+ const removed = await deps.cli(["image", "delete", reference], 60_000);
181
+ const after = await deps.cli(["image", "inspect", reference], 30_000);
182
+ const provenAbsent =
183
+ after.code !== 0 &&
184
+ after.stderr
185
+ .split("\n")
186
+ .some(
187
+ (line) => line.trim() === `Error: image not found: ${reference}`,
188
+ );
189
+ if (provenAbsent) {
190
+ removedImages.push(reference);
191
+ } else {
192
+ problems.push(
193
+ `image ${reference} could not be proven removed (delete exit ${removed.code ?? "killed"}): ${removed.stderr.trim().slice(0, 160)}`,
194
+ );
195
+ }
196
+ }
197
+ }
198
+
199
+ // 3. The shared runtime volume goes only with --purge, like ~/.uai data.
200
+ // Success and failure are BOTH judged by the exact absence proof — a
201
+ // timed-out or daemon-failed inspect proves nothing (round 3: any
202
+ // nonzero inspect was read as absence).
203
+ if (deps.purge) {
204
+ const removed = await deps.cli(["volume", "delete", "uai-asdf-data"], 30_000);
205
+ const after = await deps.cli(["volume", "inspect", "uai-asdf-data"], 30_000);
206
+ const provenAbsent =
207
+ after.code !== 0 &&
208
+ after.stderr
209
+ .split("\n")
210
+ .some(
211
+ (line) => line.trim() === "Error: volume not found: uai-asdf-data",
212
+ );
213
+ if (!provenAbsent) {
214
+ problems.push(
215
+ `shared runtime volume could not be proven removed (delete exit ${removed.code ?? "killed"}): ${removed.stderr.trim().slice(0, 160)}`,
216
+ );
217
+ }
218
+ }
219
+
220
+ // Deliberately NO `system stop`: the runtime services and their launchd
221
+ // domain may be shared with the user's own Apple container installation,
222
+ // and stopping them is not required to remove Uai.
223
+ return { removedContainers, removedImages, problems };
224
+ }