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

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 +329 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +695 -2
  5. package/bin/archil-scoped +165 -0
  6. package/dist/app.d.ts +40 -0
  7. package/dist/app.js +47 -0
  8. package/dist/claim.d.ts +222 -0
  9. package/dist/claim.js +722 -0
  10. package/dist/cli.d.ts +64 -0
  11. package/dist/cli.js +664 -0
  12. package/dist/commands.d.ts +67 -0
  13. package/dist/commands.js +299 -0
  14. package/dist/confine.d.ts +91 -0
  15. package/dist/confine.js +341 -0
  16. package/dist/env.d.ts +129 -0
  17. package/dist/env.js +608 -0
  18. package/dist/errors.d.ts +47 -0
  19. package/dist/errors.js +55 -0
  20. package/dist/fork.d.ts +41 -0
  21. package/dist/fork.js +181 -0
  22. package/dist/hosts/daytona-launch.d.ts +52 -0
  23. package/dist/hosts/daytona-launch.js +270 -0
  24. package/dist/hosts/daytona.d.ts +147 -0
  25. package/dist/hosts/daytona.js +368 -0
  26. package/dist/hosts/docker.d.ts +103 -0
  27. package/dist/hosts/docker.js +362 -0
  28. package/dist/hosts/local-host.d.ts +71 -0
  29. package/dist/hosts/local-host.js +356 -0
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +17 -0
  32. package/dist/lease.d.ts +7 -0
  33. package/dist/lease.js +6 -0
  34. package/dist/local-run.d.ts +10 -0
  35. package/dist/local-run.js +63 -0
  36. package/dist/park.d.ts +92 -0
  37. package/dist/park.js +229 -0
  38. package/dist/run.d.ts +290 -0
  39. package/dist/run.js +785 -0
  40. package/dist/serve.d.ts +101 -0
  41. package/dist/serve.js +393 -0
  42. package/dist/status.d.ts +123 -0
  43. package/dist/status.js +264 -0
  44. package/dist/store.d.ts +103 -0
  45. package/dist/store.js +217 -0
  46. package/dist/supervise.d.ts +340 -0
  47. package/dist/supervise.js +528 -0
  48. package/dist/watchdog.d.ts +39 -0
  49. package/dist/watchdog.js +137 -0
  50. package/package.json +87 -4
package/dist/env.js ADDED
@@ -0,0 +1,608 @@
1
+ // The execution environment: pi's NodeExecutionEnv rooted at the run's work/ directory, with an environment id
2
+ // that names the run, commands that cannot gain privilege (no_new_privs), optionally commands run as another user
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.
6
+ import { accessSync, constants, existsSync, statSync } from "node:fs";
7
+ import { access, chmod, lchown, lstat, mkdir, mkdtemp, open, realpath, stat } from "node:fs/promises";
8
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
9
+ import { withAbortSignal } from "@earendil-works/chord/context";
10
+ import { defineExtension, hook, ToolTask } from "@earendil-works/pi-durable";
11
+ import { err, ExecutionError, FileError, getOrThrow, ok } from "@earendil-works/pi-durable/env";
12
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
13
+ import { CodingTools } from "@earendil-works/pi-durable/tools";
14
+ import { COMMANDS_MARK, CommandRecorder } from "./commands.js";
15
+ import { canonicalizeSync, confineHooks, Confinement, fileError, inside } from "./confine.js";
16
+ import { PdaError } from "./errors.js";
17
+ /**
18
+ * What `exec` returns once the factory's `cleanup()` ran: no command was started. Its `code` is pi's `spawn_error`
19
+ * (the command could not start), `reason` tells it apart from a program that failed to spawn.
20
+ */
21
+ export class EnvClosedError extends ExecutionError {
22
+ reason = "env_closed";
23
+ constructor() {
24
+ super("spawn_error", "The environment is closed (its run released or was fenced): no command was started");
25
+ this.name = "EnvClosedError";
26
+ }
27
+ }
28
+ /** The environment cannot be built as asked. Exit 1. */
29
+ export class EnvError extends PdaError {
30
+ constructor(code, message, options = {}) {
31
+ super(code, message, options);
32
+ }
33
+ }
34
+ const POLL_INTERVAL_MS = 1000;
35
+ const SETPRIV = "/usr/bin/setpriv";
36
+ const DEFAULT_PATH = "/usr/bin:/bin";
37
+ /** Temporary directories are created under `<root>/tmp/`; the directory itself is 0755 whatever the umask. */
38
+ const TEMP_DIR = "tmp";
39
+ class ArchilExecutionEnv extends NodeExecutionEnv {
40
+ #scope;
41
+ #active = 0;
42
+ constructor(scope, options) {
43
+ super(options);
44
+ this.id = scope.id;
45
+ this.#scope = scope;
46
+ }
47
+ async exec(command, options, context) {
48
+ const { closing } = this.#scope;
49
+ // Checked before anything touches the file system, so a refusal is immediate even when the mount is hung.
50
+ if (closing.signal.aborted)
51
+ return err(new EnvClosedError());
52
+ this.#active++;
53
+ this.#scope.running.add(this);
54
+ this.#scope.recorder?.started();
55
+ try {
56
+ // A recorded environment marks its commands, so its recorder knows them from any other child of this process.
57
+ const recorder = this.#scope.recorder;
58
+ const run = recorder === undefined ? options : { ...options, env: { ...options?.env, [COMMANDS_MARK]: recorder.token } };
59
+ const confined = await this.#confine(command, run, context);
60
+ if (!confined.ok)
61
+ return confined;
62
+ // Again after the confinement lookups, which await; the signal then covers pi's own pre-spawn work: pi refuses
63
+ // before spawning once it is aborted, and kills a command that spawns after.
64
+ if (closing.signal.aborted)
65
+ return err(new EnvClosedError());
66
+ return await super.exec(confined.value, run, withAbortSignal(closing.signal, context));
67
+ }
68
+ finally {
69
+ this.#scope.recorder?.finished();
70
+ if (--this.#active === 0)
71
+ this.#scope.running.delete(this);
72
+ }
73
+ }
74
+ // `setpriv` replaces itself with the command (execvp), so the process, its group and its exit status are the
75
+ // command's own. A string command becomes the argv of the shell pi would have run it through, which is how
76
+ // setpriv gets in front of the shell; failures pi reports before spawning (no such program, no such shell) are
77
+ // reported the same way here instead of as setpriv's exit status.
78
+ async #confine(command, options, context) {
79
+ const { setpriv, noNewPrivs, runAs, shellPath, shellEnv } = this.#scope;
80
+ if (setpriv === undefined)
81
+ return ok(command);
82
+ const confine = [
83
+ setpriv,
84
+ // A root instance's capabilities stay with it: the command's bounding and inheritable sets are emptied too.
85
+ ...(runAs ? [`--reuid=${runAs.uid}`, `--regid=${runAs.gid}`, "--clear-groups", "--inh-caps=-all", "--bounding-set=-all"] : []),
86
+ ...(noNewPrivs ? ["--no-new-privs"] : []),
87
+ "--",
88
+ ];
89
+ const cwd = options?.cwd === undefined ? this.cwd : getOrDefault(await this.absolutePath(options.cwd, context), this.cwd);
90
+ const env = options?.inheritEnv === false ? { ...options.env } : { ...process.env, ...shellEnv, ...options?.env };
91
+ if (typeof command === "string") {
92
+ const shell = await resolveShell(shellPath, cwd, env);
93
+ return shell.ok ? ok([...confine, shell.value, "-c", command]) : shell;
94
+ }
95
+ const [program] = command;
96
+ if (program === undefined)
97
+ return ok(command);
98
+ if (!(await findProgram(program, cwd, env)))
99
+ return err(new ExecutionError("spawn_error", `spawn ${program} ENOENT`));
100
+ return ok([...confine, ...command]);
101
+ }
102
+ // File operations. Each one resolves its path to the file the kernel would reach and refuses it unless it lies in the
103
+ // workspace (reads may also use the read-only roots); the work is then done on handles held from the root down, so a
104
+ // component swapped for a symlink after the check is refused, not followed (confine.ts). Commands are not file
105
+ // operations: they run arbitrary code, and `exec`'s `cwd` is not confined. Methods not listed are lexical
106
+ // (`absolutePath`, `joinPath`), derived from these (`exists`, `readTextLines`), or not path-taking.
107
+ async #abs(path, context) {
108
+ return getOrThrow(await this.absolutePath(path, context));
109
+ }
110
+ async #pinned(path, context, pin, run) {
111
+ if (context.abortSignal?.aborted)
112
+ return err(new FileError("aborted", "aborted", path));
113
+ let held;
114
+ try {
115
+ held = await pin(this.#scope.confinement, await this.#abs(path, context));
116
+ return await run(held);
117
+ }
118
+ catch (error) {
119
+ return err(held === undefined ? fileError(error, path) : held.error(error, path));
120
+ }
121
+ finally {
122
+ await held?.close();
123
+ }
124
+ }
125
+ #entry(path, context, spec, run) {
126
+ return this.#pinned(path, context, (confinement, abs) => confinement.pinEntry(abs, path, spec), run);
127
+ }
128
+ /** Opens the file at `path` (a last symlink is resolved by the check, never by the open) and runs `run` on it. */
129
+ #file(path, context, spec, run) {
130
+ return this.#entry(path, context, { access: spec.access, follow: true, ...(spec.parents ? { parents: true } : {}) }, async (pin) => {
131
+ const handle = await pin.openFile(spec.flags);
132
+ try {
133
+ return await run(handle);
134
+ }
135
+ finally {
136
+ await handle.close().catch(() => undefined);
137
+ }
138
+ });
139
+ }
140
+ async readTextFile(path, context) {
141
+ if (this.#scope.confinement === undefined)
142
+ return super.readTextFile(path, context);
143
+ return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, async (handle) => ok(await handle.readFile({ encoding: "utf8", signal: context.abortSignal })));
144
+ }
145
+ async readBinaryFile(path, context) {
146
+ if (this.#scope.confinement === undefined)
147
+ return super.readBinaryFile(path, context);
148
+ return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, async (handle) => ok(await handle.readFile({ signal: context.abortSignal })));
149
+ }
150
+ async openTextLineReader(path, context) {
151
+ if (this.#scope.confinement === undefined)
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));
155
+ }
156
+ async openBinaryReader(path, options, context) {
157
+ if (this.#scope.confinement === undefined)
158
+ return super.openBinaryReader(path, options, context);
159
+ return this.#entry(path, context, { access: "read", follow: options?.noFollow !== true }, async (pin) => {
160
+ // The target's last component was resolved by the check; pi must not follow it again.
161
+ const opened = await super.openBinaryReader(pin.entry, { noFollow: true }, context);
162
+ return opened.ok ? ok(pin.binaryReader(opened.value)) : pin.restore(opened);
163
+ });
164
+ }
165
+ async writeFile(path, content, context) {
166
+ if (this.#scope.confinement === undefined)
167
+ return super.writeFile(path, content, context);
168
+ const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC;
169
+ return this.#file(path, context, { access: "write", flags, parents: true }, async (handle) => {
170
+ await handle.writeFile(content, { signal: context.abortSignal });
171
+ return ok(undefined);
172
+ });
173
+ }
174
+ async appendFile(path, content, context) {
175
+ if (this.#scope.confinement === undefined)
176
+ return super.appendFile(path, content, context);
177
+ const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND;
178
+ return this.#file(path, context, { access: "write", flags, parents: true }, async (handle) => {
179
+ await handle.appendFile(content);
180
+ return ok(undefined);
181
+ });
182
+ }
183
+ async truncateFile(path, size, context) {
184
+ if (this.#scope.confinement === undefined)
185
+ return super.truncateFile(path, size, context);
186
+ if (!Number.isSafeInteger(size) || size < 0) {
187
+ return err(new FileError("invalid", "File size must be a non-negative safe integer", path));
188
+ }
189
+ return this.#file(path, context, { access: "write", flags: constants.O_RDWR }, async (handle) => {
190
+ await handle.truncate(size);
191
+ return ok(undefined);
192
+ });
193
+ }
194
+ async flushFile(path, context) {
195
+ if (this.#scope.confinement === undefined)
196
+ return super.flushFile(path, context);
197
+ return this.#file(path, context, { access: "write", flags: constants.O_RDWR }, async (handle) => {
198
+ await handle.sync();
199
+ return ok(undefined);
200
+ });
201
+ }
202
+ async renameFile(sourcePath, destinationPath, context) {
203
+ if (this.#scope.confinement === undefined)
204
+ return super.renameFile(sourcePath, destinationPath, context);
205
+ const spec = { access: "write", follow: false, strict: true };
206
+ return this.#entry(sourcePath, context, spec, (source) => this.#entry(destinationPath, context, spec, async (destination) => destination.restore(source.restore(await super.renameFile(source.entry, destination.entry, context)))));
207
+ }
208
+ async fileInfo(path, context) {
209
+ if (this.#scope.confinement === undefined)
210
+ return super.fileInfo(path, context);
211
+ return this.#entry(path, context, { access: "read", follow: false }, async (pin) => pin.restore(await super.fileInfo(pin.entry, context)));
212
+ }
213
+ async listDir(path, context) {
214
+ if (this.#scope.confinement === undefined)
215
+ return super.listDir(path, context);
216
+ return this.#pinned(path, context, (confinement, abs) => confinement.pinDir(abs, path, { access: "read" }), async (pin) => pin.restore(await super.listDir(pin.dir, context)));
217
+ }
218
+ async openDirReader(path, context) {
219
+ if (this.#scope.confinement === undefined)
220
+ return super.openDirReader(path, context);
221
+ // The reader names its entries through the held directory as it pages, so the pin lives until the reader closes.
222
+ if (context.abortSignal?.aborted)
223
+ return err(new FileError("aborted", "aborted", path));
224
+ let held;
225
+ try {
226
+ held = await this.#scope.confinement.pinDir(await this.#abs(path, context), path, { access: "read" });
227
+ const opened = await super.openDirReader(held.dir, context);
228
+ if (!opened.ok) {
229
+ const failure = err(held.error(opened.error, path));
230
+ await held.close();
231
+ return failure;
232
+ }
233
+ return ok(held.dirReader(opened.value));
234
+ }
235
+ catch (error) {
236
+ const failure = err(held === undefined ? fileError(error, path) : held.error(error, path));
237
+ await held?.close();
238
+ return failure;
239
+ }
240
+ }
241
+ async canonicalPath(path, context) {
242
+ const confinement = this.#scope.confinement;
243
+ if (confinement === undefined)
244
+ return super.canonicalPath(path, context);
245
+ try {
246
+ await confinement.locate(await this.#abs(path, context), path, "read", true);
247
+ const canonical = await super.canonicalPath(path, context);
248
+ if (canonical.ok && ![confinement.work, ...confinement.readOnly].some((root) => inside(canonical.value, root))) {
249
+ await confinement.locate(canonical.value, path, "read", true);
250
+ }
251
+ return canonical;
252
+ }
253
+ catch (error) {
254
+ return err(fileError(error, path));
255
+ }
256
+ }
257
+ async createDir(path, options, context) {
258
+ if (this.#scope.confinement === undefined)
259
+ return super.createDir(path, options, context);
260
+ if (options?.recursive === false) {
261
+ return this.#entry(path, context, { access: "write", follow: false }, async (pin) => pin.restore(await super.createDir(pin.entry, options, context)));
262
+ }
263
+ return this.#pinned(path, context, (confinement, abs) => confinement.pinDir(abs, path, { access: "write", create: true }), async () => ok(undefined));
264
+ }
265
+ async remove(path, options, context) {
266
+ if (this.#scope.confinement === undefined)
267
+ return super.remove(path, options, context);
268
+ let gone;
269
+ const removed = await this.#entry(path, context, { access: "write", follow: false, strict: true }, async (pin) => {
270
+ const result = pin.restore(await super.remove(pin.entry, options, context));
271
+ if (result.ok)
272
+ gone = pin.canonical;
273
+ return result;
274
+ });
275
+ if (gone !== undefined)
276
+ this.#scope.confinement.forgetWrites(gone); // a temp dir it was given, removed: its grant goes with it
277
+ // `force` ignores a missing target, a missing directory above it included.
278
+ return !removed.ok && removed.error.code === "not_found" && options?.force === true ? ok(undefined) : removed;
279
+ }
280
+ async watch(targets, onChange, context) {
281
+ const confinement = this.#scope.confinement;
282
+ if (confinement === undefined)
283
+ return super.watch(targets, onChange, context);
284
+ // A watch reports that something changed, never contents, and pi's watcher re-reads its paths on its own schedule
285
+ // (and reports a watched symlink's target under the symlink's name): the targets are checked once, here, and handed
286
+ // over as named, not held.
287
+ try {
288
+ for (const target of targets)
289
+ await confinement.locate(await this.#abs(target.path, context), target.path, "read", true);
290
+ return await super.watch(targets, onChange, context);
291
+ }
292
+ catch (error) {
293
+ return err(fileError(error, targets[0]?.path ?? "."));
294
+ }
295
+ }
296
+ // pi's `createTempFile` and the spill of a command's output both go through this method.
297
+ async createTempDir(prefix, context) {
298
+ if (context.abortSignal?.aborted)
299
+ return err(new FileError("aborted", "aborted"));
300
+ const { tempRoot, confinement, tempCanonical } = this.#scope;
301
+ try {
302
+ if ((await mkdir(tempRoot, { recursive: true, mode: 0o755 })) !== undefined)
303
+ await chmod(tempRoot, 0o755);
304
+ if (confinement === undefined || tempCanonical === undefined)
305
+ return ok(await mkdtemp(join(tempRoot, prefix ?? "tmp-")));
306
+ // The tool it is handed to may write in it (vision writes its source there); the rest of tmp/ stays read-only. It is
307
+ // made through a held handle of tmp/ that does not follow a link (a tmp/ a command replaced with one is refused here),
308
+ // and granted by name under the canonical tmp/, never resolved (a directory swapped for a link later grants nothing).
309
+ const held = await open(tempRoot, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
310
+ let name;
311
+ try {
312
+ name = basename(await mkdtemp(`/proc/self/fd/${held.fd}/${prefix ?? "tmp-"}`));
313
+ }
314
+ finally {
315
+ await held.close();
316
+ }
317
+ const dir = join(tempRoot, name);
318
+ await confineHooks.afterTempDir?.(dir);
319
+ confinement.allowWrites(join(tempCanonical, name));
320
+ return ok(dir);
321
+ }
322
+ catch (error) {
323
+ return err(fileError(error, tempRoot));
324
+ }
325
+ }
326
+ }
327
+ /**
328
+ * An environment whose commands run as `runAs`, which owns `work/` and everything pi's in-process file tools create under
329
+ * it. pi creates a file and its missing parent directories as this process's user (root, where `runAs` is used), so each
330
+ * creating call notes which entries under `work/` did not exist and hands those it created to `runAs` afterwards. Each
331
+ * entry is chowned through the confinement's pinned walk (`lchown` of `/proc/self/fd/<parent>/<name>`: no component is
332
+ * followed, and none can be swapped for a symlink in between); without confinement, only while the entry's parent still
333
+ * resolves inside `work/`. An entry that existed keeps its owner. A failed chown leaves the entry root's: a command that
334
+ * then cannot change it says so.
335
+ */
336
+ class OwnedExecutionEnv extends ArchilExecutionEnv {
337
+ #closing;
338
+ #confinement;
339
+ #work;
340
+ #owner;
341
+ #workOwned;
342
+ constructor(scope, options, work, owner, workOwned) {
343
+ super(scope, options);
344
+ this.#closing = scope.closing.signal;
345
+ this.#confinement = scope.confinement;
346
+ this.#work = scope.confinement?.work ?? work;
347
+ this.#owner = owner;
348
+ this.#workOwned = workOwned;
349
+ }
350
+ async exec(command, options, context) {
351
+ // A closed factory refuses before touching the mount (ArchilExecutionEnv.exec); so does this.
352
+ if (!this.#closing.aborted)
353
+ await this.#workOwned();
354
+ return super.exec(command, options, context);
355
+ }
356
+ writeFile(path, content, context) {
357
+ return this.#creating(path, context, () => super.writeFile(path, content, context));
358
+ }
359
+ appendFile(path, content, context) {
360
+ return this.#creating(path, context, () => super.appendFile(path, content, context));
361
+ }
362
+ createDir(path, options, context) {
363
+ return this.#creating(path, context, () => super.createDir(path, options, context));
364
+ }
365
+ async #creating(path, context, call) {
366
+ await this.#workOwned();
367
+ const abs = getOrDefault(await this.absolutePath(path, context), resolve(this.cwd, path));
368
+ // The confinement's work/ is canonical, so the target is too (its last component is not followed).
369
+ const target = this.#confinement === undefined ? abs : join(canonicalizeSync(dirname(abs)), abs.slice(dirname(abs).length + 1));
370
+ const created = await missingWithin(this.#work, target);
371
+ const result = await call();
372
+ if (result.ok)
373
+ for (const entry of created)
374
+ if (!(await this.#give(entry)))
375
+ break;
376
+ return result;
377
+ }
378
+ async #give(path) {
379
+ const { uid, gid } = this.#owner;
380
+ if (this.#confinement === undefined) {
381
+ const parent = await realpath(dirname(path)).catch(() => undefined);
382
+ const work = await realpath(this.#work).catch(() => undefined);
383
+ if (parent === undefined || work === undefined || !inside(parent, work))
384
+ return false;
385
+ return lchown(path, uid, gid).then(() => true, () => false);
386
+ }
387
+ let pin;
388
+ try {
389
+ pin = await this.#confinement.pinEntry(path, path, { access: "write", follow: false });
390
+ await lchown(pin.entry, uid, gid);
391
+ return true;
392
+ }
393
+ catch {
394
+ return false;
395
+ }
396
+ finally {
397
+ await pin?.close();
398
+ }
399
+ }
400
+ }
401
+ /** `target` and its ancestors below `work` that do not exist, outermost first; empty outside `work` or on any doubt. */
402
+ async function missingWithin(work, target) {
403
+ const missing = [];
404
+ for (let path = target; path !== work && inside(path, work); path = dirname(path)) {
405
+ try {
406
+ await lstat(path);
407
+ return missing;
408
+ }
409
+ catch (error) {
410
+ if (error.code !== "ENOENT")
411
+ return [];
412
+ missing.unshift(path);
413
+ }
414
+ }
415
+ return inside(target, work) ? missing : [];
416
+ }
417
+ /** `work/` owned by `owner`, checked once per factory; a failure (no `work/` yet, a fenced mount) is retried next time. */
418
+ function ownedOnce(work, owner) {
419
+ let done;
420
+ return () => (done ??= (async () => {
421
+ const st = await lstat(work);
422
+ if (st.uid !== owner.uid || st.gid !== owner.gid)
423
+ await lchown(work, owner.uid, owner.gid);
424
+ })().catch(() => {
425
+ done = undefined;
426
+ }));
427
+ }
428
+ function getOrDefault(result, fallback) {
429
+ return result.ok ? result.value : fallback;
430
+ }
431
+ /** The shell pi runs a string command through: `shellPath`, else /bin/bash, else bash on PATH, else sh. */
432
+ async function resolveShell(shellPath, cwd, env) {
433
+ if (shellPath !== undefined && shellPath !== "") {
434
+ return (await exists(shellPath))
435
+ ? ok(shellPath)
436
+ : err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${shellPath}`));
437
+ }
438
+ if (await exists("/bin/bash"))
439
+ return ok("/bin/bash");
440
+ return ok((await findProgram("bash", cwd, env)) ? "bash" : "sh");
441
+ }
442
+ async function exists(path) {
443
+ try {
444
+ await access(path, constants.F_OK);
445
+ return true;
446
+ }
447
+ catch {
448
+ return false;
449
+ }
450
+ }
451
+ /** Whether execvp would find an executable regular file for `program`: with a slash relative to `cwd`, else on PATH. */
452
+ async function findProgram(program, cwd, env) {
453
+ const candidates = program.includes("/")
454
+ ? [resolve(cwd, program)]
455
+ : (env.PATH ?? DEFAULT_PATH).split(delimiter).map((dir) => resolve(cwd, dir === "" ? "." : dir, program));
456
+ for (const candidate of candidates) {
457
+ try {
458
+ if (!(await stat(candidate)).isFile())
459
+ continue;
460
+ await access(candidate, constants.X_OK);
461
+ return true;
462
+ }
463
+ catch {
464
+ // Not here; execvp tries the next directory.
465
+ }
466
+ }
467
+ return false;
468
+ }
469
+ function requireSetpriv(path) {
470
+ try {
471
+ if (!statSync(path).isFile())
472
+ throw new Error("not a file");
473
+ accessSync(path, constants.X_OK);
474
+ return path;
475
+ }
476
+ catch (cause) {
477
+ const reason = cause instanceof Error ? cause.message : String(cause);
478
+ throw new EnvError("SETPRIV_UNAVAILABLE", `commands run under no_new_privs (and as runAs) through setpriv (util-linux), which is not usable at ${path}: ${reason}. ` +
479
+ "Install util-linux, point setprivPath at it, or pass noNewPrivs: false on a host that already confines commands.", { cause });
480
+ }
481
+ }
482
+ /**
483
+ * The roots, canonical once here: file operations may use the workspace, the claim's `tmp/` (read only, but for the
484
+ * directories `createTempDir` hands out, which are writable) and `readRoots`.
485
+ * Taken at construction, before any agent runs, so a symlink an agent puts in place of `work` or `tmp` later cannot widen
486
+ * them: it resolves outside the roots and is refused.
487
+ */
488
+ function requireConfinement(claim, readRoots) {
489
+ if (!existsSync("/proc/self/fd")) {
490
+ throw new EnvError("PROC_UNAVAILABLE", "file operations are confined by walking paths through /proc/self/fd, which is not available here. " +
491
+ "Mount /proc, or pass confineFiles: false where the file paths are confined some other way.");
492
+ }
493
+ const tmp = join(canonicalizeSync(claim.root), TEMP_DIR);
494
+ return new Confinement(canonicalizeSync(claim.work), [tmp, ...readRoots.map((root) => canonicalizeSync(root))]);
495
+ }
496
+ export function archilEnv(claim, options = {}) {
497
+ const id = ["archil", claim.disk, claim.root].filter((part) => part !== undefined && part !== "").join(":");
498
+ const running = new Set();
499
+ const runAs = options.runAs;
500
+ if (runAs && ![runAs.uid, runAs.gid].every((n) => Number.isSafeInteger(n) && n >= 0)) {
501
+ throw new EnvError("INVALID_RUN_AS", `runAs needs a numeric uid and gid, got ${JSON.stringify(runAs)}`);
502
+ }
503
+ const noNewPrivs = options.noNewPrivs !== false;
504
+ const setpriv = noNewPrivs || runAs ? requireSetpriv(options.setprivPath ?? SETPRIV) : undefined;
505
+ const confinement = options.confineFiles === false ? undefined : requireConfinement(claim, options.readRoots ?? []);
506
+ const scope = {
507
+ id,
508
+ tempRoot: join(claim.root, TEMP_DIR),
509
+ setpriv,
510
+ noNewPrivs,
511
+ runAs,
512
+ shellPath: options.shellPath,
513
+ shellEnv: options.shellEnv,
514
+ running,
515
+ closing: new AbortController(),
516
+ confinement,
517
+ tempCanonical: confinement === undefined ? undefined : join(canonicalizeSync(claim.root), TEMP_DIR),
518
+ // A record that cannot be written (a full disk) leaves a command the next open could miss: said once on stderr, and
519
+ // the recorder keeps trying; the run goes on.
520
+ recorder: claim.commands === undefined
521
+ ? undefined
522
+ : new CommandRecorder(claim.commands, {
523
+ onError: (error) => process.stderr.write(`pi-durable-disk: cannot record the run's commands in ${claim.commands}: ${String(error)}\n`),
524
+ }),
525
+ };
526
+ const workOwned = runAs ? ownedOnce(claim.work, runAs) : undefined;
527
+ const build = (target) => {
528
+ const envOptions = {
529
+ cwd: target.cwd === undefined ? claim.work : resolve(claim.work, target.cwd),
530
+ watch: { mode: "polling", pollIntervalMs: POLL_INTERVAL_MS, ...options.watch },
531
+ ...(options.shellPath === undefined ? {} : { shellPath: options.shellPath }),
532
+ ...(options.shellEnv === undefined ? {} : { shellEnv: options.shellEnv }),
533
+ };
534
+ return runAs && workOwned ? new OwnedExecutionEnv(scope, envOptions, claim.work, runAs, workOwned) : new ArchilExecutionEnv(scope, envOptions);
535
+ };
536
+ const cleanup = async (context) => {
537
+ scope.closing.abort();
538
+ await Promise.all([...running].map((env) => env.cleanup(context)));
539
+ scope.recorder?.close();
540
+ };
541
+ return Object.assign(build, { id, cleanup });
542
+ }
543
+ /** Built-in tools that only read. A tool in this set is declared `replay: "safe"`. */
544
+ 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. */
546
+ export const WRITING_TOOLS = new Set(["write", "edit", "bash", "powershell"]);
547
+ /** The tool declared `replay: "safe"`: an interrupted call reruns on recovery instead of reporting `interrupted`. */
548
+ export function replaySafe(tool) {
549
+ return { ...tool, replay: "safe" };
550
+ }
551
+ /** `tools` with every read-only one declared replay-safe; the others, and the input array, are untouched. */
552
+ export function markReadOnlySafe(tools, readOnly = READ_ONLY_TOOLS) {
553
+ return tools.map((tool) => (readOnly.has(tool.name) ? replaySafe(tool) : tool));
554
+ }
555
+ /**
556
+ * pi's `CodingTools` with `read` declared replay-safe. It keeps pi's extension name, so installing it replaces
557
+ * `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.1.0 ships no ls, find or
558
+ * grep; an app that adds them declares them safe with `markReadOnlySafe`.
559
+ */
560
+ export const ArchilCodingTools = defineExtension({
561
+ name: CodingTools.name,
562
+ tools: markReadOnlySafe(CodingTools.tools ?? []),
563
+ });
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
+ }
@@ -0,0 +1,47 @@
1
+ /** EX_TEMPFAIL: the claim was revoked or its mount failed; nothing this process writes can become durable. */
2
+ export declare const EXIT_FENCED = 75;
3
+ /**
4
+ * Someone else holds the run: another client's delegation, another process's owner lock, another store connection. A
5
+ * held delegation is live or orphaned (its client gone); the supervisor revokes orphans.
6
+ */
7
+ export declare const EXIT_HELD = 76;
8
+ export declare class PdaError extends Error {
9
+ readonly code: string;
10
+ readonly exitCode: number;
11
+ constructor(code: string, message: string, options?: {
12
+ cause?: unknown;
13
+ exitCode?: number;
14
+ });
15
+ }
16
+ /** The claim is lost or its mount failed. Never retried on the same handle; the process exits 75. */
17
+ export declare class FencedError extends PdaError {
18
+ constructor(message: string, options?: {
19
+ cause?: unknown;
20
+ code?: string;
21
+ });
22
+ }
23
+ /** `commands`: processes a killed incarnation's commands left that did not die when the next open killed them. */
24
+ export type Holder = "claim" | "owner-lock" | "store" | "commands";
25
+ /** Someone else holds the run (see `Holder`). The process exits 76. */
26
+ export declare class HeldError extends PdaError {
27
+ readonly holder: Holder;
28
+ constructor(holder: Holder, message: string, options?: {
29
+ cause?: unknown;
30
+ code?: string;
31
+ });
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";
34
+ /** Any other claim failure (bad input, a refused or unverifiable mount, a CLI or control API failure). Exit 1. */
35
+ export declare class ClaimError extends PdaError {
36
+ constructor(code: ClaimErrorCode, message: string, options?: {
37
+ cause?: unknown;
38
+ });
39
+ }
40
+ export declare function exitCodeFor(err: unknown): number;
41
+ /**
42
+ * Any error writing a file the claim owns (its probe, `run.json`, `owner.lock`, the run directory itself) is a fence. A
43
+ * revoked mount answers such writes with EIO, EROFS, ENOENT, SQLITE_CANTOPEN or a success into its local cache,
44
+ * so no errno is evidence that the claim is still held. Taking the owner lock is not a write: a lock
45
+ * another process holds is `HeldError("owner-lock")`.
46
+ */
47
+ export declare function ownWriteFenced(what: string, cause: unknown): FencedError;