@runuai/host 0.9.149 → 0.9.151
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1847 -236
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1062 -140
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #cb3j0z: a stopped task's diff, never by Git on the host.
|
|
3
|
+
*
|
|
4
|
+
* A task controls its worktrees and its task-private repositories: each
|
|
5
|
+
* worktree's `.git` pointer, each repository's config (an fsmonitor, a
|
|
6
|
+
* textconv or filter driver), and what a project's path even is. Git run on
|
|
7
|
+
* the host against them would run what the task configured as the runner's
|
|
8
|
+
* own user, and read whatever a repointed `.git` or a linked project leads
|
|
9
|
+
* to. While the task runs, its diff runs in its own runtime; once it has
|
|
10
|
+
* stopped, on Docker it runs here: the diff's one script (TASK_DIFF_SCRIPT)
|
|
11
|
+
* in a throwaway container of the standard image (Git, and nothing of the
|
|
12
|
+
* runner's), with the workspace and the task-private repositories mounted
|
|
13
|
+
* read-only at the paths the task's own container sees them (so each
|
|
14
|
+
* worktree's gitdir pointer resolves), and nothing else of the host's. It
|
|
15
|
+
* runs as the task user, from an environment that names only Git's own
|
|
16
|
+
* settings, with no network, capabilities, writable root or logs, its
|
|
17
|
+
* memory, processes and scratch space bounded, killed from inside past its
|
|
18
|
+
* time bound, and its output read only up to its bound: past that, or past
|
|
19
|
+
* its time, the container is removed by the id Docker gave it. What the
|
|
20
|
+
* task configured runs there, contained, and a pointer or link that leads
|
|
21
|
+
* anywhere else finds nothing.
|
|
22
|
+
*
|
|
23
|
+
* On the Apple runtime it runs the same way in a one-off container of the
|
|
24
|
+
* standard image (its own VM: one at a time), bound read-only to the same
|
|
25
|
+
* directories, as the task user, from an environment naming only Git's
|
|
26
|
+
* settings, with a read-only root, no capability, an init, and a /tmp in
|
|
27
|
+
* memory (the VM's memory bounds it, and the script's producers are bounded
|
|
28
|
+
* themselves), killed from inside past its time bound, and its output read
|
|
29
|
+
* only up to its bound. That runtime has no network-less mode, so the
|
|
30
|
+
* sandbox reaches what the task's own container would, and holds nothing of
|
|
31
|
+
* the runner's to send. A container there can be left behind (a run the
|
|
32
|
+
* runtime refused, a client killed at its bound), and one left created and
|
|
33
|
+
* never started wedges the whole runtime, so each is settled after its run
|
|
34
|
+
* (apple-one-off.ts): proven gone, deleted until it is, and, when it will
|
|
35
|
+
* not go, the runtime's owner asked to recheck it (a stopped task's diff
|
|
36
|
+
* never restarts the runtime's services itself: that would stop every task's
|
|
37
|
+
* VM with nothing to recover them). One left unproven, or whose client never
|
|
38
|
+
* reported its end, is watched until it is settled, and the next sandbox
|
|
39
|
+
* starts only after: never two VMs at once.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
43
|
+
import { lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
44
|
+
import { tmpdir } from "node:os";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
|
|
47
|
+
import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
|
|
48
|
+
import {
|
|
49
|
+
appleOneOffNeedsWatch,
|
|
50
|
+
appleOneOffRunArgs,
|
|
51
|
+
askRuntimeOwnerOnce,
|
|
52
|
+
newAppleOneOff,
|
|
53
|
+
settleAppleOneOff,
|
|
54
|
+
watchAppleOneOff,
|
|
55
|
+
type AppleCli,
|
|
56
|
+
type AppleOneOff,
|
|
57
|
+
type AppleOneOffDeps,
|
|
58
|
+
} from "./apple-one-off";
|
|
59
|
+
import { appleContainerRuntimeBinding, requestContainerRuntimeRecheck } from "./container-runtime";
|
|
60
|
+
import { taskDir, taskWorkspaceDir } from "./env";
|
|
61
|
+
import { STANDARD_IMAGE_TAG } from "./standard-image";
|
|
62
|
+
import { TASK_DIFF_PATCH_BYTES } from "./git-diff";
|
|
63
|
+
import { buildTaskDiff, type TaskDiffEnvironment } from "./task-diff";
|
|
64
|
+
import { APPLE_TASK_ENVIRONMENT_PROVIDER } from "./task-environment/apple-container";
|
|
65
|
+
import { capturedCliExec, DOCKER_TASK_ENVIRONMENT_PROVIDER } from "./task-environment/docker";
|
|
66
|
+
import {
|
|
67
|
+
isEnvironmentTransportProvider,
|
|
68
|
+
type TaskEnvironmentExecRequest,
|
|
69
|
+
type TaskEnvironmentExecResult,
|
|
70
|
+
} from "./task-environment/types";
|
|
71
|
+
|
|
72
|
+
/** Where a sandbox mounts the workspace, as the task's own container does. */
|
|
73
|
+
const SANDBOX_WORKSPACE = "/workspace";
|
|
74
|
+
/** A sandbox's command is killed from inside past this, whatever became of
|
|
75
|
+
* the CLI that ran it. */
|
|
76
|
+
const SANDBOX_LIFETIME_SECONDS = 360;
|
|
77
|
+
/** What starting a sandbox may add to its command's own bound. */
|
|
78
|
+
const SANDBOX_START_MS = 30_000;
|
|
79
|
+
/** Its scratch space: the script's two bounded producers and their errors. */
|
|
80
|
+
const SANDBOX_TMP_BYTES = 2 * TASK_DIFF_PATCH_BYTES + 16 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/** An Apple sandbox's memory: its own VM. */
|
|
83
|
+
const APPLE_SANDBOX_MEMORY = "1024M";
|
|
84
|
+
const APPLE_SANDBOX_CPUS = "2";
|
|
85
|
+
|
|
86
|
+
/** Why a stopped Apple task's diff is not offered when that runtime is not
|
|
87
|
+
* this runner's now. */
|
|
88
|
+
export const APPLE_STOPPED_DIFF_UNAVAILABLE =
|
|
89
|
+
"a stopped task's diff is not available: the Apple container runtime is not this runner's runtime now";
|
|
90
|
+
/** Why a stopped machine task's diff is not offered here. */
|
|
91
|
+
export const MACHINE_STOPPED_DIFF_UNAVAILABLE =
|
|
92
|
+
"a stopped task's diff is not available: its workspace is on its machine";
|
|
93
|
+
/** Why a stopped task's diff is not offered when nothing proves its runtime. */
|
|
94
|
+
export const UNKNOWN_STOPPED_DIFF_UNAVAILABLE =
|
|
95
|
+
"a stopped task's diff is not available: this runner cannot prove which runtime it ran in";
|
|
96
|
+
/** Why a stopped task's diff is not offered when its workspace is gone. */
|
|
97
|
+
export const GONE_STOPPED_DIFF_UNAVAILABLE =
|
|
98
|
+
"a stopped task's diff is not available: its workspace is no longer on this runner";
|
|
99
|
+
|
|
100
|
+
/** What a stopped task's runner row says of its runtime; null: no row. */
|
|
101
|
+
export type StoppedTaskRuntime = { readonly environmentProvider: string | null } | null;
|
|
102
|
+
|
|
103
|
+
/** Git's own settings in the sandbox: the mounts are read-only and owned by
|
|
104
|
+
* the host's user, so it takes no optional lock and trusts the directory. */
|
|
105
|
+
const SANDBOX_GIT_ENV: Readonly<Record<string, string>> = Object.freeze({
|
|
106
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
107
|
+
GIT_CONFIG_COUNT: "1",
|
|
108
|
+
GIT_CONFIG_KEY_0: "safe.directory",
|
|
109
|
+
GIT_CONFIG_VALUE_0: "*",
|
|
110
|
+
HOME: "/tmp",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** The sandbox's own first process: its errors go nowhere, so a task's
|
|
114
|
+
* driver writing to /proc/1/fd/2 reaches nothing of the runner's; then the
|
|
115
|
+
* command, killed from inside past its bound. */
|
|
116
|
+
export const SANDBOX_INIT = 'exec 2>/dev/null; exec /usr/bin/timeout --signal=KILL "$0" "$@"';
|
|
117
|
+
|
|
118
|
+
export interface DiffSandboxDeps {
|
|
119
|
+
spawn?: typeof nodeSpawn;
|
|
120
|
+
/** The Apple runtime's `container` CLI, when it is this runner's
|
|
121
|
+
* (appleContainerRuntimeBinding). */
|
|
122
|
+
appleCli?: () => string | null;
|
|
123
|
+
/** Ask the runtime's owner to recheck it (requestContainerRuntimeRecheck). */
|
|
124
|
+
appleRecheck?: (reason: string) => boolean;
|
|
125
|
+
/** Tests only: how the watch waits. */
|
|
126
|
+
appleDelay?: (ms: number) => Promise<void>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One read-only bind: a directory of the runner's, where it appears. */
|
|
130
|
+
export interface DiffSandboxMount {
|
|
131
|
+
readonly source: string;
|
|
132
|
+
readonly target: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether `path` is a directory itself, not a link to one. */
|
|
136
|
+
function realDirectory(path: string): boolean {
|
|
137
|
+
try {
|
|
138
|
+
const stat = lstatSync(path);
|
|
139
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A path a bind can name: absolute, and nothing the bind syntax reads. */
|
|
146
|
+
function bindable(path: string): boolean {
|
|
147
|
+
return path.startsWith("/") && !/[:,\0\n]/.test(path);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The binds a stopped task's diff needs, as its own container has them: the
|
|
152
|
+
* workspace at /workspace and at its own path (and its physical alias), and
|
|
153
|
+
* each project's task-private repository at its own path (and its physical
|
|
154
|
+
* alias). Every source is the runner's own path and must be a real directory
|
|
155
|
+
* (a repository that is not is left out: its project's diff fails). Null when
|
|
156
|
+
* the workspace itself is not one.
|
|
157
|
+
*/
|
|
158
|
+
export function diffSandboxMounts(
|
|
159
|
+
taskId: string,
|
|
160
|
+
projects: ReadonlyArray<{ id: string }>,
|
|
161
|
+
): DiffSandboxMount[] | null {
|
|
162
|
+
const workspace = taskWorkspaceDir(taskId);
|
|
163
|
+
if (!realDirectory(workspace) || !bindable(workspace)) return null;
|
|
164
|
+
const mounts: DiffSandboxMount[] = [
|
|
165
|
+
{ source: workspace, target: SANDBOX_WORKSPACE },
|
|
166
|
+
{ source: workspace, target: workspace },
|
|
167
|
+
];
|
|
168
|
+
const physicalWorkspace = realpathSync(workspace);
|
|
169
|
+
if (physicalWorkspace !== workspace && bindable(physicalWorkspace)) {
|
|
170
|
+
mounts.push({ source: workspace, target: physicalWorkspace });
|
|
171
|
+
}
|
|
172
|
+
const control = join(taskDir(taskId), ".uai");
|
|
173
|
+
let physicalControl: string | null = null;
|
|
174
|
+
try {
|
|
175
|
+
physicalControl = realpathSync(control);
|
|
176
|
+
} catch {
|
|
177
|
+
physicalControl = null;
|
|
178
|
+
}
|
|
179
|
+
for (const project of projects) {
|
|
180
|
+
if (!/^[A-Za-z0-9_-]+$/.test(project.id)) continue;
|
|
181
|
+
const repository = join(control, "repos", `${project.id}.git`);
|
|
182
|
+
if (!realDirectory(repository) || !bindable(repository)) continue;
|
|
183
|
+
mounts.push({ source: repository, target: repository });
|
|
184
|
+
if (physicalControl && physicalControl !== control) {
|
|
185
|
+
const alias = join(physicalControl, "repos", `${project.id}.git`);
|
|
186
|
+
if (bindable(alias)) mounts.push({ source: repository, target: alias });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return mounts;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The `docker run` arguments for one command in a sandbox, its container
|
|
193
|
+
* id written to `cidFile`. */
|
|
194
|
+
export function diffSandboxRunArgs(
|
|
195
|
+
request: Pick<TaskEnvironmentExecRequest, "argv" | "cwd" | "env">,
|
|
196
|
+
mounts: readonly DiffSandboxMount[],
|
|
197
|
+
groupId: number | null,
|
|
198
|
+
cidFile: string,
|
|
199
|
+
): string[] {
|
|
200
|
+
const args = [
|
|
201
|
+
"run",
|
|
202
|
+
"--rm",
|
|
203
|
+
"--cidfile",
|
|
204
|
+
cidFile,
|
|
205
|
+
"--pull",
|
|
206
|
+
"never",
|
|
207
|
+
"--network",
|
|
208
|
+
"none",
|
|
209
|
+
"--cap-drop",
|
|
210
|
+
"ALL",
|
|
211
|
+
"--security-opt",
|
|
212
|
+
"no-new-privileges",
|
|
213
|
+
"--read-only",
|
|
214
|
+
"--tmpfs",
|
|
215
|
+
`/tmp:rw,nosuid,nodev,noexec,size=${SANDBOX_TMP_BYTES}`,
|
|
216
|
+
"--memory",
|
|
217
|
+
"1g",
|
|
218
|
+
"--memory-swap",
|
|
219
|
+
"1g",
|
|
220
|
+
"--pids-limit",
|
|
221
|
+
"256",
|
|
222
|
+
// Nothing it writes is kept: what reaches the runner is read, bounded.
|
|
223
|
+
"--log-driver",
|
|
224
|
+
"none",
|
|
225
|
+
"--user",
|
|
226
|
+
"node",
|
|
227
|
+
"--workdir",
|
|
228
|
+
request.cwd ?? SANDBOX_WORKSPACE,
|
|
229
|
+
];
|
|
230
|
+
// The task's own group, which its files are shared with.
|
|
231
|
+
if (groupId !== null) args.push("--group-add", String(groupId));
|
|
232
|
+
// Only Git's own settings, never the runner's environment.
|
|
233
|
+
for (const [name, value] of Object.entries({ ...request.env, ...SANDBOX_GIT_ENV })) {
|
|
234
|
+
args.push("--env", `${name}=${value}`);
|
|
235
|
+
}
|
|
236
|
+
for (const mount of mounts) args.push("-v", `${mount.source}:${mount.target}:ro`);
|
|
237
|
+
args.push(
|
|
238
|
+
"--entrypoint",
|
|
239
|
+
"/bin/sh",
|
|
240
|
+
STANDARD_IMAGE_TAG,
|
|
241
|
+
"-c",
|
|
242
|
+
SANDBOX_INIT,
|
|
243
|
+
String(SANDBOX_LIFETIME_SECONDS),
|
|
244
|
+
...request.argv,
|
|
245
|
+
);
|
|
246
|
+
return args;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The `container run` arguments for one command in the Apple sandbox
|
|
250
|
+
* `oneOff` (newAppleOneOff). */
|
|
251
|
+
export function appleDiffSandboxRunArgs(
|
|
252
|
+
request: Pick<TaskEnvironmentExecRequest, "argv" | "cwd" | "env">,
|
|
253
|
+
mounts: readonly DiffSandboxMount[],
|
|
254
|
+
oneOff: AppleOneOff,
|
|
255
|
+
): string[] {
|
|
256
|
+
const args = [
|
|
257
|
+
"run",
|
|
258
|
+
"--rm",
|
|
259
|
+
...appleOneOffRunArgs(oneOff),
|
|
260
|
+
// The engine-login sandbox's containment, in the Apple dialect: the CLI
|
|
261
|
+
// takes no tmpfs options, and the VM's memory bounds /tmp.
|
|
262
|
+
"--init",
|
|
263
|
+
"--read-only",
|
|
264
|
+
"--cap-drop",
|
|
265
|
+
"ALL",
|
|
266
|
+
"--tmpfs",
|
|
267
|
+
"/tmp",
|
|
268
|
+
"--memory",
|
|
269
|
+
APPLE_SANDBOX_MEMORY,
|
|
270
|
+
"--cpus",
|
|
271
|
+
APPLE_SANDBOX_CPUS,
|
|
272
|
+
"--user",
|
|
273
|
+
"node",
|
|
274
|
+
"--workdir",
|
|
275
|
+
request.cwd ?? SANDBOX_WORKSPACE,
|
|
276
|
+
];
|
|
277
|
+
// Only Git's own settings, never the runner's environment.
|
|
278
|
+
for (const [key, value] of Object.entries({ ...request.env, ...SANDBOX_GIT_ENV })) {
|
|
279
|
+
args.push("--env", `${key}=${value}`);
|
|
280
|
+
}
|
|
281
|
+
for (const mount of mounts) args.push("-v", `${mount.source}:${mount.target}:ro`);
|
|
282
|
+
// The standard image declares no entrypoint (the Apple runtime cannot
|
|
283
|
+
// override one): its command is this.
|
|
284
|
+
args.push(STANDARD_IMAGE_TAG, "/bin/sh", "-c", SANDBOX_INIT, String(SANDBOX_LIFETIME_SECONDS), ...request.argv);
|
|
285
|
+
return args;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Apple sandboxes run one at a time: each is a VM of its own. One whose
|
|
289
|
+
* container may still appear holds the next until it is settled. */
|
|
290
|
+
let appleSandboxes: Promise<unknown> = Promise.resolve();
|
|
291
|
+
|
|
292
|
+
function oneAppleSandboxAtATime<T>(
|
|
293
|
+
work: () => Promise<{ value: T; settling: Promise<unknown> | null }>,
|
|
294
|
+
): Promise<T> {
|
|
295
|
+
const turn = appleSandboxes.then(work, work);
|
|
296
|
+
appleSandboxes = turn.then(
|
|
297
|
+
({ settling }) => settling?.catch(() => undefined),
|
|
298
|
+
() => undefined,
|
|
299
|
+
);
|
|
300
|
+
return turn.then(({ value }) => value);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The Apple runtime's CLI, bounded, as the one-off settlement reads it. */
|
|
304
|
+
function appleCliRunner(spawn: typeof nodeSpawn, command: string): AppleCli {
|
|
305
|
+
return async (args, timeoutMs) => {
|
|
306
|
+
const result = await capturedCliExec(spawn, command, args, {
|
|
307
|
+
argv: [command],
|
|
308
|
+
projectEnvProfile: "maintenance",
|
|
309
|
+
timeoutMs,
|
|
310
|
+
maxOutputBytes: 1024 * 1024,
|
|
311
|
+
});
|
|
312
|
+
return {
|
|
313
|
+
code: result.exitCode,
|
|
314
|
+
stdout: Buffer.from(result.stdout).toString("utf8"),
|
|
315
|
+
stderr: Buffer.from(result.stderr).toString("utf8"),
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether a task with no recorded runtime is provably a Docker one from
|
|
322
|
+
* before runtimes were recorded: task-up wrote its compose file into the
|
|
323
|
+
* runner's own control tree, which no container sees.
|
|
324
|
+
*/
|
|
325
|
+
function legacyDockerTask(taskId: string): boolean {
|
|
326
|
+
try {
|
|
327
|
+
const stat = lstatSync(join(taskDir(taskId), ".uai", "docker-compose.yml"));
|
|
328
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The diff's every project, as an error. */
|
|
335
|
+
function unavailable(input: TaskDiffInput, error: string): TaskDiffResult {
|
|
336
|
+
return {
|
|
337
|
+
repos: input.projects.map((project) => ({
|
|
338
|
+
id: project.id,
|
|
339
|
+
name: project.slug,
|
|
340
|
+
mountPath: project.slug,
|
|
341
|
+
defaultBranch: "",
|
|
342
|
+
files: [],
|
|
343
|
+
error,
|
|
344
|
+
})),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The diff of a task that is not running, computed in a throwaway sandbox
|
|
350
|
+
* (see the module comment) for a task this runner can prove a Docker one
|
|
351
|
+
* (recorded as one, or with no recorded runtime but a legacy Docker task's
|
|
352
|
+
* compose file) or an Apple one while that runtime is this runner's.
|
|
353
|
+
* Anything else is refused, project by project, never taken for an empty
|
|
354
|
+
* diff: no row, a machine's (its workspace is not here), an unknown
|
|
355
|
+
* runtime, a workspace gone. A sandbox that cannot run fails the diff: no
|
|
356
|
+
* Git ever runs on the host instead.
|
|
357
|
+
*/
|
|
358
|
+
export async function stoppedTaskDiff(
|
|
359
|
+
input: TaskDiffInput,
|
|
360
|
+
task: StoppedTaskRuntime,
|
|
361
|
+
deps: DiffSandboxDeps = {},
|
|
362
|
+
): Promise<TaskDiffResult> {
|
|
363
|
+
if (task === null) return unavailable(input, UNKNOWN_STOPPED_DIFF_UNAVAILABLE);
|
|
364
|
+
const provider = task.environmentProvider;
|
|
365
|
+
if (provider === APPLE_TASK_ENVIRONMENT_PROVIDER) return appleStoppedTaskDiff(input, deps);
|
|
366
|
+
if (isEnvironmentTransportProvider(provider)) return unavailable(input, MACHINE_STOPPED_DIFF_UNAVAILABLE);
|
|
367
|
+
const docker =
|
|
368
|
+
provider === DOCKER_TASK_ENVIRONMENT_PROVIDER || (provider === null && legacyDockerTask(input.taskId));
|
|
369
|
+
if (!docker) return unavailable(input, UNKNOWN_STOPPED_DIFF_UNAVAILABLE);
|
|
370
|
+
const mounts = diffSandboxMounts(input.taskId, input.projects);
|
|
371
|
+
if (!mounts) return unavailable(input, GONE_STOPPED_DIFF_UNAVAILABLE);
|
|
372
|
+
const spawn = deps.spawn ?? nodeSpawn;
|
|
373
|
+
const groupId = lstatSync(taskWorkspaceDir(input.taskId)).gid;
|
|
374
|
+
const environment: TaskDiffEnvironment = {
|
|
375
|
+
descriptor: {
|
|
376
|
+
taskId: input.taskId,
|
|
377
|
+
locator: { schemaVersion: 1, provider: "diff-sandbox", value: {} },
|
|
378
|
+
workspacePath: SANDBOX_WORKSPACE,
|
|
379
|
+
},
|
|
380
|
+
// Each command, a sandbox of its own (a diff is one command).
|
|
381
|
+
exec: async (request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> => {
|
|
382
|
+
const scratch = mkdtempSync(join(tmpdir(), "uai-diff-sandbox-"));
|
|
383
|
+
const cidFile = join(scratch, "cid");
|
|
384
|
+
try {
|
|
385
|
+
request.admit?.();
|
|
386
|
+
const result = await capturedCliExec(spawn, "docker", diffSandboxRunArgs(request, mounts, groupId, cidFile), {
|
|
387
|
+
...request,
|
|
388
|
+
timeoutMs: request.timeoutMs + SANDBOX_START_MS,
|
|
389
|
+
});
|
|
390
|
+
// Its output past the bound, or no answer in time: the CLI was
|
|
391
|
+
// killed, and the container it started is removed by its own id.
|
|
392
|
+
// (One that ran to its end is gone already: `--rm`.)
|
|
393
|
+
let id = "";
|
|
394
|
+
try {
|
|
395
|
+
id = readFileSync(cidFile, "utf8").trim();
|
|
396
|
+
} catch {
|
|
397
|
+
id = "";
|
|
398
|
+
}
|
|
399
|
+
if (/^[0-9a-f]{64}$/.test(id) && (result.exitCode === null || result.stdoutTruncated || result.stderrTruncated)) {
|
|
400
|
+
await capturedCliExec(spawn, "docker", ["rm", "-f", id], {
|
|
401
|
+
argv: ["docker"],
|
|
402
|
+
projectEnvProfile: "maintenance",
|
|
403
|
+
timeoutMs: 30_000,
|
|
404
|
+
maxOutputBytes: 64 * 1024,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
} finally {
|
|
409
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
return buildTaskDiff(input, environment);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A stopped Apple task's diff, in an Apple sandbox (see the module
|
|
417
|
+
* comment). */
|
|
418
|
+
async function appleStoppedTaskDiff(input: TaskDiffInput, deps: DiffSandboxDeps): Promise<TaskDiffResult> {
|
|
419
|
+
const command = (deps.appleCli ?? (() => appleContainerRuntimeBinding()?.containerCliPath ?? null))();
|
|
420
|
+
if (command === null) return unavailable(input, APPLE_STOPPED_DIFF_UNAVAILABLE);
|
|
421
|
+
const mounts = diffSandboxMounts(input.taskId, input.projects);
|
|
422
|
+
if (!mounts) return unavailable(input, GONE_STOPPED_DIFF_UNAVAILABLE);
|
|
423
|
+
const spawn = deps.spawn ?? nodeSpawn;
|
|
424
|
+
const cli = appleCliRunner(spawn, command);
|
|
425
|
+
const recheck = deps.appleRecheck ?? requestContainerRuntimeRecheck;
|
|
426
|
+
/** Settles one sandbox's container; when it will not go, its runtime's
|
|
427
|
+
* owner is asked once to recheck the runtime. */
|
|
428
|
+
const settling = (): AppleOneOffDeps => ({
|
|
429
|
+
cli,
|
|
430
|
+
recycle: askRuntimeOwnerOnce(recheck, "a stopped task's diff sandbox could not be removed"),
|
|
431
|
+
...(deps.appleDelay ? { delay: deps.appleDelay } : {}),
|
|
432
|
+
});
|
|
433
|
+
const environment: TaskDiffEnvironment = {
|
|
434
|
+
descriptor: {
|
|
435
|
+
taskId: input.taskId,
|
|
436
|
+
locator: { schemaVersion: 1, provider: "diff-sandbox", value: {} },
|
|
437
|
+
workspacePath: SANDBOX_WORKSPACE,
|
|
438
|
+
},
|
|
439
|
+
// Each command, a sandbox of its own (a diff is one command).
|
|
440
|
+
exec: (request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> =>
|
|
441
|
+
oneAppleSandboxAtATime(async () => {
|
|
442
|
+
const oneOff = newAppleOneOff("diff");
|
|
443
|
+
const settles = settling();
|
|
444
|
+
request.admit?.();
|
|
445
|
+
const result = await capturedCliExec(spawn, command, appleDiffSandboxRunArgs(request, mounts, oneOff), {
|
|
446
|
+
...request,
|
|
447
|
+
timeoutMs: request.timeoutMs + SANDBOX_START_MS,
|
|
448
|
+
});
|
|
449
|
+
// Whatever became of it, nothing of it is left: proven gone, or
|
|
450
|
+
// removed. One unproven, or whose client never reported its end (its
|
|
451
|
+
// container may yet be created), is watched until it is settled, and
|
|
452
|
+
// the next sandbox waits for that.
|
|
453
|
+
const settled = await settleAppleOneOff(settles, oneOff);
|
|
454
|
+
const lateCreation = result.exitCode === null;
|
|
455
|
+
return {
|
|
456
|
+
value: result,
|
|
457
|
+
settling: appleOneOffNeedsWatch(settled, lateCreation)
|
|
458
|
+
? watchAppleOneOff(settles, oneOff, { lateCreation })
|
|
459
|
+
: null,
|
|
460
|
+
};
|
|
461
|
+
}),
|
|
462
|
+
};
|
|
463
|
+
return buildTaskDiff(input, environment);
|
|
464
|
+
}
|