@parcha/pi-durable-disk 0.1.0-beta.21 → 0.1.0-beta.26

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.
@@ -14,7 +14,7 @@
14
14
  import { spawn } from "node:child_process";
15
15
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
16
  import { hostname } from "node:os";
17
- import { extname, join } from "node:path";
17
+ import { basename, extname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { ARCHIL_SCOPED, DEAD_CONNECTION, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
20
20
  import { EXIT_DATAERR, EXIT_FENCED, EXIT_HELD, EXIT_SOFTWARE, PdaError } from "../errors.js";
@@ -73,6 +73,14 @@ export function currentBootId() {
73
73
  throw new Error("this machine's boot id cannot be read");
74
74
  return boot;
75
75
  }
76
+ /** The uid a user number or name stands for (a name through /etc/passwd), or undefined when nothing names it. */
77
+ async function uidOf(user) {
78
+ if (/^\d+$/.test(user))
79
+ return Number(user);
80
+ const rows = await readFile("/etc/passwd", "utf8").then((text) => text.split("\n").map((line) => line.split(":")), () => []);
81
+ const row = rows.find((fields) => fields.length >= 3 && fields[0] === user);
82
+ return row ? Number(row[2]) : undefined;
83
+ }
76
84
  const firstLine = (r) => (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300);
77
85
  const okRan = (r) => r.code === 0 && !r.timedOut;
78
86
  /** `systemctl show` output as a map. */
@@ -104,6 +112,35 @@ export function unitStatus(show) {
104
112
  return "unknown";
105
113
  }
106
114
  }
115
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
116
+ /**
117
+ * The text of an `EnvironmentFile=`: one `NAME="value"` line each, with `\` and `"` escaped inside the double quotes (nothing
118
+ * else is special there: systemd expands no `$` in this file). A name that is not a variable name, or a value with a newline,
119
+ * a carriage return or a NUL, cannot be written faithfully, so it is refused rather than mangled.
120
+ */
121
+ /**
122
+ * The unit's own `ExecStopPost=` (`+`: as root, whatever `User=` is) that removes its environment file when the unit ends for
123
+ * good, so an instance that parks or finishes on its own leaves nothing behind. A restart in place loads the file again, so it
124
+ * stays after any exit systemd will restart: with `Restart=on-failure`, one that is not `success` and not in `TERMINAL_EXITS`.
125
+ * (`$$` is systemd's escape for `$`; it hands the script `SERVICE_RESULT` and `EXIT_STATUS`.)
126
+ */
127
+ function environmentCleanup(path, restarts) {
128
+ if (!restarts)
129
+ return `+/bin/rm -f ${path}`;
130
+ const final = ["success:*", ...TERMINAL_EXITS.map((code) => `exit-code:${code}`)].join("|");
131
+ return `+/bin/sh -c 'case "$$SERVICE_RESULT:$$EXIT_STATUS" in ${final}) /bin/rm -f "$$1";; esac' sh ${path}`;
132
+ }
133
+ function environmentFile(env) {
134
+ const lines = [];
135
+ for (const [name, value] of Object.entries(env)) {
136
+ if (!ENV_NAME.test(name))
137
+ throw new LocalHostError("INVALID_ARGUMENT", `${JSON.stringify(name)} is not an environment variable name`);
138
+ if (typeof value !== "string" || /[\r\n\0]/.test(value))
139
+ throw new LocalHostError("INVALID_ARGUMENT", `the value of ${name} is not a string without a newline, a carriage return or a NUL`);
140
+ lines.push(`${name}="${value.replace(/[\\"]/g, "\\$&")}"`);
141
+ }
142
+ return `${lines.join("\n")}\n`;
143
+ }
107
144
  export function localHost(opts = {}) {
108
145
  const mode = opts.mode ?? "systemd";
109
146
  const mountRoot = opts.mountRoot ?? DEFAULT_MOUNT_ROOT;
@@ -126,6 +163,28 @@ export function localHost(opts = {}) {
126
163
  const procMounts = opts.procMounts ?? "/proc/self/mounts";
127
164
  const mine = (h) => h.driver === "local" && h.host === hostName;
128
165
  const mountpointOf = (ref) => join(mountRoot, runPath(ref.id));
166
+ const runMountpoint = (mp) => {
167
+ try {
168
+ return mp === join(mountRoot, runPath(basename(mp)));
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ };
174
+ /**
175
+ * The unit of a systemd handle this driver could have made: a safe name with its prefix, whose mountpoint is one run's
176
+ * under its mount root; undefined for any other. A handle comes back from run.json, which the run's user can write, and
177
+ * stop and kill act on it as root (systemctl, unmount, rmdir), as the docker and daytona drivers act only on their fleet's.
178
+ * They refuse any other with the reason, so a supervisor's log never reports a stop that did not happen.
179
+ */
180
+ const ownUnit = (h) => {
181
+ const { unit, mountpoint } = h;
182
+ if (typeof unit !== "string" || !UNIT_SAFE.test(unit) || !unit.startsWith(prefix))
183
+ return undefined;
184
+ return typeof mountpoint === "string" && runMountpoint(mountpoint) ? unit : undefined;
185
+ };
186
+ const notOwnUnit = (h, what) => new LocalHostError("INVALID_ARGUMENT", `the handle's unit ${JSON.stringify(h.unit)} at ${JSON.stringify(h.mountpoint)} is not one this driver starts (${prefix}<run id>-<stamp>, a run's mountpoint under ${mountRoot}): nothing ${what}`);
187
+ const notOwnPid = (h, what) => new LocalHostError("INVALID_ARGUMENT", `the handle names pid ${JSON.stringify(h.pid)} with start ticks ${JSON.stringify(h.startTicks)}, not a process this driver started: nothing ${what}`);
129
188
  const mounted = async (mp) => (await readFile(procMounts, "utf8")).split("\n").some((l) => l.split(" ")[1] === mp);
130
189
  const parkMs = opts.parkThresholdMs === undefined ? LOCAL_PARK_THRESHOLD_MS : opts.parkThresholdMs;
131
190
  const drainMs = Math.max(0, stopTimeoutMs - Math.min(5_000, stopTimeoutMs / 2));
@@ -137,15 +196,18 @@ export function localHost(opts = {}) {
137
196
  ];
138
197
  const holderEnv = (h) => JSON.stringify(h);
139
198
  const tokenFile = (unit) => join(TOKEN_DIR, `${unit}.mount-token`);
140
- async function writeTokenFile(path, token) {
199
+ /** The unit's environment, loaded with `EnvironmentFile=`: a value on `systemd-run`'s argv is logged by sudo and shown by `systemctl show`. */
200
+ const envFile = (unit) => join(TOKEN_DIR, `${unit}.env`);
201
+ /** Writes a root-only (0600) file under the token directory; `content` goes through stdin, never an argument. */
202
+ async function writeSecretFile(path, content, what) {
141
203
  if (!sudo) {
142
204
  await mkdir(TOKEN_DIR, { recursive: true, mode: 0o700 });
143
- await writeFile(path, token, { mode: 0o600 });
205
+ await writeFile(path, content, { mode: 0o600 });
144
206
  return;
145
207
  }
146
- const r = await root(["/bin/sh", "-c", 'umask 077 && mkdir -p "$1" && cat > "$2"', "sh", TOKEN_DIR, path], { input: token });
208
+ const r = await root(["/bin/sh", "-c", 'umask 077 && mkdir -p "$1" && cat > "$2"', "sh", TOKEN_DIR, path], { input: content });
147
209
  if (!okRan(r))
148
- throw new LocalHostError("START_FAILED", `cannot write the mount token file: ${firstLine(r)}`);
210
+ throw new LocalHostError("START_FAILED", `cannot write the ${what}: ${firstLine(r)}`);
149
211
  }
150
212
  /** Replace the token file by an empty one: the started instance's open fd keeps the token, the path no longer has it. */
151
213
  async function emptyTokenFile(path) {
@@ -165,10 +227,13 @@ export function localHost(opts = {}) {
165
227
  const mountpoint = mountpointOf(ref);
166
228
  const handle = { driver: "local", mode: "systemd", host: hostName, bootId, unit, mountpoint };
167
229
  const env = { ...opts.env, ...extra, PATH: SAFE_PATH, PDA_HOLDER: holderEnv(handle) };
230
+ const environment = environmentFile(env);
168
231
  const path = tokenFile(unit);
169
- await writeTokenFile(path, token);
232
+ const envPath = envFile(unit);
233
+ await writeSecretFile(path, token, "mount token file");
170
234
  let started = false;
171
235
  try {
236
+ await writeSecretFile(envPath, environment, "environment file");
172
237
  const argv = [
173
238
  SYSTEMD_RUN,
174
239
  `--unit=${unit}`,
@@ -181,7 +246,8 @@ export function localHost(opts = {}) {
181
246
  "-p", `User=${user}`,
182
247
  "-p", `Group=${group}`,
183
248
  "-p", `StandardInput=file:${path}`,
184
- ...Object.entries(env).map(([k, v]) => `--setenv=${k}=${v}`),
249
+ "-p", `EnvironmentFile=${envPath}`,
250
+ "-p", `ExecStopPost=${environmentCleanup(envPath, opts.restart !== false)}`,
185
251
  "--",
186
252
  ...command,
187
253
  ...runFlags(ref),
@@ -195,8 +261,13 @@ export function localHost(opts = {}) {
195
261
  }
196
262
  finally {
197
263
  // Type=exec: systemd-run returns after the exec, so the instance's stdin is already open on the token. The path
198
- // stays (empty) because a restart in place opens it again; stop removes it.
199
- await (started ? emptyTokenFile(path) : removeFile(path)).catch(() => { });
264
+ // stays (empty) because a restart in place opens it again; stop removes it. The environment file stays whole: a
265
+ // restart in place loads it again, and stop removes it. The unit's ExecStopPost removes the file when
266
+ // it ends for good (a park, a finish, a terminal exit). A start that failed leaves neither.
267
+ if (started)
268
+ await emptyTokenFile(path).catch(() => { });
269
+ else
270
+ await Promise.all([removeFile(path), removeFile(envPath)]).catch(() => { });
200
271
  }
201
272
  }
202
273
  async function startChild(ref, token, extra) {
@@ -286,8 +357,25 @@ export function localHost(opts = {}) {
286
357
  hostName,
287
358
  mountRoot,
288
359
  kills: "process",
360
+ instanceOf: (h) => {
361
+ if (h.driver !== "local" || typeof h.host !== "string")
362
+ return undefined;
363
+ const which = h.mode === "systemd" && typeof h.unit === "string" ? `unit\0${h.unit}` : h.mode === "child" && typeof h.tag === "string" ? `tag\0${h.tag}` : undefined;
364
+ return which === undefined ? undefined : `local\0${h.host}\0${String(h.bootId)}\0${which}`;
365
+ },
289
366
  async start(ref, token, attempt) {
290
367
  runPath(ref.id);
368
+ // Never as root: the instance and the agent's tools would share a uid with the archil daemon that holds the mount
369
+ // token, and own run.json and the store outright.
370
+ if (mode === "systemd" && user.trim() === "") {
371
+ throw new LocalHostError("INVALID_ARGUMENT", "the user is empty, and systemd runs a unit with an empty User= as root: name the unprivileged user the instance runs as (user)");
372
+ }
373
+ if (mode === "systemd" && (await uidOf(user)) === 0) {
374
+ throw new LocalHostError("INVALID_ARGUMENT", `user ${user} is root: name the unprivileged user the instance runs as (user)`);
375
+ }
376
+ if (mode === "child" && process.getuid?.() === 0) {
377
+ throw new LocalHostError("INVALID_ARGUMENT", "child mode runs the instance as this process's user, which is root: use systemd mode with a user");
378
+ }
291
379
  const extra = attemptEnv(attempt, (m) => new LocalHostError("INVALID_ARGUMENT", m));
292
380
  return mode === "systemd" ? startUnit(ref, token, extra) : startChild(ref, token, extra);
293
381
  },
@@ -313,7 +401,7 @@ export function localHost(opts = {}) {
313
401
  // real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
314
402
  // process it may signal), and without start ticks nothing tells a reused pid from the instance.
315
403
  if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
316
- return;
404
+ throw notOwnPid(h, "stopped");
317
405
  const alive = () => procStartTicks(pid) === h.startTicks;
318
406
  if (!alive())
319
407
  return;
@@ -329,22 +417,24 @@ export function localHost(opts = {}) {
329
417
  process.kill(-pid, "SIGKILL");
330
418
  return;
331
419
  }
332
- if (typeof h.unit !== "string" || !UNIT_SAFE.test(h.unit))
333
- return;
334
- const stopUnit = () => root([SYSTEMCTL, "stop", `${h.unit}.service`], { timeoutMs: stopTimeoutMs + 10_000 });
420
+ const unit = ownUnit(h);
421
+ if (unit === undefined)
422
+ throw notOwnUnit(h, "stopped");
423
+ const stopUnit = () => root([SYSTEMCTL, "stop", `${unit}.service`], { timeoutMs: stopTimeoutMs + 10_000 });
335
424
  let r = await stopUnit();
336
- if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
425
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
337
426
  // A unit that outlives SIGKILL is wedged in its mount (a FUSE request its daemon never answers): kill the
338
427
  // daemon so those requests fail and the processes can die, then stop once more.
339
- await killDaemons(h.unit);
428
+ await killDaemons(unit);
340
429
  r = await stopUnit();
341
- if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
342
- throw new LocalHostError("STOP_FAILED", `systemctl stop ${h.unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
430
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
431
+ throw new LocalHostError("STOP_FAILED", `systemctl stop ${unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
343
432
  }
344
433
  }
345
- await cleanMount(h.unit, String(h.mountpoint));
346
- await root([SYSTEMCTL, "reset-failed", `${h.unit}.service`]);
347
- await removeFile(tokenFile(h.unit));
434
+ await cleanMount(unit, String(h.mountpoint));
435
+ await root([SYSTEMCTL, "reset-failed", `${unit}.service`]);
436
+ await removeFile(tokenFile(unit));
437
+ await removeFile(envFile(unit));
348
438
  },
349
439
  async kill(h) {
350
440
  if (!mine(h) || h.bootId !== bootId)
@@ -352,7 +442,9 @@ export function localHost(opts = {}) {
352
442
  if (h.mode === "child") {
353
443
  const pid = Number(h.pid);
354
444
  // As in stop: only a real process, and only the one the handle names (its start ticks).
355
- if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number" || procStartTicks(pid) !== h.startTicks)
445
+ if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
446
+ throw notOwnPid(h, "killed");
447
+ if (procStartTicks(pid) !== h.startTicks)
356
448
  return;
357
449
  try {
358
450
  process.kill(-pid, "SIGKILL");
@@ -362,13 +454,14 @@ export function localHost(opts = {}) {
362
454
  }
363
455
  return;
364
456
  }
365
- if (typeof h.unit !== "string" || !UNIT_SAFE.test(h.unit))
366
- return;
457
+ const unit = ownUnit(h);
458
+ if (unit === undefined)
459
+ throw notOwnUnit(h, "killed");
367
460
  // Every process of the unit's control group at once, the instance's commands included. Its FUSE daemon runs in a
368
461
  // scope of its own and keeps the mount, as after an OOM kill; the unit restarts in place unless restart is off.
369
- const r = await root([SYSTEMCTL, "kill", "--signal=SIGKILL", "--kill-whom=all", `${h.unit}.service`]);
370
- if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
371
- throw new LocalHostError("KILL_FAILED", `systemctl kill ${h.unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
462
+ const r = await root([SYSTEMCTL, "kill", "--signal=SIGKILL", "--kill-whom=all", `${unit}.service`]);
463
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
464
+ throw new LocalHostError("KILL_FAILED", `systemctl kill ${unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
372
465
  }
373
466
  },
374
467
  };
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
10
10
  export type { AcquireOptions, ArchilHost, Claim, ControlApi, PathlessResolver, RunRef, Unmounted } from "./claim.js";
11
11
  export { readReleased, withClaim } from "./with-claim.js";
12
12
  export type { ClaimControl, HeldPath, WithClaimOptions } from "./with-claim.js";
13
+ export { listRunFiles, readRunFile, RunFileError } from "./run-files.js";
14
+ export type { RunFileControl, RunFileErrorCode } from "./run-files.js";
13
15
  export { PathOutsideWorkError } from "./confine.js";
14
16
  export type { RefusalReason } from "./confine.js";
15
17
  export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
@@ -28,7 +30,7 @@ export type { CreateSandboxBody, DaytonaClient, DaytonaHostErrorCode, DaytonaHos
28
30
  export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
29
31
  export type { RequestOptions, RunResponse, RunServer, ServeConfigErrorCode, ServeErrorCode, ServeOptions } from "./serve.js";
30
32
  export { archilPlace, localPlace, PlaceError } from "./place.js";
31
- export type { ArchilPlaceOptions, LocalPlaceOptions, Place, PlaceContext, PlaceErrorCode, PlaceExit, PlaceKill, PlaceOutcome, PlacePaths, PlaceRecordHome, PlaceRecovery, PlaceRunner, PlaceSignal, PlaceStatus, RecordControl, RunnerOrder } from "./place.js";
33
+ export type { ArchilPlaceOptions, LocalPlaceOptions, Place, PlaceContext, PlaceErrorCode, PlaceExit, PlaceKill, PlaceOutcome, PlacePaths, PlaceRecordHome, PlaceRecovery, PlaceRunner, PlaceSignal, PlaceStatus, RecordControl, RunnerOrder, UnheldHook, UnheldRun, UnheldStart } from "./place.js";
32
34
  export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
33
35
  export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
34
36
  export { fork, ForkError, forkLocal } from "./fork.js";
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@ export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySaf
9
9
  export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
10
10
  // A short exclusive claim on any path of a disk, and a consistent S3 read of a released file (an agent home beside the runs).
11
11
  export { readReleased, withClaim } from "./with-claim.js";
12
+ // A run's own files, read over the control: never a mount, a write, or a wait on the claim.
13
+ export { listRunFiles, readRunFile, RunFileError } from "./run-files.js";
12
14
  export { PathOutsideWorkError } from "./confine.js";
13
15
  export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
14
16
  // The app contract: what `--app` names.
package/dist/place.d.ts CHANGED
@@ -148,8 +148,16 @@ export interface Place {
148
148
  release(): Promise<void>;
149
149
  /** A place whose runners record where the supervisor cannot read says where, and brings the recording home. */
150
150
  readonly recordHome?: PlaceRecordHome;
151
+ /**
152
+ * The bytes of the run's own file `path` (`runs/<id>/<path>`), or null when there is no such file; every other failure
153
+ * throws a `RunFileError` (INVALID_PATH, RUN_MISSING, NOT_A_FILE, CONTROL_API_FAILED). Read only: never a write, never
154
+ * the claim. A place never parses what it reads.
155
+ */
156
+ readRunFile?(path: string): Promise<Uint8Array | null>;
157
+ /** The names directly under `dir` in the run (`""` for its root), sorted; [] for a directory not there yet. A missing run, or a failed read, throws. */
158
+ listRunFiles?(dir: string): Promise<string[]>;
151
159
  }
152
- export type PlaceErrorCode = "INVALID_ARGUMENT" | "HELD" | "START_TIMED_OUT" | "STATUS_FAILED" | "KILL_UNSUPPORTED" | "RELEASE_FAILED" | "BOX_GONE" | "CARRY_FAILED" | "BLOB_MISMATCH" | "DIRS_REFUSED";
160
+ export type PlaceErrorCode = "INVALID_ARGUMENT" | "HELD" | "START_TIMED_OUT" | "STATUS_FAILED" | "KILL_UNSUPPORTED" | "RELEASE_FAILED" | "BOX_GONE" | "CARRY_FAILED" | "BLOB_MISMATCH" | "DIRS_REFUSED" | "WINDOW_CLOSED" | "CONTROL_API_FAILED" | "UNHELD_FAILED";
153
161
  export declare class PlaceError extends PdaError {
154
162
  constructor(code: PlaceErrorCode, message: string, options?: {
155
163
  cause?: unknown;
@@ -203,7 +211,62 @@ export interface ArchilPlaceOptions {
203
211
  * `signals-failed` and ends. Default 60 s.
204
212
  */
205
213
  signalFailMs?: number;
214
+ /**
215
+ * A hook in each start's window while nobody holds the run: after ensureRunning's revoke and start mark and after the
216
+ * run's directories are made, before the driver starts anything. It runs once per start, a takeover's included, and
217
+ * what it returns is added to that start's environment. Needs `ensure.create` (what it puts is the run user's) and a
218
+ * control that lists. See `UnheldHook`.
219
+ */
220
+ unheld?: UnheldHook;
221
+ /** How long `unheld` may take. Default 30 s; past it the hook's signal aborts and the start fails (UNHELD_FAILED). */
222
+ unheldTimeoutMs?: number;
206
223
  }
224
+ /**
225
+ * The run's own files during a start's window (`runs/<id>/` only, K8's path rules): nobody holds the run, so the place
226
+ * is the only writer for this start. Two supervisors can still be in one window at once (the mount, not the window,
227
+ * admits one of their instances), so a put is not exclusive between them.
228
+ */
229
+ export interface UnheldRun {
230
+ readonly id: string;
231
+ /** Aborted at the hook's bound (`unheldTimeoutMs`). */
232
+ readonly signal: AbortSignal;
233
+ /** The file's bytes, or null when there is no such file yet (`readRunFile`). */
234
+ read(path: string): Promise<Uint8Array | null>;
235
+ /** The names directly under `dir` ("" for the run's root), sorted; [] for a directory not made yet (`listRunFiles`). */
236
+ list(dir: string): Promise<string[]>;
237
+ /**
238
+ * Create `path` holding `text`, owned by the run user, mode 0644, making each missing parent first (the run user's,
239
+ * 0755). A path already there is EXISTS, and nothing is written. For non-secret run files (labels): a secret never
240
+ * goes through put, since a put file is readable in the run. A put the server refuses because a delegation holds the
241
+ * run (409 OperationAborted), and any put after the hook returned or overran, is WINDOW_CLOSED; any other failure is
242
+ * CONTROL_API_FAILED (`RunFileError`). A put the hook starts and does not await still settles inside the window: the
243
+ * driver starts only once every put has settled, within the hook's bound.
244
+ */
245
+ put(path: string, text: string): Promise<void>;
246
+ }
247
+ /** Which start the window is for: the generation it opens, and the host box it will make when the place hands labels over. */
248
+ export type UnheldStart = {
249
+ readonly attempt: number;
250
+ /** Exactly the HOST_BOX this start carries; absent when the place hands no label over. */
251
+ readonly box?: {
252
+ readonly provider: string;
253
+ readonly key: string;
254
+ readonly run: string;
255
+ };
256
+ };
257
+ /**
258
+ * A hook in a start's window (`ArchilPlaceOptions.unheld`). What it returns is added to that start's environment, which
259
+ * the instance can read: plain values only. A name the start already sets (PATH, PDA_HOLDER, HOST_BOX or one in the
260
+ * order's environment), a name that is not a variable name, a key-shaped name (a KEY, TOKEN, SECRET, PASSWORD, PASSWD,
261
+ * CREDENTIAL or CREDENTIALS segment) or a value that is not a string without NUL fails the start (INVALID_ARGUMENT,
262
+ * naming the variable, never its value).
263
+ *
264
+ * A start fails, and the driver never starts a box, when any put in the hook was refused because the run is held
265
+ * (WINDOW_CLOSED, even if the hook caught it), when the hook throws a CONTROL_API_FAILED of the control
266
+ * (CONTROL_API_FAILED), or when it throws anything else or overruns its bound (UNHELD_FAILED). The start's caller sees
267
+ * the driver's start failure from ensureRunning (HOST_START_FAILED), with that PlaceError as its cause.
268
+ */
269
+ export type UnheldHook = (run: UnheldRun, start: UnheldStart) => Promise<Readonly<Record<string, string>> | void>;
207
270
  /** The listing `recordHome` reads the run's recordings with: S3 ListObjects, each object's size when the API gives it. */
208
271
  export interface RecordControl {
209
272
  listObjects(prefix: string, options?: {
package/dist/place.js CHANGED
@@ -13,6 +13,8 @@ import { DEFAULT_MOUNT_ROOT, findDelegations, mintMountToken, pathlessResolver,
13
13
  import { reapCommands } from "./commands.js";
14
14
  import { HeldError, PdaError } from "./errors.js";
15
15
  import { takeOwnerLock } from "./run.js";
16
+ import { listLocalRunFiles, listRunFiles, putRunFile, readLocalRunFile, readRunFile, RunFileError } from "./run-files.js";
17
+ import { ENV_NAME, keyShaped } from "./hosts/attempt-env.js";
16
18
  import { readRunRecord } from "./status.js";
17
19
  import { CONTROL_TIMEOUT_MS, ensureRunning, readRunStatus, readStartMark, TOKEN_TTL, withTimeouts, } from "./supervise.js";
18
20
  export class PlaceError extends PdaError {
@@ -173,6 +175,8 @@ export function localPlace(options) {
173
175
  return {
174
176
  name: "local",
175
177
  paths,
178
+ readRunFile: (path) => readLocalRunFile(root, paths.id, path),
179
+ listRunFiles: (dir) => listLocalRunFiles(root, paths.id, dir),
176
180
  kills: ctx.destroyBoxes ? ["process", "box"] : ["process"],
177
181
  start,
178
182
  // The runner is a child of this machine, so its kill request is the supervisor's own file.
@@ -589,6 +593,17 @@ const requestName = (attempt) => `kill-${countFrom1(attempt)}.json`;
589
593
  */
590
594
  export function archilPlace(options) {
591
595
  const { host } = options;
596
+ const unheldMs = options.unheldTimeoutMs ?? 30_000;
597
+ if (options.unheld !== undefined) {
598
+ if (typeof options.unheld !== "function")
599
+ throw new PlaceError("INVALID_ARGUMENT", "unheld is not a function");
600
+ if (!options.ensure?.create)
601
+ throw new PlaceError("INVALID_ARGUMENT", "unheld needs ensure.create: what its hook puts is the run user's, or the run's process could not rely on it");
602
+ if (typeof options.control.listObjects !== "function")
603
+ throw new PlaceError("INVALID_ARGUMENT", "unheld needs a control that can list (listObjects): its hook's read and list use it");
604
+ if (!(Number.isFinite(unheldMs) && unheldMs > 0))
605
+ throw new PlaceError("INVALID_ARGUMENT", "unheldTimeoutMs is not a positive number of milliseconds");
606
+ }
592
607
  // Every call the place makes on the disk is bounded as ensureRunning bounds its own: one that does not answer fails as
593
608
  // CONTROL_API_FAILED, so a stalled request never leaves a recovery, a release or a runner's end waiting forever.
594
609
  const control = withTimeouts(options.control, options.ensure?.controlTimeoutMs ?? CONTROL_TIMEOUT_MS);
@@ -611,6 +626,12 @@ export function archilPlace(options) {
611
626
  let resolver = pathlessResolver(control);
612
627
  const records = `${runPath(ref.id)}/${RECORDS}/`;
613
628
  const recording = typeof options.control.listObjects === "function";
629
+ /**
630
+ * Whether each host box's runner is told its label (HOST_BOX): the driver makes boxes and names their provider, and this
631
+ * place can read the label back (its read members need a control that lists). A label it could never read is never
632
+ * handed over.
633
+ */
634
+ const handingOver = recording && typeof host.box === "function" && typeof host.provider === "string" && host.provider !== "";
614
635
  const owner = options.ensure?.create;
615
636
  /**
616
637
  * Before attempt `gen`'s runner starts: the EMPTY directories it writes into, `signals/` and, with `recordHome`,
@@ -637,6 +658,88 @@ export function archilPlace(options) {
637
658
  }
638
659
  }
639
660
  };
661
+ /**
662
+ * The hook's turn in a start's window: its run members, its bound, then what it returned, checked. Fails closed: a put
663
+ * refused because the run is held fails the start even when the hook caught it, so no box starts without what the
664
+ * hook meant to write.
665
+ */
666
+ const inWindow = async (hook, attempt, box, orderEnv) => {
667
+ const bound = new AbortController();
668
+ const timer = setTimeout(() => bound.abort(new PlaceError("UNHELD_FAILED", `the start window's hook overran its ${unheldMs} ms bound`)), unheldMs);
669
+ let open = true;
670
+ let closed;
671
+ /** Every put the hook started, settled or not: the driver starts only once all of them have settled. */
672
+ const pending = new Set();
673
+ const runControl = control;
674
+ const run = {
675
+ id: ref.id,
676
+ signal: bound.signal,
677
+ read: (path) => readRunFile(runControl, ref, path, { signal: bound.signal }),
678
+ list: (dir) => listRunFiles(runControl, ref, dir, { signal: bound.signal }),
679
+ put: (path, text) => {
680
+ if (!open)
681
+ return Promise.reject(new RunFileError("WINDOW_CLOSED", "the start window is closed (its hook returned or overran its bound): nothing more is put"));
682
+ const put = putRunFile(runControl, ref, path, text, owner, { signal: bound.signal }).catch((error) => {
683
+ if (error instanceof RunFileError && error.code === "WINDOW_CLOSED")
684
+ closed ??= error;
685
+ throw error;
686
+ });
687
+ const settled = put.then(() => undefined, () => undefined);
688
+ pending.add(settled);
689
+ void settled.then(() => pending.delete(settled));
690
+ return put;
691
+ },
692
+ };
693
+ // The bound, as a rejection to race against.
694
+ const overran = new Promise((_, reject) => bound.signal.addEventListener("abort", () => reject(bound.signal.reason), { once: true }));
695
+ overran.catch(() => undefined);
696
+ let returned;
697
+ let thrown;
698
+ try {
699
+ returned = await Promise.race([Promise.resolve().then(() => hook(run, box ? { attempt, box } : { attempt })), overran]);
700
+ }
701
+ catch (error) {
702
+ thrown = { error };
703
+ }
704
+ // No put starts once the hook is done, and every put it started (awaited or not) settles inside the bound: none is
705
+ // written, or refused, after the driver has started.
706
+ open = false;
707
+ if (pending.size > 0) {
708
+ try {
709
+ await Promise.race([Promise.allSettled([...pending]), overran]);
710
+ }
711
+ catch (error) {
712
+ thrown ??= { error };
713
+ }
714
+ }
715
+ clearTimeout(timer);
716
+ if (closed)
717
+ throw new PlaceError("WINDOW_CLOSED", "a put in the start window was refused because a delegation holds the run (409 OperationAborted): this start makes no box", { cause: closed });
718
+ if (thrown) {
719
+ const { error } = thrown;
720
+ if (error === bound.signal.reason && error instanceof PlaceError)
721
+ throw error;
722
+ if (error instanceof RunFileError && error.code === "CONTROL_API_FAILED")
723
+ throw new PlaceError("CONTROL_API_FAILED", `the start window's hook failed on the control: ${error.message}`, { cause: error });
724
+ throw new PlaceError("UNHELD_FAILED", `the start window's hook failed: ${String(error?.message ?? error).slice(0, 400)}`, { cause: error });
725
+ }
726
+ if (returned === undefined)
727
+ return {};
728
+ if (returned === null || typeof returned !== "object" || Array.isArray(returned))
729
+ throw new PlaceError("INVALID_ARGUMENT", "the start window's hook returned something other than an environment (names to strings)");
730
+ const taken = new Set(["PATH", "PDA_HOLDER", "HOST_BOX", ...Object.keys(orderEnv)]);
731
+ for (const [name, value] of Object.entries(returned)) {
732
+ if (!ENV_NAME.test(name))
733
+ throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${JSON.stringify(name)}, which is not an environment variable name`);
734
+ if (taken.has(name))
735
+ throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${name}, which this start already sets (the driver, HOST_BOX or the order)`);
736
+ if (keyShaped(name))
737
+ throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${name}, a secret's name (a KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL or CREDENTIALS segment): the instance can read its environment, so a secret never goes there`);
738
+ if (typeof value !== "string" || value.includes("\0"))
739
+ throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${name} with a value that is not a string without NUL`);
740
+ }
741
+ return returned;
742
+ };
640
743
  const fetchedSizes = new Map();
641
744
  const carrying = new Map();
642
745
  const handleOf = new Map();
@@ -695,7 +798,14 @@ export function archilPlace(options) {
695
798
  // Inside ensureRunning, after its revoke and start mark: no delegation holds the run, so S3 takes the writes.
696
799
  start: async (r, token, attempt) => {
697
800
  await makeDirs(order.gen);
698
- return host.start(r, token, { attempt: attempt?.attempt ?? order.gen, env: order.env });
801
+ // The box this start makes is the next in `started`, so its index in status().boxes is started.length: its runner
802
+ // claims that label first (HOST_BOX), for the supervisor's life (RUN_LABEL).
803
+ const life = order.env.RUN_LABEL;
804
+ const box = handingOver && typeof life === "string" && life !== "" ? { provider: host.provider, key: String(started.length), run: life } : undefined;
805
+ const n = attempt?.attempt ?? order.gen;
806
+ const added = options.unheld ? await inWindow(options.unheld, n, box, order.env) : {};
807
+ const env = box ? { ...order.env, ...added, HOST_BOX: JSON.stringify(box) } : { ...order.env, ...added };
808
+ return host.start(r, token, { attempt: n, env });
699
809
  },
700
810
  status: (h) => host.status(h),
701
811
  stop: (h) => host.stop(h),
@@ -724,8 +834,15 @@ export function archilPlace(options) {
724
834
  // An adopted instance mounts with an earlier start's token: this one is unused, removed as above.
725
835
  if (result.adopted)
726
836
  await drop(m).catch(() => { });
727
- handles.push(result.handle);
728
- started.push(result.handle);
837
+ // A box this place already started, adopted again (its attempt started twice), keeps the key it has: a second
838
+ // entry would be a key no runner claims (HOST_BOX), a box counted twice, and a box stopped twice. Only the driver
839
+ // can say two handles are one instance (`instanceOf`); a box it cannot name, and every fresh one, is a new entry.
840
+ const name = result.adopted === true ? host.instanceOf?.(result.handle) : undefined;
841
+ const known = (list) => name !== undefined && list.some((h) => host.instanceOf?.(h) === name);
842
+ if (!known(handles))
843
+ handles.push(result.handle);
844
+ if (!known(started))
845
+ started.push(result.handle);
729
846
  const w = watch(result.handle);
730
847
  const mark = result.startMark ? await readStartMark(control, ref.id).catch(() => null) : null;
731
848
  const runner = { epoch: mark?.generation ?? order.gen, exited: w.exited };
@@ -770,6 +887,13 @@ export function archilPlace(options) {
770
887
  return {
771
888
  name: "archil",
772
889
  paths,
890
+ // The run's own files over the control (GET, HEAD, LIST): never a mount, a write, or a wait on the claim.
891
+ ...(recording
892
+ ? {
893
+ readRunFile: (path) => readRunFile(control, ref, path),
894
+ listRunFiles: (dir) => listRunFiles(control, ref, dir),
895
+ }
896
+ : {}),
773
897
  kills: host.kill && host.kills ? [host.kills] : [],
774
898
  token: async (attempt) => (await mint(attempt)).token,
775
899
  ...(recording
@@ -823,7 +947,12 @@ export function archilPlace(options) {
823
947
  throw error;
824
948
  }),
825
949
  ]);
826
- const boxes = host.box ? started.map((h) => host.box(h)).filter((b) => b !== null) : undefined;
950
+ // With HOST_BOX in play a box's position is its key, so a box never drops out: no receipt fails the read instead.
951
+ const receipts = host.box ? started.map((h) => host.box(h)) : undefined;
952
+ const gap = receipts?.indexOf(null) ?? -1;
953
+ if (handingOver && gap >= 0)
954
+ throw new PlaceError("STATUS_FAILED", `the driver gave no receipt for host box ${gap}, which this place started: every later box's key would shift`);
955
+ const boxes = receipts?.filter((b) => b !== null);
827
956
  return { record, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })), ...(boxes ? { boxes } : {}) };
828
957
  },
829
958
  recover: async (last, kind, order) => {
@@ -0,0 +1,72 @@
1
+ import { type RunRef } from "./claim.js";
2
+ import { PdaError } from "./errors.js";
3
+ /** The calls of the disk's control these reads use; `archilControl` has all three. */
4
+ export interface RunFileControl {
5
+ getObject(key: string): Promise<Uint8Array>;
6
+ /** null when there is no such key. */
7
+ headObject(key: string): Promise<unknown | null>;
8
+ listObjects(prefix: string, options?: {
9
+ recursive?: boolean;
10
+ }): Promise<{
11
+ objects: {
12
+ key: string;
13
+ size?: number;
14
+ }[];
15
+ commonPrefixes?: string[];
16
+ }>;
17
+ }
18
+ export type RunFileErrorCode = "INVALID_PATH" | "RUN_MISSING" | "NOT_A_FILE" | "CONTROL_API_FAILED" | "EXISTS" | "WINDOW_CLOSED";
19
+ /** A read of a run's files that cannot be answered. Its message names the path, never what a file holds. */
20
+ export declare class RunFileError extends PdaError {
21
+ readonly code: RunFileErrorCode;
22
+ constructor(code: RunFileErrorCode, message: string, options?: {
23
+ cause?: unknown;
24
+ });
25
+ }
26
+ /** The segments of `path`, or INVALID_PATH naming what is wrong (never the path). `""` is the run's root where allowed. */
27
+ export declare function runFileSegments(path: string, allowRoot: boolean): string[];
28
+ /**
29
+ * The bytes of `path` in the run (`runs/<id>/<path>`), or null when there is no such file yet, so a poller waits. A
30
+ * directory or a symlink is NOT_A_FILE; a run that does not exist is RUN_MISSING. No polling here: the caller bounds its
31
+ * own wait, with `signal` and its own retries.
32
+ */
33
+ export declare function readRunFile(control: RunFileControl, ref: RunRef, path: string, options?: {
34
+ signal?: AbortSignal;
35
+ }): Promise<Uint8Array | null>;
36
+ /**
37
+ * The names directly under `dir` in the run (`""` for the run's root), sorted: files and directories alike, names only,
38
+ * never a claim about what each one is. A directory not made yet lists nothing; a run that does not exist is RUN_MISSING.
39
+ */
40
+ export declare function listRunFiles(control: RunFileControl, ref: RunRef, dir: string, options?: {
41
+ signal?: AbortSignal;
42
+ }): Promise<string[]>;
43
+ /** The calls of the disk's control a put uses: HEAD, and PutObject with an owner. `archilControl` has both. */
44
+ export interface RunFilePutControl {
45
+ headObject(key: string): Promise<unknown | null>;
46
+ putObject(key: string, body: string, options: {
47
+ uid: number;
48
+ gid: number;
49
+ mode: number;
50
+ }): Promise<unknown>;
51
+ }
52
+ /**
53
+ * Create `path` in the run (`runs/<id>/<path>`) holding `text`, owned by `owner` with mode 0644, after making each missing
54
+ * parent directory as a marker `owner` owns, mode 0755 (a directory made by path on a mount refuses later creates).
55
+ * Create-only: a file or a directory already at `path` is EXISTS and nothing is written. The check is a HEAD before the
56
+ * PutObject, not a conditional create (Archil's client has none), so it is not exclusive against another writer at the
57
+ * same moment: claim a shared name where a single writer is certain. Only a run nobody holds takes the write; a held one
58
+ * answers 409 OperationAborted, which is WINDOW_CLOSED. For non-secret run files: a put file is readable in the run.
59
+ */
60
+ export declare function putRunFile(control: RunFilePutControl, ref: RunRef, path: string, text: string, owner: {
61
+ uid: number;
62
+ gid: number;
63
+ }, options?: {
64
+ signal?: AbortSignal;
65
+ }): Promise<void>;
66
+ /**
67
+ * `readRunFile` over the run directory `root` on this machine: the same path rules and answers. A directory, or a symlink
68
+ * anywhere on the way, is NOT_A_FILE, and a file swapped for a link after the check is refused, not followed.
69
+ */
70
+ export declare function readLocalRunFile(root: string, id: string, path: string): Promise<Uint8Array | null>;
71
+ /** `listRunFiles` over the run directory `root` on this machine: the same rules; a symlink on the way is NOT_A_FILE. */
72
+ export declare function listLocalRunFiles(root: string, id: string, dir: string): Promise<string[]>;