@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
@@ -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
+ }
@@ -0,0 +1,91 @@
1
+ import type { FileHandle } from "node:fs/promises";
2
+ import { FileError } from "@earendil-works/pi-durable/env";
3
+ import type { BinaryReader, DirReader, Result } from "@earendil-works/pi-durable/env";
4
+ export type RefusalReason = "outside_work" | "changed";
5
+ /**
6
+ * A file operation named a path that is not under the workspace (`outside_work`), or one whose components changed
7
+ * while it was being resolved (`changed`). pi turns it into a failed tool result that names the path. Its `code` is pi's
8
+ * `permission_denied`.
9
+ */
10
+ export declare class PathOutsideWorkError extends FileError {
11
+ readonly reason: RefusalReason;
12
+ constructor(path: string, reason: RefusalReason, message: string);
13
+ }
14
+ /** Test seam: runs after a path was resolved and checked, before anything is opened. */
15
+ export declare const confineHooks: {
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;
19
+ };
20
+ /** pi's mapping from a Node error to its `FileError` codes (pi does not export it). */
21
+ export declare function fileError(error: unknown, path: string): FileError;
22
+ /**
23
+ * The path the kernel would reach for `path` (absolute, lexically normalized): every existing component resolved,
24
+ * symlinks included, dangling ones followed to where they would create; a missing tail is appended as it is.
25
+ */
26
+ export declare function canonicalize(path: string): Promise<string>;
27
+ /** `canonicalize` for a directory that may not exist yet, at construction. */
28
+ export declare function canonicalizeSync(path: string): string;
29
+ export declare const inside: (path: string, root: string) => boolean;
30
+ export interface PinSpec {
31
+ readonly access: "read" | "write";
32
+ /** Whether the last component is followed when it is a symlink; `false` for operations on the link itself. */
33
+ readonly follow: boolean;
34
+ /** Create the missing directories above the target. */
35
+ readonly parents?: boolean;
36
+ /** Refuse the root itself as the target (remove, rename). */
37
+ readonly strict?: boolean;
38
+ }
39
+ /**
40
+ * A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths to hand to pi's own implementation; every
41
+ * path and message it returns is mapped back to the real one with `restore`. Close it when the operation is done.
42
+ */
43
+ export declare class Pin {
44
+ #private;
45
+ readonly canonical: string;
46
+ /** The last component, or "" when the target is the root itself. */
47
+ readonly name: string;
48
+ /** The held directory: the target itself for a directory pin, the directory holding the target for an entry pin. */
49
+ readonly dir: string;
50
+ /** The target below `dir`, for operations that do not follow a last symlink. */
51
+ readonly entry: string;
52
+ constructor(handle: FileHandle, canonical: string, name: string, dirMode: boolean);
53
+ /** `text` with the held paths replaced by the real ones. */
54
+ translate(text: string): string;
55
+ /** An error pi or Node raised on a held path, as a FileError that names the real one. */
56
+ error(error: unknown, path: string): FileError;
57
+ restore<T>(result: Result<T, FileError>): Result<T, FileError>;
58
+ /** Opens the target's last component, which is never followed, and returns its handle; the caller closes it. */
59
+ openFile(flags: number, mode?: number): Promise<FileHandle>;
60
+ binaryReader(reader: BinaryReader): BinaryReader;
61
+ /** The reader keeps the pin open: pi's reader names each entry through `dir` as it pages. */
62
+ dirReader(reader: DirReader): DirReader;
63
+ close(): Promise<void>;
64
+ }
65
+ export declare class Confinement {
66
+ #private;
67
+ readonly work: string;
68
+ readonly readOnly: readonly string[];
69
+ /** Canonical directories: `work` may be read and written, `readOnly` only read. */
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;
79
+ /** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
80
+ locate(abs: string, original: string, access: "read" | "write", follow: boolean): Promise<{
81
+ canonical: string;
82
+ root: string;
83
+ }>;
84
+ /** Pins the directory holding the target (or the root, when it is the target). */
85
+ pinEntry(abs: string, original: string, spec: PinSpec): Promise<Pin>;
86
+ /** Pins the target directory itself; `create` makes it and the directories above it when they are missing. */
87
+ pinDir(abs: string, original: string, spec: {
88
+ access: "read" | "write";
89
+ create?: boolean;
90
+ }): Promise<Pin>;
91
+ }