@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.
- package/CHANGELOG.md +262 -0
- package/README.md +281 -47
- package/dist/app.d.ts +5 -1
- package/dist/claim.d.ts +51 -3
- package/dist/claim.js +157 -17
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +112 -47
- package/dist/commands.d.ts +4 -1
- package/dist/commands.js +48 -90
- package/dist/confine.d.ts +36 -7
- package/dist/confine.js +125 -18
- package/dist/env.d.ts +7 -22
- package/dist/env.js +25 -60
- package/dist/errors.d.ts +2 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/attempt-env.d.ts +3 -0
- package/dist/hosts/attempt-env.js +15 -0
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +35 -12
- package/dist/hosts/daytona.d.ts +2 -2
- package/dist/hosts/daytona.js +48 -5
- package/dist/hosts/docker.d.ts +2 -2
- package/dist/hosts/docker.js +22 -5
- package/dist/hosts/local-host.d.ts +9 -3
- package/dist/hosts/local-host.js +59 -22
- package/dist/index.d.ts +35 -16
- package/dist/index.js +27 -16
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +4 -1
- package/dist/lease.js +2 -0
- package/dist/local-run.js +3 -2
- package/dist/place.d.ts +190 -0
- package/dist/place.js +526 -0
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +61 -10
- package/dist/run.js +69 -33
- package/dist/status.d.ts +19 -3
- package/dist/status.js +25 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +43 -1
- package/dist/supervise.js +26 -1
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +43 -12
- package/dist/with-claim.d.ts +63 -0
- package/dist/with-claim.js +325 -0
- 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
|
|
123
|
-
* path and message it returns is mapped back to the real one with `restore`. Close it when
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
/**
|
|
290
|
-
async function
|
|
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,
|
|
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
|
|
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.
|
|
37
|
-
*
|
|
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;
|
|
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
|
|
5
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
|
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;
|
|
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 {
|
|
7
|
-
import {
|
|
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,
|
|
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>;
|