@runuai/host 0.9.150 → 0.9.152

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.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1837 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +650 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -1,51 +1,676 @@
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). Behavior is
5
- * byte-identical to the direct fs access call sites used to perform — the
6
- * point of the strangler's first phase is that call sites stop KNOWING the
7
- * workspace is host-side, not that anything moves yet.
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 { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
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
- /** Map an environment-absolute workspace path to its host location. */
19
- function hostPathFor(
20
- descriptor: TaskEnvironmentDescriptor,
21
- hostWorkspaceRoot: string,
22
- path: string,
23
- ): string {
24
- assertWorkspaceScopedPath(descriptor, path);
25
- const relative = path.slice(descriptor.workspacePath.length);
26
- return join(hostWorkspaceRoot, ...relative.split("/").filter(Boolean));
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
- export function hostWorkspaceRead(
30
- descriptor: TaskEnvironmentDescriptor,
31
- hostWorkspaceRoot: string,
32
- path: string,
33
- ): Uint8Array | null {
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
+ * included. Never with O_NOFOLLOW: O_NOFOLLOW_ANY already refuses a final
105
+ * link, and macOS 27 rejects the two together with EINVAL (live
106
+ * 2026-10-11: every workspace read and write failed on the Studio). */
107
+ const DARWIN = process.platform === "darwin";
108
+ /** Elsewhere (tests forcing this mode on Linux, where O_NOFOLLOW_ANY means
109
+ * nothing) O_NOFOLLOW stays, so a final link is still refused there. */
110
+ const DIRECTORY_NOFOLLOW_ANY = DIRECTORY | O_NOFOLLOW_ANY | (DARWIN ? 0 : constants.O_NOFOLLOW);
111
+ /** The flags one file is opened with in this mode: on macOS `flags` without
112
+ * O_NOFOLLOW, which O_NOFOLLOW_ANY covers and cannot be combined with. */
113
+ function nofollowAnyFlags(flags: number): number {
114
+ return (DARWIN ? flags & ~constants.O_NOFOLLOW : flags) | O_NOFOLLOW_ANY;
115
+ }
116
+
117
+ type Containment = "proc-fd" | "nofollow-any" | "verify";
118
+ let containment: Containment | null = null;
119
+
120
+ /** How this host opens a path below a root without following a link
121
+ * anywhere below it. Asked of the host once, by trying it. */
122
+ function containmentMode(): Containment {
123
+ containment ??= probeContainment();
124
+ return containment;
125
+ }
126
+
127
+ function probeContainment(): Containment {
128
+ if (process.platform === "linux") {
129
+ let directory: number | null = null;
130
+ try {
131
+ directory = openSync(tmpdir(), DIRECTORY);
132
+ closeSync(openSync(`/proc/self/fd/${directory}/.`, DIRECTORY));
133
+ return "proc-fd";
134
+ } catch {
135
+ return "verify";
136
+ } finally {
137
+ if (directory !== null) closeSync(directory);
138
+ }
139
+ }
140
+ if (process.platform === "darwin") {
141
+ // Resolved: macOS's temporary directory is below /var, itself a link,
142
+ // which O_NOFOLLOW_ANY refuses on every path through it.
143
+ const scratch = realpathSync(mkdtempSync(join(tmpdir(), "uai-nofollow-any-")));
144
+ try {
145
+ mkdirSync(join(scratch, "real"));
146
+ writeFileSync(join(scratch, "real", "file"), "");
147
+ symlinkSync(join(scratch, "real"), join(scratch, "link"));
148
+ try {
149
+ closeSync(openSync(join(scratch, "link", "file"), constants.O_RDONLY | O_NOFOLLOW_ANY));
150
+ // Opened through the link: the flag is not honored here.
151
+ return "verify";
152
+ } catch (error) {
153
+ if ((error as NodeJS.ErrnoException).code !== "ELOOP") return "verify";
154
+ }
155
+ // The exact flags the real opens use must work on a plain file and
156
+ // directory too: a flag the kernel refuses in combination would
157
+ // otherwise fail every workspace operation, not just links.
158
+ try {
159
+ closeSync(
160
+ openSync(
161
+ join(scratch, "real", "file"),
162
+ nofollowAnyFlags(constants.O_WRONLY | constants.O_APPEND | constants.O_NONBLOCK | constants.O_NOFOLLOW),
163
+ ),
164
+ );
165
+ closeSync(openSync(join(scratch, "real"), DIRECTORY_NOFOLLOW_ANY));
166
+ } catch {
167
+ return "verify";
168
+ }
169
+ return "nofollow-any";
170
+ } finally {
171
+ rmSync(scratch, { recursive: true, force: true });
172
+ }
173
+ }
174
+ return "verify";
175
+ }
176
+
177
+ /** Tests only: probe the host again, or pretend it offers `mode`. */
178
+ export function setWorkspaceContainmentForTests(mode: Containment | null): void {
179
+ containment = mode;
180
+ }
181
+
182
+ function checkedParts(parts: readonly string[]): void {
183
+ if (
184
+ parts.length === 0 ||
185
+ parts.some((part) => part === "" || part === "." || part === ".." || part.includes("/") || part.includes("\0"))
186
+ ) {
187
+ throw new WorkspacePathRefusedError("a malformed workspace path");
188
+ }
189
+ }
190
+
191
+ function refusal(error: unknown): unknown {
192
+ const code = (error as NodeJS.ErrnoException).code;
193
+ return code === "ELOOP" || code === "ENOTDIR" || code === "EMLINK" ? new WorkspacePathRefusedError() : error;
194
+ }
195
+
196
+ /** The root, resolved (it is the runner's own path); null when absent. */
197
+ function resolvedRoot(root: string): string | null {
34
198
  try {
35
- return readFileSync(hostPathFor(descriptor, hostWorkspaceRoot, path));
199
+ return realpathSync(root);
36
200
  } catch (error) {
37
201
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
38
202
  throw error;
39
203
  }
40
204
  }
41
205
 
206
+ /**
207
+ * macOS: a directory directly below the resolved root, made by name. Nothing
208
+ * above it is the task's, and mkdir never follows a link in its own place;
209
+ * what is there is then opened without following one.
210
+ */
211
+ function makeTopDirectory(base: string, name: string): void {
212
+ try {
213
+ mkdirSync(join(base, name));
214
+ } catch (error) {
215
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
216
+ }
217
+ closeSync(openSync(join(base, name), DIRECTORY_NOFOLLOW_ANY));
218
+ }
219
+
220
+ /**
221
+ * Linux: the directory `parts` names below `base`, opened one component at
222
+ * a time, each relative to the one before, none a link. With `make`, a
223
+ * missing component is made in the directory already opened. Null when one
224
+ * is missing and not made.
225
+ */
226
+ function walkDirectory(base: string, parts: readonly string[], make: boolean): number | null {
227
+ let directory = openSync(base, DIRECTORY);
228
+ try {
229
+ for (const part of parts) {
230
+ const at = `/proc/self/fd/${directory}/${part}`;
231
+ let next: number;
232
+ try {
233
+ next = openSync(at, DIRECTORY | constants.O_NOFOLLOW);
234
+ } catch (error) {
235
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
236
+ if (!make) return null;
237
+ try {
238
+ mkdirSync(at);
239
+ } catch (made) {
240
+ if ((made as NodeJS.ErrnoException).code !== "EEXIST") throw made;
241
+ }
242
+ next = openSync(at, DIRECTORY | constants.O_NOFOLLOW);
243
+ }
244
+ closeSync(directory);
245
+ directory = next;
246
+ }
247
+ const opened = directory;
248
+ directory = -1;
249
+ return opened;
250
+ } finally {
251
+ if (directory >= 0) closeSync(directory);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Open `parts` below `root` with `flags`, without following a link anywhere
257
+ * below it (O_NOFOLLOW and O_NONBLOCK are always added). Null when it does
258
+ * not exist and `flags` creates nothing, the root included. Never makes a
259
+ * directory.
260
+ */
261
+ function openWithin(root: string, parts: readonly string[], flags: number): number | null {
262
+ checkedParts(parts);
263
+ const creates = (flags & constants.O_CREAT) !== 0;
264
+ const base = resolvedRoot(root);
265
+ if (base === null) {
266
+ if (creates) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
267
+ return null;
268
+ }
269
+ const finalFlags = flags | constants.O_NONBLOCK | constants.O_NOFOLLOW;
270
+ const mode = containmentMode();
271
+ try {
272
+ if (mode === "proc-fd") {
273
+ const directory = walkDirectory(base, parts.slice(0, -1), false);
274
+ if (directory === null) {
275
+ if (creates) throw new WorkspaceDirectoryMissingError();
276
+ return null;
277
+ }
278
+ try {
279
+ return openSync(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, finalFlags, 0o644);
280
+ } finally {
281
+ closeSync(directory);
282
+ }
283
+ }
284
+ if (mode === "nofollow-any") {
285
+ return openSync(join(base, ...parts), nofollowAnyFlags(finalFlags), 0o644);
286
+ }
287
+ // Neither: a read verifies what it opened; nothing is written.
288
+ if ((flags & (constants.O_WRONLY | constants.O_RDWR | constants.O_CREAT)) !== 0) {
289
+ throw new WorkspacePathRefusedError("this host cannot write a task's workspace without following its links");
290
+ }
291
+ const path = join(base, ...parts);
292
+ const fd = openSync(path, finalFlags);
293
+ try {
294
+ const opened = fstatSync(fd);
295
+ const resolved = realpathSync(path);
296
+ const there = statSync(resolved);
297
+ if (!resolved.startsWith(base + sep) || there.dev !== opened.dev || there.ino !== opened.ino) {
298
+ throw new WorkspacePathRefusedError();
299
+ }
300
+ return fd;
301
+ } catch (error) {
302
+ closeSync(fd);
303
+ throw error;
304
+ }
305
+ } catch (error) {
306
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
307
+ if (creates) throw new WorkspaceDirectoryMissingError();
308
+ return null;
309
+ }
310
+ throw refusal(error);
311
+ }
312
+ }
313
+
314
+ /** Open a regular file below `root` (refused, and closed, if it is not
315
+ * one); null when absent. */
316
+ function openRegular(root: string, parts: readonly string[], flags: number): number | null {
317
+ const fd = openWithin(root, parts, flags);
318
+ if (fd === null) return null;
319
+ if (!fstatSync(fd).isFile()) {
320
+ closeSync(fd);
321
+ throw new NotARegularFileError();
322
+ }
323
+ return fd;
324
+ }
325
+
326
+ /**
327
+ * Read at most `maxBytes` of a regular file below `root` that a task
328
+ * controls, from its start or its end: opened non-blocking within `root`,
329
+ * refused unless the file it opened is a regular one, read off the event
330
+ * loop and given up past its bound. Null when it does not exist.
331
+ */
332
+ export async function readBoundedFile(
333
+ root: string,
334
+ parts: readonly string[],
335
+ options: Pick<TaskEnvironmentReadOptions, "maxBytes" | "from"> & { timeoutMs?: number } = {},
336
+ ): Promise<Uint8Array | null> {
337
+ const maxBytes = Math.max(0, options.maxBytes ?? WORKSPACE_READ_MAX_BYTES);
338
+ const fd = openRegular(root, parts, constants.O_RDONLY);
339
+ if (fd === null) return null;
340
+ const reading = (async (): Promise<Uint8Array> => {
341
+ const size = fstatSync(fd).size;
342
+ const length = Math.min(size, maxBytes);
343
+ const position = options.from === "end" ? size - length : 0;
344
+ const buffer = Buffer.alloc(length);
345
+ let done = 0;
346
+ while (done < length) {
347
+ const { bytesRead } = await readAt(fd, buffer, done, length - done, position + done);
348
+ if (bytesRead === 0) break;
349
+ done += bytesRead;
350
+ }
351
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, done);
352
+ })();
353
+ // Closed once the read has ended, however it ended.
354
+ void reading.then(
355
+ () => closeSync(fd),
356
+ () => closeSync(fd),
357
+ );
358
+ let timer: ReturnType<typeof setTimeout> | undefined;
359
+ const bound = new Promise<never>((_, reject) => {
360
+ timer = setTimeout(
361
+ () => reject(new Error("a workspace read did not finish in time")),
362
+ options.timeoutMs ?? WORKSPACE_READ_TIMEOUT_MS,
363
+ );
364
+ });
365
+ try {
366
+ return await Promise.race([reading, bound]);
367
+ } finally {
368
+ clearTimeout(timer);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * The bytes of a regular file below `root` from `offset`, at most
374
+ * `maxBytes`, and its size: for a reader that follows a file a task's
375
+ * runtime appends to. Null when it does not exist.
376
+ */
377
+ export function readContainedFrom(
378
+ root: string,
379
+ parts: readonly string[],
380
+ offset: number,
381
+ maxBytes: number,
382
+ ): { bytes: Buffer; size: number } | null {
383
+ const fd = openRegular(root, parts, constants.O_RDONLY);
384
+ if (fd === null) return null;
385
+ try {
386
+ const size = fstatSync(fd).size;
387
+ const length = Math.max(0, Math.min(size - offset, maxBytes));
388
+ const bytes = Buffer.alloc(length);
389
+ let done = 0;
390
+ while (done < length) {
391
+ const bytesRead = readSync(fd, bytes, done, length - done, offset + done);
392
+ if (bytesRead <= 0) break;
393
+ done += bytesRead;
394
+ }
395
+ return { bytes: bytes.subarray(0, done), size };
396
+ } finally {
397
+ closeSync(fd);
398
+ }
399
+ }
400
+
401
+ /** When a regular file below `root` was last modified; null when absent. */
402
+ export function containedModifiedAt(root: string, parts: readonly string[]): number | null {
403
+ const fd = openRegular(root, parts, constants.O_RDONLY);
404
+ if (fd === null) return null;
405
+ try {
406
+ return fstatSync(fd).mtimeMs;
407
+ } finally {
408
+ closeSync(fd);
409
+ }
410
+ }
411
+
412
+ function writeAll(fd: number, bytes: Uint8Array, position: number | null): void {
413
+ let done = 0;
414
+ while (done < bytes.length) {
415
+ done += writeSync(fd, bytes, done, bytes.length - done, position === null ? null : position + done);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Write `bytes` as the whole of a regular file below `root`, made if
421
+ * missing in a directory that exists, never through a link: truncated and
422
+ * written only once the file opened is found to be a regular one, so a FIFO
423
+ * or device a task put in its place is never written.
424
+ */
425
+ export function writeContainedFileSync(
426
+ root: string,
427
+ parts: readonly string[],
428
+ bytes: Uint8Array,
429
+ options: { mode?: number } = {},
430
+ ): void {
431
+ const fd = openRegular(root, parts, constants.O_WRONLY | constants.O_CREAT)!;
432
+ try {
433
+ ftruncateSync(fd, 0);
434
+ writeAll(fd, bytes, 0);
435
+ if (options.mode !== undefined) fchmodSync(fd, options.mode);
436
+ } finally {
437
+ closeSync(fd);
438
+ }
439
+ }
440
+
441
+ export async function writeContainedFile(
442
+ root: string,
443
+ parts: readonly string[],
444
+ bytes: Uint8Array,
445
+ options: { mode?: number } = {},
446
+ ): Promise<void> {
447
+ writeContainedFileSync(root, parts, bytes, options);
448
+ }
449
+
450
+ /** Append `bytes` to a regular file below `root`, made if missing in a
451
+ * directory that exists, never through a link. */
452
+ export function appendContainedFileSync(root: string, parts: readonly string[], bytes: Uint8Array): void {
453
+ const fd = openRegular(root, parts, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND)!;
454
+ try {
455
+ writeAll(fd, bytes, null);
456
+ } finally {
457
+ closeSync(fd);
458
+ }
459
+ }
460
+
461
+ export async function appendContainedFile(root: string, parts: readonly string[], bytes: Uint8Array): Promise<void> {
462
+ appendContainedFileSync(root, parts, bytes);
463
+ }
464
+
465
+ /**
466
+ * Make the directories `parts` names below `root` (each missing one), never
467
+ * through a link: on Linux relative to the directory already opened; on
468
+ * macOS one directly below the root by name, deeper ones through `runtime`,
469
+ * the task's own runtime, then checked from the host; refused without one.
470
+ * A link a task puts anywhere in the chain after is refused at the next open.
471
+ */
472
+ export async function makeContainedDirectories(
473
+ root: string,
474
+ parts: readonly string[],
475
+ runtime?: RuntimeNamespace,
476
+ ): Promise<void> {
477
+ checkedParts(parts);
478
+ const base = resolvedRoot(root);
479
+ if (base === null) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
480
+ const mode = containmentMode();
481
+ try {
482
+ if (mode === "proc-fd") {
483
+ closeSync(walkDirectory(base, parts, true)!);
484
+ return;
485
+ }
486
+ if (mode === "nofollow-any") {
487
+ try {
488
+ closeSync(openSync(join(base, ...parts), DIRECTORY_NOFOLLOW_ANY));
489
+ return;
490
+ } catch (error) {
491
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
492
+ }
493
+ if (parts.length === 1) {
494
+ makeTopDirectory(base, parts[0]!);
495
+ return;
496
+ }
497
+ if (!runtime) throw new WorkspacePathRefusedError("a missing workspace directory is never made here");
498
+ await runtime.makeDirectories([runtime.workspacePath, ...parts].join("/"));
499
+ closeSync(openSync(join(base, ...parts), DIRECTORY_NOFOLLOW_ANY));
500
+ return;
501
+ }
502
+ throw new WorkspacePathRefusedError("this host cannot write a task's workspace without following its links");
503
+ } catch (error) {
504
+ throw refusal(error);
505
+ }
506
+ }
507
+
508
+ /** Make them synchronously (true): on Linux, and on macOS one directly
509
+ * below the root; false where only the task's runtime can (deeper on
510
+ * macOS) or nothing can. */
511
+ export function makeContainedDirectoriesSync(root: string, parts: readonly string[]): boolean {
512
+ checkedParts(parts);
513
+ const base = resolvedRoot(root);
514
+ if (base === null) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
515
+ const mode = containmentMode();
516
+ try {
517
+ if (mode === "proc-fd") {
518
+ closeSync(walkDirectory(base, parts, true)!);
519
+ return true;
520
+ }
521
+ if (mode === "nofollow-any" && parts.length === 1) {
522
+ makeTopDirectory(base, parts[0]!);
523
+ return true;
524
+ }
525
+ } catch (error) {
526
+ throw refusal(error);
527
+ }
528
+ return false;
529
+ }
530
+
531
+ /** The task's runtime as a `RuntimeNamespace`: each operation an exec there,
532
+ * as the task user, where a link resolves inside the runtime. */
533
+ export function runtimeNamespaceOf(
534
+ environment: Pick<TaskEnvironmentHandle, "exec" | "descriptor">,
535
+ ): RuntimeNamespace {
536
+ const run = async (argv: [string, ...string[]], what: string): Promise<void> => {
537
+ const result = await environment.exec({
538
+ argv,
539
+ projectEnvProfile: "maintenance",
540
+ timeoutMs: 30_000,
541
+ maxOutputBytes: 4 * 1024,
542
+ });
543
+ if (result.exitCode !== 0) throw new Error(`the task runtime could not ${what} in its workspace`);
544
+ };
545
+ return {
546
+ workspacePath: environment.descriptor.workspacePath,
547
+ makeDirectories: (path) => run(["/bin/mkdir", "-p", "--", path], "make a directory"),
548
+ remove: (path) => run(["/bin/rm", "-f", "--", path], "remove an entry"),
549
+ replaceLink: (target, path) => run(["/bin/ln", "-sfn", "--", target, path], "replace a link"),
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Remove the entry `parts` names below `root` (a file, or a link itself,
555
+ * never what it points to); nothing when absent. On Linux relative to its
556
+ * directory opened without following links; on macOS through `runtime`;
557
+ * refused without one.
558
+ */
559
+ export async function removeContainedEntry(
560
+ root: string,
561
+ parts: readonly string[],
562
+ runtime?: RuntimeNamespace,
563
+ ): Promise<void> {
564
+ removeContainedEntrySyncOrRuntime(root, parts, runtime);
565
+ if (containmentMode() === "nofollow-any" && runtime) {
566
+ await runtime.remove([runtime.workspacePath, ...parts].join("/"));
567
+ }
568
+ }
569
+
570
+ function removeContainedEntrySyncOrRuntime(root: string, parts: readonly string[], runtime?: RuntimeNamespace): void {
571
+ checkedParts(parts);
572
+ const base = resolvedRoot(root);
573
+ if (base === null) return;
574
+ const mode = containmentMode();
575
+ if (mode === "nofollow-any" && runtime) return;
576
+ if (mode !== "proc-fd") {
577
+ throw new WorkspacePathRefusedError("this host cannot remove a task's workspace entry without following its links");
578
+ }
579
+ let directory: number | null;
580
+ try {
581
+ directory = walkDirectory(base, parts.slice(0, -1), false);
582
+ } catch (error) {
583
+ throw refusal(error);
584
+ }
585
+ if (directory === null) return;
586
+ try {
587
+ unlinkSync(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`);
588
+ } catch (error) {
589
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw refusal(error);
590
+ } finally {
591
+ closeSync(directory);
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Point the link `parts` names below `root` at `target` (a name in the same
597
+ * directory), replacing it whole: made beside it and renamed over it,
598
+ * relative to its directory opened without following links (Linux), or
599
+ * through `runtime` (macOS); refused without one.
600
+ */
601
+ export async function replaceContainedLink(
602
+ root: string,
603
+ parts: readonly string[],
604
+ target: string,
605
+ runtime?: RuntimeNamespace,
606
+ ): Promise<void> {
607
+ checkedParts(parts);
608
+ checkedParts([target]);
609
+ const base = resolvedRoot(root);
610
+ if (base === null) throw new WorkspacePathRefusedError("a workspace that does not exist is never written");
611
+ const mode = containmentMode();
612
+ if (mode === "proc-fd") {
613
+ let directory: number | null;
614
+ try {
615
+ directory = walkDirectory(base, parts.slice(0, -1), false);
616
+ } catch (error) {
617
+ throw refusal(error);
618
+ }
619
+ if (directory === null) throw new WorkspacePathRefusedError("a missing workspace directory is never made here");
620
+ const name = parts[parts.length - 1]!;
621
+ const pending = `.${name}-${process.pid}-${Date.now()}`;
622
+ try {
623
+ symlinkSync(target, `/proc/self/fd/${directory}/${pending}`);
624
+ renameSync(`/proc/self/fd/${directory}/${pending}`, `/proc/self/fd/${directory}/${name}`);
625
+ } catch (error) {
626
+ try {
627
+ unlinkSync(`/proc/self/fd/${directory}/${pending}`);
628
+ } catch {
629
+ // Nothing was made.
630
+ }
631
+ throw refusal(error);
632
+ } finally {
633
+ closeSync(directory);
634
+ }
635
+ return;
636
+ }
637
+ if (mode === "nofollow-any" && runtime) {
638
+ await runtime.replaceLink(target, [runtime.workspacePath, ...parts].join("/"));
639
+ return;
640
+ }
641
+ throw new WorkspacePathRefusedError("this host cannot link in a task's workspace without following its links");
642
+ }
643
+
644
+ /** An environment-absolute workspace path's components below the
645
+ * workspace root. */
646
+ export function workspacePathParts(descriptor: TaskEnvironmentDescriptor, path: string): string[] {
647
+ assertWorkspaceScopedPath(descriptor, path);
648
+ return path.slice(descriptor.workspacePath.length).split("/").filter(Boolean);
649
+ }
650
+
651
+ /** A host path's components below `root`; refused if it is not below it. */
652
+ export function hostPathParts(root: string, path: string): string[] {
653
+ const prefix = root.endsWith(sep) ? root : root + sep;
654
+ if (!path.startsWith(prefix)) throw new WorkspacePathRefusedError("a path outside the task's workspace");
655
+ const parts = path.slice(prefix.length).split(sep).filter(Boolean);
656
+ checkedParts(parts);
657
+ return parts;
658
+ }
659
+
660
+ export function hostWorkspaceRead(
661
+ descriptor: TaskEnvironmentDescriptor,
662
+ hostWorkspaceRoot: string,
663
+ path: string,
664
+ options?: TaskEnvironmentReadOptions,
665
+ ): Promise<Uint8Array | null> {
666
+ return readBoundedFile(hostWorkspaceRoot, workspacePathParts(descriptor, path), options);
667
+ }
668
+
42
669
  export function hostWorkspaceWrite(
43
670
  descriptor: TaskEnvironmentDescriptor,
44
671
  hostWorkspaceRoot: string,
45
672
  path: string,
46
673
  bytes: Uint8Array,
47
- ): void {
48
- const target = hostPathFor(descriptor, hostWorkspaceRoot, path);
49
- mkdirSync(dirname(target), { recursive: true });
50
- writeFileSync(target, bytes);
674
+ ): Promise<void> {
675
+ return writeContainedFile(hostWorkspaceRoot, workspacePathParts(descriptor, path), bytes);
51
676
  }