@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.
- package/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +33 -2
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3051 -200
- package/lib/preview-sidecar.ts +68 -14
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +956 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +459 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1405 -107
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +766 -42
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- 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
|
+
}
|