@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.
- 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 +3060 -218
- package/lib/preview-sidecar.ts +57 -13
- 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 +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -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,1338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container-runtime detection and process-wide selection (ADR-101/ADR-106).
|
|
3
|
+
*
|
|
4
|
+
* The Docker paths keep the selected endpoint in `DOCKER_HOST` so every
|
|
5
|
+
* docker-invoking module follows the same daemon. ADR-106 adds the bundled
|
|
6
|
+
* Apple container backend, preferred wherever it can run: `auto` resolves the
|
|
7
|
+
* durable stateful-provider claim first, then the eligible bundled runtime,
|
|
8
|
+
* then ambient Docker — and the process pin plus the minted installation
|
|
9
|
+
* identity keep a wiped or replaced toolchain state fail-closed, exactly like
|
|
10
|
+
* a replaced Docker daemon at the same socket.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
|
|
16
|
+
import { release as osRelease } from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
20
|
+
import { env } from "./env";
|
|
21
|
+
import { claimManagedRuntimeCapabilityFloor } from "./managed-runtime";
|
|
22
|
+
import {
|
|
23
|
+
fileRuntimeProviderStateStore,
|
|
24
|
+
runtimeProviderStatePath,
|
|
25
|
+
type RuntimeProviderStateStore,
|
|
26
|
+
} from "./runtime-provider-state";
|
|
27
|
+
|
|
28
|
+
export type RuntimePreference = "auto" | "docker" | "apple-container";
|
|
29
|
+
|
|
30
|
+
export type ContainerRuntimeState =
|
|
31
|
+
| {
|
|
32
|
+
status: "checking";
|
|
33
|
+
preference: RuntimePreference;
|
|
34
|
+
/** Human-readable activation phase (boot visibility; ADR-106 smoke). */
|
|
35
|
+
detail?: string;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
status: "ready";
|
|
39
|
+
preference: "auto" | "docker";
|
|
40
|
+
provider: "docker";
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
status: "ready";
|
|
44
|
+
preference: "auto" | "apple-container";
|
|
45
|
+
provider: "apple-container";
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
status: "no-runtime";
|
|
49
|
+
preference: RuntimePreference;
|
|
50
|
+
message: string;
|
|
51
|
+
action: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const RUNTIME_RECHECK_COMMAND = "uai-host runtime recheck";
|
|
55
|
+
const RUNTIME_RESTART_COMMAND = "uai-host restart";
|
|
56
|
+
|
|
57
|
+
const NO_RUNTIME_MESSAGE =
|
|
58
|
+
"No working container runtime was found. Install or start Docker, then run " +
|
|
59
|
+
`\`${RUNTIME_RECHECK_COMMAND}\`.`;
|
|
60
|
+
const APPLE_RUNTIME_UNAVAILABLE_MESSAGE =
|
|
61
|
+
"The bundled Apple container runtime could not be started. " +
|
|
62
|
+
`Run \`${RUNTIME_RECHECK_COMMAND}\` to retry, or set UAI_RUNTIME=docker ` +
|
|
63
|
+
`and run \`${RUNTIME_RESTART_COMMAND}\` to use Docker instead.`;
|
|
64
|
+
const PINNED_RUNTIME_UNAVAILABLE_MESSAGE =
|
|
65
|
+
"The Docker runtime selected for this Uai Host process is not responding. " +
|
|
66
|
+
`Start it and run \`${RUNTIME_RECHECK_COMMAND}\`. ` +
|
|
67
|
+
`To use a different Docker context or endpoint, run \`${RUNTIME_RESTART_COMMAND}\` ` +
|
|
68
|
+
"after selecting it.";
|
|
69
|
+
|
|
70
|
+
let state: ContainerRuntimeState = {
|
|
71
|
+
status: "checking",
|
|
72
|
+
preference: runtimePreference(process.env.UAI_RUNTIME),
|
|
73
|
+
};
|
|
74
|
+
let operational = false;
|
|
75
|
+
let activationRequired = false;
|
|
76
|
+
let readinessEpoch = 0;
|
|
77
|
+
|
|
78
|
+
type RuntimeChangeListener = (
|
|
79
|
+
next: ContainerRuntimeState,
|
|
80
|
+
previous: ContainerRuntimeState,
|
|
81
|
+
) => void;
|
|
82
|
+
|
|
83
|
+
const listeners = new Set<RuntimeChangeListener>();
|
|
84
|
+
let detectionInFlight: Promise<ContainerRuntimeState> | null = null;
|
|
85
|
+
// Once a local endpoint answers, every Docker/Compose child in this process
|
|
86
|
+
// must stay on it. Switching a live host to another daemon strands the first
|
|
87
|
+
// daemon's containers, images, and volumes and makes taskDown target the wrong
|
|
88
|
+
// machine. A service restart is the deliberate endpoint-switch boundary.
|
|
89
|
+
let pinnedDockerHost: string | null = null;
|
|
90
|
+
let pinnedRuntimeProvider: "docker" | "apple-container" | null = null;
|
|
91
|
+
let pinnedDockerEngineId: string | null = null;
|
|
92
|
+
function defaultRuntimeProviderStateStore(): RuntimeProviderStateStore {
|
|
93
|
+
// Resolve env lazily. Several consumers import standard-image/runtime code
|
|
94
|
+
// under narrow env mocks, and module import must remain side-effect free.
|
|
95
|
+
const store = fileRuntimeProviderStateStore(
|
|
96
|
+
runtimeProviderStatePath(env.dataDir),
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
read: () => store.read(),
|
|
100
|
+
claim: async (provider) => {
|
|
101
|
+
// The version-independent rollback guard must commit under the managed
|
|
102
|
+
// operation lock before the provider marker. A crash between them is
|
|
103
|
+
// conservative (capability-1 rollback stays disabled) and owns no
|
|
104
|
+
// provider resources; the inverse order could strand state on an old
|
|
105
|
+
// runtime.
|
|
106
|
+
// Apple provider state is only usable by a capability-3 generation
|
|
107
|
+
// (bundled CLI + kernel). Floor 2 would let an interrupted rollback
|
|
108
|
+
// resurrect a Lima-era generation over preserved Apple state.
|
|
109
|
+
await claimManagedRuntimeCapabilityFloor(3);
|
|
110
|
+
return store.claim(provider);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface OperationalWaiter {
|
|
116
|
+
epoch: number;
|
|
117
|
+
finish: (operational: boolean) => void;
|
|
118
|
+
timer: NodeJS.Timeout;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const operationalWaiters = new Set<OperationalWaiter>();
|
|
122
|
+
|
|
123
|
+
interface DockerSelection {
|
|
124
|
+
host: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface DockerDaemonProof {
|
|
128
|
+
/** Docker Engine's stable daemon identity (`docker info .ID`). */
|
|
129
|
+
engineId: string;
|
|
130
|
+
serverVersion: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface RuntimeDetectionDeps {
|
|
134
|
+
preference?: RuntimePreference;
|
|
135
|
+
dockerInfo?: (selection?: DockerSelection) => Promise<DockerResult>;
|
|
136
|
+
/** Test seam for the context/endpoint resolver. */
|
|
137
|
+
dockerSelection?: () => Promise<DockerSelection | null>;
|
|
138
|
+
/** Bundled Apple container seam; `undefined` uses the registered binding. */
|
|
139
|
+
appleRuntime?: AppleRuntimeBinding | null;
|
|
140
|
+
/** Test seam for bounded bundled-CLI invocations. */
|
|
141
|
+
appleCli?: (
|
|
142
|
+
args: readonly string[],
|
|
143
|
+
options: { timeoutMs: number; maxOutputBytes: number },
|
|
144
|
+
) => Promise<{ status: number | null; stdout: string; stderr: string }>;
|
|
145
|
+
/** Test seam for the minted installation identity. */
|
|
146
|
+
appleInstallationId?: {
|
|
147
|
+
read: (endpoint: string) => Promise<string | null>;
|
|
148
|
+
mint: (endpoint: string) => Promise<string>;
|
|
149
|
+
};
|
|
150
|
+
/** Test seam for the durable-row selection guard. */
|
|
151
|
+
appleSelectionGuard?:
|
|
152
|
+
| ((endpoint: string) => boolean | Promise<boolean>)
|
|
153
|
+
| null;
|
|
154
|
+
/** Test seam; production always uses os.release() (Darwin 25 = macOS 26). */
|
|
155
|
+
darwinRelease?: string;
|
|
156
|
+
/** Durable task-state ownership seam. */
|
|
157
|
+
providerStateStore?: RuntimeProviderStateStore;
|
|
158
|
+
/** Test seam; production always uses process.platform. */
|
|
159
|
+
platform?: NodeJS.Platform;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface RuntimeAutoRecheckOptions extends RuntimeDetectionDeps {
|
|
163
|
+
/** Fast first retry covers Docker Desktop / OrbStack boot ordering. */
|
|
164
|
+
firstDelayMs?: number;
|
|
165
|
+
/** Later retries stay cheap while a daemon or desktop app starts. */
|
|
166
|
+
intervalMs?: number;
|
|
167
|
+
/** Bound background probing; an explicit recheck remains available later. */
|
|
168
|
+
maxAttempts?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Parse an operator override without letting a typo silently pick a backend. */
|
|
172
|
+
export function runtimePreference(value: string | undefined): RuntimePreference {
|
|
173
|
+
return value === "docker" || value === "apple-container" ? value : "auto";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Authenticated bundled Apple container toolchain (ADR-106). */
|
|
177
|
+
export interface AppleRuntimeBinding {
|
|
178
|
+
/** Absolute path of the signed bundled `container` CLI. */
|
|
179
|
+
readonly containerCliPath: string;
|
|
180
|
+
/** Absolute path of the pinned guest kernel staged in the bundle. */
|
|
181
|
+
readonly kernelPath: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let appleRuntime: AppleRuntimeBinding | null = null;
|
|
185
|
+
|
|
186
|
+
/** Register the bundled Apple container toolchain from the active signed
|
|
187
|
+
* runtime. A source checkout, Intel Mac, or Linux archive registers null and
|
|
188
|
+
* keeps apple-container selection fail-closed. */
|
|
189
|
+
export function setAppleContainerRuntime(
|
|
190
|
+
binding: AppleRuntimeBinding | null,
|
|
191
|
+
): void {
|
|
192
|
+
if (
|
|
193
|
+
pinnedRuntimeProvider !== null &&
|
|
194
|
+
(binding === null) !== (appleRuntime === null)
|
|
195
|
+
) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
"the Apple container runtime cannot change after container runtime selection",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
appleRuntime = binding;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The active apple-container selection's toolchain, for provider binding. */
|
|
204
|
+
export function appleContainerRuntimeBinding(): AppleRuntimeBinding | null {
|
|
205
|
+
return pinnedRuntimeProvider === "apple-container" ? appleRuntime : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let appleSelectionGuard:
|
|
209
|
+
| ((endpoint: string) => boolean | Promise<boolean>)
|
|
210
|
+
| null = null;
|
|
211
|
+
|
|
212
|
+
/** Register the durable task/environment preflight for apple-container
|
|
213
|
+
* selection. Docker rows never wrote a stateful claim (they won `auto` by
|
|
214
|
+
* default before ADR-106), so this guard is how an existing Docker host
|
|
215
|
+
* keeps its rows: rows pinned elsewhere make `auto` fall through to Docker
|
|
216
|
+
* instead of activating the bundled runtime over foreign state. */
|
|
217
|
+
export function setAppleContainerSelectionGuard(
|
|
218
|
+
guard: ((endpoint: string) => boolean | Promise<boolean>) | null,
|
|
219
|
+
): void {
|
|
220
|
+
appleSelectionGuard = guard;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A copy suitable for capability advertisements and command guards. */
|
|
224
|
+
export function containerRuntimeState(): ContainerRuntimeState {
|
|
225
|
+
if (state.status === "checking" && activationPhase !== null) {
|
|
226
|
+
return { ...state, detail: activationPhase };
|
|
227
|
+
}
|
|
228
|
+
return { ...state };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let activationPhase: string | null = null;
|
|
232
|
+
|
|
233
|
+
/** Publish the current activation phase for status surfaces and the log. A
|
|
234
|
+
* silent multi-minute boot (image build, volume seed, CLI reconcile) is
|
|
235
|
+
* indistinguishable from a wedge without this. */
|
|
236
|
+
export function publishActivationPhase(phase: string | null): void {
|
|
237
|
+
activationPhase = phase;
|
|
238
|
+
if (phase !== null) console.log(`[host-agent] activation: ${phase}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Exact machine identity persisted by TaskEnvironment records. An injected
|
|
242
|
+
* test probe that never resolved a local endpoint intentionally has no durable
|
|
243
|
+
* identity; production selection always pins one before reporting ready. */
|
|
244
|
+
/** The pinned backend, independent of the public readiness state. Boot-time
|
|
245
|
+
* maintenance (standard image, volume seeding, writable-container quarantine)
|
|
246
|
+
* runs while the public state is deliberately `checking`; gating its engine
|
|
247
|
+
* selection on readiness deadlocks a Docker-free Mac (found live 2026-08-15:
|
|
248
|
+
* activation could never complete because its own maintenance fell back to a
|
|
249
|
+
* docker binary that does not exist). */
|
|
250
|
+
export function pinnedContainerRuntimeProvider():
|
|
251
|
+
| "docker"
|
|
252
|
+
| "apple-container"
|
|
253
|
+
| null {
|
|
254
|
+
return pinnedRuntimeProvider;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Pin-based identity, independent of readiness/operational state. Teardown
|
|
258
|
+
* and reconstruction must work while the runtime is still `checking`:
|
|
259
|
+
* removing load is how a degraded host heals, and the live 2026-08-16 ring —
|
|
260
|
+
* task holds the shared volume → maintenance cannot attach → never
|
|
261
|
+
* operational → teardown refused → task never dies — was closed by exactly
|
|
262
|
+
* this. The locator comparison downstream still requires an exact match. */
|
|
263
|
+
export function pinnedContainerRuntimeMachineIdentity(): {
|
|
264
|
+
backend: "docker" | "apple-container";
|
|
265
|
+
endpoint: string;
|
|
266
|
+
engineId: string;
|
|
267
|
+
} | null {
|
|
268
|
+
if (
|
|
269
|
+
pinnedRuntimeProvider === null ||
|
|
270
|
+
pinnedDockerHost === null ||
|
|
271
|
+
pinnedDockerEngineId === null
|
|
272
|
+
) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
backend: pinnedRuntimeProvider,
|
|
277
|
+
endpoint: pinnedDockerHost,
|
|
278
|
+
engineId: pinnedDockerEngineId,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Failure message for TEARDOWN admission: only a truly absent/unpinned
|
|
283
|
+
* runtime refuses teardown. `checking` with a pin admits it. */
|
|
284
|
+
export function containerRuntimeTeardownProblem(): string | null {
|
|
285
|
+
if (state.status === "no-runtime") return state.message;
|
|
286
|
+
if (pinnedContainerRuntimeMachineIdentity() === null) {
|
|
287
|
+
return (
|
|
288
|
+
"The container runtime has not selected a backend yet. " +
|
|
289
|
+
"Retry after runtime detection completes."
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function containerRuntimeMachineIdentity(): {
|
|
296
|
+
backend: "docker" | "apple-container";
|
|
297
|
+
endpoint: string;
|
|
298
|
+
engineId: string;
|
|
299
|
+
} | null {
|
|
300
|
+
if (
|
|
301
|
+
state.status !== "ready" ||
|
|
302
|
+
pinnedRuntimeProvider === null ||
|
|
303
|
+
pinnedDockerHost === null ||
|
|
304
|
+
pinnedDockerEngineId === null ||
|
|
305
|
+
state.provider !== pinnedRuntimeProvider
|
|
306
|
+
) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
backend: pinnedRuntimeProvider,
|
|
311
|
+
endpoint: pinnedDockerHost,
|
|
312
|
+
engineId: pinnedDockerEngineId,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** ADR-106: provider-specific asset roots belonged to the retired bundled-VM
|
|
317
|
+
* fallback; ambient Docker and apple-container both use the package context. */
|
|
318
|
+
export function containerRuntimeStandardAssetsRoot(): string | null {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function containerRuntimeStandardImageRoot(): string | null {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function containerRuntimeBindRoot(): string | null {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Public/control-plane view: detected is not usable until maintenance and
|
|
331
|
+
* persisted-task recovery have crossed their readiness fence. */
|
|
332
|
+
export function containerRuntimeCapabilityState(): ContainerRuntimeState {
|
|
333
|
+
if (state.status === "ready" && !operational) {
|
|
334
|
+
return { status: "checking", preference: state.preference };
|
|
335
|
+
}
|
|
336
|
+
return containerRuntimeState();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Complete one readiness transition after image maintenance + recovery. */
|
|
340
|
+
export function markContainerRuntimeOperational(
|
|
341
|
+
expectedEpoch = readinessEpoch,
|
|
342
|
+
): boolean {
|
|
343
|
+
if (state.status !== "ready" || expectedEpoch !== readinessEpoch) return false;
|
|
344
|
+
operational = true;
|
|
345
|
+
activationRequired = false;
|
|
346
|
+
settleOperationalWaiters();
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Fail closed while a known-ready daemon is being revalidated. Explicit
|
|
351
|
+
* operator recovery can also invalidate the generation so ready-to-ready
|
|
352
|
+
* probes still run maintenance and persisted-task recovery. */
|
|
353
|
+
export function suspendContainerRuntime(
|
|
354
|
+
invalidateGeneration = false,
|
|
355
|
+
): number | null {
|
|
356
|
+
if (state.status !== "ready") return null;
|
|
357
|
+
if (!operational && !invalidateGeneration) return null;
|
|
358
|
+
operational = false;
|
|
359
|
+
if (invalidateGeneration) {
|
|
360
|
+
readinessEpoch += 1;
|
|
361
|
+
activationRequired = true;
|
|
362
|
+
settleOperationalWaiters();
|
|
363
|
+
}
|
|
364
|
+
return readinessEpoch;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Token for work that must not survive a daemon readiness transition. */
|
|
368
|
+
export function containerRuntimeReadinessEpoch(): number {
|
|
369
|
+
return readinessEpoch;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function containerRuntimeEpochIsCurrent(epoch: number): boolean {
|
|
373
|
+
return epoch === readinessEpoch && state.status === "ready";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Command-scoped fence: the exact generation is still current AND the
|
|
377
|
+
* runtime is operational right now. Plain suspension (connection
|
|
378
|
+
* revalidation) keeps the epoch and ready status while operational drops —
|
|
379
|
+
* a pass fenced only on the epoch would keep mutating against an
|
|
380
|
+
* unverified daemon. */
|
|
381
|
+
export function containerRuntimeGenerationOperational(epoch: number): boolean {
|
|
382
|
+
return epoch === readinessEpoch && state.status === "ready" && operational;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether a ready verdict must cross maintenance/recovery before use. */
|
|
386
|
+
export function containerRuntimeActivationRequired(): boolean {
|
|
387
|
+
return state.status === "ready" && activationRequired;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Wait for image maintenance and persisted-task recovery to release one exact
|
|
392
|
+
* runtime generation. A later daemon/provider generation resolves false; it
|
|
393
|
+
* can never accidentally release a task admitted for the older backend.
|
|
394
|
+
*/
|
|
395
|
+
export function waitForContainerRuntimeOperational(
|
|
396
|
+
expectedEpoch = readinessEpoch,
|
|
397
|
+
timeoutMs = 15 * 60_000,
|
|
398
|
+
): Promise<boolean> {
|
|
399
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
400
|
+
return Promise.reject(new Error("runtime operational timeout is invalid"));
|
|
401
|
+
}
|
|
402
|
+
if (expectedEpoch !== readinessEpoch || state.status !== "ready") {
|
|
403
|
+
return Promise.resolve(false);
|
|
404
|
+
}
|
|
405
|
+
if (operational) return Promise.resolve(true);
|
|
406
|
+
if (timeoutMs === 0) return Promise.resolve(false);
|
|
407
|
+
|
|
408
|
+
return new Promise<boolean>((resolve) => {
|
|
409
|
+
const waiter: OperationalWaiter = {
|
|
410
|
+
epoch: expectedEpoch,
|
|
411
|
+
finish: resolve,
|
|
412
|
+
timer: setTimeout(() => {
|
|
413
|
+
if (!operationalWaiters.delete(waiter)) return;
|
|
414
|
+
resolve(false);
|
|
415
|
+
}, timeoutMs),
|
|
416
|
+
};
|
|
417
|
+
operationalWaiters.add(waiter);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Claim apple-container as stateful before task-up performs its first
|
|
423
|
+
* container mutation. Docker needs no marker because it wins `auto` by
|
|
424
|
+
* default. A stale generation after the fsync is conservative (future `auto`
|
|
425
|
+
* stays on the bundled runtime), never unsafe.
|
|
426
|
+
*/
|
|
427
|
+
/**
|
|
428
|
+
* Claim apple-container as stateful before ACTIVATION's first mutation
|
|
429
|
+
* (shared-volume create, image build) — which happens before any task
|
|
430
|
+
* exists. Uninstall reads an absent marker as "nothing to tear down", so a
|
|
431
|
+
* zero-task install→activate→uninstall previously skipped Apple cleanup
|
|
432
|
+
* entirely (review 2026-08-18 round 4). No readiness gate: activation
|
|
433
|
+
* maintenance runs while the public state is still `checking` by design.
|
|
434
|
+
*/
|
|
435
|
+
export async function claimContainerRuntimeForMaintenance(
|
|
436
|
+
store: RuntimeProviderStateStore = defaultRuntimeProviderStateStore(),
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
// The full claim commits a rollback guard under the managed operation
|
|
439
|
+
// lock — once per host lifetime is its job. Every later activation pass
|
|
440
|
+
// takes the cheap read-skip.
|
|
441
|
+
if ((await store.read())?.statefulProvider === "apple-container") return;
|
|
442
|
+
await store.claim("apple-container");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function claimContainerRuntimeForTask(
|
|
446
|
+
expectedEpoch = readinessEpoch,
|
|
447
|
+
store: RuntimeProviderStateStore = defaultRuntimeProviderStateStore(),
|
|
448
|
+
): Promise<boolean> {
|
|
449
|
+
if (
|
|
450
|
+
expectedEpoch !== readinessEpoch ||
|
|
451
|
+
state.status !== "ready" ||
|
|
452
|
+
!operational
|
|
453
|
+
) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
if (state.provider === "docker") return true;
|
|
457
|
+
await store.claim("apple-container");
|
|
458
|
+
return (
|
|
459
|
+
expectedEpoch === readinessEpoch &&
|
|
460
|
+
state.status === "ready" &&
|
|
461
|
+
state.provider === "apple-container" &&
|
|
462
|
+
operational
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Explicit operator recovery: invalidate even a stale ready verdict before
|
|
467
|
+
* probing so a healthy ready-to-ready result still requires a full activation
|
|
468
|
+
* and persisted-task recovery pass. */
|
|
469
|
+
export async function forceContainerRuntimeRecheck(
|
|
470
|
+
deps: RuntimeDetectionDeps = {},
|
|
471
|
+
): Promise<{ state: ContainerRuntimeState; epoch: number | null }> {
|
|
472
|
+
// Gate new work synchronously, but do not invalidate the generation until
|
|
473
|
+
// an older ordinary probe has settled. Invalidating first would let that
|
|
474
|
+
// probe's ready-to-ready publication start maintenance for an intermediate
|
|
475
|
+
// epoch while this forced refresh was still waiting for the shared detector.
|
|
476
|
+
if (state.status === "ready") suspendContainerRuntime();
|
|
477
|
+
// A forced request must not get absorbed by an older ordinary probe. Wait
|
|
478
|
+
// for it, invalidate its final verdict exactly once, then run the refresh.
|
|
479
|
+
if (detectionInFlight) await detectionInFlight;
|
|
480
|
+
const forcedEpoch =
|
|
481
|
+
state.status === "ready" ? suspendContainerRuntime(true) : null;
|
|
482
|
+
// Re-probe the process pin. Never clear DOCKER_HOST or consult a newly
|
|
483
|
+
// selected ambient context here: even a short async gap would let unrelated
|
|
484
|
+
// Docker work escape to another daemon. Switching endpoints requires a
|
|
485
|
+
// service restart and is explained by the degraded-state message.
|
|
486
|
+
const next = await initializeContainerRuntime(deps);
|
|
487
|
+
return {
|
|
488
|
+
state: next,
|
|
489
|
+
epoch:
|
|
490
|
+
next.status === "ready"
|
|
491
|
+
? (forcedEpoch ?? containerRuntimeReadinessEpoch())
|
|
492
|
+
: null,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Subscribe to successful rechecks; used to refresh the capability frame. */
|
|
497
|
+
export function onContainerRuntimeChange(
|
|
498
|
+
listener: RuntimeChangeListener,
|
|
499
|
+
): () => void {
|
|
500
|
+
listeners.add(listener);
|
|
501
|
+
return () => listeners.delete(listener);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Quarantine a selected backend after durable environment reconstruction
|
|
506
|
+
* proves it is not the machine those tasks belong to. The process pin is
|
|
507
|
+
* retained so no later recheck can escape to a different daemon; only a host
|
|
508
|
+
* restart after explicit operator repair can change it.
|
|
509
|
+
*/
|
|
510
|
+
export function rejectContainerRuntimeSelection(
|
|
511
|
+
message: string,
|
|
512
|
+
action = RUNTIME_RESTART_COMMAND,
|
|
513
|
+
): void {
|
|
514
|
+
const safeMessage = message.trim();
|
|
515
|
+
const safeAction = action.trim();
|
|
516
|
+
publishContainerRuntimeState(
|
|
517
|
+
{
|
|
518
|
+
status: "no-runtime",
|
|
519
|
+
preference: state.preference,
|
|
520
|
+
message:
|
|
521
|
+
safeMessage.length > 0 && safeMessage.length <= 1_000
|
|
522
|
+
? safeMessage
|
|
523
|
+
: "The selected container runtime does not match persisted task state.",
|
|
524
|
+
action:
|
|
525
|
+
safeAction.length > 0 && safeAction.length <= 200
|
|
526
|
+
? safeAction
|
|
527
|
+
: RUNTIME_RESTART_COMMAND,
|
|
528
|
+
},
|
|
529
|
+
true,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Detect and apply the process-wide runtime selection.
|
|
535
|
+
*
|
|
536
|
+
* Never throws: a missing CLI, stopped daemon, or probe timeout is a normal
|
|
537
|
+
* degraded-host state, not a reason to crash-loop the service.
|
|
538
|
+
*/
|
|
539
|
+
export function initializeContainerRuntime(
|
|
540
|
+
deps: RuntimeDetectionDeps = {},
|
|
541
|
+
): Promise<ContainerRuntimeState> {
|
|
542
|
+
// The bounded background retry and an operator-triggered recheck can land
|
|
543
|
+
// together. Join them so a slower stale failure cannot overwrite a newer
|
|
544
|
+
// ready verdict (and so only one `docker info` process runs at a time).
|
|
545
|
+
if (detectionInFlight) return detectionInFlight;
|
|
546
|
+
const running = detectContainerRuntime(deps)
|
|
547
|
+
.then(publishContainerRuntimeState)
|
|
548
|
+
.finally(() => {
|
|
549
|
+
if (detectionInFlight === running) detectionInFlight = null;
|
|
550
|
+
});
|
|
551
|
+
detectionInFlight = running;
|
|
552
|
+
return running;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Task-admission entrypoint: repeat ordinary detection so a task that raced
|
|
557
|
+
* host boot still gets a runtime. Runtime activation is intentionally owned
|
|
558
|
+
* by the existing state-change listener; callers can wait on its exact epoch
|
|
559
|
+
* with waitForContainerRuntimeOperational.
|
|
560
|
+
*/
|
|
561
|
+
export async function ensureContainerRuntimeForTask(
|
|
562
|
+
deps: RuntimeDetectionDeps = {},
|
|
563
|
+
): Promise<ContainerRuntimeState> {
|
|
564
|
+
// ADR-106: apple-container activation (service start, kernel install,
|
|
565
|
+
// identity mint) happens inside ordinary detection, so a task needs no
|
|
566
|
+
// separate lazy-provisioning path.
|
|
567
|
+
return initializeContainerRuntime(deps);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function startContainerRuntimeAutoRecheck(
|
|
571
|
+
options: RuntimeAutoRecheckOptions = {},
|
|
572
|
+
): () => void {
|
|
573
|
+
if (
|
|
574
|
+
state.status !== "no-runtime" ||
|
|
575
|
+
state.action === RUNTIME_RESTART_COMMAND ||
|
|
576
|
+
(options.maxAttempts ?? 45) <= 0
|
|
577
|
+
) {
|
|
578
|
+
return () => {};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const firstDelayMs = options.firstDelayMs ?? 5_000;
|
|
582
|
+
const intervalMs = options.intervalMs ?? 20_000;
|
|
583
|
+
const maxAttempts = options.maxAttempts ?? 45;
|
|
584
|
+
let attempts = 0;
|
|
585
|
+
let stopped = false;
|
|
586
|
+
let timer: NodeJS.Timeout | null = null;
|
|
587
|
+
|
|
588
|
+
const schedule = (delayMs: number): void => {
|
|
589
|
+
timer = setTimeout(() => {
|
|
590
|
+
timer = null;
|
|
591
|
+
if (stopped || state.status === "ready") return;
|
|
592
|
+
attempts += 1;
|
|
593
|
+
void initializeContainerRuntime(options).then((next) => {
|
|
594
|
+
if (stopped || next.status === "ready" || attempts >= maxAttempts) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
schedule(intervalMs);
|
|
598
|
+
});
|
|
599
|
+
}, delayMs);
|
|
600
|
+
timer.unref?.();
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
schedule(firstDelayMs);
|
|
604
|
+
return () => {
|
|
605
|
+
stopped = true;
|
|
606
|
+
if (timer) clearTimeout(timer);
|
|
607
|
+
timer = null;
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Pure detection seam kept injectable for deterministic startup tests. */
|
|
612
|
+
interface AppleSystemProbe {
|
|
613
|
+
readonly endpoint: string;
|
|
614
|
+
readonly installationId: string;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const APPLE_INSTALLATION_ID_FILE = "uai-installation-id";
|
|
618
|
+
const APPLE_INSTALLATION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$/;
|
|
619
|
+
|
|
620
|
+
function defaultAppleCli(
|
|
621
|
+
binding: AppleRuntimeBinding,
|
|
622
|
+
): NonNullable<RuntimeDetectionDeps["appleCli"]> {
|
|
623
|
+
return (args, options) =>
|
|
624
|
+
new Promise((resolve) => {
|
|
625
|
+
const child = spawn(binding.containerCliPath, [...args], {
|
|
626
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
627
|
+
});
|
|
628
|
+
const stdout: Buffer[] = [];
|
|
629
|
+
const stderr: Buffer[] = [];
|
|
630
|
+
let bytes = 0;
|
|
631
|
+
// A SIGKILLed CLI normally closes at once, but an unreapable child
|
|
632
|
+
// must never strand boot/recheck/activation on a promise that only
|
|
633
|
+
// settles on close.
|
|
634
|
+
const killAndSettle = (): void => {
|
|
635
|
+
child.kill("SIGKILL");
|
|
636
|
+
const settle = setTimeout(() => finish(null), 5_000);
|
|
637
|
+
settle.unref?.();
|
|
638
|
+
};
|
|
639
|
+
const collect = (target: Buffer[]) => (chunk: Buffer) => {
|
|
640
|
+
bytes += chunk.length;
|
|
641
|
+
if (bytes > options.maxOutputBytes) killAndSettle();
|
|
642
|
+
else target.push(chunk);
|
|
643
|
+
};
|
|
644
|
+
child.stdout.on("data", collect(stdout));
|
|
645
|
+
child.stderr.on("data", collect(stderr));
|
|
646
|
+
const timer = setTimeout(killAndSettle, options.timeoutMs);
|
|
647
|
+
timer.unref?.();
|
|
648
|
+
let settled = false;
|
|
649
|
+
const finish = (status: number | null): void => {
|
|
650
|
+
if (settled) return;
|
|
651
|
+
settled = true;
|
|
652
|
+
clearTimeout(timer);
|
|
653
|
+
resolve({
|
|
654
|
+
status,
|
|
655
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
656
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
child.once("error", () => finish(null));
|
|
660
|
+
child.once("close", (code) => finish(code));
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function defaultAppleInstallationId(): NonNullable<
|
|
665
|
+
RuntimeDetectionDeps["appleInstallationId"]
|
|
666
|
+
> {
|
|
667
|
+
return {
|
|
668
|
+
async read(endpoint) {
|
|
669
|
+
try {
|
|
670
|
+
const raw = (
|
|
671
|
+
await fsReadFile(path.join(endpoint, APPLE_INSTALLATION_ID_FILE), "utf8")
|
|
672
|
+
).trim();
|
|
673
|
+
return APPLE_INSTALLATION_ID.test(raw) ? raw : null;
|
|
674
|
+
} catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
async mint(endpoint) {
|
|
679
|
+
const id = randomUUID();
|
|
680
|
+
await fsWriteFile(
|
|
681
|
+
path.join(endpoint, APPLE_INSTALLATION_ID_FILE),
|
|
682
|
+
`${id}\n`,
|
|
683
|
+
{ mode: 0o600, flag: "wx" },
|
|
684
|
+
);
|
|
685
|
+
return id;
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function parseAppleSystemStatus(
|
|
691
|
+
stdout: string,
|
|
692
|
+
): { running: boolean; appRoot: string } | null {
|
|
693
|
+
let running = false;
|
|
694
|
+
let appRoot: string | null = null;
|
|
695
|
+
for (const line of stdout.split("\n")) {
|
|
696
|
+
if (/^status\s+/.test(line)) {
|
|
697
|
+
running = line.replace(/^status\s+/, "").trim() === "running";
|
|
698
|
+
} else if (/^appRoot\s+/.test(line)) {
|
|
699
|
+
appRoot = line.replace(/^appRoot\s+/, "").trim().replace(/\/$/, "");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (appRoot === null || !appRoot.startsWith("/")) return null;
|
|
703
|
+
return { running, appRoot };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function selectedAppleRuntime(
|
|
707
|
+
deps: RuntimeDetectionDeps,
|
|
708
|
+
): AppleRuntimeBinding | null {
|
|
709
|
+
return deps.appleRuntime === undefined ? appleRuntime : deps.appleRuntime;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function appleRuntimeEligible(deps: RuntimeDetectionDeps): boolean {
|
|
713
|
+
if ((deps.platform ?? process.platform) !== "darwin") return false;
|
|
714
|
+
// Darwin kernel 25 shipped with macOS 26 — the toolchain's floor. The
|
|
715
|
+
// artifact itself is arm64-only, so architecture is settled upstream.
|
|
716
|
+
const release = deps.darwinRelease ?? osRelease();
|
|
717
|
+
return Number.parseInt(release.split(".")[0] ?? "0", 10) >= 25;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Prove the per-user toolchain state and its minted installation identity
|
|
721
|
+
* WITHOUT starting anything — the pinned-runtime recheck path. */
|
|
722
|
+
async function probeAppleSystem(
|
|
723
|
+
deps: RuntimeDetectionDeps,
|
|
724
|
+
): Promise<AppleSystemProbe | null> {
|
|
725
|
+
const binding = selectedAppleRuntime(deps);
|
|
726
|
+
if (binding === null || !appleRuntimeEligible(deps)) return null;
|
|
727
|
+
const cli = deps.appleCli ?? defaultAppleCli(binding);
|
|
728
|
+
const status = await cli(["system", "status"], {
|
|
729
|
+
timeoutMs: 15_000,
|
|
730
|
+
maxOutputBytes: 64 * 1024,
|
|
731
|
+
});
|
|
732
|
+
if (status.status !== 0) return null;
|
|
733
|
+
const parsed = parseAppleSystemStatus(status.stdout);
|
|
734
|
+
if (parsed === null || !parsed.running) return null;
|
|
735
|
+
const ids = deps.appleInstallationId ?? defaultAppleInstallationId();
|
|
736
|
+
const installationId = await ids.read(parsed.appRoot);
|
|
737
|
+
if (installationId === null) return null;
|
|
738
|
+
return { endpoint: parsed.appRoot, installationId };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Activate and select the bundled Apple container runtime: start the user
|
|
743
|
+
* services when they are down, install the pinned guest kernel and mint the
|
|
744
|
+
* durable installation identity on first activation, then pin the process to
|
|
745
|
+
* (apple-container, state root, installation id). Returns null when the
|
|
746
|
+
* backend is ineligible or activation fails — `auto` falls through to Docker.
|
|
747
|
+
*/
|
|
748
|
+
async function probeAndSelectApple(
|
|
749
|
+
preference: "auto" | "apple-container",
|
|
750
|
+
deps: RuntimeDetectionDeps,
|
|
751
|
+
): Promise<ContainerRuntimeState | null> {
|
|
752
|
+
const binding = selectedAppleRuntime(deps);
|
|
753
|
+
if (binding === null || !appleRuntimeEligible(deps)) return null;
|
|
754
|
+
const cli = deps.appleCli ?? defaultAppleCli(binding);
|
|
755
|
+
const ids = deps.appleInstallationId ?? defaultAppleInstallationId();
|
|
756
|
+
|
|
757
|
+
let status = await cli(["system", "status"], {
|
|
758
|
+
timeoutMs: 15_000,
|
|
759
|
+
maxOutputBytes: 64 * 1024,
|
|
760
|
+
});
|
|
761
|
+
let parsed = status.status === 0 ? parseAppleSystemStatus(status.stdout) : null;
|
|
762
|
+
if (parsed === null || !parsed.running) {
|
|
763
|
+
const started = await cli(["system", "start"], {
|
|
764
|
+
timeoutMs: 120_000,
|
|
765
|
+
maxOutputBytes: 256 * 1024,
|
|
766
|
+
});
|
|
767
|
+
if (started.status !== 0) return null;
|
|
768
|
+
status = await cli(["system", "status"], {
|
|
769
|
+
timeoutMs: 15_000,
|
|
770
|
+
maxOutputBytes: 64 * 1024,
|
|
771
|
+
});
|
|
772
|
+
parsed = status.status === 0 ? parseAppleSystemStatus(status.stdout) : null;
|
|
773
|
+
if (parsed === null || !parsed.running) return null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Durable rows pinned to another backend keep their machine: fall through
|
|
777
|
+
// (auto reaches Docker) rather than activating over foreign state. The
|
|
778
|
+
// exact engine-identity fence still runs before maintenance either way.
|
|
779
|
+
const guard =
|
|
780
|
+
deps.appleSelectionGuard === undefined
|
|
781
|
+
? appleSelectionGuard
|
|
782
|
+
: deps.appleSelectionGuard;
|
|
783
|
+
if (guard) {
|
|
784
|
+
try {
|
|
785
|
+
if ((await guard(parsed.appRoot)) !== true) return null;
|
|
786
|
+
} catch {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
let installationId = await ids.read(parsed.appRoot);
|
|
792
|
+
if (installationId === null) {
|
|
793
|
+
// First activation of this state root: the guest kernel must be in place
|
|
794
|
+
// before any container can boot, and the identity is minted only after
|
|
795
|
+
// the kernel install succeeded so a crash re-runs both.
|
|
796
|
+
const kernel = await cli(
|
|
797
|
+
["system", "kernel", "set", "--binary", binding.kernelPath],
|
|
798
|
+
{ timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
|
|
799
|
+
);
|
|
800
|
+
if (kernel.status !== 0) return null;
|
|
801
|
+
try {
|
|
802
|
+
installationId = await ids.mint(parsed.appRoot);
|
|
803
|
+
} catch {
|
|
804
|
+
installationId = await ids.read(parsed.appRoot);
|
|
805
|
+
if (installationId === null) return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (
|
|
810
|
+
!pinRuntime(
|
|
811
|
+
"apple-container",
|
|
812
|
+
parsed.appRoot,
|
|
813
|
+
installationId,
|
|
814
|
+
binding.containerCliPath,
|
|
815
|
+
)
|
|
816
|
+
) {
|
|
817
|
+
return detectPinnedRuntime(preference, deps);
|
|
818
|
+
}
|
|
819
|
+
return { status: "ready", preference, provider: "apple-container" };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export async function detectContainerRuntime(
|
|
823
|
+
deps: RuntimeDetectionDeps = {},
|
|
824
|
+
): Promise<ContainerRuntimeState> {
|
|
825
|
+
const configured = process.env.UAI_RUNTIME;
|
|
826
|
+
if (
|
|
827
|
+
deps.preference === undefined &&
|
|
828
|
+
configured !== undefined &&
|
|
829
|
+
configured !== "auto" &&
|
|
830
|
+
configured !== "docker" &&
|
|
831
|
+
configured !== "apple-container"
|
|
832
|
+
) {
|
|
833
|
+
return {
|
|
834
|
+
status: "no-runtime",
|
|
835
|
+
preference: "auto",
|
|
836
|
+
message:
|
|
837
|
+
"UAI_RUNTIME has an invalid value. " +
|
|
838
|
+
"Use `auto`, `docker`, or `apple-container`, then restart Uai Host.",
|
|
839
|
+
action: RUNTIME_RESTART_COMMAND,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
const preference =
|
|
843
|
+
deps.preference ?? runtimePreference(process.env.UAI_RUNTIME);
|
|
844
|
+
|
|
845
|
+
// The selected provider/endpoint is immutable for this process. Rechecks
|
|
846
|
+
// may restart the same toolchain services or prove the same ambient
|
|
847
|
+
// daemon, but cannot cross the boundary while task/credential work may
|
|
848
|
+
// still be in flight.
|
|
849
|
+
if (pinnedDockerHost && pinnedRuntimeProvider) {
|
|
850
|
+
return detectPinnedRuntime(preference, deps);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const store =
|
|
854
|
+
deps.providerStateStore ?? defaultRuntimeProviderStateStore();
|
|
855
|
+
|
|
856
|
+
if (preference === "apple-container") {
|
|
857
|
+
return (
|
|
858
|
+
(await probeAndSelectApple(preference, deps)) ??
|
|
859
|
+
appleUnavailable(preference, selectedAppleRuntime(deps) === null, deps)
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (preference === "auto") {
|
|
864
|
+
let stickyApple = false;
|
|
865
|
+
try {
|
|
866
|
+
stickyApple =
|
|
867
|
+
(await store.read())?.statefulProvider === "apple-container";
|
|
868
|
+
} catch {
|
|
869
|
+
return {
|
|
870
|
+
status: "no-runtime",
|
|
871
|
+
preference,
|
|
872
|
+
message:
|
|
873
|
+
"Uai Host could not validate its durable container-runtime selection. " +
|
|
874
|
+
"Repair the local runtime state, then restart Uai Host.",
|
|
875
|
+
action: RUNTIME_RESTART_COMMAND,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if (stickyApple) {
|
|
879
|
+
// The bundled runtime owns durable task state: never silently move
|
|
880
|
+
// that state to an ambient daemon, even when activation fails.
|
|
881
|
+
return (
|
|
882
|
+
(await probeAndSelectApple(preference, deps)) ??
|
|
883
|
+
appleUnavailable(preference, selectedAppleRuntime(deps) === null, deps)
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
// ADR-106: prefer the bundled runtime wherever it can run — ambient
|
|
887
|
+
// Docker existing is not a reason to skip it. A failed activation falls
|
|
888
|
+
// through to Docker rather than degrading a machine that has one.
|
|
889
|
+
const selected = await probeAndSelectApple(preference, deps);
|
|
890
|
+
if (selected !== null) return selected;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return detectAmbientDocker(preference, deps);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function appleUnavailable(
|
|
897
|
+
preference: RuntimePreference,
|
|
898
|
+
unbundled: boolean,
|
|
899
|
+
deps: RuntimeDetectionDeps,
|
|
900
|
+
): ContainerRuntimeState {
|
|
901
|
+
const eligible = appleRuntimeEligible(deps);
|
|
902
|
+
return {
|
|
903
|
+
status: "no-runtime",
|
|
904
|
+
preference,
|
|
905
|
+
message: unbundled
|
|
906
|
+
? "The bundled Apple container runtime is not part of this installation. " +
|
|
907
|
+
"Install the standalone runtime on Apple Silicon with macOS 26 or newer, " +
|
|
908
|
+
`or set UAI_RUNTIME=docker and run \`${RUNTIME_RESTART_COMMAND}\`.`
|
|
909
|
+
: eligible
|
|
910
|
+
? APPLE_RUNTIME_UNAVAILABLE_MESSAGE
|
|
911
|
+
: "The bundled Apple container runtime needs Apple Silicon and " +
|
|
912
|
+
"macOS 26 or newer. Use Docker on this machine: set " +
|
|
913
|
+
`UAI_RUNTIME=docker and run \`${RUNTIME_RESTART_COMMAND}\`.`,
|
|
914
|
+
action: RUNTIME_RECHECK_COMMAND,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function detectAmbientDocker(
|
|
919
|
+
preference: "auto" | "docker",
|
|
920
|
+
deps: RuntimeDetectionDeps,
|
|
921
|
+
): Promise<ContainerRuntimeState> {
|
|
922
|
+
// A successful ambient `docker info` is not a durable selection: collapse a
|
|
923
|
+
// named context to its exact local endpoint before publishing DOCKER_HOST.
|
|
924
|
+
const selection =
|
|
925
|
+
deps.dockerSelection !== undefined
|
|
926
|
+
? await deps.dockerSelection()
|
|
927
|
+
: deps.dockerInfo === undefined
|
|
928
|
+
? await resolveDockerSelection()
|
|
929
|
+
: null;
|
|
930
|
+
if (selection && !isLocalDockerEndpoint(selection.host)) {
|
|
931
|
+
return {
|
|
932
|
+
status: "no-runtime",
|
|
933
|
+
preference,
|
|
934
|
+
message:
|
|
935
|
+
"The selected Docker endpoint is remote. " +
|
|
936
|
+
"Uai Host requires a local Unix-socket daemon for workspace mounts and loopback tunnels. " +
|
|
937
|
+
`Select a local Docker context, then run \`${RUNTIME_RESTART_COMMAND}\`.`,
|
|
938
|
+
action: RUNTIME_RESTART_COMMAND,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (deps.dockerInfo === undefined && selection === null) {
|
|
943
|
+
return {
|
|
944
|
+
status: "no-runtime",
|
|
945
|
+
preference,
|
|
946
|
+
message:
|
|
947
|
+
"Uai Host could not resolve the selected Docker context to a local endpoint. " +
|
|
948
|
+
`Select a local Docker context and run \`${RUNTIME_RECHECK_COMMAND}\`.`,
|
|
949
|
+
action: RUNTIME_RECHECK_COMMAND,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const proof = await probeDocker(selection, deps);
|
|
954
|
+
if (proof !== null) {
|
|
955
|
+
if (selection) {
|
|
956
|
+
if (!pinRuntime("docker", selection.host, proof.engineId)) {
|
|
957
|
+
return pinnedRuntimeUnavailable(preference);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return { status: "ready", preference, provider: "docker" };
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
status: "no-runtime",
|
|
964
|
+
preference,
|
|
965
|
+
message:
|
|
966
|
+
preference === "docker"
|
|
967
|
+
? "No working Docker runtime was found. Install or start Docker, then run " +
|
|
968
|
+
`\`${RUNTIME_RECHECK_COMMAND}\`.`
|
|
969
|
+
: NO_RUNTIME_MESSAGE,
|
|
970
|
+
action: RUNTIME_RECHECK_COMMAND,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function detectPinnedRuntime(
|
|
975
|
+
preference: RuntimePreference,
|
|
976
|
+
deps: RuntimeDetectionDeps,
|
|
977
|
+
): Promise<ContainerRuntimeState> {
|
|
978
|
+
const provider = pinnedRuntimeProvider!;
|
|
979
|
+
const host = pinnedDockerHost!;
|
|
980
|
+
if (
|
|
981
|
+
(preference === "docker" && provider !== "docker") ||
|
|
982
|
+
(preference === "apple-container" && provider !== "apple-container")
|
|
983
|
+
) {
|
|
984
|
+
return pinnedRuntimeUnavailable(preference);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (provider === "apple-container") {
|
|
988
|
+
// Re-prove the same per-user state root and the same minted installation
|
|
989
|
+
// id; a wiped or replaced toolchain state must fail closed exactly like a
|
|
990
|
+
// replaced Docker daemon at the same socket.
|
|
991
|
+
const probe = await probeAppleSystem(deps);
|
|
992
|
+
if (
|
|
993
|
+
probe === null ||
|
|
994
|
+
probe.endpoint !== host ||
|
|
995
|
+
pinnedDockerEngineId === null ||
|
|
996
|
+
probe.installationId !== pinnedDockerEngineId
|
|
997
|
+
) {
|
|
998
|
+
return pinnedRuntimeUnavailable(preference);
|
|
999
|
+
}
|
|
1000
|
+
applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
|
|
1001
|
+
return {
|
|
1002
|
+
status: "ready",
|
|
1003
|
+
preference: preference === "auto" ? "auto" : "apple-container",
|
|
1004
|
+
provider,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
applyPinnedDockerHost(host);
|
|
1009
|
+
const proof = await probeDocker({ host }, deps);
|
|
1010
|
+
if (
|
|
1011
|
+
proof === null ||
|
|
1012
|
+
pinnedDockerEngineId === null ||
|
|
1013
|
+
proof.engineId !== pinnedDockerEngineId
|
|
1014
|
+
) {
|
|
1015
|
+
return pinnedRuntimeUnavailable(preference);
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
status: "ready",
|
|
1019
|
+
preference: preference === "auto" ? "auto" : "docker",
|
|
1020
|
+
provider: "docker",
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Read-only TOCTOU fence for work that inspected daemon-owned resources.
|
|
1025
|
+
* Unlike a normal recheck this does not publish state or run a controller
|
|
1026
|
+
* transition; its caller must quarantine the selection when null is returned. */
|
|
1027
|
+
export async function reprobeContainerRuntimeMachineIdentity(
|
|
1028
|
+
deps: Pick<
|
|
1029
|
+
RuntimeDetectionDeps,
|
|
1030
|
+
| "dockerInfo"
|
|
1031
|
+
| "appleRuntime"
|
|
1032
|
+
| "appleCli"
|
|
1033
|
+
| "appleInstallationId"
|
|
1034
|
+
| "darwinRelease"
|
|
1035
|
+
> = {},
|
|
1036
|
+
): Promise<ReturnType<typeof containerRuntimeMachineIdentity>> {
|
|
1037
|
+
if (
|
|
1038
|
+
state.status !== "ready" ||
|
|
1039
|
+
pinnedRuntimeProvider === null ||
|
|
1040
|
+
pinnedDockerHost === null ||
|
|
1041
|
+
pinnedDockerEngineId === null
|
|
1042
|
+
) {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
if (pinnedRuntimeProvider === "apple-container") {
|
|
1046
|
+
// The pinned endpoint is the per-user state root, not a Docker socket:
|
|
1047
|
+
// publishing it as DOCKER_HOST (and clearing the script selection
|
|
1048
|
+
// variables) would both fail this fence and corrupt the process
|
|
1049
|
+
// environment for every agent script. Re-prove the same state root and
|
|
1050
|
+
// minted installation id through the Apple probe instead.
|
|
1051
|
+
const probe = await probeAppleSystem(deps);
|
|
1052
|
+
if (
|
|
1053
|
+
probe === null ||
|
|
1054
|
+
probe.endpoint !== pinnedDockerHost ||
|
|
1055
|
+
probe.installationId !== pinnedDockerEngineId
|
|
1056
|
+
) {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
|
|
1060
|
+
return {
|
|
1061
|
+
backend: pinnedRuntimeProvider,
|
|
1062
|
+
endpoint: pinnedDockerHost,
|
|
1063
|
+
engineId: pinnedDockerEngineId,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
applyPinnedDockerHost(pinnedDockerHost);
|
|
1067
|
+
const proof = await probeDocker({ host: pinnedDockerHost }, deps);
|
|
1068
|
+
if (proof?.engineId !== pinnedDockerEngineId) return null;
|
|
1069
|
+
return {
|
|
1070
|
+
backend: pinnedRuntimeProvider,
|
|
1071
|
+
endpoint: pinnedDockerHost,
|
|
1072
|
+
engineId: pinnedDockerEngineId,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function probeDocker(
|
|
1077
|
+
selection: DockerSelection | null,
|
|
1078
|
+
deps: RuntimeDetectionDeps,
|
|
1079
|
+
): Promise<DockerDaemonProof | null> {
|
|
1080
|
+
const dockerInfo =
|
|
1081
|
+
deps.dockerInfo ??
|
|
1082
|
+
((selected?: DockerSelection) =>
|
|
1083
|
+
dockerCli(
|
|
1084
|
+
[
|
|
1085
|
+
"--host",
|
|
1086
|
+
selected!.host,
|
|
1087
|
+
"info",
|
|
1088
|
+
"--format",
|
|
1089
|
+
"{{.ID}}\t{{.ServerVersion}}",
|
|
1090
|
+
],
|
|
1091
|
+
{ timeoutMs: 10_000, maxOutputBytes: 4_096 },
|
|
1092
|
+
));
|
|
1093
|
+
try {
|
|
1094
|
+
return parseDockerDaemonProof(await dockerInfo(selection ?? undefined));
|
|
1095
|
+
} catch {
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Strict parser for the one atomic identity/liveness probe. Trimming is
|
|
1101
|
+
* deliberately forbidden: a second record, CRLF ambiguity, or whitespace in
|
|
1102
|
+
* either identity component is not a daemon proof. */
|
|
1103
|
+
export function parseDockerDaemonProof(
|
|
1104
|
+
result: DockerResult | null,
|
|
1105
|
+
): DockerDaemonProof | null {
|
|
1106
|
+
if (
|
|
1107
|
+
result?.status !== 0 ||
|
|
1108
|
+
result.outputTruncated === true ||
|
|
1109
|
+
Buffer.byteLength(result.stdout) > 4_096
|
|
1110
|
+
) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
const match = /^([A-Za-z0-9][A-Za-z0-9:._-]{0,511})\t([!-~]{1,128})\n$/.exec(
|
|
1114
|
+
result.stdout,
|
|
1115
|
+
);
|
|
1116
|
+
if (!match) return null;
|
|
1117
|
+
return { engineId: match[1]!, serverVersion: match[2]! };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async function resolveDockerSelection(): Promise<DockerSelection | null> {
|
|
1121
|
+
const configuredContext = process.env.DOCKER_CONTEXT?.trim();
|
|
1122
|
+
const configuredHost = process.env.DOCKER_HOST?.trim();
|
|
1123
|
+
// Docker gives an explicit context precedence over DOCKER_HOST, so resolve
|
|
1124
|
+
// that same selection before collapsing it to the immutable local endpoint.
|
|
1125
|
+
if (!configuredContext && configuredHost) return { host: configuredHost };
|
|
1126
|
+
|
|
1127
|
+
let context = configuredContext;
|
|
1128
|
+
if (!context) {
|
|
1129
|
+
const shown = await dockerCli(["context", "show"], { timeoutMs: 10_000 });
|
|
1130
|
+
if (shown.status !== 0) return null;
|
|
1131
|
+
context = shown.stdout.trim();
|
|
1132
|
+
}
|
|
1133
|
+
if (!context) return null;
|
|
1134
|
+
const inspected = await dockerCli(
|
|
1135
|
+
[
|
|
1136
|
+
"context",
|
|
1137
|
+
"inspect",
|
|
1138
|
+
context,
|
|
1139
|
+
"--format",
|
|
1140
|
+
"{{.Endpoints.docker.Host}}",
|
|
1141
|
+
],
|
|
1142
|
+
{ timeoutMs: 10_000 },
|
|
1143
|
+
);
|
|
1144
|
+
const host = inspected.stdout.trim();
|
|
1145
|
+
return inspected.status === 0 && host ? { host } : null;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function applyPinnedDockerHost(host: string): DockerSelection {
|
|
1149
|
+
process.env.DOCKER_HOST = host;
|
|
1150
|
+
// DOCKER_CONTEXT takes precedence over DOCKER_HOST. Clear it after resolving
|
|
1151
|
+
// the named context so `docker context use` cannot retarget later children.
|
|
1152
|
+
delete process.env.DOCKER_CONTEXT;
|
|
1153
|
+
// A Docker selection must never leave a stale Apple pin visible to the
|
|
1154
|
+
// agent scripts, which consume the backend through these variables.
|
|
1155
|
+
delete process.env.UAI_TASK_RUNTIME;
|
|
1156
|
+
delete process.env.UAI_CONTAINER_CLI;
|
|
1157
|
+
return { host };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Publish the Apple selection to the agent scripts (task-up/down/status
|
|
1161
|
+
* branch on UAI_TASK_RUNTIME and exec the bundled CLI at UAI_CONTAINER_CLI).
|
|
1162
|
+
* The scripts fail closed on a missing/unexecutable CLI path, so an absent
|
|
1163
|
+
* binding deletes the variable rather than inventing a fallback. */
|
|
1164
|
+
function applyPinnedAppleRuntimeEnvironment(cliPath: string | null): void {
|
|
1165
|
+
process.env.UAI_TASK_RUNTIME = "apple-container";
|
|
1166
|
+
if (cliPath !== null && cliPath.length > 0) {
|
|
1167
|
+
process.env.UAI_CONTAINER_CLI = cliPath;
|
|
1168
|
+
} else {
|
|
1169
|
+
delete process.env.UAI_CONTAINER_CLI;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
let pinnedAppleCliPath: string | null = null;
|
|
1174
|
+
|
|
1175
|
+
function pinRuntime(
|
|
1176
|
+
provider: "docker" | "apple-container",
|
|
1177
|
+
host: string,
|
|
1178
|
+
engineId: string,
|
|
1179
|
+
appleCliPath?: string,
|
|
1180
|
+
): boolean {
|
|
1181
|
+
if (
|
|
1182
|
+
(pinnedDockerHost !== null && pinnedDockerHost !== host) ||
|
|
1183
|
+
(pinnedRuntimeProvider !== null && pinnedRuntimeProvider !== provider) ||
|
|
1184
|
+
(pinnedDockerEngineId !== null && pinnedDockerEngineId !== engineId)
|
|
1185
|
+
) {
|
|
1186
|
+
if (pinnedDockerHost && pinnedRuntimeProvider === "docker") {
|
|
1187
|
+
applyPinnedDockerHost(pinnedDockerHost);
|
|
1188
|
+
} else if (pinnedDockerHost && pinnedRuntimeProvider === "apple-container") {
|
|
1189
|
+
applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
|
|
1190
|
+
}
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
pinnedDockerHost = host;
|
|
1194
|
+
pinnedRuntimeProvider = provider;
|
|
1195
|
+
pinnedDockerEngineId = engineId;
|
|
1196
|
+
// apple-container has no Docker socket: the endpoint pin is the per-user
|
|
1197
|
+
// state root; DOCKER_HOST is deliberately left untouched while the script
|
|
1198
|
+
// selection variables are published instead.
|
|
1199
|
+
if (provider === "docker") {
|
|
1200
|
+
applyPinnedDockerHost(host);
|
|
1201
|
+
} else {
|
|
1202
|
+
pinnedAppleCliPath = appleCliPath ?? pinnedAppleCliPath;
|
|
1203
|
+
applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
|
|
1204
|
+
}
|
|
1205
|
+
return true;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function isLocalDockerEndpoint(host: string): boolean {
|
|
1209
|
+
if (
|
|
1210
|
+
host.length > 4_096 ||
|
|
1211
|
+
!host.startsWith("unix:///") ||
|
|
1212
|
+
/[\0\r\n]/.test(host) ||
|
|
1213
|
+
host.includes("?") ||
|
|
1214
|
+
host.includes("#")
|
|
1215
|
+
) {
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
return host.slice("unix://".length).startsWith("/");
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function pinnedRuntimeUnavailable(
|
|
1222
|
+
preference: RuntimePreference,
|
|
1223
|
+
): ContainerRuntimeState {
|
|
1224
|
+
return {
|
|
1225
|
+
status: "no-runtime",
|
|
1226
|
+
preference,
|
|
1227
|
+
message:
|
|
1228
|
+
pinnedRuntimeProvider === "apple-container"
|
|
1229
|
+
? "The Apple container runtime selected for this Uai Host process is " +
|
|
1230
|
+
`not responding. Run \`${RUNTIME_RECHECK_COMMAND}\` to repair it. ` +
|
|
1231
|
+
"To switch providers, set UAI_RUNTIME explicitly and run " +
|
|
1232
|
+
`\`${RUNTIME_RESTART_COMMAND}\`.`
|
|
1233
|
+
: PINNED_RUNTIME_UNAVAILABLE_MESSAGE,
|
|
1234
|
+
action: RUNTIME_RECHECK_COMMAND,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
function publishContainerRuntimeState(
|
|
1240
|
+
next: ContainerRuntimeState,
|
|
1241
|
+
forceInvalidateGeneration = false,
|
|
1242
|
+
): ContainerRuntimeState {
|
|
1243
|
+
const previous = containerRuntimeState();
|
|
1244
|
+
const previousProvider =
|
|
1245
|
+
previous.status === "ready" ? previous.provider : null;
|
|
1246
|
+
const nextProvider = next.status === "ready" ? next.provider : null;
|
|
1247
|
+
if (
|
|
1248
|
+
forceInvalidateGeneration ||
|
|
1249
|
+
(previous.status === "ready") !== (next.status === "ready") ||
|
|
1250
|
+
(previousProvider !== null &&
|
|
1251
|
+
nextProvider !== null &&
|
|
1252
|
+
previousProvider !== nextProvider)
|
|
1253
|
+
) {
|
|
1254
|
+
readinessEpoch += 1;
|
|
1255
|
+
}
|
|
1256
|
+
if (
|
|
1257
|
+
next.status === "ready" &&
|
|
1258
|
+
(previous.status !== "ready" || previousProvider !== nextProvider)
|
|
1259
|
+
) {
|
|
1260
|
+
activationRequired = true;
|
|
1261
|
+
}
|
|
1262
|
+
if (
|
|
1263
|
+
next.status !== "ready" ||
|
|
1264
|
+
previous.status !== "ready" ||
|
|
1265
|
+
previousProvider !== nextProvider
|
|
1266
|
+
) {
|
|
1267
|
+
operational = false;
|
|
1268
|
+
}
|
|
1269
|
+
state = next;
|
|
1270
|
+
settleOperationalWaiters();
|
|
1271
|
+
for (const listener of listeners) {
|
|
1272
|
+
try {
|
|
1273
|
+
listener(containerRuntimeState(), previous);
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
console.warn(
|
|
1276
|
+
`[host-agent] container runtime listener failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return containerRuntimeState();
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function settleOperationalWaiters(): void {
|
|
1284
|
+
for (const waiter of operationalWaiters) {
|
|
1285
|
+
if (waiter.epoch === readinessEpoch && state.status === "ready") {
|
|
1286
|
+
if (!operational) continue;
|
|
1287
|
+
operationalWaiters.delete(waiter);
|
|
1288
|
+
clearTimeout(waiter.timer);
|
|
1289
|
+
waiter.finish(true);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
operationalWaiters.delete(waiter);
|
|
1293
|
+
clearTimeout(waiter.timer);
|
|
1294
|
+
waiter.finish(false);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** @internal Test isolation for the process-lifetime selection invariant. */
|
|
1299
|
+
export function resetContainerRuntimeSelectionForTests(): void {
|
|
1300
|
+
if (process.env.NODE_ENV !== "test") {
|
|
1301
|
+
throw new Error("runtime selection reset is test-only");
|
|
1302
|
+
}
|
|
1303
|
+
pinnedDockerHost = null;
|
|
1304
|
+
pinnedRuntimeProvider = null;
|
|
1305
|
+
pinnedDockerEngineId = null;
|
|
1306
|
+
appleRuntime = null;
|
|
1307
|
+
operational = false;
|
|
1308
|
+
activationRequired = false;
|
|
1309
|
+
state = {
|
|
1310
|
+
status: "checking",
|
|
1311
|
+
preference: runtimePreference(process.env.UAI_RUNTIME),
|
|
1312
|
+
};
|
|
1313
|
+
for (const waiter of operationalWaiters) {
|
|
1314
|
+
operationalWaiters.delete(waiter);
|
|
1315
|
+
clearTimeout(waiter.timer);
|
|
1316
|
+
waiter.finish(false);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** Human-facing task failure, or null while the host can attempt Docker work. */
|
|
1321
|
+
export function containerRuntimeProblem(): string | null {
|
|
1322
|
+
if (state.status === "no-runtime") return state.message;
|
|
1323
|
+
if (state.status !== "ready" || !operational) {
|
|
1324
|
+
return (
|
|
1325
|
+
"The container runtime is still preparing this host. " +
|
|
1326
|
+
"Retry after runtime recovery completes."
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Expensive repair/recovery work belongs only to a genuine readiness edge. */
|
|
1333
|
+
export function containerRuntimeBecameReady(
|
|
1334
|
+
next: ContainerRuntimeState,
|
|
1335
|
+
previous: ContainerRuntimeState,
|
|
1336
|
+
): boolean {
|
|
1337
|
+
return next.status === "ready" && previous.status !== "ready";
|
|
1338
|
+
}
|