@parcha/pi-durable-disk 0.1.0-beta.13 → 0.1.0-beta.15

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 (50) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +281 -47
  3. package/dist/app.d.ts +5 -1
  4. package/dist/claim.d.ts +51 -3
  5. package/dist/claim.js +157 -17
  6. package/dist/cli.d.ts +6 -0
  7. package/dist/cli.js +112 -47
  8. package/dist/commands.d.ts +4 -1
  9. package/dist/commands.js +48 -90
  10. package/dist/confine.d.ts +36 -7
  11. package/dist/confine.js +125 -18
  12. package/dist/env.d.ts +7 -22
  13. package/dist/env.js +25 -60
  14. package/dist/errors.d.ts +2 -1
  15. package/dist/fork.d.ts +23 -0
  16. package/dist/fork.js +103 -3
  17. package/dist/hosts/attempt-env.d.ts +3 -0
  18. package/dist/hosts/attempt-env.js +15 -0
  19. package/dist/hosts/daytona-launch.d.ts +3 -1
  20. package/dist/hosts/daytona-launch.js +35 -12
  21. package/dist/hosts/daytona.d.ts +2 -2
  22. package/dist/hosts/daytona.js +48 -5
  23. package/dist/hosts/docker.d.ts +2 -2
  24. package/dist/hosts/docker.js +22 -5
  25. package/dist/hosts/local-host.d.ts +9 -3
  26. package/dist/hosts/local-host.js +59 -22
  27. package/dist/index.d.ts +35 -16
  28. package/dist/index.js +27 -16
  29. package/dist/internals.d.ts +3 -0
  30. package/dist/internals.js +6 -0
  31. package/dist/lease.d.ts +4 -1
  32. package/dist/lease.js +2 -0
  33. package/dist/local-run.js +3 -2
  34. package/dist/place.d.ts +190 -0
  35. package/dist/place.js +526 -0
  36. package/dist/process-table.d.ts +38 -0
  37. package/dist/process-table.js +158 -0
  38. package/dist/run.d.ts +61 -10
  39. package/dist/run.js +69 -33
  40. package/dist/status.d.ts +19 -3
  41. package/dist/status.js +25 -13
  42. package/dist/store.d.ts +14 -2
  43. package/dist/store.js +16 -4
  44. package/dist/supervise.d.ts +43 -1
  45. package/dist/supervise.js +26 -1
  46. package/dist/watchdog.d.ts +3 -1
  47. package/dist/watchdog.js +43 -12
  48. package/dist/with-claim.d.ts +63 -0
  49. package/dist/with-claim.js +325 -0
  50. package/package.json +9 -3
package/dist/confine.js CHANGED
@@ -10,8 +10,14 @@
10
10
  // openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS) done in user space. Node has no openat, so a handle's directory is named
11
11
  // `/proc/self/fd/<fd>`: the kernel jumps straight to the held inode there and never re-walks the directory's own path. A
12
12
  // swapped component fails the open (ELOOP or ENOTDIR) and the call is refused as changed; nothing is retried.
13
+ //
14
+ // macOS has no /proc/self/fd, so there (`byPath`) each step is opened by its full path, still with O_NOFOLLOW, and the
15
+ // walk ends by checking that the path still resolves to itself and names the directory held (same device and inode). A
16
+ // component that is a symlink, or was swapped for one before that check, is refused as changed. The operation then
17
+ // names the path again, so a command that swaps a directory above the target between the check and the operation can
18
+ // win: by path, the confinement holds against an agent's paths and the links already in place, not a command racing it.
13
19
  import { constants, existsSync, realpathSync } from "node:fs";
14
- import { lstat, mkdir, open, readlink, realpath } from "node:fs/promises";
20
+ import { lstat, mkdir, open, readlink, realpath, stat } from "node:fs/promises";
15
21
  import { basename, dirname, join } from "node:path";
16
22
  import { err, FileError, ok } from "@earendil-works/pi-durable/env";
17
23
  /**
@@ -119,8 +125,9 @@ export const inside = (path, root) => path === root || path.startsWith(root.ends
119
125
  const FILE_INFO_KEYS = ["name", "path", "kind", "size", "mtimeMs"];
120
126
  const isFileInfo = (value) => typeof value === "object" && value !== null && FILE_INFO_KEYS.every((key) => key in value) && typeof value.path === "string";
121
127
  /**
122
- * A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths to hand to pi's own implementation; every
123
- * path and message it returns is mapped back to the real one with `restore`. Close it when the operation is done.
128
+ * A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths (by path, the real ones) to hand to pi's
129
+ * own implementation; every path and message it returns is mapped back to the real one with `restore`. Close it when
130
+ * the operation is done.
124
131
  */
125
132
  export class Pin {
126
133
  canonical;
@@ -134,17 +141,29 @@ export class Pin {
134
141
  /** What `dir` stands for: the directory itself, or the directory holding the entry. */
135
142
  #real;
136
143
  #closed = false;
137
- constructor(handle, canonical, name, dirMode) {
144
+ #byPath;
145
+ #names;
146
+ /**
147
+ * `held` is the walk's: whether it went by path, and then the held directory's real path; and the names of the
148
+ * confinement's roots (`Confinement`'s `names`).
149
+ */
150
+ constructor(handle, canonical, name, dirMode, held = { byPath: false, path: "" }) {
138
151
  this.#handle = handle;
139
152
  this.canonical = canonical;
140
153
  this.name = name;
141
- this.dir = `/proc/self/fd/${handle.fd}`;
154
+ this.#byPath = held.byPath;
155
+ this.#names = held.names ?? [];
156
+ this.dir = held.byPath ? held.path : `/proc/self/fd/${handle.fd}`;
142
157
  this.entry = dirMode ? this.dir : name === "" ? `${this.dir}/.` : `${this.dir}/${name}`;
143
158
  this.#real = dirMode || name === "" ? canonical : dirname(canonical);
144
159
  }
145
- /** `text` with the held paths replaced by the real ones. */
160
+ /** `text` with the held paths replaced by the real ones, and a root named as the caller names it. */
146
161
  translate(text) {
147
- return text.split(this.entry).join(this.canonical).split(this.dir).join(this.#real);
162
+ let real = text.split(this.entry).join(this.canonical).split(this.dir).join(this.#real);
163
+ // A function, so `$&` or `$$` in the name is kept as written rather than expanded.
164
+ for (const { canonical, named } of this.#names)
165
+ real = real.replace(canonical, () => named);
166
+ return real;
148
167
  }
149
168
  /** An error pi or Node raised on a held path, as a FileError that names the real one. */
150
169
  error(error, path) {
@@ -169,16 +188,25 @@ export class Pin {
169
188
  }
170
189
  return value;
171
190
  }
172
- /** Opens the target's last component, which is never followed, and returns its handle; the caller closes it. */
191
+ /**
192
+ * Opens the target's last component, which is never followed, and returns its handle; the caller closes it. By path,
193
+ * the directory's path must still name the directory held once the file is open.
194
+ */
173
195
  async openFile(flags, mode) {
196
+ let file;
174
197
  try {
175
- return await open(this.entry, flags | FILE, mode);
198
+ file = await open(this.entry, flags | FILE, mode);
176
199
  }
177
200
  catch (error) {
178
201
  if (errno(error) === "ELOOP")
179
202
  throw changed(this.canonical);
180
203
  throw error;
181
204
  }
205
+ if (this.#byPath && !(await namesHeld(this.dir, this.#handle))) {
206
+ await file.close().catch(() => undefined);
207
+ throw changed(this.canonical);
208
+ }
209
+ return file;
182
210
  }
183
211
  binaryReader(reader) {
184
212
  return {
@@ -213,12 +241,24 @@ const changed = (path) => new PathOutsideWorkError(path, "changed", `${path}: a
213
241
  export class Confinement {
214
242
  work;
215
243
  readOnly;
244
+ /** Whether the walk goes by path (macOS, which has no `/proc/self/fd`; see the top of this file). */
245
+ byPath;
216
246
  /** Directories handed out for writing after construction (the environment's temp dirs), canonical. */
217
247
  #handed = new Set();
218
- /** Canonical directories: `work` may be read and written, `readOnly` only read. */
219
- constructor(work, readOnly) {
248
+ #names;
249
+ /**
250
+ * Canonical directories: `work` may be read and written, `readOnly` only read. `byPath` defaults to true on macOS only.
251
+ * `names` maps a canonical root to the path the caller names it by, where they differ (a root under a symlinked
252
+ * directory, as `/tmp` and `/var` are on macOS): results name paths under it as the caller does, as pi's own do.
253
+ */
254
+ constructor(work, readOnly, options = {}) {
220
255
  this.work = work;
221
256
  this.readOnly = readOnly;
257
+ this.byPath = options.byPath ?? process.platform === "darwin";
258
+ // Longest first, so a root inside another is named before the outer one could take its prefix.
259
+ this.#names = [...(options.names ?? [])]
260
+ .sort(([a], [b]) => b.length - a.length)
261
+ .map(([canonical, named]) => ({ canonical: new RegExp(`${canonical.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![^/'"\\s,;:)\\]])`, "g"), named }));
222
262
  }
223
263
  /**
224
264
  * Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
@@ -257,18 +297,18 @@ export class Confinement {
257
297
  const holder = this.#handed.has(root) ? this.readOnly.find((candidate) => dirname(root) === candidate) : undefined;
258
298
  if (holder === undefined)
259
299
  throw new PathOutsideWorkError(original, "outside_work", `${original}: the workspace root itself cannot be removed or renamed`);
260
- return new Pin(await walk(holder, [], "none", canonical), canonical, basename(root), false);
300
+ return new Pin(await walk(holder, [], "none", canonical, this.byPath), canonical, basename(root), false, { byPath: this.byPath, path: holder, names: this.#names });
261
301
  }
262
302
  const name = names.pop() ?? "";
263
- const handle = await walk(root, names, spec.parents === true ? "all" : "none", canonical);
264
- return new Pin(handle, canonical, name, false);
303
+ const handle = await walk(root, names, spec.parents === true ? "all" : "none", canonical, this.byPath);
304
+ return new Pin(handle, canonical, name, false, { byPath: this.byPath, path: join(root, ...names), names: this.#names });
265
305
  }
266
306
  /** Pins the target directory itself; `create` makes it and the directories above it when they are missing. */
267
307
  async pinDir(abs, original, spec) {
268
308
  const { canonical, root } = await this.locate(abs, original, spec.access, true);
269
309
  await confineHooks.afterCheck?.(canonical);
270
- const handle = await walk(root, relative(root, canonical), spec.create === true ? "all" : "none", canonical);
271
- return new Pin(handle, canonical, basename(canonical), true);
310
+ const handle = await walk(root, relative(root, canonical), spec.create === true ? "all" : "none", canonical, this.byPath);
311
+ return new Pin(handle, canonical, basename(canonical), true, { byPath: this.byPath, path: canonical, names: this.#names });
272
312
  }
273
313
  }
274
314
  const relative = (root, path) => (path === root ? [] : path.slice(root.length + (root.endsWith("/") ? 0 : 1)).split("/"));
@@ -286,8 +326,23 @@ async function swapped(error, path) {
286
326
  return false;
287
327
  return (await lstat(path).catch(() => undefined))?.isSymbolicLink() === true;
288
328
  }
289
- /** Opens `root`, then each name below it, every step relative to the handle before it and without following links. */
290
- async function walk(root, names, create, canonical) {
329
+ /** Whether `path` still names the directory `held` holds (same device and inode); false when it cannot be read. */
330
+ export async function namesHeld(path, held) {
331
+ try {
332
+ const [named, opened] = await Promise.all([stat(path, { bigint: true }), held.stat({ bigint: true })]);
333
+ return named.dev === opened.dev && named.ino === opened.ino;
334
+ }
335
+ catch {
336
+ return false;
337
+ }
338
+ }
339
+ /**
340
+ * Opens `root`, then each name below it, every step relative to the handle before it and without following links. By
341
+ * path, every step is opened by its full path instead, and the last must then resolve to itself and be the one held.
342
+ */
343
+ async function walk(root, names, create, canonical, byPath = false) {
344
+ if (byPath)
345
+ return walkByPath(root, names, create, canonical);
291
346
  let dir;
292
347
  try {
293
348
  dir = await open(root, DIRECTORY);
@@ -339,3 +394,55 @@ async function walk(root, names, create, canonical) {
339
394
  throw error;
340
395
  }
341
396
  }
397
+ async function walkByPath(root, names, create, canonical) {
398
+ let real = root;
399
+ let dir;
400
+ try {
401
+ dir = await open(root, DIRECTORY);
402
+ }
403
+ catch (error) {
404
+ if (await swapped(error, root))
405
+ throw changed(canonical);
406
+ throw error;
407
+ }
408
+ try {
409
+ for (const name of names) {
410
+ if (name === "" || name === "." || name === ".." || name.includes("\0"))
411
+ throw changed(canonical);
412
+ real = join(real, name);
413
+ let next;
414
+ try {
415
+ next = await open(real, DIRECTORY);
416
+ }
417
+ catch (error) {
418
+ if (create === "all" && errno(error) === "ENOENT") {
419
+ await mkdir(real).catch((made) => {
420
+ if (errno(made) !== "EEXIST")
421
+ throw made;
422
+ });
423
+ next = await open(real, DIRECTORY).catch(async (opened) => {
424
+ throw (await swapped(opened, real)) ? changed(canonical) : opened;
425
+ });
426
+ }
427
+ else if (await swapped(error, real)) {
428
+ throw changed(canonical);
429
+ }
430
+ else {
431
+ throw error;
432
+ }
433
+ }
434
+ await dir.close();
435
+ dir = next;
436
+ }
437
+ // Each step was opened by its full path, and only the last component of each open is never followed: the path must
438
+ // still resolve to itself (no component above is a link) and name the directory held (none was swapped and back).
439
+ const resolved = await realpath(real).catch(() => undefined);
440
+ if (resolved !== real || !(await namesHeld(real, dir)))
441
+ throw changed(canonical);
442
+ return dir;
443
+ }
444
+ catch (error) {
445
+ await dir.close().catch(() => undefined);
446
+ throw error;
447
+ }
448
+ }
package/dist/env.d.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import type { Context } from "@earendil-works/chord";
2
- import type { Extension, ToolHooks, ToolRegistration } from "@earendil-works/pi-durable";
2
+ import type { Extension, ToolRegistration } from "@earendil-works/pi-durable";
3
3
  import { ExecutionError } from "@earendil-works/pi-durable/env";
4
4
  import { NodeExecutionEnv, type NodeWatchOptions } from "@earendil-works/pi-durable/env/node";
5
5
  import { PdaError } from "./errors.js";
6
- type ToolCall = Parameters<ToolHooks["afterTool"]>[0];
7
6
  /** What the environment needs of a claim; the claim satisfies it structurally. */
8
7
  export interface ArchilEnvClaim {
9
8
  /** Absolute mount path of the run directory; the same on every host that claims the run. */
@@ -26,15 +25,17 @@ export interface ArchilEnvOptions {
26
25
  /**
27
26
  * Run every command with no_new_privs (`setpriv --no-new-privs --`): setuid programs and file capabilities stop
28
27
  * granting privilege, so a command cannot `sudo` even when the run user has a sudo rule (the host needs one for the
29
- * archil verbs). Default true. Set false on a host that already confines commands, or where util-linux is absent.
28
+ * archil verbs). Default true, but false on macOS, which has no no_new_privs: there a command keeps the user's rights,
29
+ * sudo included. Set false on a host that already confines commands, or where util-linux is absent.
30
30
  */
31
31
  readonly noNewPrivs?: boolean;
32
32
  /** The util-linux `setpriv` that `noNewPrivs` and `runAs` run; default `/usr/bin/setpriv`. */
33
33
  readonly setprivPath?: string;
34
34
  /**
35
35
  * Confine every file operation to the workspace (`PathOutsideWorkError` otherwise): read, write, edit and every other
36
- * path pi's file system takes. Default true. Set false only where the paths are confined some other way, or where
37
- * `/proc/self/fd` is unavailable (the confinement walks paths through it).
36
+ * path pi's file system takes. Default true. On macOS, which has no `/proc/self/fd`, the confinement walks paths by
37
+ * name instead, and holds against an agent's paths and the links in place, not a command racing it (confine.ts). Set
38
+ * false only where the paths are confined some other way, or where `/proc/self/fd` is unavailable on Linux.
38
39
  */
39
40
  readonly confineFiles?: boolean;
40
41
  /**
@@ -99,7 +100,7 @@ export interface ArchilEnvFactory {
99
100
  export declare function archilEnv(claim: ArchilEnvClaim, options?: ArchilEnvOptions): ArchilEnvFactory;
100
101
  /** Built-in tools that only read. A tool in this set is declared `replay: "safe"`. */
101
102
  export declare const READ_ONLY_TOOLS: ReadonlySet<string>;
102
- /** Built-in tools that can change the workspace; their results follow the workspace barrier. */
103
+ /** Built-in tools that can change the workspace; none of them is declared replay-safe. */
103
104
  export declare const WRITING_TOOLS: ReadonlySet<string>;
104
105
  /** The tool declared `replay: "safe"`: an interrupted call reruns on recovery instead of reporting `interrupted`. */
105
106
  export declare function replaySafe<T extends ToolRegistration>(tool: T): T;
@@ -111,19 +112,3 @@ export declare function markReadOnlySafe<T extends ToolRegistration>(tools: read
111
112
  * grep; an app that adds them declares them safe with `markReadOnlySafe`.
112
113
  */
113
114
  export declare const ArchilCodingTools: Extension;
114
- export interface WorkspaceBarrierOptions {
115
- /** Tools whose result follows a barrier; default `WRITING_TOOLS`. An app adds its own writing tools here. */
116
- readonly tools?: Iterable<string>;
117
- }
118
- /**
119
- * The hook point for the rule that the workspace changes a tool made are durable before its result is committed.
120
- * Install this extension only when the result commit's own fsync does not already satisfy the rule; with it
121
- * not installed nothing runs. `barrier` is the claim's (`claim.barrier()`), called after a writing tool ran and before
122
- * pi appends its result entry, for the calls whose tool name is in `options.tools`.
123
- *
124
- * pi reports and ignores an `afterTool` hook that throws, so a failed barrier cannot hold the result back by throwing.
125
- * It replaces the result with an error result carrying the diagnostic `workspace_not_durable`: a success never commits
126
- * for a workspace that is not durable. The host decides separately whether a failed barrier means the claim is fenced.
127
- */
128
- export declare function workspaceBarrier(barrier: (call: ToolCall, context: Context) => Promise<void>, options?: WorkspaceBarrierOptions): Extension;
129
- export {};
package/dist/env.js CHANGED
@@ -1,18 +1,18 @@
1
1
  // The execution environment: pi's NodeExecutionEnv rooted at the run's work/ directory, with an environment id
2
2
  // that names the run, commands that cannot gain privilege (no_new_privs), optionally commands run as another user
3
3
  // (`runAs`, for an instance that runs as root in a container), file operations confined to work/ (confine.ts),
4
- // read-only built-in tools declared replay-safe, and the hook point for the workspace barrier. Tools run
5
- // on the host that holds the claim, against the claimed mount.
4
+ // and read-only built-in tools declared replay-safe. Tools run on the host that holds the claim, against the claimed
5
+ // mount.
6
6
  import { accessSync, constants, existsSync, statSync } from "node:fs";
7
7
  import { access, chmod, lchown, lstat, mkdir, mkdtemp, open, realpath, stat } from "node:fs/promises";
8
8
  import { basename, delimiter, dirname, join, resolve } from "node:path";
9
9
  import { withAbortSignal } from "@earendil-works/chord/context";
10
- import { defineExtension, hook, ToolTask } from "@earendil-works/pi-durable";
10
+ import { defineExtension } from "@earendil-works/pi-durable";
11
11
  import { err, ExecutionError, FileError, getOrThrow, ok } from "@earendil-works/pi-durable/env";
12
12
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
13
13
  import { CodingTools } from "@earendil-works/pi-durable/tools";
14
14
  import { COMMANDS_MARK, CommandRecorder } from "./commands.js";
15
- import { canonicalizeSync, confineHooks, Confinement, fileError, inside } from "./confine.js";
15
+ import { canonicalizeSync, confineHooks, Confinement, fileError, inside, namesHeld, PathOutsideWorkError } from "./confine.js";
16
16
  import { PdaError } from "./errors.js";
17
17
  /**
18
18
  * What `exec` returns once the factory's `cleanup()` ran: no command was started. Its `code` is pi's `spawn_error`
@@ -130,7 +130,7 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
130
130
  return this.#entry(path, context, { access: spec.access, follow: true, ...(spec.parents ? { parents: true } : {}) }, async (pin) => {
131
131
  const handle = await pin.openFile(spec.flags);
132
132
  try {
133
- return await run(handle);
133
+ return await run(handle, pin);
134
134
  }
135
135
  finally {
136
136
  await handle.close().catch(() => undefined);
@@ -150,8 +150,10 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
150
150
  async openTextLineReader(path, context) {
151
151
  if (this.#scope.confinement === undefined)
152
152
  return super.openTextLineReader(path, context);
153
- // pi opens the file again through the handle's own `/proc/self/fd` entry, which names that inode and no path.
154
- return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, (handle) => super.openTextLineReader(`/proc/self/fd/${handle.fd}`, context));
153
+ // pi opens the file again through the handle's own `/proc/self/fd` entry, which names that inode and no path; by path
154
+ // (macOS), through the path just checked.
155
+ const byPath = this.#scope.confinement.byPath;
156
+ return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, (handle, pin) => super.openTextLineReader(byPath ? pin.entry : `/proc/self/fd/${handle.fd}`, context));
155
157
  }
156
158
  async openBinaryReader(path, options, context) {
157
159
  if (this.#scope.confinement === undefined)
@@ -309,7 +311,10 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
309
311
  const held = await open(tempRoot, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
310
312
  let name;
311
313
  try {
312
- name = basename(await mkdtemp(`/proc/self/fd/${held.fd}/${prefix ?? "tmp-"}`));
314
+ // By path (macOS): made under tmp/'s path once that path is checked to still name the directory held.
315
+ if (confinement.byPath && !(await namesHeld(tempRoot, held)))
316
+ throw new PathOutsideWorkError(tempRoot, "changed", `${tempRoot}: changed while it was being resolved`);
317
+ name = basename(await mkdtemp(`${confinement.byPath ? tempRoot : `/proc/self/fd/${held.fd}`}/${prefix ?? "tmp-"}`));
313
318
  }
314
319
  finally {
315
320
  await held.close();
@@ -485,13 +490,17 @@ function requireSetpriv(path) {
485
490
  * Taken at construction, before any agent runs, so a symlink an agent puts in place of `work` or `tmp` later cannot widen
486
491
  * them: it resolves outside the roots and is refused.
487
492
  */
488
- function requireConfinement(claim, readRoots) {
489
- if (!existsSync("/proc/self/fd")) {
493
+ function requireConfinement(claim, readRoots, byPath) {
494
+ if (!byPath && !existsSync("/proc/self/fd")) {
490
495
  throw new EnvError("PROC_UNAVAILABLE", "file operations are confined by walking paths through /proc/self/fd, which is not available here. " +
491
496
  "Mount /proc, or pass confineFiles: false where the file paths are confined some other way.");
492
497
  }
493
- const tmp = join(canonicalizeSync(claim.root), TEMP_DIR);
494
- return new Confinement(canonicalizeSync(claim.work), [tmp, ...readRoots.map((root) => canonicalizeSync(root))]);
498
+ // Each root as the caller names it, where that differs from its canonical path (under /tmp or /var on macOS).
499
+ const names = new Map();
500
+ const named = (canonical, as) => (canonical === as ? canonical : (names.set(canonical, as), canonical));
501
+ const tmp = named(join(canonicalizeSync(claim.root), TEMP_DIR), join(claim.root, TEMP_DIR));
502
+ const work = named(canonicalizeSync(claim.work), claim.work);
503
+ return new Confinement(work, [tmp, ...readRoots.map((root) => named(canonicalizeSync(root), root))], { byPath, names });
495
504
  }
496
505
  export function archilEnv(claim, options = {}) {
497
506
  const id = ["archil", claim.disk, claim.root].filter((part) => part !== undefined && part !== "").join(":");
@@ -500,9 +509,10 @@ export function archilEnv(claim, options = {}) {
500
509
  if (runAs && ![runAs.uid, runAs.gid].every((n) => Number.isSafeInteger(n) && n >= 0)) {
501
510
  throw new EnvError("INVALID_RUN_AS", `runAs needs a numeric uid and gid, got ${JSON.stringify(runAs)}`);
502
511
  }
503
- const noNewPrivs = options.noNewPrivs !== false;
512
+ const darwin = process.platform === "darwin";
513
+ const noNewPrivs = options.noNewPrivs ?? !darwin;
504
514
  const setpriv = noNewPrivs || runAs ? requireSetpriv(options.setprivPath ?? SETPRIV) : undefined;
505
- const confinement = options.confineFiles === false ? undefined : requireConfinement(claim, options.readRoots ?? []);
515
+ const confinement = options.confineFiles === false ? undefined : requireConfinement(claim, options.readRoots ?? [], darwin);
506
516
  const scope = {
507
517
  id,
508
518
  tempRoot: join(claim.root, TEMP_DIR),
@@ -542,7 +552,7 @@ export function archilEnv(claim, options = {}) {
542
552
  }
543
553
  /** Built-in tools that only read. A tool in this set is declared `replay: "safe"`. */
544
554
  export const READ_ONLY_TOOLS = new Set(["read", "ls", "find", "grep"]);
545
- /** Built-in tools that can change the workspace; their results follow the workspace barrier. */
555
+ /** Built-in tools that can change the workspace; none of them is declared replay-safe. */
546
556
  export const WRITING_TOOLS = new Set(["write", "edit", "bash", "powershell"]);
547
557
  /** The tool declared `replay: "safe"`: an interrupted call reruns on recovery instead of reporting `interrupted`. */
548
558
  export function replaySafe(tool) {
@@ -561,48 +571,3 @@ export const ArchilCodingTools = defineExtension({
561
571
  name: CodingTools.name,
562
572
  tools: markReadOnlySafe(CodingTools.tools ?? []),
563
573
  });
564
- /**
565
- * The hook point for the rule that the workspace changes a tool made are durable before its result is committed.
566
- * Install this extension only when the result commit's own fsync does not already satisfy the rule; with it
567
- * not installed nothing runs. `barrier` is the claim's (`claim.barrier()`), called after a writing tool ran and before
568
- * pi appends its result entry, for the calls whose tool name is in `options.tools`.
569
- *
570
- * pi reports and ignores an `afterTool` hook that throws, so a failed barrier cannot hold the result back by throwing.
571
- * It replaces the result with an error result carrying the diagnostic `workspace_not_durable`: a success never commits
572
- * for a workspace that is not durable. The host decides separately whether a failed barrier means the claim is fenced.
573
- */
574
- export function workspaceBarrier(barrier, options = {}) {
575
- const writing = new Set(options.tools ?? WRITING_TOOLS);
576
- return defineExtension({
577
- name: "archil-workspace-barrier",
578
- hooks: [
579
- hook(ToolTask, {
580
- afterTool: async (call, result, _api, context) => {
581
- if (!writing.has(call.name))
582
- return undefined;
583
- try {
584
- await barrier(call, context);
585
- return undefined;
586
- }
587
- catch (error) {
588
- // An aborted invocation must propagate: pi rethrows hook errors only while it is aborted.
589
- if (context.abortSignal?.aborted)
590
- throw error;
591
- return notDurable(result, error);
592
- }
593
- },
594
- }),
595
- ],
596
- });
597
- }
598
- function notDurable(result, error) {
599
- const reason = error instanceof Error ? error.message : String(error);
600
- return {
601
- ...result,
602
- isError: true,
603
- diagnostics: [
604
- ...(result.diagnostics ?? []),
605
- { severity: "error", code: "workspace_not_durable", message: `The workspace could not be made durable: ${reason}` },
606
- ],
607
- };
608
- }
package/dist/errors.d.ts CHANGED
@@ -30,11 +30,12 @@ export declare class HeldError extends PdaError {
30
30
  code?: string;
31
31
  });
32
32
  }
33
- export type ClaimErrorCode = "INVALID_ARGUMENT" | "MOUNTPOINT_BUSY" | "MOUNTPOINT_NOT_EMPTY" | "MOUNT_FAILED" | "CLAIM_NOT_VERIFIED" | "ARCHIL_CLI_FAILED" | "UNMOUNT_FAILED" | "DEAD_MOUNT_CLEANUP_FAILED" | "CLAIM_RELEASED" | "CONTROL_API_FAILED";
33
+ export type ClaimErrorCode = "INVALID_ARGUMENT" | "MOUNTPOINT_BUSY" | "MOUNTPOINT_NOT_EMPTY" | "MOUNTPOINT_WRITTEN" | "MOUNTPOINT_UNCHECKED" | "MOUNT_FAILED" | "CLAIM_NOT_VERIFIED" | "ARCHIL_CLI_FAILED" | "UNMOUNT_FAILED" | "DEAD_MOUNT_CLEANUP_FAILED" | "CLAIM_RELEASED" | "CONTROL_API_FAILED" | "NOT_RELEASED";
34
34
  /** Any other claim failure (bad input, a refused or unverifiable mount, a CLI or control API failure). Exit 1. */
35
35
  export declare class ClaimError extends PdaError {
36
36
  constructor(code: ClaimErrorCode, message: string, options?: {
37
37
  cause?: unknown;
38
+ exitCode?: number;
38
39
  });
39
40
  }
40
41
  export declare function exitCodeFor(err: unknown): number;
package/dist/fork.d.ts CHANGED
@@ -39,3 +39,26 @@ export type ForkResult = {
39
39
  * and mount of its own is removed either way.
40
40
  */
41
41
  export declare function fork(ref: RunRef, newId: string, options: ForkOptions): Promise<ForkResult>;
42
+ export interface ForkLocalResult {
43
+ /** The new run's id, and the directory it lives in. */
44
+ readonly run: string;
45
+ readonly root: string;
46
+ readonly from: string;
47
+ readonly sealedSeq: number;
48
+ readonly sourceGeneration: number;
49
+ readonly files: number;
50
+ readonly bytes: number;
51
+ }
52
+ /**
53
+ * Fork a run on a local directory (`localRun`) into one new run directory per entry of `into`: the same copy as `fork`, for
54
+ * one machine and without a disk. `from` must hold a released, sealed run (run.json paused, sleeping, done or failed with a
55
+ * `sealedSeq`); anything else is `SOURCE_NOT_RELEASED`, and no target may exist (`TARGET_EXISTS`). The source is only read.
56
+ * Every copy is made beside its target under a temporary name and none is published until all are complete, so a failed
57
+ * batch leaves nothing behind and never takes back a run another process opened; each opens as
58
+ * generation 1 of a run of its own, refusing a copy that lost a committed sequence like any fork. Nothing guards a source
59
+ * that another process is opening at the same moment: a local directory has no mount to take it exclusively.
60
+ */
61
+ export declare function forkLocal(from: string, into: readonly {
62
+ readonly id: string;
63
+ readonly root: string;
64
+ }[]): Promise<ForkLocalResult[]>;
package/dist/fork.js CHANGED
@@ -3,8 +3,9 @@
3
3
  // root of their own, never where an instance mounts. The source is only read (its claim probe aside, which every mount
4
4
  // writes). The new run's run.json starts at generation 0 and carries the source's seal: its first open is generation 1
5
5
  // and refuses a copy that lost a committed sequence (STORE_BEHIND_SEAL). Supervisor side: it needs the API key.
6
- import { chmod, copyFile, lchown, lstat, mkdir, readdir, readlink, rm, rmdir, symlink, utimes } from "node:fs/promises";
7
- import { join } from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { chmod, copyFile, lchown, lstat, mkdir, readdir, readlink, realpath, rename, rm, rmdir, symlink, utimes } from "node:fs/promises";
8
+ import { basename, dirname, join, resolve, sep } from "node:path";
8
9
  import { acquire, createRunDir, findDelegations, mintMountToken, removeMountToken, runPath } from "./claim.js";
9
10
  import { HeldError, PdaError } from "./errors.js";
10
11
  import { persistRecord, readRunRecord, RUN_JSON, RUN_JSON_TEMP } from "./status.js";
@@ -16,7 +17,7 @@ const RELEASED = ["paused", "sleeping", "done", "failed"];
16
17
  * Not copied: the run's own lifecycle files (the fork writes its own run.json), the supervisor's start mark (copied, it
17
18
  * would hold the new run in a start grace for a start that never happened), and the scratch directory.
18
19
  */
19
- const SKIP = new Set([RUN_JSON, RUN_JSON_TEMP, OWNER_LOCK, `${OWNER_LOCK}-journal`, ".claim", START_MARK, "tmp"]);
20
+ const SKIP = new Set([RUN_JSON, RUN_JSON_TEMP, OWNER_LOCK, `${OWNER_LOCK}-journal`, ".claim", START_MARK, "tmp", "commands"]);
20
21
  export class ForkError extends PdaError {
21
22
  constructor(code, message, options = {}) {
22
23
  super(code, message, options);
@@ -179,3 +180,102 @@ export async function fork(ref, newId, options) {
179
180
  await rmdir(base).catch(() => { });
180
181
  }
181
182
  }
183
+ /**
184
+ * Fork a run on a local directory (`localRun`) into one new run directory per entry of `into`: the same copy as `fork`, for
185
+ * one machine and without a disk. `from` must hold a released, sealed run (run.json paused, sleeping, done or failed with a
186
+ * `sealedSeq`); anything else is `SOURCE_NOT_RELEASED`, and no target may exist (`TARGET_EXISTS`). The source is only read.
187
+ * Every copy is made beside its target under a temporary name and none is published until all are complete, so a failed
188
+ * batch leaves nothing behind and never takes back a run another process opened; each opens as
189
+ * generation 1 of a run of its own, refusing a copy that lost a committed sequence like any fork. Nothing guards a source
190
+ * that another process is opening at the same moment: a local directory has no mount to take it exclusively.
191
+ */
192
+ export async function forkLocal(from, into) {
193
+ for (const t of into)
194
+ runPath(t.id); // a run id the disk would refuse is refused here too
195
+ const source = await realpath(resolve(from)).catch(() => resolve(from));
196
+ const targets = await Promise.all(into.map(async (t) => ({ id: t.id, root: await resolvedPath(t.root) })));
197
+ if (targets.length === 0)
198
+ throw new ForkError("INVALID_ARGUMENT", "forkLocal needs at least one target");
199
+ // Two targets the same, or one beneath another (`/runs/a` and `/runs/a/b`): staging the inner one would make the outer one's directory.
200
+ if (targets.some((t, i) => targets.some((u, j) => i !== j && (t.root === u.root || t.root.startsWith(`${u.root}${sep}`))))) {
201
+ throw new ForkError("INVALID_ARGUMENT", "two targets are the same directory, or one is inside another");
202
+ }
203
+ // A target inside the source (by a symlink too) would be copied into itself and would write into a source that is only read.
204
+ if (targets.some((t) => t.root === source || t.root.startsWith(`${source}${sep}`)))
205
+ throw new ForkError("INVALID_ARGUMENT", "a fork needs a directory of its own, outside the source");
206
+ const record = await readRunRecord(source).catch((cause) => {
207
+ throw new ForkError("SOURCE_NOT_RELEASED", `${source} holds no readable run.json`, { cause });
208
+ });
209
+ if (!record || !RELEASED.includes(record.status) || record.sealedSeq === null) {
210
+ throw new ForkError("SOURCE_NOT_RELEASED", `the run in ${source} is not released and sealed (${record ? `${record.status}, sealedSeq ${record.sealedSeq}` : "no run.json"})`);
211
+ }
212
+ if (targets.some((t) => t.id === record.run))
213
+ throw new ForkError("INVALID_ARGUMENT", "a fork needs a new run id");
214
+ for (const t of targets) {
215
+ if (await lstat(t.root).then(() => true, () => false))
216
+ throw new ForkError("TARGET_EXISTS", `${t.root} already exists`);
217
+ }
218
+ const asRoot = process.getuid?.() === 0;
219
+ const st = await lstat(source);
220
+ // Every copy is made beside its target under a temporary name and none is published until all are complete: a failed
221
+ // batch removes only temporary directories, never a run another process could already have opened.
222
+ const staged = [];
223
+ const published = [];
224
+ try {
225
+ for (const t of targets) {
226
+ const temp = `${t.root}.fork-${randomUUID()}`; // unique per call: two forks of one target never share a temporary directory
227
+ const entry = { ...t, temp, files: 0, bytes: 0 };
228
+ await mkdir(dirname(t.root), { recursive: true });
229
+ await mkdir(temp);
230
+ staged.push(entry); // tracked for cleanup only once this call made it
231
+ const count = { files: 0, bytes: 0 };
232
+ await copyTree(source, temp, true, asRoot, count);
233
+ Object.assign(entry, count);
234
+ const forked = {
235
+ run: t.id,
236
+ status: "paused",
237
+ generation: 0,
238
+ sealedSeq: record.sealedSeq,
239
+ wakeAt: null,
240
+ holder: null,
241
+ heartbeatAt: null,
242
+ updatedAt: new Date().toISOString(),
243
+ detail: { forkedFrom: { run: record.run, generation: record.generation, sealedSeq: record.sealedSeq } },
244
+ };
245
+ await persistRecord(temp, `${JSON.stringify(forked, null, 2)}\n`);
246
+ // The run's directory is the source's: its owner and mode, so whoever owned the source can open the fork.
247
+ if (asRoot)
248
+ await lchown(temp, st.uid, st.gid);
249
+ await chmod(temp, st.mode & 0o7777);
250
+ }
251
+ for (const entry of staged) {
252
+ await rename(entry.temp, entry.root);
253
+ published.push(entry.root);
254
+ }
255
+ }
256
+ catch (error) {
257
+ for (const entry of staged)
258
+ await rm(entry.temp, { recursive: true, force: true }).catch(() => undefined);
259
+ // Only a copy no one has opened is taken back: an owner lock means a process already runs it.
260
+ for (const root of published)
261
+ if (!(await lstat(join(root, OWNER_LOCK)).then(() => true, () => false)))
262
+ await rm(root, { recursive: true, force: true }).catch(() => undefined);
263
+ throw error instanceof ForkError ? error : new ForkError("COPY_FAILED", `forking ${source} failed: ${error.message}`, { cause: error });
264
+ }
265
+ return staged.map((t) => ({ run: t.id, root: t.root, from: record.run, sealedSeq: record.sealedSeq, sourceGeneration: record.generation, files: t.files, bytes: t.bytes }));
266
+ }
267
+ /** `path` made absolute with every symlink of its existing part resolved, so two spellings of one place compare equal. */
268
+ async function resolvedPath(path) {
269
+ const absolute = resolve(path);
270
+ const missing = [];
271
+ for (let at = absolute;; at = dirname(at)) {
272
+ try {
273
+ return join(await realpath(at), ...missing.reverse());
274
+ }
275
+ catch {
276
+ if (dirname(at) === at)
277
+ return absolute;
278
+ missing.push(basename(at));
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,3 @@
1
+ import type { StartAttempt } from "../supervise.js";
2
+ /** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
3
+ export declare function attemptEnv(attempt: StartAttempt | undefined, refuse: (message: string) => Error): Record<string, string>;
@@ -0,0 +1,15 @@
1
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
+ const DRIVER_KEYS = new Set(["PATH", "PDA_HOLDER"]);
3
+ /** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
4
+ export function attemptEnv(attempt, refuse) {
5
+ const env = attempt?.env ?? {};
6
+ for (const [name, value] of Object.entries(env)) {
7
+ if (!ENV_NAME.test(name))
8
+ throw refuse(`${JSON.stringify(name)} is not an environment variable name`);
9
+ if (DRIVER_KEYS.has(name))
10
+ throw refuse(`${name} is the driver's: a start cannot set it`);
11
+ if (typeof value !== "string" || value.includes("\0"))
12
+ throw refuse(`the value of ${name} is not a string without NUL`);
13
+ }
14
+ return env;
15
+ }
@@ -42,7 +42,9 @@ export type LaunchStatus = {
42
42
  instanceAlive: boolean;
43
43
  };
44
44
  export declare function readState(dir: string, name: string): LaunchState | null;
45
- /** running while either process lives; otherwise stopped after a clean exit, failed after any other end. */
45
+ /** running while either process lives and the launcher has not recorded its exit; otherwise stopped after a clean exit,
46
+ * failed after any other end. Every "exited" the launcher saves is final and comes after its instance ended, so once it is
47
+ * saved (and no instance lives) the record is the answer, even while the launcher's own process is still exiting. */
46
48
  export declare function launchStatus(dir: string, name: string): LaunchStatus;
47
49
  /** The loop `serve` runs; resolves with the final state. */
48
50
  export declare function serve(dir: string, name: string): Promise<LaunchState>;