@runuai/host 0.9.150 → 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 +1830 -224
- 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 +1061 -139
- 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
|
@@ -1,51 +1,649 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ADR-121 strangler ops: the host-filesystem implementation of the
|
|
3
3
|
* workspace file operations, shared by every provider whose workspace
|
|
4
|
-
* physically lives on the host (docker, apple-container)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* physically lives on the host (docker, apple-container), by the self
|
|
5
|
+
* provider's reads, and by every runner-side writer of a task's `.uai`
|
|
6
|
+
* (transcript, skills, durable sessions, attachments, the agent CLI).
|
|
7
|
+
*
|
|
8
|
+
* A task controls its workspace, links and special files included, and on
|
|
9
|
+
* an owned runner that workspace sits on the owner's own disk. So nothing
|
|
10
|
+
* here follows a link anywhere below the workspace's root, or reads, writes
|
|
11
|
+
* or appends to anything but a regular file: whatever the task arranges, no
|
|
12
|
+
* read returns, and no write touches, anything outside its workspace
|
|
13
|
+
* (#cb3j0z).
|
|
14
|
+
* - Linux opens each path one component at a time, relative to the directory
|
|
15
|
+
* already opened (through /proc/self/fd, the openat Node lacks), and makes,
|
|
16
|
+
* removes and links entries the same way.
|
|
17
|
+
* - macOS opens with O_NOFOLLOW_ANY. It has no way to make, remove or link an
|
|
18
|
+
* entry relative to a directory it opened, so those go through the task's
|
|
19
|
+
* own runtime (`RuntimeNamespace`), where a link resolves inside the
|
|
20
|
+
* runtime; without one they are refused. Only a directory directly below
|
|
21
|
+
* the root is made by name: nothing above it is the task's.
|
|
22
|
+
* - A host that offers neither reads only what it verifies after opening,
|
|
23
|
+
* and writes nothing.
|
|
8
24
|
*/
|
|
9
25
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
26
|
+
import {
|
|
27
|
+
closeSync,
|
|
28
|
+
constants,
|
|
29
|
+
fchmodSync,
|
|
30
|
+
fstatSync,
|
|
31
|
+
ftruncateSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
mkdtempSync,
|
|
34
|
+
openSync,
|
|
35
|
+
read,
|
|
36
|
+
readSync,
|
|
37
|
+
realpathSync,
|
|
38
|
+
renameSync,
|
|
39
|
+
rmSync,
|
|
40
|
+
statSync,
|
|
41
|
+
symlinkSync,
|
|
42
|
+
unlinkSync,
|
|
43
|
+
writeFileSync,
|
|
44
|
+
writeSync,
|
|
45
|
+
} from "node:fs";
|
|
46
|
+
import { tmpdir } from "node:os";
|
|
47
|
+
import { join, sep } from "node:path";
|
|
48
|
+
import { promisify } from "node:util";
|
|
12
49
|
|
|
13
50
|
import {
|
|
14
51
|
assertWorkspaceScopedPath,
|
|
52
|
+
WORKSPACE_READ_MAX_BYTES,
|
|
53
|
+
WORKSPACE_READ_TIMEOUT_MS,
|
|
15
54
|
type TaskEnvironmentDescriptor,
|
|
55
|
+
type TaskEnvironmentHandle,
|
|
56
|
+
type TaskEnvironmentReadOptions,
|
|
16
57
|
} from "./types";
|
|
17
58
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
59
|
+
const readAt = promisify(read);
|
|
60
|
+
|
|
61
|
+
/** A workspace path that is not a regular file: never read, written or
|
|
62
|
+
* appended to, never waited on. */
|
|
63
|
+
export class NotARegularFileError extends Error {
|
|
64
|
+
constructor() {
|
|
65
|
+
super("a workspace path that is not a regular file is never read or written");
|
|
66
|
+
this.name = "NotARegularFileError";
|
|
67
|
+
}
|
|
27
68
|
}
|
|
28
69
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
path
|
|
33
|
-
)
|
|
70
|
+
/** A workspace path through a link, or through something that is not a
|
|
71
|
+
* directory: never followed. */
|
|
72
|
+
export class WorkspacePathRefusedError extends Error {
|
|
73
|
+
constructor(message = "a workspace path through a link is never followed") {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "WorkspacePathRefusedError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A write below a workspace directory that does not exist: refused,
|
|
80
|
+
* nothing made (a caller makes directories explicitly). */
|
|
81
|
+
export class WorkspaceDirectoryMissingError extends WorkspacePathRefusedError {
|
|
82
|
+
constructor() {
|
|
83
|
+
super("a missing workspace directory is never made by a write");
|
|
84
|
+
this.name = "WorkspaceDirectoryMissingError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What a runner-side writer may run in the task's own runtime, where a link
|
|
90
|
+
* resolves inside the runtime: making directories, removing an entry,
|
|
91
|
+
* replacing a link. Paths are the runtime's (below its workspace path).
|
|
92
|
+
*/
|
|
93
|
+
export interface RuntimeNamespace {
|
|
94
|
+
readonly workspacePath: string;
|
|
95
|
+
makeDirectories(path: string): Promise<void>;
|
|
96
|
+
remove(path: string): Promise<void>;
|
|
97
|
+
replaceLink(target: string, path: string): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** macOS (11 and later): fail if any component of the path is a link. */
|
|
101
|
+
const O_NOFOLLOW_ANY = 0x20000000;
|
|
102
|
+
const DIRECTORY = constants.O_RDONLY | constants.O_DIRECTORY;
|
|
103
|
+
/** macOS: a directory, a link nowhere in its path (the last component
|
|
104
|
+
* refused by O_NOFOLLOW as well). */
|
|
105
|
+
const DIRECTORY_NOFOLLOW_ANY = DIRECTORY | O_NOFOLLOW_ANY | constants.O_NOFOLLOW;
|
|
106
|
+
|
|
107
|
+
type Containment = "proc-fd" | "nofollow-any" | "verify";
|
|
108
|
+
let containment: Containment | null = null;
|
|
109
|
+
|
|
110
|
+
/** How this host opens a path below a root without following a link
|
|
111
|
+
* anywhere below it. Asked of the host once, by trying it. */
|
|
112
|
+
function containmentMode(): Containment {
|
|
113
|
+
containment ??= probeContainment();
|
|
114
|
+
return containment;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function probeContainment(): Containment {
|
|
118
|
+
if (process.platform === "linux") {
|
|
119
|
+
let directory: number | null = null;
|
|
120
|
+
try {
|
|
121
|
+
directory = openSync(tmpdir(), DIRECTORY);
|
|
122
|
+
closeSync(openSync(`/proc/self/fd/${directory}/.`, DIRECTORY));
|
|
123
|
+
return "proc-fd";
|
|
124
|
+
} catch {
|
|
125
|
+
return "verify";
|
|
126
|
+
} finally {
|
|
127
|
+
if (directory !== null) closeSync(directory);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (process.platform === "darwin") {
|
|
131
|
+
const scratch = mkdtempSync(join(tmpdir(), "uai-nofollow-any-"));
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(join(scratch, "real"));
|
|
134
|
+
writeFileSync(join(scratch, "real", "file"), "");
|
|
135
|
+
symlinkSync(join(scratch, "real"), join(scratch, "link"));
|
|
136
|
+
try {
|
|
137
|
+
closeSync(openSync(join(scratch, "link", "file"), constants.O_RDONLY | O_NOFOLLOW_ANY));
|
|
138
|
+
// Opened through the link: the flag is not honored here.
|
|
139
|
+
return "verify";
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return (error as NodeJS.ErrnoException).code === "ELOOP" ? "nofollow-any" : "verify";
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return "verify";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tests only: probe the host again, or pretend it offers `mode`. */
|
|
151
|
+
export function setWorkspaceContainmentForTests(mode: Containment | null): void {
|
|
152
|
+
containment = mode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function checkedParts(parts: readonly string[]): void {
|
|
156
|
+
if (
|
|
157
|
+
parts.length === 0 ||
|
|
158
|
+
parts.some((part) => part === "" || part === "." || part === ".." || part.includes("/") || part.includes("\0"))
|
|
159
|
+
) {
|
|
160
|
+
throw new WorkspacePathRefusedError("a malformed workspace path");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function refusal(error: unknown): unknown {
|
|
165
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
166
|
+
return code === "ELOOP" || code === "ENOTDIR" || code === "EMLINK" ? new WorkspacePathRefusedError() : error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The root, resolved (it is the runner's own path); null when absent. */
|
|
170
|
+
function resolvedRoot(root: string): string | null {
|
|
34
171
|
try {
|
|
35
|
-
return
|
|
172
|
+
return realpathSync(root);
|
|
36
173
|
} catch (error) {
|
|
37
174
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
38
175
|
throw error;
|
|
39
176
|
}
|
|
40
177
|
}
|
|
41
178
|
|
|
179
|
+
/**
|
|
180
|
+
* macOS: a directory directly below the resolved root, made by name. Nothing
|
|
181
|
+
* above it is the task's, and mkdir never follows a link in its own place;
|
|
182
|
+
* what is there is then opened without following one.
|
|
183
|
+
*/
|
|
184
|
+
function makeTopDirectory(base: string, name: string): void {
|
|
185
|
+
try {
|
|
186
|
+
mkdirSync(join(base, name));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
189
|
+
}
|
|
190
|
+
closeSync(openSync(join(base, name), DIRECTORY_NOFOLLOW_ANY));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Linux: the directory `parts` names below `base`, opened one component at
|
|
195
|
+
* a time, each relative to the one before, none a link. With `make`, a
|
|
196
|
+
* missing component is made in the directory already opened. Null when one
|
|
197
|
+
* is missing and not made.
|
|
198
|
+
*/
|
|
199
|
+
function walkDirectory(base: string, parts: readonly string[], make: boolean): number | null {
|
|
200
|
+
let directory = openSync(base, DIRECTORY);
|
|
201
|
+
try {
|
|
202
|
+
for (const part of parts) {
|
|
203
|
+
const at = `/proc/self/fd/${directory}/${part}`;
|
|
204
|
+
let next: number;
|
|
205
|
+
try {
|
|
206
|
+
next = openSync(at, DIRECTORY | constants.O_NOFOLLOW);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
209
|
+
if (!make) return null;
|
|
210
|
+
try {
|
|
211
|
+
mkdirSync(at);
|
|
212
|
+
} catch (made) {
|
|
213
|
+
if ((made as NodeJS.ErrnoException).code !== "EEXIST") throw made;
|
|
214
|
+
}
|
|
215
|
+
next = openSync(at, DIRECTORY | constants.O_NOFOLLOW);
|
|
216
|
+
}
|
|
217
|
+
closeSync(directory);
|
|
218
|
+
directory = next;
|
|
219
|
+
}
|
|
220
|
+
const opened = directory;
|
|
221
|
+
directory = -1;
|
|
222
|
+
return opened;
|
|
223
|
+
} finally {
|
|
224
|
+
if (directory >= 0) closeSync(directory);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Open `parts` below `root` with `flags`, without following a link anywhere
|
|
230
|
+
* below it (O_NOFOLLOW and O_NONBLOCK are always added). Null when it does
|
|
231
|
+
* not exist and `flags` creates nothing, the root included. Never makes a
|
|
232
|
+
* directory.
|
|
233
|
+
*/
|
|
234
|
+
function openWithin(root: string, parts: readonly string[], flags: number): number | null {
|
|
235
|
+
checkedParts(parts);
|
|
236
|
+
const creates = (flags & constants.O_CREAT) !== 0;
|
|
237
|
+
const base = resolvedRoot(root);
|
|
238
|
+
if (base === null) {
|
|
239
|
+
if (creates) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const finalFlags = flags | constants.O_NONBLOCK | constants.O_NOFOLLOW;
|
|
243
|
+
const mode = containmentMode();
|
|
244
|
+
try {
|
|
245
|
+
if (mode === "proc-fd") {
|
|
246
|
+
const directory = walkDirectory(base, parts.slice(0, -1), false);
|
|
247
|
+
if (directory === null) {
|
|
248
|
+
if (creates) throw new WorkspaceDirectoryMissingError();
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return openSync(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, finalFlags, 0o644);
|
|
253
|
+
} finally {
|
|
254
|
+
closeSync(directory);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (mode === "nofollow-any") {
|
|
258
|
+
return openSync(join(base, ...parts), finalFlags | O_NOFOLLOW_ANY, 0o644);
|
|
259
|
+
}
|
|
260
|
+
// Neither: a read verifies what it opened; nothing is written.
|
|
261
|
+
if ((flags & (constants.O_WRONLY | constants.O_RDWR | constants.O_CREAT)) !== 0) {
|
|
262
|
+
throw new WorkspacePathRefusedError("this host cannot write a task's workspace without following its links");
|
|
263
|
+
}
|
|
264
|
+
const path = join(base, ...parts);
|
|
265
|
+
const fd = openSync(path, finalFlags);
|
|
266
|
+
try {
|
|
267
|
+
const opened = fstatSync(fd);
|
|
268
|
+
const resolved = realpathSync(path);
|
|
269
|
+
const there = statSync(resolved);
|
|
270
|
+
if (!resolved.startsWith(base + sep) || there.dev !== opened.dev || there.ino !== opened.ino) {
|
|
271
|
+
throw new WorkspacePathRefusedError();
|
|
272
|
+
}
|
|
273
|
+
return fd;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
closeSync(fd);
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
280
|
+
if (creates) throw new WorkspaceDirectoryMissingError();
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
throw refusal(error);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Open a regular file below `root` (refused, and closed, if it is not
|
|
288
|
+
* one); null when absent. */
|
|
289
|
+
function openRegular(root: string, parts: readonly string[], flags: number): number | null {
|
|
290
|
+
const fd = openWithin(root, parts, flags);
|
|
291
|
+
if (fd === null) return null;
|
|
292
|
+
if (!fstatSync(fd).isFile()) {
|
|
293
|
+
closeSync(fd);
|
|
294
|
+
throw new NotARegularFileError();
|
|
295
|
+
}
|
|
296
|
+
return fd;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Read at most `maxBytes` of a regular file below `root` that a task
|
|
301
|
+
* controls, from its start or its end: opened non-blocking within `root`,
|
|
302
|
+
* refused unless the file it opened is a regular one, read off the event
|
|
303
|
+
* loop and given up past its bound. Null when it does not exist.
|
|
304
|
+
*/
|
|
305
|
+
export async function readBoundedFile(
|
|
306
|
+
root: string,
|
|
307
|
+
parts: readonly string[],
|
|
308
|
+
options: Pick<TaskEnvironmentReadOptions, "maxBytes" | "from"> & { timeoutMs?: number } = {},
|
|
309
|
+
): Promise<Uint8Array | null> {
|
|
310
|
+
const maxBytes = Math.max(0, options.maxBytes ?? WORKSPACE_READ_MAX_BYTES);
|
|
311
|
+
const fd = openRegular(root, parts, constants.O_RDONLY);
|
|
312
|
+
if (fd === null) return null;
|
|
313
|
+
const reading = (async (): Promise<Uint8Array> => {
|
|
314
|
+
const size = fstatSync(fd).size;
|
|
315
|
+
const length = Math.min(size, maxBytes);
|
|
316
|
+
const position = options.from === "end" ? size - length : 0;
|
|
317
|
+
const buffer = Buffer.alloc(length);
|
|
318
|
+
let done = 0;
|
|
319
|
+
while (done < length) {
|
|
320
|
+
const { bytesRead } = await readAt(fd, buffer, done, length - done, position + done);
|
|
321
|
+
if (bytesRead === 0) break;
|
|
322
|
+
done += bytesRead;
|
|
323
|
+
}
|
|
324
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, done);
|
|
325
|
+
})();
|
|
326
|
+
// Closed once the read has ended, however it ended.
|
|
327
|
+
void reading.then(
|
|
328
|
+
() => closeSync(fd),
|
|
329
|
+
() => closeSync(fd),
|
|
330
|
+
);
|
|
331
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
332
|
+
const bound = new Promise<never>((_, reject) => {
|
|
333
|
+
timer = setTimeout(
|
|
334
|
+
() => reject(new Error("a workspace read did not finish in time")),
|
|
335
|
+
options.timeoutMs ?? WORKSPACE_READ_TIMEOUT_MS,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
return await Promise.race([reading, bound]);
|
|
340
|
+
} finally {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The bytes of a regular file below `root` from `offset`, at most
|
|
347
|
+
* `maxBytes`, and its size: for a reader that follows a file a task's
|
|
348
|
+
* runtime appends to. Null when it does not exist.
|
|
349
|
+
*/
|
|
350
|
+
export function readContainedFrom(
|
|
351
|
+
root: string,
|
|
352
|
+
parts: readonly string[],
|
|
353
|
+
offset: number,
|
|
354
|
+
maxBytes: number,
|
|
355
|
+
): { bytes: Buffer; size: number } | null {
|
|
356
|
+
const fd = openRegular(root, parts, constants.O_RDONLY);
|
|
357
|
+
if (fd === null) return null;
|
|
358
|
+
try {
|
|
359
|
+
const size = fstatSync(fd).size;
|
|
360
|
+
const length = Math.max(0, Math.min(size - offset, maxBytes));
|
|
361
|
+
const bytes = Buffer.alloc(length);
|
|
362
|
+
let done = 0;
|
|
363
|
+
while (done < length) {
|
|
364
|
+
const bytesRead = readSync(fd, bytes, done, length - done, offset + done);
|
|
365
|
+
if (bytesRead <= 0) break;
|
|
366
|
+
done += bytesRead;
|
|
367
|
+
}
|
|
368
|
+
return { bytes: bytes.subarray(0, done), size };
|
|
369
|
+
} finally {
|
|
370
|
+
closeSync(fd);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** When a regular file below `root` was last modified; null when absent. */
|
|
375
|
+
export function containedModifiedAt(root: string, parts: readonly string[]): number | null {
|
|
376
|
+
const fd = openRegular(root, parts, constants.O_RDONLY);
|
|
377
|
+
if (fd === null) return null;
|
|
378
|
+
try {
|
|
379
|
+
return fstatSync(fd).mtimeMs;
|
|
380
|
+
} finally {
|
|
381
|
+
closeSync(fd);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function writeAll(fd: number, bytes: Uint8Array, position: number | null): void {
|
|
386
|
+
let done = 0;
|
|
387
|
+
while (done < bytes.length) {
|
|
388
|
+
done += writeSync(fd, bytes, done, bytes.length - done, position === null ? null : position + done);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Write `bytes` as the whole of a regular file below `root`, made if
|
|
394
|
+
* missing in a directory that exists, never through a link: truncated and
|
|
395
|
+
* written only once the file opened is found to be a regular one, so a FIFO
|
|
396
|
+
* or device a task put in its place is never written.
|
|
397
|
+
*/
|
|
398
|
+
export function writeContainedFileSync(
|
|
399
|
+
root: string,
|
|
400
|
+
parts: readonly string[],
|
|
401
|
+
bytes: Uint8Array,
|
|
402
|
+
options: { mode?: number } = {},
|
|
403
|
+
): void {
|
|
404
|
+
const fd = openRegular(root, parts, constants.O_WRONLY | constants.O_CREAT)!;
|
|
405
|
+
try {
|
|
406
|
+
ftruncateSync(fd, 0);
|
|
407
|
+
writeAll(fd, bytes, 0);
|
|
408
|
+
if (options.mode !== undefined) fchmodSync(fd, options.mode);
|
|
409
|
+
} finally {
|
|
410
|
+
closeSync(fd);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function writeContainedFile(
|
|
415
|
+
root: string,
|
|
416
|
+
parts: readonly string[],
|
|
417
|
+
bytes: Uint8Array,
|
|
418
|
+
options: { mode?: number } = {},
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
writeContainedFileSync(root, parts, bytes, options);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Append `bytes` to a regular file below `root`, made if missing in a
|
|
424
|
+
* directory that exists, never through a link. */
|
|
425
|
+
export function appendContainedFileSync(root: string, parts: readonly string[], bytes: Uint8Array): void {
|
|
426
|
+
const fd = openRegular(root, parts, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND)!;
|
|
427
|
+
try {
|
|
428
|
+
writeAll(fd, bytes, null);
|
|
429
|
+
} finally {
|
|
430
|
+
closeSync(fd);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function appendContainedFile(root: string, parts: readonly string[], bytes: Uint8Array): Promise<void> {
|
|
435
|
+
appendContainedFileSync(root, parts, bytes);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Make the directories `parts` names below `root` (each missing one), never
|
|
440
|
+
* through a link: on Linux relative to the directory already opened; on
|
|
441
|
+
* macOS one directly below the root by name, deeper ones through `runtime`,
|
|
442
|
+
* the task's own runtime, then checked from the host; refused without one.
|
|
443
|
+
* A link a task puts anywhere in the chain after is refused at the next open.
|
|
444
|
+
*/
|
|
445
|
+
export async function makeContainedDirectories(
|
|
446
|
+
root: string,
|
|
447
|
+
parts: readonly string[],
|
|
448
|
+
runtime?: RuntimeNamespace,
|
|
449
|
+
): Promise<void> {
|
|
450
|
+
checkedParts(parts);
|
|
451
|
+
const base = resolvedRoot(root);
|
|
452
|
+
if (base === null) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
|
|
453
|
+
const mode = containmentMode();
|
|
454
|
+
try {
|
|
455
|
+
if (mode === "proc-fd") {
|
|
456
|
+
closeSync(walkDirectory(base, parts, true)!);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (mode === "nofollow-any") {
|
|
460
|
+
try {
|
|
461
|
+
closeSync(openSync(join(base, ...parts), DIRECTORY_NOFOLLOW_ANY));
|
|
462
|
+
return;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
465
|
+
}
|
|
466
|
+
if (parts.length === 1) {
|
|
467
|
+
makeTopDirectory(base, parts[0]!);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (!runtime) throw new WorkspacePathRefusedError("a missing workspace directory is never made here");
|
|
471
|
+
await runtime.makeDirectories([runtime.workspacePath, ...parts].join("/"));
|
|
472
|
+
closeSync(openSync(join(base, ...parts), DIRECTORY_NOFOLLOW_ANY));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
throw new WorkspacePathRefusedError("this host cannot write a task's workspace without following its links");
|
|
476
|
+
} catch (error) {
|
|
477
|
+
throw refusal(error);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Make them synchronously (true): on Linux, and on macOS one directly
|
|
482
|
+
* below the root; false where only the task's runtime can (deeper on
|
|
483
|
+
* macOS) or nothing can. */
|
|
484
|
+
export function makeContainedDirectoriesSync(root: string, parts: readonly string[]): boolean {
|
|
485
|
+
checkedParts(parts);
|
|
486
|
+
const base = resolvedRoot(root);
|
|
487
|
+
if (base === null) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
|
|
488
|
+
const mode = containmentMode();
|
|
489
|
+
try {
|
|
490
|
+
if (mode === "proc-fd") {
|
|
491
|
+
closeSync(walkDirectory(base, parts, true)!);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
if (mode === "nofollow-any" && parts.length === 1) {
|
|
495
|
+
makeTopDirectory(base, parts[0]!);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
} catch (error) {
|
|
499
|
+
throw refusal(error);
|
|
500
|
+
}
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The task's runtime as a `RuntimeNamespace`: each operation an exec there,
|
|
505
|
+
* as the task user, where a link resolves inside the runtime. */
|
|
506
|
+
export function runtimeNamespaceOf(
|
|
507
|
+
environment: Pick<TaskEnvironmentHandle, "exec" | "descriptor">,
|
|
508
|
+
): RuntimeNamespace {
|
|
509
|
+
const run = async (argv: [string, ...string[]], what: string): Promise<void> => {
|
|
510
|
+
const result = await environment.exec({
|
|
511
|
+
argv,
|
|
512
|
+
projectEnvProfile: "maintenance",
|
|
513
|
+
timeoutMs: 30_000,
|
|
514
|
+
maxOutputBytes: 4 * 1024,
|
|
515
|
+
});
|
|
516
|
+
if (result.exitCode !== 0) throw new Error(`the task runtime could not ${what} in its workspace`);
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
workspacePath: environment.descriptor.workspacePath,
|
|
520
|
+
makeDirectories: (path) => run(["/bin/mkdir", "-p", "--", path], "make a directory"),
|
|
521
|
+
remove: (path) => run(["/bin/rm", "-f", "--", path], "remove an entry"),
|
|
522
|
+
replaceLink: (target, path) => run(["/bin/ln", "-sfn", "--", target, path], "replace a link"),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Remove the entry `parts` names below `root` (a file, or a link itself,
|
|
528
|
+
* never what it points to); nothing when absent. On Linux relative to its
|
|
529
|
+
* directory opened without following links; on macOS through `runtime`;
|
|
530
|
+
* refused without one.
|
|
531
|
+
*/
|
|
532
|
+
export async function removeContainedEntry(
|
|
533
|
+
root: string,
|
|
534
|
+
parts: readonly string[],
|
|
535
|
+
runtime?: RuntimeNamespace,
|
|
536
|
+
): Promise<void> {
|
|
537
|
+
removeContainedEntrySyncOrRuntime(root, parts, runtime);
|
|
538
|
+
if (containmentMode() === "nofollow-any" && runtime) {
|
|
539
|
+
await runtime.remove([runtime.workspacePath, ...parts].join("/"));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function removeContainedEntrySyncOrRuntime(root: string, parts: readonly string[], runtime?: RuntimeNamespace): void {
|
|
544
|
+
checkedParts(parts);
|
|
545
|
+
const base = resolvedRoot(root);
|
|
546
|
+
if (base === null) return;
|
|
547
|
+
const mode = containmentMode();
|
|
548
|
+
if (mode === "nofollow-any" && runtime) return;
|
|
549
|
+
if (mode !== "proc-fd") {
|
|
550
|
+
throw new WorkspacePathRefusedError("this host cannot remove a task's workspace entry without following its links");
|
|
551
|
+
}
|
|
552
|
+
let directory: number | null;
|
|
553
|
+
try {
|
|
554
|
+
directory = walkDirectory(base, parts.slice(0, -1), false);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
throw refusal(error);
|
|
557
|
+
}
|
|
558
|
+
if (directory === null) return;
|
|
559
|
+
try {
|
|
560
|
+
unlinkSync(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw refusal(error);
|
|
563
|
+
} finally {
|
|
564
|
+
closeSync(directory);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Point the link `parts` names below `root` at `target` (a name in the same
|
|
570
|
+
* directory), replacing it whole: made beside it and renamed over it,
|
|
571
|
+
* relative to its directory opened without following links (Linux), or
|
|
572
|
+
* through `runtime` (macOS); refused without one.
|
|
573
|
+
*/
|
|
574
|
+
export async function replaceContainedLink(
|
|
575
|
+
root: string,
|
|
576
|
+
parts: readonly string[],
|
|
577
|
+
target: string,
|
|
578
|
+
runtime?: RuntimeNamespace,
|
|
579
|
+
): Promise<void> {
|
|
580
|
+
checkedParts(parts);
|
|
581
|
+
checkedParts([target]);
|
|
582
|
+
const base = resolvedRoot(root);
|
|
583
|
+
if (base === null) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
|
|
584
|
+
const mode = containmentMode();
|
|
585
|
+
if (mode === "proc-fd") {
|
|
586
|
+
let directory: number | null;
|
|
587
|
+
try {
|
|
588
|
+
directory = walkDirectory(base, parts.slice(0, -1), false);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
throw refusal(error);
|
|
591
|
+
}
|
|
592
|
+
if (directory === null) throw new WorkspacePathRefusedError("a missing workspace directory is never made here");
|
|
593
|
+
const name = parts[parts.length - 1]!;
|
|
594
|
+
const pending = `.${name}-${process.pid}-${Date.now()}`;
|
|
595
|
+
try {
|
|
596
|
+
symlinkSync(target, `/proc/self/fd/${directory}/${pending}`);
|
|
597
|
+
renameSync(`/proc/self/fd/${directory}/${pending}`, `/proc/self/fd/${directory}/${name}`);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
try {
|
|
600
|
+
unlinkSync(`/proc/self/fd/${directory}/${pending}`);
|
|
601
|
+
} catch {
|
|
602
|
+
// Nothing was made.
|
|
603
|
+
}
|
|
604
|
+
throw refusal(error);
|
|
605
|
+
} finally {
|
|
606
|
+
closeSync(directory);
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (mode === "nofollow-any" && runtime) {
|
|
611
|
+
await runtime.replaceLink(target, [runtime.workspacePath, ...parts].join("/"));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
throw new WorkspacePathRefusedError("this host cannot link in a task's workspace without following its links");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** An environment-absolute workspace path's components below the
|
|
618
|
+
* workspace root. */
|
|
619
|
+
export function workspacePathParts(descriptor: TaskEnvironmentDescriptor, path: string): string[] {
|
|
620
|
+
assertWorkspaceScopedPath(descriptor, path);
|
|
621
|
+
return path.slice(descriptor.workspacePath.length).split("/").filter(Boolean);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** A host path's components below `root`; refused if it is not below it. */
|
|
625
|
+
export function hostPathParts(root: string, path: string): string[] {
|
|
626
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
627
|
+
if (!path.startsWith(prefix)) throw new WorkspacePathRefusedError("a path outside the task's workspace");
|
|
628
|
+
const parts = path.slice(prefix.length).split(sep).filter(Boolean);
|
|
629
|
+
checkedParts(parts);
|
|
630
|
+
return parts;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function hostWorkspaceRead(
|
|
634
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
635
|
+
hostWorkspaceRoot: string,
|
|
636
|
+
path: string,
|
|
637
|
+
options?: TaskEnvironmentReadOptions,
|
|
638
|
+
): Promise<Uint8Array | null> {
|
|
639
|
+
return readBoundedFile(hostWorkspaceRoot, workspacePathParts(descriptor, path), options);
|
|
640
|
+
}
|
|
641
|
+
|
|
42
642
|
export function hostWorkspaceWrite(
|
|
43
643
|
descriptor: TaskEnvironmentDescriptor,
|
|
44
644
|
hostWorkspaceRoot: string,
|
|
45
645
|
path: string,
|
|
46
646
|
bytes: Uint8Array,
|
|
47
|
-
): void {
|
|
48
|
-
|
|
49
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
50
|
-
writeFileSync(target, bytes);
|
|
647
|
+
): Promise<void> {
|
|
648
|
+
return writeContainedFile(hostWorkspaceRoot, workspacePathParts(descriptor, path), bytes);
|
|
51
649
|
}
|