@runuai/host 0.9.13 → 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 +69 -4
- 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 +3070 -223
- 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 +1463 -109
- 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 +871 -50
- 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,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-106 Apple container TaskEnvironment provider.
|
|
3
|
+
*
|
|
4
|
+
* One lightweight VM per task container via Apple's `container` toolchain,
|
|
5
|
+
* invoked ONLY through the authenticated bundled CLI path resolved from the
|
|
6
|
+
* capability-3 descriptor — never from PATH. Container IPs are host-routable
|
|
7
|
+
* over vmnet, so editor/preview routing dials the container address directly
|
|
8
|
+
* and this provider publishes no host ports at all.
|
|
9
|
+
*
|
|
10
|
+
* CLI semantics encoded here were probed against container 1.2.2:
|
|
11
|
+
* - `inspect <name>` exits 1 with "container not found" for absence and
|
|
12
|
+
* otherwise prints a JSON array with {id, status:{state, networks[]},
|
|
13
|
+
* configuration:{labels, image, mounts}}; `id` IS the container name.
|
|
14
|
+
* - `stop` is idempotent (exit 0 for an already-stopped container).
|
|
15
|
+
* - `cp` uses docker-style `name:path` addressing.
|
|
16
|
+
* - `exec` supports -i/-d, --env K=V and bare K inheritance, --user,
|
|
17
|
+
* --workdir.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
21
|
+
import { lstatSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
import type { TaskDownResult } from "../agent";
|
|
25
|
+
import { assertSafeHostTaskId } from "../task-identity";
|
|
26
|
+
import {
|
|
27
|
+
capturedCliExec,
|
|
28
|
+
streamingCliExec,
|
|
29
|
+
} from "./docker";
|
|
30
|
+
import {
|
|
31
|
+
assertTaskEnvironmentProcessRequest,
|
|
32
|
+
assertTaskEnvironmentSessionRequest,
|
|
33
|
+
type TaskEnvironmentCopyRequest,
|
|
34
|
+
type TaskEnvironmentDescriptor,
|
|
35
|
+
type TaskEnvironmentDetachedSessionRequest,
|
|
36
|
+
type TaskEnvironmentExecRequest,
|
|
37
|
+
type TaskEnvironmentExecResult,
|
|
38
|
+
type TaskEnvironmentHandle,
|
|
39
|
+
type TaskEnvironmentLocator,
|
|
40
|
+
type TaskEnvironmentPort,
|
|
41
|
+
type TaskEnvironmentProcess,
|
|
42
|
+
type TaskEnvironmentProvider,
|
|
43
|
+
type TaskEnvironmentRecoveryContext,
|
|
44
|
+
type TaskEnvironmentRecoveryResult,
|
|
45
|
+
type TaskEnvironmentSessionRequest,
|
|
46
|
+
type TaskEnvironmentSpawnRequest,
|
|
47
|
+
type TaskEnvironmentStatus,
|
|
48
|
+
} from "./types";
|
|
49
|
+
|
|
50
|
+
export const APPLE_TASK_ENVIRONMENT_PROVIDER = "apple-container";
|
|
51
|
+
const APPLE_LOCATOR_SCHEMA_VERSION = 1;
|
|
52
|
+
const MAX_LOCATOR_COMPONENT = 512;
|
|
53
|
+
const INSPECT_OUTPUT_LIMIT = 256 * 1024;
|
|
54
|
+
export const APPLE_TASK_LABEL = "com.uai.task";
|
|
55
|
+
|
|
56
|
+
export interface AppleMachineIdentity {
|
|
57
|
+
backend: "apple-container";
|
|
58
|
+
/** The per-user state root that owns every container (application
|
|
59
|
+
* support directory of the bundled toolchain). */
|
|
60
|
+
endpoint: string;
|
|
61
|
+
/** Host-minted durable installation id — the daemon-ID equivalent for a
|
|
62
|
+
* runtime with no daemon identity of its own. */
|
|
63
|
+
engineId: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface AppleTaskEnvironmentLocatorValue {
|
|
67
|
+
schemaVersion: typeof APPLE_LOCATOR_SCHEMA_VERSION;
|
|
68
|
+
taskId: string;
|
|
69
|
+
containerName: string;
|
|
70
|
+
hostWorktreePath: string;
|
|
71
|
+
machine: AppleMachineIdentity;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AppleCliResult {
|
|
75
|
+
status: number | null;
|
|
76
|
+
stdout: string;
|
|
77
|
+
stderr: string;
|
|
78
|
+
truncated?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type AppleCliRunner = (
|
|
82
|
+
args: readonly string[],
|
|
83
|
+
options: { timeoutMs: number; maxOutputBytes: number },
|
|
84
|
+
) => Promise<AppleCliResult>;
|
|
85
|
+
|
|
86
|
+
export interface AppleContainerTaskEnvironmentDeps<
|
|
87
|
+
TInput = never,
|
|
88
|
+
TCredentials = never,
|
|
89
|
+
TResult = never,
|
|
90
|
+
> {
|
|
91
|
+
/** Absolute path of the authenticated bundled `container` CLI. Late-bound
|
|
92
|
+
* so an HMR reload or runtime re-resolution cannot strand a stale path. */
|
|
93
|
+
containerCliPath: () => string;
|
|
94
|
+
machineIdentity: () => AppleMachineIdentity;
|
|
95
|
+
/** Captured (non-streaming) CLI operations. Tests inject a fake. */
|
|
96
|
+
run?: AppleCliRunner;
|
|
97
|
+
spawn?: typeof nodeSpawn;
|
|
98
|
+
/** Provisioning is wired only where the concrete task-up contract exists
|
|
99
|
+
* (lib/task-environment/index.ts); this module stays agnostic of it. An
|
|
100
|
+
* absent group keeps provision() fail-closed. */
|
|
101
|
+
provisioning?: AppleContainerProvisioningDeps<TInput, TCredentials, TResult>;
|
|
102
|
+
/** Complete teardown through task-down.sh (container + derived image +
|
|
103
|
+
* workspace + durable row). Production wiring always supplies it. */
|
|
104
|
+
taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
|
|
105
|
+
/** Late-bound recovery implementation (orchestrator-owned, like Docker's). */
|
|
106
|
+
recoveryDriver?: () => AppleTaskEnvironmentRecoveryDriver | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The operational surface every handle needs; provisioning stays separate so
|
|
110
|
+
* a reconstruct-only registration keeps its narrower obligations. */
|
|
111
|
+
interface ResolvedAppleContainerDeps {
|
|
112
|
+
containerCliPath: () => string;
|
|
113
|
+
machineIdentity: () => AppleMachineIdentity;
|
|
114
|
+
run: AppleCliRunner;
|
|
115
|
+
spawn: typeof nodeSpawn;
|
|
116
|
+
taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
|
|
117
|
+
recoveryDriver?: () => AppleTaskEnvironmentRecoveryDriver | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type AppleTaskEnvironmentRecoveryDriver = (
|
|
121
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
122
|
+
context: TaskEnvironmentRecoveryContext,
|
|
123
|
+
) => Promise<TaskEnvironmentRecoveryResult>;
|
|
124
|
+
|
|
125
|
+
export interface AppleContainerProvisioningDeps<TInput, TCredentials, TResult> {
|
|
126
|
+
/** Runs task-up with the selection environment already published by the
|
|
127
|
+
* runtime pin (UAI_TASK_RUNTIME / UAI_CONTAINER_CLI). */
|
|
128
|
+
taskUp: (input: TInput, credentials?: TCredentials) => Promise<TResult>;
|
|
129
|
+
inputTaskId: (input: TInput) => string;
|
|
130
|
+
/** The host worktree path this task will occupy, derivable before task-up
|
|
131
|
+
* runs so a crash mid-provision still leaves a recoverable locator. */
|
|
132
|
+
hostWorktreePath: (taskId: string) => string;
|
|
133
|
+
resultWorktreePath: (result: TResult) => string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function appleTaskContainerName(taskId: string): string {
|
|
137
|
+
assertSafeHostTaskId(taskId);
|
|
138
|
+
return `task-${taskId}-app`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createAppleContainerTaskEnvironmentProvider<
|
|
142
|
+
TInput = never,
|
|
143
|
+
TCredentials = never,
|
|
144
|
+
TResult = never,
|
|
145
|
+
>(
|
|
146
|
+
deps: AppleContainerTaskEnvironmentDeps<TInput, TCredentials, TResult>,
|
|
147
|
+
): TaskEnvironmentProvider<TInput, TCredentials, TResult, TaskDownResult> {
|
|
148
|
+
const resolved = {
|
|
149
|
+
containerCliPath: deps.containerCliPath,
|
|
150
|
+
machineIdentity: deps.machineIdentity,
|
|
151
|
+
run: deps.run ?? defaultRunner(deps.containerCliPath),
|
|
152
|
+
spawn: deps.spawn ?? nodeSpawn,
|
|
153
|
+
taskEnvironmentDown: deps.taskEnvironmentDown,
|
|
154
|
+
recoveryDriver: deps.recoveryDriver,
|
|
155
|
+
};
|
|
156
|
+
const provisioning = deps.provisioning;
|
|
157
|
+
return {
|
|
158
|
+
kind: APPLE_TASK_ENVIRONMENT_PROVIDER,
|
|
159
|
+
async provision(request) {
|
|
160
|
+
if (provisioning === undefined) {
|
|
161
|
+
throw new Error("apple-container provisioning is not available yet");
|
|
162
|
+
}
|
|
163
|
+
if (request.taskId !== provisioning.inputTaskId(request.input)) {
|
|
164
|
+
throw new Error("task environment provision identity mismatch");
|
|
165
|
+
}
|
|
166
|
+
const machine = validateAppleMachineIdentity(resolved.machineIdentity());
|
|
167
|
+
const preparedLocator = appleTaskEnvironmentLocator(
|
|
168
|
+
request.taskId,
|
|
169
|
+
provisioning.hostWorktreePath(request.taskId),
|
|
170
|
+
machine,
|
|
171
|
+
);
|
|
172
|
+
await request.onPrepared?.(preparedLocator);
|
|
173
|
+
const result = await provisioning.taskUp(
|
|
174
|
+
request.input,
|
|
175
|
+
request.credentials,
|
|
176
|
+
);
|
|
177
|
+
const prepared = parseAppleTaskEnvironmentLocator(preparedLocator);
|
|
178
|
+
// task-up composes its worktree path in shell from the operator's
|
|
179
|
+
// UAI_WORKSPACE_ROOT verbatim; resolve before comparing so a benign
|
|
180
|
+
// trailing slash cannot fail the proof after the container is up, while
|
|
181
|
+
// a genuinely different directory still fails closed.
|
|
182
|
+
if (
|
|
183
|
+
path.resolve(provisioning.resultWorktreePath(result)) !==
|
|
184
|
+
prepared.hostWorktreePath
|
|
185
|
+
) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
"task environment returned an unexpected worktree path",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
handle: new AppleContainerTaskEnvironmentHandle(
|
|
192
|
+
preparedLocator,
|
|
193
|
+
resolved,
|
|
194
|
+
),
|
|
195
|
+
result,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
async reconstruct(locator) {
|
|
199
|
+
const persisted = parseAppleTaskEnvironmentLocator(locator);
|
|
200
|
+
const current = validateAppleMachineIdentity(resolved.machineIdentity());
|
|
201
|
+
if (
|
|
202
|
+
persisted.machine.endpoint !== current.endpoint ||
|
|
203
|
+
persisted.machine.engineId !== current.engineId
|
|
204
|
+
) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
"task environment belongs to a different container backend",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return new AppleContainerTaskEnvironmentHandle(locator, resolved);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function appleTaskEnvironmentLocator(
|
|
215
|
+
taskId: string,
|
|
216
|
+
hostWorktreePath: string,
|
|
217
|
+
machine: AppleMachineIdentity,
|
|
218
|
+
): TaskEnvironmentLocator {
|
|
219
|
+
const value: AppleTaskEnvironmentLocatorValue = {
|
|
220
|
+
schemaVersion: APPLE_LOCATOR_SCHEMA_VERSION,
|
|
221
|
+
taskId,
|
|
222
|
+
containerName: appleTaskContainerName(taskId),
|
|
223
|
+
hostWorktreePath,
|
|
224
|
+
machine: validateAppleMachineIdentity(machine),
|
|
225
|
+
};
|
|
226
|
+
validateAppleLocatorValue(value);
|
|
227
|
+
return {
|
|
228
|
+
schemaVersion: 1,
|
|
229
|
+
provider: APPLE_TASK_ENVIRONMENT_PROVIDER,
|
|
230
|
+
value,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function parseAppleTaskEnvironmentLocator(
|
|
235
|
+
locator: TaskEnvironmentLocator,
|
|
236
|
+
): AppleTaskEnvironmentLocatorValue {
|
|
237
|
+
if (
|
|
238
|
+
locator.schemaVersion !== 1 ||
|
|
239
|
+
locator.provider !== APPLE_TASK_ENVIRONMENT_PROVIDER
|
|
240
|
+
) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
"task environment locator does not belong to Apple container",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return validateAppleLocatorValue(locator.value);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
class AppleContainerTaskEnvironmentHandle
|
|
249
|
+
implements TaskEnvironmentHandle<TaskDownResult>
|
|
250
|
+
{
|
|
251
|
+
readonly descriptor: TaskEnvironmentDescriptor;
|
|
252
|
+
readonly durableIdentity: string;
|
|
253
|
+
readonly #value: AppleTaskEnvironmentLocatorValue;
|
|
254
|
+
|
|
255
|
+
constructor(
|
|
256
|
+
locator: TaskEnvironmentLocator,
|
|
257
|
+
private readonly deps: ResolvedAppleContainerDeps,
|
|
258
|
+
) {
|
|
259
|
+
this.#value = parseAppleTaskEnvironmentLocator(locator);
|
|
260
|
+
this.durableIdentity = this.#value.containerName;
|
|
261
|
+
this.descriptor = {
|
|
262
|
+
taskId: this.#value.taskId,
|
|
263
|
+
locator,
|
|
264
|
+
workspacePath: "/workspace",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
|
|
269
|
+
assertTaskEnvironmentProcessRequest(request);
|
|
270
|
+
return capturedCliExec(
|
|
271
|
+
this.deps.spawn,
|
|
272
|
+
this.deps.containerCliPath(),
|
|
273
|
+
appleExecArgs(this.#value.containerName, request, "interactive"),
|
|
274
|
+
request,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async spawn(
|
|
279
|
+
request: TaskEnvironmentSpawnRequest,
|
|
280
|
+
): Promise<TaskEnvironmentProcess> {
|
|
281
|
+
assertTaskEnvironmentProcessRequest(request);
|
|
282
|
+
return streamingCliExec(
|
|
283
|
+
this.deps.spawn,
|
|
284
|
+
this.deps.containerCliPath(),
|
|
285
|
+
appleExecArgs(this.#value.containerName, request, "interactive"),
|
|
286
|
+
request,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async spawnSession(
|
|
291
|
+
request: TaskEnvironmentSessionRequest,
|
|
292
|
+
): Promise<TaskEnvironmentProcess> {
|
|
293
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
294
|
+
return streamingCliExec(
|
|
295
|
+
this.deps.spawn,
|
|
296
|
+
this.deps.containerCliPath(),
|
|
297
|
+
appleExecArgs(this.#value.containerName, request, "interactive"),
|
|
298
|
+
request,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async launchDetachedSession(
|
|
303
|
+
request: TaskEnvironmentDetachedSessionRequest,
|
|
304
|
+
): Promise<TaskEnvironmentExecResult> {
|
|
305
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
306
|
+
const result = await this.deps.run(
|
|
307
|
+
appleExecArgs(this.#value.containerName, request, "detached"),
|
|
308
|
+
{
|
|
309
|
+
timeoutMs: request.launchTimeoutMs,
|
|
310
|
+
maxOutputBytes: request.maxOutputBytes,
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
return {
|
|
314
|
+
exitCode: result.status,
|
|
315
|
+
signal: null,
|
|
316
|
+
stdout: Buffer.from(result.stdout),
|
|
317
|
+
stderr: Buffer.from(result.stderr),
|
|
318
|
+
stdoutTruncated: result.truncated === true,
|
|
319
|
+
stderrTruncated: result.truncated === true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
|
|
324
|
+
validateCopyRequest(request);
|
|
325
|
+
const args =
|
|
326
|
+
request.direction === "into"
|
|
327
|
+
? [
|
|
328
|
+
"cp",
|
|
329
|
+
request.source,
|
|
330
|
+
`${this.#value.containerName}:${request.destination}`,
|
|
331
|
+
]
|
|
332
|
+
: [
|
|
333
|
+
"cp",
|
|
334
|
+
`${this.#value.containerName}:${request.source}`,
|
|
335
|
+
request.destination,
|
|
336
|
+
];
|
|
337
|
+
const result = await this.deps.run(args, {
|
|
338
|
+
timeoutMs: 120_000,
|
|
339
|
+
maxOutputBytes: 1024 * 1024,
|
|
340
|
+
});
|
|
341
|
+
requireCliSuccess(result, "copy task environment data");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async ports(): Promise<TaskEnvironmentPort[]> {
|
|
345
|
+
// Direct-IP model: nothing is published on the host. Editor/preview
|
|
346
|
+
// routing resolves the container's vmnet address per connection through
|
|
347
|
+
// the provider-private inspection below.
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async status(): Promise<TaskEnvironmentStatus> {
|
|
352
|
+
const inspected = await this.inspect();
|
|
353
|
+
// Workspace presence is a DISK fact, independent of container state: an
|
|
354
|
+
// absent container preserves its worktree (the cloud's stopped-vs-error
|
|
355
|
+
// decision hangs on exactly this), and a present container can have lost
|
|
356
|
+
// it. Only a real non-symlink directory counts.
|
|
357
|
+
const workspacePresent = ((): boolean => {
|
|
358
|
+
try {
|
|
359
|
+
const stat = lstatSync(
|
|
360
|
+
path.join(this.#value.hostWorktreePath, "workspace"),
|
|
361
|
+
);
|
|
362
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
if (inspected.kind === "absent") {
|
|
368
|
+
return { state: "absent", instances: [], workspacePresent };
|
|
369
|
+
}
|
|
370
|
+
if (inspected.kind === "unknown") {
|
|
371
|
+
return { state: "unknown", detail: inspected.detail };
|
|
372
|
+
}
|
|
373
|
+
if (inspected.taskLabel !== this.#value.taskId) {
|
|
374
|
+
// A same-name container that does not carry this task's label is a
|
|
375
|
+
// foreign object, not this environment. It must never be started,
|
|
376
|
+
// executed into, or treated as evidence of any state.
|
|
377
|
+
return {
|
|
378
|
+
state: "unknown",
|
|
379
|
+
detail: "the task container name is held by a foreign container",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// Only exact terminal states map. A transitional answer ("starting",
|
|
383
|
+
// "stopping", ...) reported as stopped would let recovery issue a
|
|
384
|
+
// redundant start and persist stopped while the container goes live.
|
|
385
|
+
if (inspected.state !== "running" && inspected.state !== "stopped") {
|
|
386
|
+
return {
|
|
387
|
+
state: "unknown",
|
|
388
|
+
detail: `the task container is in a transitional state (${inspected.state})`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
state: inspected.state,
|
|
393
|
+
instances: [this.#value.containerName],
|
|
394
|
+
workspacePresent,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async recover(
|
|
399
|
+
context: TaskEnvironmentRecoveryContext,
|
|
400
|
+
): Promise<TaskEnvironmentRecoveryResult> {
|
|
401
|
+
const driver = this.deps.recoveryDriver?.();
|
|
402
|
+
if (driver) return driver(this.descriptor, context);
|
|
403
|
+
// Without a driver (provider tests), only a live environment may claim
|
|
404
|
+
// recovery; stopped/absent is deferred so it can never read as healthy.
|
|
405
|
+
const status = await this.status();
|
|
406
|
+
if (status.state === "unknown") {
|
|
407
|
+
return { outcome: "deferred", detail: status.detail };
|
|
408
|
+
}
|
|
409
|
+
if (status.state === "running") {
|
|
410
|
+
return { outcome: "recovered", status };
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
outcome: "deferred",
|
|
414
|
+
detail: `task environment is ${status.state} and no recovery driver is bound`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async stop(): Promise<void> {
|
|
419
|
+
// Idempotent by CLI contract (verified: exit 0 on already-stopped).
|
|
420
|
+
const result = await this.deps.run(
|
|
421
|
+
["stop", this.#value.containerName],
|
|
422
|
+
{ timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
|
|
423
|
+
);
|
|
424
|
+
if (
|
|
425
|
+
result.status !== 0 &&
|
|
426
|
+
!isNotFound(result, this.#value.containerName)
|
|
427
|
+
) {
|
|
428
|
+
requireCliSuccess(result, "stop task environment");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async teardown(): Promise<TaskDownResult> {
|
|
433
|
+
// task-down.sh owns complete destruction — container, derived image, the
|
|
434
|
+
// task directory with its writable Git data, and the durable row — with
|
|
435
|
+
// its own name-anchored absence proof. The container-only path below
|
|
436
|
+
// exists for provider tests; production wiring always injects the script.
|
|
437
|
+
if (this.deps.taskEnvironmentDown !== undefined) {
|
|
438
|
+
return this.deps.taskEnvironmentDown(this.#value.taskId);
|
|
439
|
+
}
|
|
440
|
+
await this.stop();
|
|
441
|
+
const removed = await this.deps.run(
|
|
442
|
+
["rm", this.#value.containerName],
|
|
443
|
+
{ timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
|
|
444
|
+
);
|
|
445
|
+
const alreadyGone = isNotFound(removed, this.#value.containerName);
|
|
446
|
+
if (removed.status !== 0 && !alreadyGone) {
|
|
447
|
+
requireCliSuccess(removed, "remove task environment");
|
|
448
|
+
}
|
|
449
|
+
const after = await this.inspect();
|
|
450
|
+
if (after.kind !== "absent") {
|
|
451
|
+
throw new Error(
|
|
452
|
+
after.kind === "unknown"
|
|
453
|
+
? `task environment teardown is unconfirmed: ${after.detail}`
|
|
454
|
+
: "task environment teardown left the container present",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return alreadyGone ? { alreadyGone: true } : { status: "removed" };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Provider-private identity-bound inspection: the routing layer uses the
|
|
461
|
+
* returned vmnet address only together with the task-label proof. */
|
|
462
|
+
async inspectRoute(): Promise<
|
|
463
|
+
| { kind: "running"; ipv4Address: string }
|
|
464
|
+
| { kind: "unavailable" }
|
|
465
|
+
| { kind: "unknown"; detail: string }
|
|
466
|
+
> {
|
|
467
|
+
const inspected = await this.inspect();
|
|
468
|
+
if (inspected.kind === "absent") return { kind: "unavailable" };
|
|
469
|
+
if (inspected.kind === "unknown") return inspected;
|
|
470
|
+
if (
|
|
471
|
+
inspected.state !== "running" ||
|
|
472
|
+
inspected.taskLabel !== this.#value.taskId ||
|
|
473
|
+
inspected.ipv4Address === null
|
|
474
|
+
) {
|
|
475
|
+
return { kind: "unavailable" };
|
|
476
|
+
}
|
|
477
|
+
return { kind: "running", ipv4Address: inspected.ipv4Address };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async inspect(): Promise<
|
|
481
|
+
| { kind: "present"; state: string; taskLabel: string | null; ipv4Address: string | null }
|
|
482
|
+
| { kind: "absent" }
|
|
483
|
+
| { kind: "unknown"; detail: string }
|
|
484
|
+
> {
|
|
485
|
+
const result = await this.deps.run(
|
|
486
|
+
["inspect", this.#value.containerName],
|
|
487
|
+
{ timeoutMs: 15_000, maxOutputBytes: INSPECT_OUTPUT_LIMIT },
|
|
488
|
+
);
|
|
489
|
+
if (result.status !== 0) {
|
|
490
|
+
if (isNotFound(result, this.#value.containerName)) {
|
|
491
|
+
return { kind: "absent" };
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
kind: "unknown",
|
|
495
|
+
detail: boundedDetail(result.stderr) || "container inspect failed",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (result.truncated === true) {
|
|
499
|
+
return { kind: "unknown", detail: "container inspect output overflowed" };
|
|
500
|
+
}
|
|
501
|
+
let parsed: unknown;
|
|
502
|
+
try {
|
|
503
|
+
parsed = JSON.parse(result.stdout);
|
|
504
|
+
} catch {
|
|
505
|
+
return { kind: "unknown", detail: "container inspect output is not JSON" };
|
|
506
|
+
}
|
|
507
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
|
508
|
+
return { kind: "unknown", detail: "container inspect shape is invalid" };
|
|
509
|
+
}
|
|
510
|
+
const entry = parsed[0] as {
|
|
511
|
+
id?: unknown;
|
|
512
|
+
status?: { state?: unknown; networks?: unknown };
|
|
513
|
+
configuration?: { labels?: unknown };
|
|
514
|
+
};
|
|
515
|
+
if (entry.id !== this.#value.containerName) {
|
|
516
|
+
return { kind: "unknown", detail: "container inspect identity mismatch" };
|
|
517
|
+
}
|
|
518
|
+
const state =
|
|
519
|
+
typeof entry.status?.state === "string" ? entry.status.state : null;
|
|
520
|
+
if (state === null) {
|
|
521
|
+
return { kind: "unknown", detail: "container inspect state is missing" };
|
|
522
|
+
}
|
|
523
|
+
const labels = entry.configuration?.labels;
|
|
524
|
+
const taskLabel =
|
|
525
|
+
labels && typeof labels === "object" && !Array.isArray(labels)
|
|
526
|
+
? typeof (labels as Record<string, unknown>)[APPLE_TASK_LABEL] === "string"
|
|
527
|
+
? ((labels as Record<string, unknown>)[APPLE_TASK_LABEL] as string)
|
|
528
|
+
: null
|
|
529
|
+
: null;
|
|
530
|
+
return {
|
|
531
|
+
kind: "present",
|
|
532
|
+
state,
|
|
533
|
+
taskLabel,
|
|
534
|
+
ipv4Address: firstIpv4(entry.status?.networks),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function appleExecArgs(
|
|
540
|
+
containerName: string,
|
|
541
|
+
request:
|
|
542
|
+
| TaskEnvironmentExecRequest
|
|
543
|
+
| TaskEnvironmentSpawnRequest
|
|
544
|
+
| TaskEnvironmentSessionRequest
|
|
545
|
+
| TaskEnvironmentDetachedSessionRequest,
|
|
546
|
+
mode: "interactive" | "detached",
|
|
547
|
+
): string[] {
|
|
548
|
+
const args = ["exec", mode === "detached" ? "-d" : "-i"];
|
|
549
|
+
if (request.cwd) args.push("--workdir", request.cwd);
|
|
550
|
+
if (request.user) args.push("--user", request.user);
|
|
551
|
+
for (const name of request.inheritEnv ?? []) {
|
|
552
|
+
if (process.env[name] !== undefined) args.push("--env", name);
|
|
553
|
+
}
|
|
554
|
+
for (const [name, value] of Object.entries(request.env ?? {}).sort(
|
|
555
|
+
([left], [right]) => left.localeCompare(right),
|
|
556
|
+
)) {
|
|
557
|
+
args.push("--env", `${name}=${value}`);
|
|
558
|
+
}
|
|
559
|
+
args.push(containerName, ...request.argv);
|
|
560
|
+
return args;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function appleCliRunner(
|
|
564
|
+
containerCliPath: () => string,
|
|
565
|
+
): AppleCliRunner {
|
|
566
|
+
return defaultRunner(containerCliPath);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function defaultRunner(containerCliPath: () => string): AppleCliRunner {
|
|
570
|
+
return (args, options) =>
|
|
571
|
+
new Promise((resolve) => {
|
|
572
|
+
const child = nodeSpawn(containerCliPath(), [...args], {
|
|
573
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
574
|
+
});
|
|
575
|
+
const stdout: Buffer[] = [];
|
|
576
|
+
const stderr: Buffer[] = [];
|
|
577
|
+
let bytes = 0;
|
|
578
|
+
let truncated = false;
|
|
579
|
+
const collect = (target: Buffer[]) => (chunk: Buffer) => {
|
|
580
|
+
bytes += chunk.length;
|
|
581
|
+
if (bytes > options.maxOutputBytes) {
|
|
582
|
+
truncated = true;
|
|
583
|
+
child.kill("SIGKILL");
|
|
584
|
+
// The overflow kill needs the same bounded settlement as the
|
|
585
|
+
// deadline kill: an unreapable child must not strand the caller.
|
|
586
|
+
const settle = setTimeout(() => finish(null), 5_000);
|
|
587
|
+
settle.unref?.();
|
|
588
|
+
} else {
|
|
589
|
+
target.push(chunk);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
child.stdout.on("data", collect(stdout));
|
|
593
|
+
child.stderr.on("data", collect(stderr));
|
|
594
|
+
let settled = false;
|
|
595
|
+
const timer = setTimeout(() => {
|
|
596
|
+
child.kill("SIGKILL");
|
|
597
|
+
// A SIGKILLed CLI normally closes at once, but an unreapable child
|
|
598
|
+
// must not strand activation/recovery on a promise that never
|
|
599
|
+
// settles — the caller's deadline has already expired.
|
|
600
|
+
const settle = setTimeout(() => finish(null), 5_000);
|
|
601
|
+
settle.unref?.();
|
|
602
|
+
}, options.timeoutMs);
|
|
603
|
+
timer.unref?.();
|
|
604
|
+
const finish = (status: number | null): void => {
|
|
605
|
+
if (settled) return;
|
|
606
|
+
settled = true;
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
resolve({
|
|
609
|
+
status,
|
|
610
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
611
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
612
|
+
truncated,
|
|
613
|
+
});
|
|
614
|
+
};
|
|
615
|
+
child.once("error", (error) => {
|
|
616
|
+
stderr.push(Buffer.from(error.message));
|
|
617
|
+
finish(null);
|
|
618
|
+
});
|
|
619
|
+
child.once("close", (code) => finish(code));
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function validateAppleMachineIdentity(
|
|
624
|
+
value: AppleMachineIdentity,
|
|
625
|
+
): AppleMachineIdentity {
|
|
626
|
+
if (value.backend !== "apple-container") {
|
|
627
|
+
throw new Error("Apple container machine backend is invalid");
|
|
628
|
+
}
|
|
629
|
+
const endpoint = exactComponent(value.endpoint, "machine endpoint");
|
|
630
|
+
if (!endpoint.startsWith("/")) {
|
|
631
|
+
throw new Error("Apple container machine endpoint must be absolute");
|
|
632
|
+
}
|
|
633
|
+
const engineId = exactComponent(value.engineId, "installation id");
|
|
634
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(engineId)) {
|
|
635
|
+
throw new Error("Apple container installation id is invalid");
|
|
636
|
+
}
|
|
637
|
+
return { backend: "apple-container", endpoint, engineId };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function validateAppleLocatorValue(
|
|
641
|
+
value: unknown,
|
|
642
|
+
): AppleTaskEnvironmentLocatorValue {
|
|
643
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
644
|
+
throw new Error("Apple container locator must be an object");
|
|
645
|
+
}
|
|
646
|
+
const record = value as Record<string, unknown>;
|
|
647
|
+
const allowed = new Set([
|
|
648
|
+
"schemaVersion",
|
|
649
|
+
"taskId",
|
|
650
|
+
"containerName",
|
|
651
|
+
"hostWorktreePath",
|
|
652
|
+
"machine",
|
|
653
|
+
]);
|
|
654
|
+
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
|
655
|
+
throw new Error("Apple container locator has unexpected fields");
|
|
656
|
+
}
|
|
657
|
+
if (record.schemaVersion !== APPLE_LOCATOR_SCHEMA_VERSION) {
|
|
658
|
+
throw new Error("unsupported Apple container locator schema");
|
|
659
|
+
}
|
|
660
|
+
const taskId = exactComponent(record.taskId, "task id");
|
|
661
|
+
assertSafeHostTaskId(taskId);
|
|
662
|
+
if (record.containerName !== appleTaskContainerName(taskId)) {
|
|
663
|
+
throw new Error("Apple container locator name does not match its task");
|
|
664
|
+
}
|
|
665
|
+
const hostWorktreePath = exactComponent(
|
|
666
|
+
record.hostWorktreePath,
|
|
667
|
+
"worktree path",
|
|
668
|
+
4096,
|
|
669
|
+
);
|
|
670
|
+
if (!hostWorktreePath.startsWith("/")) {
|
|
671
|
+
throw new Error("Apple container locator worktree path must be absolute");
|
|
672
|
+
}
|
|
673
|
+
const machine = validateAppleMachineIdentity(
|
|
674
|
+
record.machine as AppleMachineIdentity,
|
|
675
|
+
);
|
|
676
|
+
return {
|
|
677
|
+
schemaVersion: APPLE_LOCATOR_SCHEMA_VERSION,
|
|
678
|
+
taskId,
|
|
679
|
+
containerName: record.containerName,
|
|
680
|
+
hostWorktreePath,
|
|
681
|
+
machine,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function validateCopyRequest(request: TaskEnvironmentCopyRequest): void {
|
|
686
|
+
if (request.direction !== "into" && request.direction !== "out") {
|
|
687
|
+
throw new Error("task environment copy direction is invalid");
|
|
688
|
+
}
|
|
689
|
+
for (const [label, value] of [
|
|
690
|
+
["source", request.source],
|
|
691
|
+
["destination", request.destination],
|
|
692
|
+
] as const) {
|
|
693
|
+
if (
|
|
694
|
+
typeof value !== "string" ||
|
|
695
|
+
value.length === 0 ||
|
|
696
|
+
value.length > 4096 ||
|
|
697
|
+
/[\0\r\n]/.test(value) ||
|
|
698
|
+
value.includes(":")
|
|
699
|
+
) {
|
|
700
|
+
throw new Error(`task environment copy ${label} is invalid`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function requireCliSuccess(result: AppleCliResult, operation: string): void {
|
|
706
|
+
if (result.status === 0) return;
|
|
707
|
+
const detail = boundedDetail(result.stderr || result.stdout);
|
|
708
|
+
throw new Error(`${operation} failed${detail ? `: ${detail}` : ""}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The CLI exits nonzero for both a missing container and a broken
|
|
712
|
+
* apiserver; only the not-found error that names this exact container proves
|
|
713
|
+
* absence. Anything else is unknown and must never authorize destruction. */
|
|
714
|
+
function isNotFound(result: AppleCliResult, containerName: string): boolean {
|
|
715
|
+
// Substring matching would let `container not found: <name>-shadow` prove
|
|
716
|
+
// the absence of <name>. Require the complete bounded error line and no
|
|
717
|
+
// stdout at all.
|
|
718
|
+
return (
|
|
719
|
+
result.status !== 0 &&
|
|
720
|
+
result.stdout.trim() === "" &&
|
|
721
|
+
result.stderr
|
|
722
|
+
.split("\n")
|
|
723
|
+
.some((line) => line.trim() === `Error: container not found: ${containerName}`)
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function boundedDetail(text: string): string {
|
|
728
|
+
return text.trim().slice(0, 512);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function firstIpv4(networks: unknown): string | null {
|
|
732
|
+
if (!Array.isArray(networks)) return null;
|
|
733
|
+
for (const network of networks) {
|
|
734
|
+
const address = (network as { ipv4Address?: unknown })?.ipv4Address;
|
|
735
|
+
if (typeof address === "string") {
|
|
736
|
+
const bare = address.split("/")[0]!;
|
|
737
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bare)) return bare;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function exactComponent(
|
|
744
|
+
value: unknown,
|
|
745
|
+
label: string,
|
|
746
|
+
maxLength = MAX_LOCATOR_COMPONENT,
|
|
747
|
+
): string {
|
|
748
|
+
if (
|
|
749
|
+
typeof value !== "string" ||
|
|
750
|
+
value.length === 0 ||
|
|
751
|
+
value.length > maxLength ||
|
|
752
|
+
/[\0\r\n]/.test(value)
|
|
753
|
+
) {
|
|
754
|
+
throw new Error(`Apple container ${label} is invalid`);
|
|
755
|
+
}
|
|
756
|
+
return value;
|
|
757
|
+
}
|