@runuai/host 0.9.14 → 0.9.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +33 -2
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3051 -200
- package/lib/preview-sidecar.ts +68 -14
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +956 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +459 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1405 -107
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +766 -42
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
import {
|
|
2
|
+
spawn as nodeSpawn,
|
|
3
|
+
type ChildProcessWithoutNullStreams,
|
|
4
|
+
} from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
AgentError,
|
|
10
|
+
agent,
|
|
11
|
+
type TaskDownResult,
|
|
12
|
+
type TaskStatusResult,
|
|
13
|
+
type TaskUpCredentials,
|
|
14
|
+
type TaskLaunchInput,
|
|
15
|
+
type TaskUpResult,
|
|
16
|
+
} from "../agent";
|
|
17
|
+
import { dockerCli, type DockerResult } from "../docker-exec";
|
|
18
|
+
import { taskDir } from "../env";
|
|
19
|
+
import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
|
|
20
|
+
import { assertSafeHostTaskId } from "../task-identity";
|
|
21
|
+
import {
|
|
22
|
+
assertTaskEnvironmentProcessRequest,
|
|
23
|
+
assertTaskEnvironmentSessionRequest,
|
|
24
|
+
type TaskEnvironmentCopyRequest,
|
|
25
|
+
type TaskEnvironmentDescriptor,
|
|
26
|
+
type TaskEnvironmentDetachedSessionRequest,
|
|
27
|
+
type TaskEnvironmentExecRequest,
|
|
28
|
+
type TaskEnvironmentExecResult,
|
|
29
|
+
type TaskEnvironmentHandle,
|
|
30
|
+
type TaskEnvironmentLocator,
|
|
31
|
+
type TaskEnvironmentPort,
|
|
32
|
+
type TaskEnvironmentProcess,
|
|
33
|
+
type TaskEnvironmentProcessExit,
|
|
34
|
+
type TaskEnvironmentProvider,
|
|
35
|
+
type TaskEnvironmentRecoveryContext,
|
|
36
|
+
type TaskEnvironmentRecoveryResult,
|
|
37
|
+
type TaskEnvironmentSessionRequest,
|
|
38
|
+
type TaskEnvironmentSpawnRequest,
|
|
39
|
+
type TaskEnvironmentStatus,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
export const DOCKER_TASK_ENVIRONMENT_PROVIDER = "docker-compose";
|
|
43
|
+
const DOCKER_LOCATOR_SCHEMA_VERSION = 2;
|
|
44
|
+
const MAX_LOCATOR_COMPONENT = 512;
|
|
45
|
+
|
|
46
|
+
export type DockerMachineBackend = "docker";
|
|
47
|
+
|
|
48
|
+
export interface DockerMachineIdentity {
|
|
49
|
+
backend: DockerMachineBackend;
|
|
50
|
+
/** Stable process/durable endpoint identity, never a credential-bearing URL. */
|
|
51
|
+
endpoint: string;
|
|
52
|
+
/** Stable Docker daemon `.ID`, proved atomically with server liveness. */
|
|
53
|
+
engineId: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DockerTaskEnvironmentLocatorValue {
|
|
57
|
+
schemaVersion: typeof DOCKER_LOCATOR_SCHEMA_VERSION;
|
|
58
|
+
taskId: string;
|
|
59
|
+
composeProject: string;
|
|
60
|
+
containerName: string;
|
|
61
|
+
hostWorktreePath: string;
|
|
62
|
+
machine: DockerMachineIdentity;
|
|
63
|
+
codeServerPort?: number;
|
|
64
|
+
previewPorts?: Array<{ name: string; hostPort: number }>;
|
|
65
|
+
initWarning?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DockerTaskEnvironmentDeps {
|
|
69
|
+
taskUp?: (
|
|
70
|
+
input: TaskLaunchInput,
|
|
71
|
+
credentials?: TaskUpCredentials,
|
|
72
|
+
) => Promise<TaskUpResult>;
|
|
73
|
+
taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
|
|
74
|
+
taskStatus?: (taskId: string) => Promise<TaskStatusResult>;
|
|
75
|
+
docker?: typeof dockerCli;
|
|
76
|
+
spawn?: typeof nodeSpawn;
|
|
77
|
+
machineIdentity: () => DockerMachineIdentity;
|
|
78
|
+
/** Full L4/L5-aware recovery hook. Absence intentionally does not start. */
|
|
79
|
+
recover?: DockerTaskEnvironmentRecoveryDriver;
|
|
80
|
+
/** Late-bound production seam avoids an orchestrator/provider import cycle
|
|
81
|
+
* while letting tests supply an instance-local recovery implementation. */
|
|
82
|
+
recoveryDriver?: () => DockerTaskEnvironmentRecoveryDriver | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type DockerTaskEnvironmentRecoveryDriver = (
|
|
86
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
87
|
+
context: TaskEnvironmentRecoveryContext,
|
|
88
|
+
) => Promise<TaskEnvironmentRecoveryResult>;
|
|
89
|
+
|
|
90
|
+
export function createDockerTaskEnvironmentProvider(
|
|
91
|
+
deps: DockerTaskEnvironmentDeps,
|
|
92
|
+
): TaskEnvironmentProvider<
|
|
93
|
+
TaskLaunchInput,
|
|
94
|
+
TaskUpCredentials,
|
|
95
|
+
TaskUpResult,
|
|
96
|
+
TaskDownResult
|
|
97
|
+
> {
|
|
98
|
+
const resolved = {
|
|
99
|
+
taskUp: deps.taskUp ?? agent.taskUp,
|
|
100
|
+
taskEnvironmentDown:
|
|
101
|
+
deps.taskEnvironmentDown ?? agent.taskEnvironmentDown,
|
|
102
|
+
taskStatus: deps.taskStatus,
|
|
103
|
+
docker: deps.docker ?? dockerCli,
|
|
104
|
+
spawn: deps.spawn ?? nodeSpawn,
|
|
105
|
+
machineIdentity: deps.machineIdentity,
|
|
106
|
+
recover: deps.recover,
|
|
107
|
+
recoveryDriver: deps.recoveryDriver,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
kind: DOCKER_TASK_ENVIRONMENT_PROVIDER,
|
|
112
|
+
async provision(request) {
|
|
113
|
+
if (request.taskId !== request.input.task.id) {
|
|
114
|
+
throw new Error("task environment provision identity mismatch");
|
|
115
|
+
}
|
|
116
|
+
const machine = validateMachineIdentity(resolved.machineIdentity());
|
|
117
|
+
const preparedLocator = dockerTaskEnvironmentPreparedLocator(
|
|
118
|
+
request.taskId,
|
|
119
|
+
machine,
|
|
120
|
+
);
|
|
121
|
+
await request.onPrepared?.(preparedLocator);
|
|
122
|
+
const result = await resolved.taskUp(request.input, request.credentials);
|
|
123
|
+
const prepared = parseDockerTaskEnvironmentLocator(preparedLocator);
|
|
124
|
+
// task-up composes its worktree path in shell from the operator's
|
|
125
|
+
// UAI_WORKSPACE_ROOT verbatim, so a benign trailing slash would fail a
|
|
126
|
+
// strict string compare AFTER the stack is already up. Resolve before
|
|
127
|
+
// comparing; a path that differs after resolution (wrong directory,
|
|
128
|
+
// unexpanded `~`) still fails closed.
|
|
129
|
+
if (
|
|
130
|
+
result.composeProject !== prepared.composeProject ||
|
|
131
|
+
path.resolve(result.worktreePath) !== prepared.hostWorktreePath
|
|
132
|
+
) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"task environment returned an unexpected Compose project or worktree path",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const locator = dockerTaskEnvironmentLocator(
|
|
138
|
+
request.taskId,
|
|
139
|
+
// Persist the canonical resolved path, not the shell-composed spelling.
|
|
140
|
+
{ ...result, worktreePath: prepared.hostWorktreePath },
|
|
141
|
+
machine,
|
|
142
|
+
);
|
|
143
|
+
return {
|
|
144
|
+
handle: new DockerTaskEnvironmentHandle(locator, resolved),
|
|
145
|
+
result,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
async reconstruct(locator) {
|
|
149
|
+
const persisted = parseDockerTaskEnvironmentLocator(locator);
|
|
150
|
+
const current = validateMachineIdentity(resolved.machineIdentity());
|
|
151
|
+
if (
|
|
152
|
+
persisted.machine.backend !== current.backend ||
|
|
153
|
+
persisted.machine.endpoint !== current.endpoint ||
|
|
154
|
+
persisted.machine.engineId !== current.engineId
|
|
155
|
+
) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"task environment belongs to a different container backend",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return new DockerTaskEnvironmentHandle(locator, resolved);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function dockerTaskEnvironmentLocator(
|
|
166
|
+
taskId: string,
|
|
167
|
+
result: TaskUpResult,
|
|
168
|
+
machine: DockerMachineIdentity,
|
|
169
|
+
): TaskEnvironmentLocator {
|
|
170
|
+
assertSafeHostTaskId(taskId);
|
|
171
|
+
const composeProject = `task-${taskId}`;
|
|
172
|
+
if (result.composeProject !== composeProject) {
|
|
173
|
+
throw new Error("task environment returned an unexpected Compose project");
|
|
174
|
+
}
|
|
175
|
+
const value: DockerTaskEnvironmentLocatorValue = {
|
|
176
|
+
schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
|
|
177
|
+
taskId,
|
|
178
|
+
composeProject,
|
|
179
|
+
containerName: `${composeProject}-app-1`,
|
|
180
|
+
hostWorktreePath: result.worktreePath,
|
|
181
|
+
machine: validateMachineIdentity(machine),
|
|
182
|
+
...(result.codeServerPort === undefined
|
|
183
|
+
? {}
|
|
184
|
+
: { codeServerPort: result.codeServerPort }),
|
|
185
|
+
...(result.previewPorts === undefined
|
|
186
|
+
? {}
|
|
187
|
+
: { previewPorts: result.previewPorts }),
|
|
188
|
+
...(result.initWarning === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: { initWarning: result.initWarning }),
|
|
191
|
+
};
|
|
192
|
+
validateDockerLocatorValue(value);
|
|
193
|
+
return {
|
|
194
|
+
schemaVersion: 1,
|
|
195
|
+
provider: DOCKER_TASK_ENVIRONMENT_PROVIDER,
|
|
196
|
+
value,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Deterministic crash-recovery identity, derived solely from admitted host
|
|
201
|
+
* configuration and task id. Never accept a worktree path from the caller. */
|
|
202
|
+
export function dockerTaskEnvironmentPreparedLocator(
|
|
203
|
+
taskId: string,
|
|
204
|
+
machine: DockerMachineIdentity,
|
|
205
|
+
): TaskEnvironmentLocator {
|
|
206
|
+
assertSafeHostTaskId(taskId);
|
|
207
|
+
const composeProject = `task-${taskId}`;
|
|
208
|
+
const value: DockerTaskEnvironmentLocatorValue = {
|
|
209
|
+
schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
|
|
210
|
+
taskId,
|
|
211
|
+
composeProject,
|
|
212
|
+
containerName: `${composeProject}-app-1`,
|
|
213
|
+
hostWorktreePath: taskDir(taskId),
|
|
214
|
+
machine: validateMachineIdentity(machine),
|
|
215
|
+
};
|
|
216
|
+
validateDockerLocatorValue(value);
|
|
217
|
+
return {
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
provider: DOCKER_TASK_ENVIRONMENT_PROVIDER,
|
|
220
|
+
value,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function parseDockerTaskEnvironmentLocator(
|
|
225
|
+
locator: TaskEnvironmentLocator,
|
|
226
|
+
): DockerTaskEnvironmentLocatorValue {
|
|
227
|
+
if (
|
|
228
|
+
locator.schemaVersion !== 1 ||
|
|
229
|
+
locator.provider !== DOCKER_TASK_ENVIRONMENT_PROVIDER
|
|
230
|
+
) {
|
|
231
|
+
throw new Error("task environment locator does not belong to Docker Compose");
|
|
232
|
+
}
|
|
233
|
+
return validateDockerLocatorValue(locator.value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
class DockerTaskEnvironmentHandle
|
|
237
|
+
implements TaskEnvironmentHandle<TaskDownResult>
|
|
238
|
+
{
|
|
239
|
+
readonly descriptor: TaskEnvironmentDescriptor;
|
|
240
|
+
readonly durableIdentity: string;
|
|
241
|
+
readonly #value: DockerTaskEnvironmentLocatorValue;
|
|
242
|
+
|
|
243
|
+
constructor(
|
|
244
|
+
locator: TaskEnvironmentLocator,
|
|
245
|
+
private readonly deps: Required<
|
|
246
|
+
Omit<
|
|
247
|
+
DockerTaskEnvironmentDeps,
|
|
248
|
+
"recover" | "recoveryDriver" | "taskStatus"
|
|
249
|
+
>
|
|
250
|
+
> &
|
|
251
|
+
Pick<
|
|
252
|
+
DockerTaskEnvironmentDeps,
|
|
253
|
+
"recover" | "recoveryDriver" | "taskStatus"
|
|
254
|
+
>,
|
|
255
|
+
) {
|
|
256
|
+
this.#value = parseDockerTaskEnvironmentLocator(locator);
|
|
257
|
+
this.durableIdentity = this.#value.containerName;
|
|
258
|
+
this.descriptor = {
|
|
259
|
+
taskId: this.#value.taskId,
|
|
260
|
+
locator,
|
|
261
|
+
workspacePath: "/workspace",
|
|
262
|
+
...(this.#value.codeServerPort === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: { codeServerPort: this.#value.codeServerPort }),
|
|
265
|
+
...(this.#value.previewPorts === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { previewPorts: this.#value.previewPorts }),
|
|
268
|
+
...(this.#value.initWarning === undefined
|
|
269
|
+
? {}
|
|
270
|
+
: { initWarning: this.#value.initWarning }),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
|
|
275
|
+
assertTaskEnvironmentProcessRequest(request);
|
|
276
|
+
return capturedCliExec(
|
|
277
|
+
this.deps.spawn,
|
|
278
|
+
"docker",
|
|
279
|
+
dockerExecArgs(this.#value.containerName, request),
|
|
280
|
+
request,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async spawn(
|
|
285
|
+
request: TaskEnvironmentSpawnRequest,
|
|
286
|
+
): Promise<TaskEnvironmentProcess> {
|
|
287
|
+
assertTaskEnvironmentProcessRequest(request);
|
|
288
|
+
return streamingCliExec(
|
|
289
|
+
this.deps.spawn,
|
|
290
|
+
"docker",
|
|
291
|
+
dockerExecArgs(this.#value.containerName, request),
|
|
292
|
+
request,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async spawnSession(
|
|
297
|
+
request: TaskEnvironmentSessionRequest,
|
|
298
|
+
): Promise<TaskEnvironmentProcess> {
|
|
299
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
300
|
+
return streamingCliExec(
|
|
301
|
+
this.deps.spawn,
|
|
302
|
+
"docker",
|
|
303
|
+
dockerExecArgs(this.#value.containerName, request),
|
|
304
|
+
request,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async launchDetachedSession(
|
|
309
|
+
request: TaskEnvironmentDetachedSessionRequest,
|
|
310
|
+
): Promise<TaskEnvironmentExecResult> {
|
|
311
|
+
assertTaskEnvironmentSessionRequest(request);
|
|
312
|
+
const result = await this.deps.docker(
|
|
313
|
+
dockerExecArgs(this.#value.containerName, request, "detached"),
|
|
314
|
+
{
|
|
315
|
+
timeoutMs: request.launchTimeoutMs,
|
|
316
|
+
maxOutputBytes: request.maxOutputBytes,
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
return {
|
|
320
|
+
exitCode: result.status,
|
|
321
|
+
signal: null,
|
|
322
|
+
stdout: Buffer.from(result.stdout),
|
|
323
|
+
stderr: Buffer.from(result.stderr),
|
|
324
|
+
stdoutTruncated: result.outputTruncated === true,
|
|
325
|
+
stderrTruncated: result.outputTruncated === true,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
|
|
330
|
+
validateCopyRequest(request);
|
|
331
|
+
const args =
|
|
332
|
+
request.direction === "into"
|
|
333
|
+
? [
|
|
334
|
+
"cp",
|
|
335
|
+
request.source,
|
|
336
|
+
`${this.#value.containerName}:${request.destination}`,
|
|
337
|
+
]
|
|
338
|
+
: [
|
|
339
|
+
"cp",
|
|
340
|
+
`${this.#value.containerName}:${request.source}`,
|
|
341
|
+
request.destination,
|
|
342
|
+
];
|
|
343
|
+
const result = await this.deps.docker(args, {
|
|
344
|
+
timeoutMs: 120_000,
|
|
345
|
+
maxOutputBytes: 1024 * 1024,
|
|
346
|
+
});
|
|
347
|
+
requireDockerSuccess(result, "copy task environment data");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async ports(): Promise<TaskEnvironmentPort[]> {
|
|
351
|
+
const result = await this.deps.docker(
|
|
352
|
+
["port", this.#value.containerName],
|
|
353
|
+
{ timeoutMs: 10_000, maxOutputBytes: 256 * 1024 },
|
|
354
|
+
);
|
|
355
|
+
requireDockerSuccess(result, "inspect task environment ports");
|
|
356
|
+
return parseDockerPorts(result.stdout);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async status(): Promise<TaskEnvironmentStatus> {
|
|
360
|
+
if (this.deps.taskStatus) {
|
|
361
|
+
return taskStatusToEnvironmentStatus(
|
|
362
|
+
await this.deps.taskStatus(this.#value.taskId),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return inspectDockerTaskEnvironmentStatus(this.deps.docker, this.#value);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async recover(
|
|
369
|
+
context: TaskEnvironmentRecoveryContext,
|
|
370
|
+
): Promise<TaskEnvironmentRecoveryResult> {
|
|
371
|
+
const recover = this.deps.recover ?? this.deps.recoveryDriver?.();
|
|
372
|
+
if (recover) {
|
|
373
|
+
return recover(this.descriptor, context);
|
|
374
|
+
}
|
|
375
|
+
const status = await this.status();
|
|
376
|
+
return status.state === "unknown"
|
|
377
|
+
? { outcome: "deferred", detail: status.detail }
|
|
378
|
+
: { outcome: "recovered", status };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async stop(): Promise<void> {
|
|
382
|
+
// Stop the whole Compose project (app + sidecars) while keeping every
|
|
383
|
+
// container, volume, and the worktree so the task can be resumed. The
|
|
384
|
+
// label filter is scoped by the locator's project, which provision
|
|
385
|
+
// derived from the admitted task id.
|
|
386
|
+
const listed = await this.deps.docker(
|
|
387
|
+
[
|
|
388
|
+
"ps",
|
|
389
|
+
"-q",
|
|
390
|
+
"--filter",
|
|
391
|
+
`label=com.docker.compose.project=${this.#value.composeProject}`,
|
|
392
|
+
],
|
|
393
|
+
{ timeoutMs: 30_000, maxOutputBytes: 256 * 1024 },
|
|
394
|
+
);
|
|
395
|
+
requireDockerSuccess(listed, "list task environment containers");
|
|
396
|
+
const ids = listed.stdout
|
|
397
|
+
.split("\n")
|
|
398
|
+
.map((id) => id.trim())
|
|
399
|
+
.filter(Boolean);
|
|
400
|
+
if (ids.length === 0) return;
|
|
401
|
+
const stopped = await this.deps.docker(["stop", ...ids], {
|
|
402
|
+
timeoutMs: 60_000,
|
|
403
|
+
maxOutputBytes: 256 * 1024,
|
|
404
|
+
});
|
|
405
|
+
requireDockerSuccess(stopped, "stop task environment containers");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async teardown(): Promise<TaskDownResult> {
|
|
409
|
+
const result = await this.deps.taskEnvironmentDown(this.#value.taskId);
|
|
410
|
+
let status: TaskEnvironmentStatus;
|
|
411
|
+
try {
|
|
412
|
+
status = await this.status();
|
|
413
|
+
} catch (error) {
|
|
414
|
+
// Preserve structured agent failures so the host command boundary can
|
|
415
|
+
// still poison a vanished daemon. Other provider errors remain an
|
|
416
|
+
// explicit lack of absence proof.
|
|
417
|
+
if (AgentError.is(error)) throw error;
|
|
418
|
+
throw new Error(
|
|
419
|
+
`task environment teardown is unconfirmed: ${boundedErrorMessage(error)}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (status.state !== "absent") {
|
|
423
|
+
throw new Error(
|
|
424
|
+
status.state === "unknown"
|
|
425
|
+
? `task environment teardown is unconfirmed: ${status.detail}`
|
|
426
|
+
: `task environment teardown left the task ${status.state}`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function validateDockerLocatorValue(
|
|
434
|
+
value: unknown,
|
|
435
|
+
): DockerTaskEnvironmentLocatorValue {
|
|
436
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
437
|
+
throw new Error("Docker task environment locator must be an object");
|
|
438
|
+
}
|
|
439
|
+
const record = value as Record<string, unknown>;
|
|
440
|
+
const allowed = new Set([
|
|
441
|
+
"schemaVersion",
|
|
442
|
+
"taskId",
|
|
443
|
+
"composeProject",
|
|
444
|
+
"containerName",
|
|
445
|
+
"hostWorktreePath",
|
|
446
|
+
"machine",
|
|
447
|
+
"codeServerPort",
|
|
448
|
+
"previewPorts",
|
|
449
|
+
"initWarning",
|
|
450
|
+
]);
|
|
451
|
+
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
|
452
|
+
throw new Error("Docker task environment locator has unexpected fields");
|
|
453
|
+
}
|
|
454
|
+
if (record.schemaVersion !== DOCKER_LOCATOR_SCHEMA_VERSION) {
|
|
455
|
+
throw new Error("unsupported Docker task environment locator schema");
|
|
456
|
+
}
|
|
457
|
+
const taskId = exactComponent(record.taskId, "task id");
|
|
458
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,127}$/.test(taskId)) {
|
|
459
|
+
throw new Error("Docker task environment task id is invalid");
|
|
460
|
+
}
|
|
461
|
+
const composeProject = exactComponent(record.composeProject, "Compose project");
|
|
462
|
+
const containerName = exactComponent(record.containerName, "container name");
|
|
463
|
+
if (
|
|
464
|
+
composeProject !== `task-${taskId}` ||
|
|
465
|
+
containerName !== `${composeProject}-app-1`
|
|
466
|
+
) {
|
|
467
|
+
throw new Error("Docker task environment identity is inconsistent");
|
|
468
|
+
}
|
|
469
|
+
const hostWorktreePath = exactComponent(
|
|
470
|
+
record.hostWorktreePath,
|
|
471
|
+
"host worktree path",
|
|
472
|
+
);
|
|
473
|
+
if (
|
|
474
|
+
!path.isAbsolute(hostWorktreePath) ||
|
|
475
|
+
path.resolve(hostWorktreePath) !== hostWorktreePath ||
|
|
476
|
+
/[\r\n]/.test(hostWorktreePath)
|
|
477
|
+
) {
|
|
478
|
+
throw new Error("Docker task environment worktree path must be absolute");
|
|
479
|
+
}
|
|
480
|
+
const machine = validateMachineIdentity(record.machine);
|
|
481
|
+
const codeServerPort = optionalPort(record.codeServerPort, "code server port");
|
|
482
|
+
const previewPorts = validatePreviewPorts(record.previewPorts);
|
|
483
|
+
const initWarning =
|
|
484
|
+
record.initWarning === undefined
|
|
485
|
+
? undefined
|
|
486
|
+
: exactComponent(record.initWarning, "init warning", 4096);
|
|
487
|
+
return {
|
|
488
|
+
schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
|
|
489
|
+
taskId,
|
|
490
|
+
composeProject,
|
|
491
|
+
containerName,
|
|
492
|
+
hostWorktreePath,
|
|
493
|
+
machine,
|
|
494
|
+
...(codeServerPort === undefined ? {} : { codeServerPort }),
|
|
495
|
+
...(previewPorts === undefined ? {} : { previewPorts }),
|
|
496
|
+
...(initWarning === undefined ? {} : { initWarning }),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function validateMachineIdentity(value: unknown): DockerMachineIdentity {
|
|
501
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
502
|
+
throw new Error("Docker machine identity must be an object");
|
|
503
|
+
}
|
|
504
|
+
const record = value as Record<string, unknown>;
|
|
505
|
+
if (
|
|
506
|
+
Object.keys(record).sort().join("\0") !==
|
|
507
|
+
"backend\0endpoint\0engineId"
|
|
508
|
+
) {
|
|
509
|
+
throw new Error("Docker machine identity has unexpected fields");
|
|
510
|
+
}
|
|
511
|
+
if (record.backend !== "docker") {
|
|
512
|
+
throw new Error("Docker machine backend is invalid");
|
|
513
|
+
}
|
|
514
|
+
const endpoint = exactComponent(record.endpoint, "Docker endpoint", 2048);
|
|
515
|
+
const socketPath = endpoint.slice("unix://".length);
|
|
516
|
+
if (
|
|
517
|
+
!endpoint.startsWith("unix:///") ||
|
|
518
|
+
/[@?#\r\n]/.test(endpoint) ||
|
|
519
|
+
!path.isAbsolute(socketPath) ||
|
|
520
|
+
path.resolve(socketPath) !== socketPath
|
|
521
|
+
) {
|
|
522
|
+
throw new Error("Docker machine endpoint must be a local Unix socket");
|
|
523
|
+
}
|
|
524
|
+
const engineId = exactComponent(record.engineId, "Docker engine id", 512);
|
|
525
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(engineId)) {
|
|
526
|
+
throw new Error("Docker engine id is invalid");
|
|
527
|
+
}
|
|
528
|
+
return { backend: record.backend, endpoint, engineId };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function validatePreviewPorts(
|
|
532
|
+
value: unknown,
|
|
533
|
+
): Array<{ name: string; hostPort: number }> | undefined {
|
|
534
|
+
if (value === undefined) return undefined;
|
|
535
|
+
if (!Array.isArray(value) || value.length > 64) {
|
|
536
|
+
throw new Error("Docker task environment preview ports are invalid");
|
|
537
|
+
}
|
|
538
|
+
return value.map((entry) => {
|
|
539
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
540
|
+
throw new Error("Docker task environment preview port is invalid");
|
|
541
|
+
}
|
|
542
|
+
const record = entry as Record<string, unknown>;
|
|
543
|
+
if (Object.keys(record).sort().join("\0") !== "hostPort\0name") {
|
|
544
|
+
throw new Error("Docker task environment preview port has unexpected fields");
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
name: exactComponent(record.name, "preview port name"),
|
|
548
|
+
hostPort: requiredPort(record.hostPort, "preview host port"),
|
|
549
|
+
};
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function exactComponent(
|
|
554
|
+
value: unknown,
|
|
555
|
+
name: string,
|
|
556
|
+
max = MAX_LOCATOR_COMPONENT,
|
|
557
|
+
): string {
|
|
558
|
+
if (
|
|
559
|
+
typeof value !== "string" ||
|
|
560
|
+
value.length === 0 ||
|
|
561
|
+
value.length > max ||
|
|
562
|
+
value.includes("\0")
|
|
563
|
+
) {
|
|
564
|
+
throw new Error(`${name} is invalid`);
|
|
565
|
+
}
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function optionalPort(value: unknown, name: string): number | undefined {
|
|
570
|
+
return value === undefined ? undefined : requiredPort(value, name);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function requiredPort(value: unknown, name: string): number {
|
|
574
|
+
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65_535) {
|
|
575
|
+
throw new Error(`${name} is invalid`);
|
|
576
|
+
}
|
|
577
|
+
return Number(value);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function dockerExecArgs(
|
|
581
|
+
containerName: string,
|
|
582
|
+
request:
|
|
583
|
+
| TaskEnvironmentExecRequest
|
|
584
|
+
| TaskEnvironmentSpawnRequest
|
|
585
|
+
| TaskEnvironmentSessionRequest
|
|
586
|
+
| TaskEnvironmentDetachedSessionRequest,
|
|
587
|
+
mode: "interactive" | "detached" = "interactive",
|
|
588
|
+
): string[] {
|
|
589
|
+
const args = ["exec", mode === "detached" ? "-d" : "-i"];
|
|
590
|
+
if (request.cwd) args.push("--workdir", request.cwd);
|
|
591
|
+
if (request.user) args.push("--user", request.user);
|
|
592
|
+
for (const name of request.inheritEnv ?? []) {
|
|
593
|
+
if (process.env[name] !== undefined) args.push("--env", name);
|
|
594
|
+
}
|
|
595
|
+
for (const [name, value] of Object.entries(request.env ?? {}).sort(([a], [b]) =>
|
|
596
|
+
a.localeCompare(b),
|
|
597
|
+
)) {
|
|
598
|
+
args.push("--env", `${name}=${value}`);
|
|
599
|
+
}
|
|
600
|
+
args.push(containerName, ...request.argv);
|
|
601
|
+
return args;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Bounded one-shot exec, generalized over the CLI so the Apple container
|
|
605
|
+
* provider reuses the exact truncation/timeout/exit-mapping semantics. */
|
|
606
|
+
export function capturedCliExec(
|
|
607
|
+
spawn: typeof nodeSpawn,
|
|
608
|
+
command: string,
|
|
609
|
+
args: readonly string[],
|
|
610
|
+
request: TaskEnvironmentExecRequest,
|
|
611
|
+
): Promise<TaskEnvironmentExecResult> {
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
const child = spawn(command, [...args], {
|
|
614
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
615
|
+
}) as ChildProcessWithoutNullStreams;
|
|
616
|
+
let stdout: Buffer[] = [];
|
|
617
|
+
let stderr: Buffer[] = [];
|
|
618
|
+
let retained = 0;
|
|
619
|
+
let stdoutTruncated = false;
|
|
620
|
+
let stderrTruncated = false;
|
|
621
|
+
let settled = false;
|
|
622
|
+
const append = (kind: "stdout" | "stderr", chunk: Buffer): void => {
|
|
623
|
+
if (settled) return;
|
|
624
|
+
const remaining = Math.max(0, request.maxOutputBytes - retained);
|
|
625
|
+
const kept = remaining === 0 ? Buffer.alloc(0) : chunk.subarray(0, remaining);
|
|
626
|
+
if (kept.length > 0) {
|
|
627
|
+
if (kind === "stdout") stdout.push(kept);
|
|
628
|
+
else stderr.push(kept);
|
|
629
|
+
retained += kept.length;
|
|
630
|
+
}
|
|
631
|
+
if (kept.length !== chunk.length) {
|
|
632
|
+
if (kind === "stdout") stdoutTruncated = true;
|
|
633
|
+
else stderrTruncated = true;
|
|
634
|
+
child.kill("SIGKILL");
|
|
635
|
+
// Same bounded settlement as the deadline kill: never wait forever
|
|
636
|
+
// on close after an overflow kill.
|
|
637
|
+
const settle = setTimeout(() => finish(null, "SIGKILL"), 5_000);
|
|
638
|
+
settle.unref?.();
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
child.stdout.on("data", (chunk: Buffer) => append("stdout", Buffer.from(chunk)));
|
|
642
|
+
child.stderr.on("data", (chunk: Buffer) => append("stderr", Buffer.from(chunk)));
|
|
643
|
+
const timer = setTimeout(() => {
|
|
644
|
+
child.kill("SIGKILL");
|
|
645
|
+
// Settle even if the killed child never emits close (unreapable state);
|
|
646
|
+
// the caller's own deadline already expired.
|
|
647
|
+
const settle = setTimeout(() => finish(null, "SIGKILL"), 5_000);
|
|
648
|
+
settle.unref?.();
|
|
649
|
+
}, request.timeoutMs);
|
|
650
|
+
timer.unref?.();
|
|
651
|
+
const finish = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
|
652
|
+
if (settled) return;
|
|
653
|
+
settled = true;
|
|
654
|
+
clearTimeout(timer);
|
|
655
|
+
resolve({
|
|
656
|
+
exitCode,
|
|
657
|
+
signal,
|
|
658
|
+
stdout: Buffer.concat(stdout),
|
|
659
|
+
stderr: Buffer.concat(stderr),
|
|
660
|
+
stdoutTruncated,
|
|
661
|
+
stderrTruncated,
|
|
662
|
+
});
|
|
663
|
+
stdout = [];
|
|
664
|
+
stderr = [];
|
|
665
|
+
};
|
|
666
|
+
child.once("error", (error) => {
|
|
667
|
+
stderr.push(Buffer.from(error.message));
|
|
668
|
+
finish(null, null);
|
|
669
|
+
});
|
|
670
|
+
child.once("close", finish);
|
|
671
|
+
child.stdin.on("error", () => {});
|
|
672
|
+
child.stdin.end(request.stdin);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Streaming exec, generalized over the CLI for the same reason. */
|
|
677
|
+
export function streamingCliExec(
|
|
678
|
+
spawn: typeof nodeSpawn,
|
|
679
|
+
command: string,
|
|
680
|
+
args: readonly string[],
|
|
681
|
+
request: TaskEnvironmentSpawnRequest | TaskEnvironmentSessionRequest,
|
|
682
|
+
): TaskEnvironmentProcess {
|
|
683
|
+
const child = spawn(command, [...args], {
|
|
684
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
685
|
+
}) as ChildProcessWithoutNullStreams;
|
|
686
|
+
// Every kill forces settlement within a bounded grace, like the captured
|
|
687
|
+
// runner: a wedged client must not leave an agent session's completion
|
|
688
|
+
// pending indefinitely.
|
|
689
|
+
let forceSettle: (() => void) | null = null;
|
|
690
|
+
const killAndSettle = (): void => {
|
|
691
|
+
child.kill("SIGKILL");
|
|
692
|
+
const settle = setTimeout(() => forceSettle?.(), 5_000);
|
|
693
|
+
settle.unref?.();
|
|
694
|
+
};
|
|
695
|
+
const budget = new SharedOutputBudget(request.maxOutputBytes, killAndSettle);
|
|
696
|
+
const stdout = new BoundedByteQueue(budget);
|
|
697
|
+
const stderr = new BoundedByteQueue(budget);
|
|
698
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(Buffer.from(chunk)));
|
|
699
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(Buffer.from(chunk)));
|
|
700
|
+
const timer =
|
|
701
|
+
"timeoutMs" in request
|
|
702
|
+
? setTimeout(killAndSettle, request.timeoutMs)
|
|
703
|
+
: null;
|
|
704
|
+
timer?.unref?.();
|
|
705
|
+
const completion = new Promise<TaskEnvironmentProcessExit>((resolve) => {
|
|
706
|
+
let settled = false;
|
|
707
|
+
const finish = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
|
708
|
+
if (settled) return;
|
|
709
|
+
settled = true;
|
|
710
|
+
if (timer) clearTimeout(timer);
|
|
711
|
+
stdout.end();
|
|
712
|
+
stderr.end();
|
|
713
|
+
resolve({ exitCode, signal });
|
|
714
|
+
};
|
|
715
|
+
forceSettle = () => finish(null, "SIGKILL");
|
|
716
|
+
child.once("error", () => finish(null, null));
|
|
717
|
+
child.once("close", finish);
|
|
718
|
+
});
|
|
719
|
+
child.stdin.on("error", () => {});
|
|
720
|
+
return {
|
|
721
|
+
stdout,
|
|
722
|
+
stderr,
|
|
723
|
+
completion,
|
|
724
|
+
write: (chunk) => writeChildInput(child, chunk),
|
|
725
|
+
closeInput: async () => {
|
|
726
|
+
child.stdin.end();
|
|
727
|
+
},
|
|
728
|
+
terminate: async (signal = "SIGTERM") => {
|
|
729
|
+
child.kill(signal);
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function writeChildInput(
|
|
735
|
+
child: ChildProcessWithoutNullStreams,
|
|
736
|
+
chunk: Uint8Array,
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
if (child.stdin.destroyed || !child.stdin.writable) {
|
|
739
|
+
return Promise.reject(new Error("task environment process input is closed"));
|
|
740
|
+
}
|
|
741
|
+
return new Promise((resolve, reject) => {
|
|
742
|
+
const onError = (error: Error): void => {
|
|
743
|
+
child.stdin.off("drain", onDrain);
|
|
744
|
+
reject(error);
|
|
745
|
+
};
|
|
746
|
+
const onDrain = (): void => {
|
|
747
|
+
child.stdin.off("error", onError);
|
|
748
|
+
resolve();
|
|
749
|
+
};
|
|
750
|
+
child.stdin.once("error", onError);
|
|
751
|
+
if (child.stdin.write(chunk)) {
|
|
752
|
+
child.stdin.off("error", onError);
|
|
753
|
+
resolve();
|
|
754
|
+
} else {
|
|
755
|
+
child.stdin.once("drain", onDrain);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
class SharedOutputBudget {
|
|
761
|
+
#bytes = 0;
|
|
762
|
+
#overflowed = false;
|
|
763
|
+
|
|
764
|
+
constructor(
|
|
765
|
+
private readonly maxBytes: number,
|
|
766
|
+
private readonly onOverflow: () => void,
|
|
767
|
+
) {}
|
|
768
|
+
|
|
769
|
+
retain(bytes: number): boolean {
|
|
770
|
+
if (this.#overflowed || this.#bytes + bytes > this.maxBytes) {
|
|
771
|
+
if (!this.#overflowed) {
|
|
772
|
+
this.#overflowed = true;
|
|
773
|
+
this.onOverflow();
|
|
774
|
+
}
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
this.#bytes += bytes;
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
release(bytes: number): void {
|
|
782
|
+
this.#bytes = Math.max(0, this.#bytes - bytes);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
class BoundedByteQueue implements AsyncIterable<Uint8Array> {
|
|
787
|
+
readonly #chunks: Buffer[] = [];
|
|
788
|
+
readonly #waiters: Array<
|
|
789
|
+
(result: IteratorResult<Uint8Array>) => void
|
|
790
|
+
> = [];
|
|
791
|
+
#ended = false;
|
|
792
|
+
|
|
793
|
+
constructor(private readonly budget: SharedOutputBudget) {}
|
|
794
|
+
|
|
795
|
+
push(chunk: Buffer): void {
|
|
796
|
+
if (this.#ended || chunk.length === 0 || !this.budget.retain(chunk.length)) return;
|
|
797
|
+
const waiter = this.#waiters.shift();
|
|
798
|
+
if (waiter) {
|
|
799
|
+
this.budget.release(chunk.length);
|
|
800
|
+
waiter({ done: false, value: chunk });
|
|
801
|
+
} else {
|
|
802
|
+
this.#chunks.push(chunk);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
end(): void {
|
|
807
|
+
if (this.#ended) return;
|
|
808
|
+
this.#ended = true;
|
|
809
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
810
|
+
waiter({ done: true, value: undefined });
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
[Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
|
|
815
|
+
return {
|
|
816
|
+
next: () => {
|
|
817
|
+
const chunk = this.#chunks.shift();
|
|
818
|
+
if (chunk) {
|
|
819
|
+
this.budget.release(chunk.length);
|
|
820
|
+
return Promise.resolve({ done: false as const, value: chunk });
|
|
821
|
+
}
|
|
822
|
+
if (this.#ended) {
|
|
823
|
+
return Promise.resolve({ done: true as const, value: undefined });
|
|
824
|
+
}
|
|
825
|
+
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function validateCopyRequest(request: TaskEnvironmentCopyRequest): void {
|
|
832
|
+
for (const path of [request.source, request.destination]) {
|
|
833
|
+
if (!path.startsWith("/") || path.includes("\0") || path.includes(":")) {
|
|
834
|
+
throw new Error("task environment copy paths must be absolute and unambiguous");
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function parseDockerPorts(stdout: string): TaskEnvironmentPort[] {
|
|
840
|
+
const ports: TaskEnvironmentPort[] = [];
|
|
841
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
842
|
+
if (!line.trim()) continue;
|
|
843
|
+
const match = /^(\d+)(?:\/(?:tcp|udp))? -> 127\.0\.0\.1:(\d+)$/.exec(
|
|
844
|
+
line.trim(),
|
|
845
|
+
);
|
|
846
|
+
if (!match) {
|
|
847
|
+
throw new Error("task environment published a non-loopback or malformed port");
|
|
848
|
+
}
|
|
849
|
+
ports.push({
|
|
850
|
+
containerPort: requiredPort(Number(match[1]), "container port"),
|
|
851
|
+
host: "127.0.0.1",
|
|
852
|
+
hostPort: requiredPort(Number(match[2]), "host port"),
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
return ports;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function taskStatusToEnvironmentStatus(
|
|
859
|
+
status: TaskStatusResult,
|
|
860
|
+
): TaskEnvironmentStatus {
|
|
861
|
+
return {
|
|
862
|
+
state: status.composeRunning
|
|
863
|
+
? "running"
|
|
864
|
+
: status.containers.length > 0 || status.worktreePresent
|
|
865
|
+
? "stopped"
|
|
866
|
+
: "absent",
|
|
867
|
+
instances: [...status.containers],
|
|
868
|
+
workspacePresent: status.worktreePresent,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// The fourth column is the preview-sidecar marker: a sidecar wearing the
|
|
873
|
+
// task's compose labels must read as "not the app", not as malformed
|
|
874
|
+
// identity data — the latter turned a healthy running task's status into
|
|
875
|
+
// "unknown" (live 2026-08-18).
|
|
876
|
+
const DOCKER_STATUS_FORMAT =
|
|
877
|
+
`{{.Names}}\t{{.Label "com.docker.compose.project"}}\t{{.State}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
|
|
878
|
+
|
|
879
|
+
async function inspectDockerTaskEnvironmentStatus(
|
|
880
|
+
docker: typeof dockerCli,
|
|
881
|
+
environment: DockerTaskEnvironmentLocatorValue,
|
|
882
|
+
): Promise<TaskEnvironmentStatus> {
|
|
883
|
+
const result = await docker(
|
|
884
|
+
[
|
|
885
|
+
"ps",
|
|
886
|
+
"--all",
|
|
887
|
+
"--filter",
|
|
888
|
+
`label=com.docker.compose.project=${environment.composeProject}`,
|
|
889
|
+
"--filter",
|
|
890
|
+
"label=com.docker.compose.service=app",
|
|
891
|
+
"--format",
|
|
892
|
+
DOCKER_STATUS_FORMAT,
|
|
893
|
+
],
|
|
894
|
+
{ timeoutMs: 30_000, maxOutputBytes: 256 * 1024 },
|
|
895
|
+
);
|
|
896
|
+
if (result.status !== 0) {
|
|
897
|
+
return {
|
|
898
|
+
state: "unknown",
|
|
899
|
+
detail: boundedDockerFailure(result, "inspect task environment"),
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
let state: string | undefined;
|
|
904
|
+
for (const raw of result.stdout.split(/\r?\n/)) {
|
|
905
|
+
// No trim before splitting: the app row's LAST column (the sidecar
|
|
906
|
+
// marker) is empty, and trimming would eat its tab and shift the shape.
|
|
907
|
+
if (!raw.trim()) continue;
|
|
908
|
+
const [name, project, candidateState, sidecar, ...extra] = raw.split("\t");
|
|
909
|
+
// A preview sidecar wearing the task's compose labels is not the app
|
|
910
|
+
// container; skipping it keeps a healthy task from reading as unknown.
|
|
911
|
+
if (sidecar !== undefined && sidecar !== "") continue;
|
|
912
|
+
if (
|
|
913
|
+
state !== undefined ||
|
|
914
|
+
name !== environment.containerName ||
|
|
915
|
+
project !== environment.composeProject ||
|
|
916
|
+
!candidateState ||
|
|
917
|
+
!candidateState.trim() ||
|
|
918
|
+
sidecar === undefined ||
|
|
919
|
+
extra.length > 0
|
|
920
|
+
) {
|
|
921
|
+
return {
|
|
922
|
+
state: "unknown",
|
|
923
|
+
detail: "task environment inventory returned malformed identity data",
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
state = candidateState.trim();
|
|
927
|
+
}
|
|
928
|
+
const workspacePresent = existsSync(environment.hostWorktreePath);
|
|
929
|
+
if (state === undefined) {
|
|
930
|
+
return {
|
|
931
|
+
state: workspacePresent ? "stopped" : "absent",
|
|
932
|
+
instances: [],
|
|
933
|
+
workspacePresent,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
state: state === "running" ? "running" : "stopped",
|
|
938
|
+
instances: [environment.containerName],
|
|
939
|
+
workspacePresent,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function requireDockerSuccess(result: DockerResult, operation: string): void {
|
|
944
|
+
if (result.status === 0) return;
|
|
945
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 512);
|
|
946
|
+
throw new Error(`${operation} failed${detail ? `: ${detail}` : ""}`);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function boundedDockerFailure(result: DockerResult, operation: string): string {
|
|
950
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 512);
|
|
951
|
+
return `${operation} failed${detail ? `: ${detail}` : ""}`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function boundedErrorMessage(error: unknown): string {
|
|
955
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 512);
|
|
956
|
+
}
|