@parcha/pi-durable-disk 0.1.0-beta.12 → 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.
@@ -0,0 +1,67 @@
1
+ /** The environment variable that marks a command as one environment's: its value is that recorder's token. */
2
+ export declare const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
3
+ /** One process, as recorded: a pid alone could be reused, a pid with its start ticks on one boot cannot. */
4
+ export interface ProcessMark {
5
+ readonly pid: number;
6
+ readonly start: number;
7
+ }
8
+ /** What a record holds: a command's group leader (null once only members of it outlived a reap) and the descendants seen. */
9
+ export interface CommandRecord {
10
+ readonly boot: string;
11
+ readonly leader: ProcessMark | null;
12
+ readonly members: readonly ProcessMark[];
13
+ }
14
+ /** What a reap did: how many processes it signalled, and those still alive at its deadline (kept for the next open). */
15
+ export interface Reaped {
16
+ readonly signalled: number;
17
+ readonly survivors: readonly ProcessMark[];
18
+ }
19
+ export interface CommandsOptions {
20
+ /** The `/proc` to read; for tests. */
21
+ readonly proc?: string;
22
+ /** How a reap signals; default `process.kill`. For tests. */
23
+ readonly kill?: (target: number, signal: NodeJS.Signals) => void;
24
+ }
25
+ /**
26
+ * Records this environment's command groups under this process while commands run. `started()` and `finished()` bracket
27
+ * each command, which carries `COMMANDS_MARK=<token>` in its environment; while any runs, the tree is walked soon after a
28
+ * start and then every `everyMs`. A record is written when its group gains a process and removed once nothing in it is
29
+ * alive. A walk that fails (a record cannot be written: a full disk) goes to `onError` the first time, never out of a
30
+ * timer, and the next walk tries again.
31
+ */
32
+ export declare class CommandRecorder {
33
+ #private;
34
+ /** The value of `COMMANDS_MARK` in this environment's commands. */
35
+ readonly token: `${string}-${string}-${string}-${string}-${string}`;
36
+ constructor(dir: string, options?: CommandsOptions & {
37
+ readonly instance?: number;
38
+ readonly everyMs?: number;
39
+ readonly onError?: (error: unknown) => void;
40
+ });
41
+ /** A command is about to start. */
42
+ started(): void;
43
+ /** A command ended (its result is in). The last one stops the walks after a final one. */
44
+ finished(): void;
45
+ /** Stops recording (the environment closed); the records stay for the next open. */
46
+ close(): void;
47
+ /** One walk. It is called from timers, so it never throws: a failure goes to `onError` once, and the next walk retries. */
48
+ scan(): void;
49
+ }
50
+ /**
51
+ * Ends what a previous incarnation's commands left running: for each record of this boot, the leader's whole group when
52
+ * the leader is still the recorded process, and each recorded descendant that still is. Waits up to `timeoutMs` until
53
+ * none of them is alive. A record whose processes are all gone is removed; one that still names a live process (it did
54
+ * not die at the deadline: a process in an uninterruptible wait) is rewritten with just those, so the next open tries
55
+ * again, and they are returned. Run only while holding the run (after its owner lock), before the Harness opens.
56
+ */
57
+ export declare function reapCommands(dir: string, options?: CommandsOptions & {
58
+ readonly timeoutMs?: number;
59
+ }): Promise<Reaped>;
60
+ /**
61
+ * `reapCommands` at an open: refuses the run (`HeldError("commands")`, exit 76) when a process a killed incarnation's
62
+ * commands left is still alive after it, since the run must not resume beside it. Its record stays, and the next open
63
+ * tries again.
64
+ */
65
+ export declare function reapOrRefuse(dir: string, options?: CommandsOptions & {
66
+ readonly timeoutMs?: number;
67
+ }): Promise<Reaped>;
@@ -0,0 +1,299 @@
1
+ // The commands a run's environment starts, recorded where the next open of the same claim finds them. pi makes each
2
+ // command the leader of a process group of its own, so killing an instance does not reach its commands: a host's unit
3
+ // cgroup or a container ends them with it, and a run on a local directory has neither. While commands run, the instance
4
+ // walks its own process tree (`/proc/<pid>/task/<tid>/children`) and records each of its commands' group leader and every
5
+ // descendant it has seen, each by pid, start ticks and boot. Its commands are the children that lead their own group and
6
+ // carry its mark (COMMANDS_MARK=<its token>) in their environment: another environment's commands in the same process,
7
+ // or the app's own helpers, are not its to record. The next open, which holds the owner lock, kills whatever a
8
+ // previous incarnation recorded that is still the same process (pid, start ticks and boot all match), waits until it is
9
+ // gone, and clears the records, before the Harness opens. Only the instance writes records: a command never does.
10
+ // Nothing stops a command from writing one too, and none needs stopping: a run's commands run as the instance's user (or,
11
+ // with `runAs`, as another one that cannot write the instance's 0700 directory), so a record a command forged names only
12
+ // what that command could already kill itself. A record grants nothing.
13
+ import { randomUUID } from "node:crypto";
14
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { HeldError } from "./errors.js";
17
+ /** The environment variable that marks a command as one environment's: its value is that recorder's token. */
18
+ export const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
19
+ /** `/proc/<pid>/stat`: fields after the last `)` (the command name may hold spaces and parentheses); a zombie is gone. */
20
+ function stat(proc, pid) {
21
+ try {
22
+ const text = readFileSync(join(proc, String(pid), "stat"), "utf8");
23
+ const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
24
+ if (fields[0] === "Z" || fields[0] === "X")
25
+ return undefined;
26
+ return { ppid: Number(fields[1]), pgrp: Number(fields[2]), start: Number(fields[19]) };
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ function bootId(proc) {
33
+ try {
34
+ return readFileSync(join(proc, "sys/kernel/random/boot_id"), "utf8").trim() || undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /** The direct children of `pid`, from each of its threads' `children` list. */
41
+ function children(proc, pid) {
42
+ let tasks;
43
+ try {
44
+ tasks = readdirSync(join(proc, String(pid), "task"));
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ return tasks.flatMap((tid) => {
50
+ try {
51
+ return readFileSync(join(proc, String(pid), "task", tid, "children"), "utf8").split(" ").filter(Boolean).map(Number);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ });
57
+ }
58
+ /** Whether the process `pid` was started with `name=value` in its environment (false when it cannot be read). */
59
+ function marked(proc, pid, name, value) {
60
+ try {
61
+ return readFileSync(join(proc, String(pid), "environ"), "utf8").split("\0").includes(`${name}=${value}`);
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /** Whether `mark` is still the process it was: same pid, same start ticks, not a zombie. Never pid 1 or below. */
68
+ function same(proc, mark) {
69
+ return mark.pid > 1 && stat(proc, mark.pid)?.start === mark.start;
70
+ }
71
+ /** A process's command line for a message, best effort. */
72
+ function commandLine(proc, pid) {
73
+ try {
74
+ return readFileSync(join(proc, String(pid), "cmdline"), "utf8").split("\0").filter(Boolean).join(" ") || "?";
75
+ }
76
+ catch {
77
+ return "?";
78
+ }
79
+ }
80
+ const RECORD = /^(\d+)-(\d+)\.json$/;
81
+ /**
82
+ * Records this environment's command groups under this process while commands run. `started()` and `finished()` bracket
83
+ * each command, which carries `COMMANDS_MARK=<token>` in its environment; while any runs, the tree is walked soon after a
84
+ * start and then every `everyMs`. A record is written when its group gains a process and removed once nothing in it is
85
+ * alive. A walk that fails (a record cannot be written: a full disk) goes to `onError` the first time, never out of a
86
+ * timer, and the next walk tries again.
87
+ */
88
+ export class CommandRecorder {
89
+ /** The value of `COMMANDS_MARK` in this environment's commands. */
90
+ token = randomUUID();
91
+ #dir;
92
+ #proc;
93
+ #instance;
94
+ #everyMs;
95
+ #boot;
96
+ #onError;
97
+ #reported = false;
98
+ #records = new Map();
99
+ /** The records on disk, by key; a group's first walk writes its record even before it has members. */
100
+ #written = new Set();
101
+ #active = 0;
102
+ #timer;
103
+ constructor(dir, options = {}) {
104
+ this.#dir = dir;
105
+ this.#proc = options.proc ?? "/proc";
106
+ this.#instance = options.instance ?? process.pid;
107
+ this.#everyMs = options.everyMs ?? 100;
108
+ this.#boot = bootId(this.#proc);
109
+ this.#onError = options.onError;
110
+ }
111
+ /** A command is about to start. */
112
+ started() {
113
+ if (this.#boot === undefined)
114
+ return; // nothing could verify a record on the next open
115
+ if (this.#active++ === 0) {
116
+ this.#timer = setInterval(() => this.scan(), this.#everyMs);
117
+ this.#timer.unref();
118
+ }
119
+ setTimeout(() => this.scan(), 20).unref();
120
+ }
121
+ /** A command ended (its result is in). The last one stops the walks after a final one. */
122
+ finished() {
123
+ if (this.#boot === undefined || this.#active === 0)
124
+ return;
125
+ if (--this.#active === 0) {
126
+ clearInterval(this.#timer);
127
+ this.#timer = undefined;
128
+ this.scan();
129
+ }
130
+ }
131
+ /** Stops recording (the environment closed); the records stay for the next open. */
132
+ close() {
133
+ clearInterval(this.#timer);
134
+ this.#timer = undefined;
135
+ this.#active = 0;
136
+ }
137
+ /** One walk. It is called from timers, so it never throws: a failure goes to `onError` once, and the next walk retries. */
138
+ scan() {
139
+ try {
140
+ this.#scan();
141
+ }
142
+ catch (error) {
143
+ if (this.#reported)
144
+ return;
145
+ this.#reported = true;
146
+ this.#onError?.(error);
147
+ }
148
+ }
149
+ /** Record what is new under each of this environment's command groups, and drop the records of groups that are gone. */
150
+ #scan() {
151
+ const boot = this.#boot;
152
+ if (boot === undefined)
153
+ return;
154
+ const own = stat(this.#proc, this.#instance);
155
+ if (own === undefined)
156
+ return;
157
+ // New commands: children of this process that lead their own group (pi spawns each one so) and carry this
158
+ // environment's mark. Once recorded, a leader stays its environment's whatever it execs next (same pid and start).
159
+ for (const pid of children(this.#proc, this.#instance)) {
160
+ const leader = stat(this.#proc, pid);
161
+ if (leader === undefined || leader.pgrp !== pid || leader.pgrp === own.pgrp)
162
+ continue;
163
+ const key = `${pid}-${leader.start}`;
164
+ if (this.#records.has(key) || !marked(this.#proc, pid, COMMANDS_MARK, this.token))
165
+ continue;
166
+ this.#records.set(key, { leader: { pid, start: leader.start }, members: new Map() });
167
+ }
168
+ // Every record: walk from each of its processes still alive, the leader and every member, so a background worker whose
169
+ // leader (or parent) exited and was reparented away is still followed, and so are the children it starts later.
170
+ for (const [key, record] of this.#records) {
171
+ const live = [record.leader, ...record.members.values()].filter((mark) => same(this.#proc, mark));
172
+ if (live.length === 0) {
173
+ this.#records.delete(key);
174
+ this.#written.delete(key);
175
+ rmSync(join(this.#dir, `${key}.json`), { force: true });
176
+ continue;
177
+ }
178
+ let grew = !this.#written.has(key);
179
+ const walk = (parent) => {
180
+ for (const child of children(this.#proc, parent)) {
181
+ const s = stat(this.#proc, child);
182
+ if (s === undefined || child === record.leader.pid)
183
+ continue;
184
+ const known = record.members.get(child)?.start === s.start;
185
+ if (!known) {
186
+ record.members.set(child, { pid: child, start: s.start });
187
+ grew = true;
188
+ }
189
+ if (!known || !live.some((mark) => mark.pid === child))
190
+ walk(child);
191
+ }
192
+ };
193
+ for (const mark of live)
194
+ walk(mark.pid);
195
+ if (grew)
196
+ this.#write(key, { boot, leader: record.leader, members: [...record.members.values()] });
197
+ }
198
+ }
199
+ #write(key, record) {
200
+ mkdirSync(this.#dir, { recursive: true, mode: 0o700 });
201
+ const file = join(this.#dir, `${key}.json`);
202
+ writeFileSync(`${file}.tmp`, JSON.stringify(record));
203
+ renameSync(`${file}.tmp`, file);
204
+ this.#written.add(key);
205
+ }
206
+ }
207
+ /**
208
+ * Ends what a previous incarnation's commands left running: for each record of this boot, the leader's whole group when
209
+ * the leader is still the recorded process, and each recorded descendant that still is. Waits up to `timeoutMs` until
210
+ * none of them is alive. A record whose processes are all gone is removed; one that still names a live process (it did
211
+ * not die at the deadline: a process in an uninterruptible wait) is rewritten with just those, so the next open tries
212
+ * again, and they are returned. Run only while holding the run (after its owner lock), before the Harness opens.
213
+ */
214
+ export async function reapCommands(dir, options = {}) {
215
+ const proc = options.proc ?? "/proc";
216
+ const signal = options.kill ?? ((target, name) => process.kill(target, name));
217
+ let files;
218
+ try {
219
+ files = readdirSync(dir).filter((name) => RECORD.test(name));
220
+ }
221
+ catch {
222
+ return { signalled: 0, survivors: [] };
223
+ }
224
+ const boot = bootId(proc);
225
+ const own = stat(proc, process.pid);
226
+ let signalled = 0;
227
+ /**
228
+ * False for what is never a target: this process, its group, and a pid of 1 or below, since `kill(0)` is the caller's
229
+ * group and `kill(-1)` every process the user may signal.
230
+ */
231
+ const kill = (target, mark) => {
232
+ if (!(mark.pid > 1) || mark.pid === process.pid || (target < 0 && -target === own?.pgrp))
233
+ return false;
234
+ try {
235
+ signal(target, "SIGKILL");
236
+ signalled++;
237
+ }
238
+ catch {
239
+ // gone between the check and the kill, or not ours to signal: still a target, a survivor if it is alive
240
+ }
241
+ return true;
242
+ };
243
+ const pending = [];
244
+ for (const name of files) {
245
+ const file = join(dir, name);
246
+ let record;
247
+ try {
248
+ record = JSON.parse(readFileSync(file, "utf8"));
249
+ }
250
+ catch {
251
+ rmSync(file, { force: true }); // a torn write of a record names nothing
252
+ continue;
253
+ }
254
+ if (boot === undefined || record.boot !== boot) {
255
+ rmSync(file, { force: true }); // another boot's pids mean nothing on this one
256
+ continue;
257
+ }
258
+ const leader = record.leader !== null && same(proc, record.leader) && kill(-record.leader.pid, record.leader) ? record.leader : null;
259
+ // A member that is the leader went with its group.
260
+ const members = (record.members ?? []).filter((member) => member.pid !== record.leader?.pid && same(proc, member) && kill(member.pid, member));
261
+ pending.push({ file, boot, leader, members });
262
+ }
263
+ const alive = () => pending.flatMap((p) => [...(p.leader ? [p.leader] : []), ...p.members]).filter((mark) => same(proc, mark));
264
+ const deadline = Date.now() + (options.timeoutMs ?? 5_000);
265
+ while (alive().length > 0 && Date.now() < deadline)
266
+ await new Promise((resolve) => setTimeout(resolve, 10));
267
+ const survivors = [];
268
+ for (const record of pending) {
269
+ const leader = record.leader !== null && same(proc, record.leader) ? record.leader : null;
270
+ const members = record.members.filter((member) => same(proc, member));
271
+ if (leader === null && members.length === 0) {
272
+ rmSync(record.file, { force: true });
273
+ continue;
274
+ }
275
+ survivors.push(...(leader ? [leader] : []), ...members);
276
+ writeFileSync(`${record.file}.tmp`, JSON.stringify({ boot: record.boot, leader, members }));
277
+ renameSync(`${record.file}.tmp`, record.file);
278
+ }
279
+ if (survivors.length === 0)
280
+ rmSync(dir, { recursive: true, force: true });
281
+ return { signalled, survivors };
282
+ }
283
+ /**
284
+ * `reapCommands` at an open: refuses the run (`HeldError("commands")`, exit 76) when a process a killed incarnation's
285
+ * commands left is still alive after it, since the run must not resume beside it. Its record stays, and the next open
286
+ * tries again.
287
+ */
288
+ export async function reapOrRefuse(dir, options = {}) {
289
+ const reaped = await reapCommands(dir, options);
290
+ if (reaped.survivors.length > 0) {
291
+ const proc = options.proc ?? "/proc";
292
+ const pids = reaped.survivors.map((s) => s.pid);
293
+ throw new HeldError("commands", `${pids.length} process(es) a killed incarnation's commands started are still alive after SIGKILL: ` +
294
+ `${reaped.survivors.map((s) => `pid ${s.pid} (${commandLine(proc, s.pid)})`).join(", ")}. ` +
295
+ `The run does not resume beside them. End them (kill -9 ${pids.join(" ")}, as their owner, or wait for them) and open the run again; ` +
296
+ `they stay recorded in ${dir} until then.`);
297
+ }
298
+ return reaped;
299
+ }
package/dist/confine.d.ts CHANGED
@@ -14,6 +14,8 @@ export declare class PathOutsideWorkError extends FileError {
14
14
  /** Test seam: runs after a path was resolved and checked, before anything is opened. */
15
15
  export declare const confineHooks: {
16
16
  afterCheck?: (canonical: string) => Promise<void> | void;
17
+ /** After `createTempDir` made a directory and before it is granted (a command may act in between). */
18
+ afterTempDir?: (dir: string) => Promise<void> | void;
17
19
  };
18
20
  /** pi's mapping from a Node error to its `FileError` codes (pi does not export it). */
19
21
  export declare function fileError(error: unknown, path: string): FileError;
@@ -61,10 +63,19 @@ export declare class Pin {
61
63
  close(): Promise<void>;
62
64
  }
63
65
  export declare class Confinement {
66
+ #private;
64
67
  readonly work: string;
65
68
  readonly readOnly: readonly string[];
66
69
  /** Canonical directories: `work` may be read and written, `readOnly` only read. */
67
70
  constructor(work: string, readOnly: readonly string[]);
71
+ /**
72
+ * Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
73
+ * read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
74
+ * directory is later swapped for a link, a path through it resolves elsewhere and is refused.
75
+ */
76
+ allowWrites(dir: string): void;
77
+ /** `dir` was removed: writes inside it, or inside any handed directory below it, are no longer allowed. */
78
+ forgetWrites(dir: string): void;
68
79
  /** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
69
80
  locate(abs: string, original: string, access: "read" | "write", follow: boolean): Promise<{
70
81
  canonical: string;
package/dist/confine.js CHANGED
@@ -213,15 +213,32 @@ const changed = (path) => new PathOutsideWorkError(path, "changed", `${path}: a
213
213
  export class Confinement {
214
214
  work;
215
215
  readOnly;
216
+ /** Directories handed out for writing after construction (the environment's temp dirs), canonical. */
217
+ #handed = new Set();
216
218
  /** Canonical directories: `work` may be read and written, `readOnly` only read. */
217
219
  constructor(work, readOnly) {
218
220
  this.work = work;
219
221
  this.readOnly = readOnly;
220
222
  }
223
+ /**
224
+ * Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
225
+ * read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
226
+ * directory is later swapped for a link, a path through it resolves elsewhere and is refused.
227
+ */
228
+ allowWrites(dir) {
229
+ if (this.readOnly.some((root) => dirname(dir) === root))
230
+ this.#handed.add(dir);
231
+ }
232
+ /** `dir` was removed: writes inside it, or inside any handed directory below it, are no longer allowed. */
233
+ forgetWrites(dir) {
234
+ for (const handed of this.#handed)
235
+ if (inside(handed, dir))
236
+ this.#handed.delete(handed);
237
+ }
221
238
  /** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
222
239
  async locate(abs, original, access, follow) {
223
240
  const canonical = follow ? await canonicalize(abs) : join(await canonicalize(dirname(abs)), basename(abs));
224
- const roots = access === "write" ? [this.work] : [this.work, ...this.readOnly];
241
+ const roots = access === "write" ? [this.work, ...this.#handed] : [this.work, ...this.#handed, ...this.readOnly];
225
242
  const root = roots.find((candidate) => inside(canonical, candidate));
226
243
  if (root === undefined) {
227
244
  const where = canonical === abs ? "" : ` (it resolves to ${canonical})`;
@@ -235,7 +252,12 @@ export class Confinement {
235
252
  await confineHooks.afterCheck?.(canonical);
236
253
  const names = relative(root, canonical);
237
254
  if (names.length === 0 && spec.strict === true) {
238
- throw new PathOutsideWorkError(original, "outside_work", `${original}: the workspace root itself cannot be removed or renamed`);
255
+ // a handed directory itself (a tool removing the temp dir it was given): pinned as an entry of the read-only root that
256
+ // holds it, so it can go; the workspace root never can
257
+ const holder = this.#handed.has(root) ? this.readOnly.find((candidate) => dirname(root) === candidate) : undefined;
258
+ if (holder === undefined)
259
+ 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);
239
261
  }
240
262
  const name = names.pop() ?? "";
241
263
  const handle = await walk(root, names, spec.parents === true ? "all" : "none", canonical);
package/dist/env.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface ArchilEnvClaim {
12
12
  readonly work: string;
13
13
  /** The disk's id; part of the environment id when present. */
14
14
  readonly disk?: string;
15
+ /**
16
+ * Where the commands this environment starts are recorded, so that the next open of the claim ends what a killed
17
+ * incarnation left running (`reapCommands`). Set by a claim that nothing else cleans up after: a local run's.
18
+ */
19
+ readonly commands?: string;
15
20
  }
16
21
  export interface ArchilEnvOptions {
17
22
  /** How `watch` runs. Polling is the default because a FUSE mount does not report changes made elsewhere. */
@@ -34,8 +39,8 @@ export interface ArchilEnvOptions {
34
39
  readonly confineFiles?: boolean;
35
40
  /**
36
41
  * Directories file operations may read but never write, besides the workspace and the claim's `tmp/` (where a bash
37
- * command's spilled output lives and which the bash tool names to the model). For an app's own skills or reference
38
- * material.
42
+ * command's spilled output lives and which the bash tool names to the model; a directory `createTempDir` returned is
43
+ * writable too, for the tool it was handed to). For an app's own skills or reference material.
39
44
  */
40
45
  readonly readRoots?: readonly string[];
41
46
  /**
@@ -72,7 +77,8 @@ export declare class EnvError extends PdaError {
72
77
  * resumed run on another host has the same identity. Temporary files and directories (a bash command's spilled
73
78
  * `pi-output-*.log` among them) live under `<root>/tmp/`, inside the claim and outside `work/`, so a path a result
74
79
  * names still exists after a takeover on another host. Commands run under no_new_privs unless `noNewPrivs` is false, and
75
- * every file operation is confined to the workspace (reads also to `tmp/` and `readRoots`) unless `confineFiles` is false.
80
+ * every file operation is confined to the workspace (reads also to `tmp/` and `readRoots`, writes also inside a directory
81
+ * `createTempDir` returned) unless `confineFiles` is false.
76
82
  */
77
83
  export interface ArchilEnvFactory {
78
84
  /** Rooted at the claim's `work/`; an agent `cwd` is resolved against it. */
@@ -101,7 +107,7 @@ export declare function replaySafe<T extends ToolRegistration>(tool: T): T;
101
107
  export declare function markReadOnlySafe<T extends ToolRegistration>(tools: readonly T[], readOnly?: ReadonlySet<string>): T[];
102
108
  /**
103
109
  * pi's `CodingTools` with `read` declared replay-safe. It keeps pi's extension name, so installing it replaces
104
- * `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.0.4 ships no ls, find or
110
+ * `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.1.0 ships no ls, find or
105
111
  * grep; an app that adds them declares them safe with `markReadOnlySafe`.
106
112
  */
107
113
  export declare const ArchilCodingTools: Extension;
package/dist/env.js CHANGED
@@ -4,14 +4,15 @@
4
4
  // read-only built-in tools declared replay-safe, and the hook point for the workspace barrier. Tools run
5
5
  // on the host that holds the claim, against the claimed mount.
6
6
  import { accessSync, constants, existsSync, statSync } from "node:fs";
7
- import { access, chmod, lchown, lstat, mkdir, mkdtemp, realpath, stat } from "node:fs/promises";
8
- import { delimiter, dirname, join, resolve } from "node:path";
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
9
  import { withAbortSignal } from "@earendil-works/chord/context";
10
10
  import { defineExtension, hook, ToolTask } 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
- import { canonicalizeSync, Confinement, fileError, inside } from "./confine.js";
14
+ import { COMMANDS_MARK, CommandRecorder } from "./commands.js";
15
+ import { canonicalizeSync, confineHooks, Confinement, fileError, inside } from "./confine.js";
15
16
  import { PdaError } from "./errors.js";
16
17
  /**
17
18
  * What `exec` returns once the factory's `cleanup()` ran: no command was started. Its `code` is pi's `spawn_error`
@@ -50,17 +51,22 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
50
51
  return err(new EnvClosedError());
51
52
  this.#active++;
52
53
  this.#scope.running.add(this);
54
+ this.#scope.recorder?.started();
53
55
  try {
54
- const confined = await this.#confine(command, options, context);
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);
55
60
  if (!confined.ok)
56
61
  return confined;
57
62
  // Again after the confinement lookups, which await; the signal then covers pi's own pre-spawn work: pi refuses
58
63
  // before spawning once it is aborted, and kills a command that spawns after.
59
64
  if (closing.signal.aborted)
60
65
  return err(new EnvClosedError());
61
- return await super.exec(confined.value, options, withAbortSignal(closing.signal, context));
66
+ return await super.exec(confined.value, run, withAbortSignal(closing.signal, context));
62
67
  }
63
68
  finally {
69
+ this.#scope.recorder?.finished();
64
70
  if (--this.#active === 0)
65
71
  this.#scope.running.delete(this);
66
72
  }
@@ -259,7 +265,15 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
259
265
  async remove(path, options, context) {
260
266
  if (this.#scope.confinement === undefined)
261
267
  return super.remove(path, options, context);
262
- const removed = await this.#entry(path, context, { access: "write", follow: false, strict: true }, async (pin) => pin.restore(await super.remove(pin.entry, 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
263
277
  // `force` ignores a missing target, a missing directory above it included.
264
278
  return !removed.ok && removed.error.code === "not_found" && options?.force === true ? ok(undefined) : removed;
265
279
  }
@@ -283,11 +297,27 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
283
297
  async createTempDir(prefix, context) {
284
298
  if (context.abortSignal?.aborted)
285
299
  return err(new FileError("aborted", "aborted"));
286
- const { tempRoot } = this.#scope;
300
+ const { tempRoot, confinement, tempCanonical } = this.#scope;
287
301
  try {
288
302
  if ((await mkdir(tempRoot, { recursive: true, mode: 0o755 })) !== undefined)
289
303
  await chmod(tempRoot, 0o755);
290
- return ok(await mkdtemp(join(tempRoot, prefix ?? "tmp-")));
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);
291
321
  }
292
322
  catch (error) {
293
323
  return err(fileError(error, tempRoot));
@@ -450,7 +480,8 @@ function requireSetpriv(path) {
450
480
  }
451
481
  }
452
482
  /**
453
- * The roots, canonical once here: file operations may use the workspace, the claim's `tmp/` (read only) and `readRoots`.
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`.
454
485
  * Taken at construction, before any agent runs, so a symlink an agent puts in place of `work` or `tmp` later cannot widen
455
486
  * them: it resolves outside the roots and is refused.
456
487
  */
@@ -483,6 +514,14 @@ export function archilEnv(claim, options = {}) {
483
514
  running,
484
515
  closing: new AbortController(),
485
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
+ }),
486
525
  };
487
526
  const workOwned = runAs ? ownedOnce(claim.work, runAs) : undefined;
488
527
  const build = (target) => {
@@ -497,6 +536,7 @@ export function archilEnv(claim, options = {}) {
497
536
  const cleanup = async (context) => {
498
537
  scope.closing.abort();
499
538
  await Promise.all([...running].map((env) => env.cleanup(context)));
539
+ scope.recorder?.close();
500
540
  };
501
541
  return Object.assign(build, { id, cleanup });
502
542
  }
@@ -514,7 +554,7 @@ export function markReadOnlySafe(tools, readOnly = READ_ONLY_TOOLS) {
514
554
  }
515
555
  /**
516
556
  * pi's `CodingTools` with `read` declared replay-safe. It keeps pi's extension name, so installing it replaces
517
- * `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.0.4 ships no ls, find or
557
+ * `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.1.0 ships no ls, find or
518
558
  * grep; an app that adds them declares them safe with `markReadOnlySafe`.
519
559
  */
520
560
  export const ArchilCodingTools = defineExtension({
package/dist/errors.d.ts CHANGED
@@ -20,7 +20,8 @@ export declare class FencedError extends PdaError {
20
20
  code?: string;
21
21
  });
22
22
  }
23
- export type Holder = "claim" | "owner-lock" | "store";
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";
24
25
  /** Someone else holds the run (see `Holder`). The process exits 76. */
25
26
  export declare class HeldError extends PdaError {
26
27
  readonly holder: Holder;
package/dist/errors.js CHANGED
@@ -25,7 +25,7 @@ export class FencedError extends PdaError {
25
25
  super(options.code ?? "FENCED", message, { cause: options.cause, exitCode: EXIT_FENCED });
26
26
  }
27
27
  }
28
- const HELD_CODES = { claim: "CLAIM_HELD", "owner-lock": "OWNER_LOCK_HELD", store: "STORE_BUSY" };
28
+ const HELD_CODES = { claim: "CLAIM_HELD", "owner-lock": "OWNER_LOCK_HELD", store: "STORE_BUSY", commands: "COMMANDS_ALIVE" };
29
29
  /** Someone else holds the run (see `Holder`). The process exits 76. */
30
30
  export class HeldError extends PdaError {
31
31
  holder;
@@ -40,7 +40,9 @@ function writeState(path, state) {
40
40
  writeFileSync(`${path}.tmp`, `${JSON.stringify(state)}\n`, { mode: 0o600 });
41
41
  renameSync(`${path}.tmp`, path);
42
42
  }
43
- const alive = (pid, ticks) => pid !== null && ticks !== null && procStartTicks(pid) === ticks;
43
+ // Only a real process with its start ticks: `stop` signals these pids, the instance's as a group, and a pid of 1 or below
44
+ // names a group (`kill(-1)` is every process this user may signal). The state file holds whatever was written there.
45
+ const alive = (pid, ticks) => Number.isInteger(pid) && pid > 1 && typeof ticks === "number" && procStartTicks(pid) === ticks;
44
46
  /** running while either process lives; otherwise stopped after a clean exit, failed after any other end. */
45
47
  export function launchStatus(dir, name) {
46
48
  const state = readState(dir, name);