@parcha/pi-durable-disk 0.1.0-beta.20 → 0.1.0-beta.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0-beta.23 (2026-10-22)
6
+
7
+ - The CLI's duration flags take a unit: `ms`, `s`, `m` or `h` (`500ms`, `30s`, `1.5m`, `1h`). A bare number used to mean milliseconds, so `--every 90` ran every 90 ms. It is now refused with exit 2 and a hint: `not a duration: "90": give a unit, e.g. 90s or 90ms`. Every duration flag a command is given is checked before the command acts. **Who breaks:** a script or service unit that passes a bare number to `--park-threshold`, `--park-idle`, `--drain-timeout`, `--stop-timeout`, `--control-timeout`, `--lease-expiry`, `--stonith-timeout`, `--start-grace`, `--start-backoff-max`, `--token-grace` or `--every`. **What to do:** add the unit you meant (`90ms` keeps today's behaviour).
8
+ - Flags named -ms keep bare milliseconds (`--heartbeat-ms`, `--lease-expiry-ms`, `--lease-margin-ms`): their name carries the unit, and units are still accepted.
9
+ - A bare `0` still turns off `--park-threshold` (no parking), `--start-grace` (no grace, so no backoff) and `--start-backoff-max` (no backoff). Elsewhere `0` takes a unit (`0s`) and behaves as before.
10
+ - **SECURITY: `localHost` stops and kills only a unit it could have started, and says so when it refuses one.** A handle comes back from `run.json`, which the run's user can write, and `stop` and `kill` act on it as root (`systemctl stop` and `kill`, `archil unmount`, `fusermount`, `umount`, `rmdir`). Before, only the shape of the unit's name was checked, so a handle that named another service on the host had it stopped. Now `stop` and `kill` act only on a unit with the driver's `unitPrefix` whose mountpoint is one run's under the driver's mount root, as `dockerHost` and `daytonaHost` act only on their fleet's. For any other handle of this host and boot, and for a child-mode handle whose pid is 1 or below or that has no start ticks, they throw `INVALID_ARGUMENT`, naming the handle and saying that nothing was stopped or killed. `supervise` logs that STONITH as `failed` with the reason, where it used to log `stopped`, then revokes and starts as before. A handle of another host or boot, and a child that is already gone, are still no-ops; `status` is unchanged.
11
+
12
+ - Who notices: a supervisor that changed `--unit-prefix` or `--mount-root` between runs no longer stops the units started under the old values. Their STONITH logs the refusal, and the revoke fences them as before.
13
+ - **SECURITY: `localHost` and `archilEnv` refuse to run the agent as root.** The docs say instances and the agent's tools never run as root, and `--run-as`, `daytonaHost` and the in-box launcher already refused uid 0. `localHost` only required a `user` when the supervisor runs as root, so `user: "root"`, `"0"` or `0` started the unit with `User=0`, and child mode in a root process ran the instance as root; `archilEnv` accepted `runAs: { uid: 0 }`. Now `localHost.start` throws `INVALID_ARGUMENT` for a user that is uid 0 (a name is looked up in `/etc/passwd`) or empty (systemd runs a unit with an empty `User=` as root), and for child mode in a root process, and `archilEnv` throws `INVALID_RUN_AS` for a `runAs` uid of 0, each before anything runs. A driver is constructed as before, so the `status` command's read-only driver is unaffected.
14
+ - **SECURITY: `localHost` in systemd mode no longer puts the instance environment on the `systemd-run` command line, which sudo logged to the journal and `systemctl show` exposed; it passes it through a root-only file.**
15
+ - Before, each variable of the driver's `env` and of a start's own environment (`StartAttempt.env`) went to `systemd-run` as `--setenv=NAME=value`, under `sudo -n`. `sudo` logs the whole command line to the journal, and `--setenv` is stored in the unit's `Environment=` property, which any local user reads with `systemctl show`. A secret in either place was in both. The mount token and the API key were never affected: the token travels on stdin and the API key never reaches the driver.
16
+ - Now the environment is written to a root-only (mode 0600) file beside the mount token's, through stdin, and the unit loads it with `-p EnvironmentFile=<path>`. No value is on any argument. The file lives as long as the unit may restart (a restart in place loads it again). It is removed when the unit is stopped, when a start fails, and by the unit itself (`ExecStopPost=`) when it ends for good: a park or a finish, or a terminal exit. A unit that systemd gave up restarting keeps it until `stop`. A value with a newline, a carriage return or a NUL, or a name that is not a variable name, is refused with `INVALID_ARGUMENT` before anything runs, rather than written mangled.
17
+ - A deployment that put a secret in `localHost`'s `env` or in `StartAttempt.env` under systemd mode should treat it as exposed to anyone who could read its journal or run `systemctl show` on the unit, and rotate it. Child mode is unaffected: its environment goes in `spawn`'s `env`.
18
+ - Every install line in the README and the examples pins `@earendil-works/pi-durable`, `@earendil-works/pi-ai` and `@earendil-works/chord` at 1.3.0. pi 1.4.0 is now npm's `latest`, and an unpinned line ends in ERESOLVE against the peer ranges (`>=1.0.4 <1.4.0`). pi 1.4 is not supported yet; a later release widens the range.
19
+ - The README opens with the agent as the workspace, and the package description is its short form: its conversation and its files live together in
20
+ one directory on a disk, which any Linux host or container can mount, release and mount somewhere else, and the agent carries on
21
+ where the last host released it. The second paragraph says what opening and releasing a run do on an Archil disk (mount and
22
+ claim, then seal, give up the claim and unmount) and on a plain directory (no mount).
23
+
24
+ ## 0.1.0-beta.21 (2026-10-21)
25
+
26
+ - `supervise --no-restart` is in the usage text and the reference: it turns off the local driver's restart on failure (`localHost({ restart: false })`), so an instance that exits stays down until the supervisor's next pass.
27
+
5
28
  ## 0.1.0-beta.20 (2026-10-20)
6
29
 
7
30
  - A start mark keeper whose rewrite fails because the run's directory moved after its check now warns that the directory is no longer the one it started on, and stops. Before, the `PDA_START_MARK` warning said the rewrite failed (ENOENT or ENOTDIR). Nothing is written in either case; only the stated reason changes.
@@ -509,7 +532,6 @@ published.
509
532
  - Unchanged, because they name the Archil disk: `bin/archil-scoped` (and the `./bin/archil-scoped` export), `ARCHIL_API_KEY`, `archilEnv`,
510
533
  `ArchilCodingTools`, the claim's internals and the `archil` client it runs.
511
534
 
512
-
513
535
  **A holder the control API lists without a path**
514
536
  - Archil's control API resolves a delegation's path best-effort and can list one with no path, live or orphaned, even on a
515
537
  run id used once. `findDelegations` matched by path only, so such a holder read as none everywhere it is used
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # @parcha/pi-durable-disk
2
2
 
3
- Run a [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable) agent from a directory or a network disk, so any process or host can open the run and carry on where the last one released it.
3
+ The agent is the workspace: its conversation and its files live together in one directory on a disk. Mount the disk on any Linux host or container, release it, and mount it somewhere else: the agent carries on where the last host released it.
4
4
 
5
- An agent's home and its runs live on the disk, not on a machine: the conversation, the agent's files and what it was waiting
6
- for are all in the run's directory, so a process is somewhere a run visits, not where it lives. This is a beta
7
- (`0.1.0-beta.N`): a change that breaks a caller is listed in [CHANGELOG.md](CHANGELOG.md) when it ships.
5
+ On an [Archil](https://archil.com) disk, opening a run mounts its directory (`runs/<id>` of the disk) and takes an exclusive claim on it, and `release()` seals the
6
+ run, gives up the claim and unmounts. On a plain directory of this machine (`localRun`) there is no mount: one machine, no disk,
7
+ no key. It runs [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable) agents, on Linux (macOS is for development;
8
+ Windows is not supported). This is a beta (`0.1.0-beta.N`): a change that breaks a caller is listed in [CHANGELOG.md](CHANGELOG.md)
9
+ when it ships.
8
10
 
9
11
  ## What it is
10
12
 
@@ -42,9 +44,11 @@ Archil is not supported: the package needs the disk to give an exclusive claim a
42
44
  On Linux with Node 22.19 or later (or on macOS [for development](docs/reference.md#on-macos)). In an empty directory:
43
45
 
44
46
  ```sh
45
- npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
47
+ npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable@1.3.0 @earendil-works/pi-ai@1.3.0 @earendil-works/chord@1.3.0
46
48
  ```
47
49
 
50
+ pi 1.4 is not supported yet; a later release widens the range.
51
+
48
52
  Save this as `pick-up.mts` (Node 22.19 or later runs the types away; `.mts` makes it an ES module whatever your
49
53
  `package.json` says). It is [examples/pick-up-a-run/main.ts](examples/pick-up-a-run/main.ts). It starts three processes, one after another, on
50
54
  one directory. Each opens the run, answers one question of the same chat, and releases it. pi-ai's faux model answers, so it
@@ -216,11 +220,11 @@ what it imports.
216
220
  ## Install
217
221
 
218
222
  ```sh
219
- npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
223
+ npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable@1.3.0 @earendil-works/pi-ai@1.3.0 @earendil-works/chord@1.3.0
220
224
  ```
221
225
 
222
- `@earendil-works/pi-durable` and `@earendil-works/chord` are peer dependencies, `>=1.0.4 <1.4.0`. Node 22.19 or later;
223
- Linux, or macOS for development.
226
+ `@earendil-works/pi-durable` and `@earendil-works/chord` are peer dependencies, `>=1.0.4 <1.4.0`: pi 1.4 is not supported yet; a
227
+ later release widens the range. Node 22.19 or later; Linux, or macOS for development.
224
228
 
225
229
  From a checkout (this package is `packages/pi-durable-disk` of the repository; the root build compiles `dist/`):
226
230
 
package/dist/claim.d.ts CHANGED
@@ -234,6 +234,11 @@ export declare function writtenSince(dir: string, since: number, limit?: number)
234
234
  fresh: string[];
235
235
  unchecked: string | null;
236
236
  }>;
237
+ /**
238
+ * The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
239
+ * to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
240
+ */
241
+ export declare const NO_TOKEN = "none";
237
242
  /**
238
243
  * Take the run's claim: mount `<disk>:/runs/<id>` exclusively at `<mountRoot>/runs/<id>` and verify it. A live mount of
239
244
  * the same run already there is kept (in-place restart); a dead one is cleaned first. With no mount there, a stale archil
@@ -242,11 +247,6 @@ export declare function writtenSince(dir: string, since: number, limit?: number)
242
247
  * verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
243
248
  * empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
244
249
  */
245
- /**
246
- * The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
247
- * to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
248
- */
249
- export declare const NO_TOKEN = "none";
250
250
  export declare function acquire(opts: AcquireOptions): Promise<Claim>;
251
251
  /** One exclusive mount of a path, as `withClaim` holds it: its own mountpoint, never reused. */
252
252
  export interface ExclusiveMount {
package/dist/claim.js CHANGED
@@ -7,7 +7,7 @@ import { lstat, mkdir, open, readdir, readFile, rmdir, stat } from "node:fs/prom
7
7
  import { basename, isAbsolute, join } from "node:path";
8
8
  import { setTimeout as sleep } from "node:timers/promises";
9
9
  import { fileURLToPath } from "node:url";
10
- import { ClaimError, FencedError, HeldError, ownWriteFenced } from "./errors.js";
10
+ import { ClaimError, EXIT_DATAERR, FencedError, HeldError, ownWriteFenced } from "./errors.js";
11
11
  export const DEFAULT_MOUNT_ROOT = "/mnt/archil";
12
12
  /** This package's `bin/archil-scoped`. A deployment installs a root-owned copy and passes its path as `archil`. */
13
13
  export const ARCHIL_SCOPED = fileURLToPath(new URL("../bin/archil-scoped", import.meta.url));
@@ -620,7 +620,7 @@ export async function writtenSince(dir, since, limit = STRAY_CHECK_LIMIT) {
620
620
  return { fresh, unchecked: null };
621
621
  }
622
622
  /** The exit of a start refused for the app's own writes before its claim (EX_DATAERR): a restart would write them again. */
623
- const EXIT_WRITTEN_BEFORE_MOUNT = 65;
623
+ const EXIT_WRITTEN_BEFORE_MOUNT = EXIT_DATAERR;
624
624
  /** How many entries a directory holds; 0 when it cannot be read. */
625
625
  const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
626
626
  /**
@@ -701,6 +701,11 @@ async function verifyTarget(host, path, mountpoint, probe) {
701
701
  throw ownWriteFenced(join(mountpoint, CLAIM_PROBE), err);
702
702
  }
703
703
  }
704
+ /**
705
+ * The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
706
+ * to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
707
+ */
708
+ export const NO_TOKEN = "none";
704
709
  /**
705
710
  * Take the run's claim: mount `<disk>:/runs/<id>` exclusively at `<mountRoot>/runs/<id>` and verify it. A live mount of
706
711
  * the same run already there is kept (in-place restart); a dead one is cleaned first. With no mount there, a stale archil
@@ -709,11 +714,6 @@ async function verifyTarget(host, path, mountpoint, probe) {
709
714
  * verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
710
715
  * empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
711
716
  */
712
- /**
713
- * The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
714
- * to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
715
- */
716
- export const NO_TOKEN = "none";
717
717
  export async function acquire(opts) {
718
718
  const { ref, token } = opts;
719
719
  const path = runPath(ref.id);
package/dist/cli.d.ts CHANGED
@@ -3,7 +3,6 @@ import { type Parking } from "./park.js";
3
3
  import { type RunServer } from "./serve.js";
4
4
  import { type ArchilControl, type DiskClient } from "./supervise.js";
5
5
  import { type DurableRun } from "./run.js";
6
- /** "30s", "500ms", "2m", "1h", or plain milliseconds. */
7
6
  /**
8
7
  * Whether the lease watchdog may kill every other process in the instance's cgroup: only inside its own systemd unit,
9
8
  * whose cgroup holds nothing but the instance and its commands. Elsewhere the cgroup may be shared (a login session, an
@@ -21,7 +20,17 @@ export declare function watchdogOwnsCgroup(holder: Record<string, unknown>, opti
21
20
  owns: boolean;
22
21
  warning?: string;
23
22
  };
24
- export declare function parseDuration(text: string): number;
23
+ /**
24
+ * A duration: a number and its unit, `ms`, `s`, `m` or `h` ("500ms", "30s", "1.5m", "1h"). A bare number is refused with
25
+ * a hint, so "90" is never silently 90 ms. A flag whose name carries its unit (`bareMs`, the `-ms` flags) takes a bare
26
+ * number as milliseconds; a flag whose 0 turns its feature off (`zeroOff`) also takes a bare `0`.
27
+ */
28
+ export declare function parseDuration(text: string, rule?: DurationRule): number;
29
+ /** How a duration flag reads a bare number: as milliseconds (`bareMs`), or a bare `0` as off (`zeroOff`); otherwise never. */
30
+ export type DurationRule = {
31
+ readonly bareMs?: boolean;
32
+ readonly zeroOff?: boolean;
33
+ };
25
34
  /** `--run-as NAME|UID[:GID]`: the user the agent's commands run as, with its group and home from /etc/passwd when listed. */
26
35
  export declare function runUser(spec: string, passwd?: string): {
27
36
  uid: number;
package/dist/cli.js CHANGED
@@ -42,7 +42,7 @@ const USAGE = `usage:
42
42
  [--user U] [--group G] [--app MODULE] [--run-arg ARG ...] [--lease-expiry 90s] [--stonith-timeout 30s]
43
43
  [--start-grace 90s] [--start-backoff-max 10m] [--stop-timeout 30s] [--token-ttl 24h] [--token-prefix P] [--demand] [--create] [--env K=V ...] [--log-dir DIR]
44
44
  [--archil BIN] [--control-timeout 10s] [--sweep-tokens] [--token-grace 15m]
45
- [--park-threshold 60s] [--api-key-env NAME]
45
+ [--park-threshold 60s] [--no-restart] [--api-key-env NAME]
46
46
  with --host docker: --image IMAGE [--app-root DIR] [--fleet F] [--name-prefix P] [--run-as USER]
47
47
  [--docker-arg ARG ...] [--apparmor auto|PROFILE|none] [--docker BIN]
48
48
  pi-durable-disk supervise --check --disk D --region R [--mount-root DIR] [--user U] [--group G] [--check-id-prefix P]
@@ -50,8 +50,9 @@ const USAGE = `usage:
50
50
  pi-durable-disk status --disk D --region R --id ID [--host-name NAME] [--api-key-env NAME] [--docker BIN]
51
51
  pi-durable-disk release --id ID [--mount-root DIR]
52
52
  pi-durable-disk fork --disk D --region R --id ID --new-id NEW [--mount-root DIR] [--archil BIN] [--token-prefix P]
53
- [--api-key-env NAME]`;
54
- /** "30s", "500ms", "2m", "1h", or plain milliseconds. */
53
+ [--api-key-env NAME]
54
+ durations take a unit (500ms, 30s, 1.5m, 1h); flags named -ms take bare milliseconds; a bare 0 turns off
55
+ --park-threshold, --start-grace and --start-backoff-max`;
55
56
  /**
56
57
  * Whether the lease watchdog may kill every other process in the instance's cgroup: only inside its own systemd unit,
57
58
  * whose cgroup holds nothing but the instance and its commands. Elsewhere the cgroup may be shared (a login session, an
@@ -90,19 +91,63 @@ export function watchdogOwnsCgroup(holder, options = {}) {
90
91
  return refuse(`the parent shares cgroup ${own}, so this process only inherited the handle`);
91
92
  return { owns: true };
92
93
  }
93
- export function parseDuration(text) {
94
- const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(text.trim());
94
+ /**
95
+ * A duration: a number and its unit, `ms`, `s`, `m` or `h` ("500ms", "30s", "1.5m", "1h"). A bare number is refused with
96
+ * a hint, so "90" is never silently 90 ms. A flag whose name carries its unit (`bareMs`, the `-ms` flags) takes a bare
97
+ * number as milliseconds; a flag whose 0 turns its feature off (`zeroOff`) also takes a bare `0`.
98
+ */
99
+ export function parseDuration(text, rule = {}) {
100
+ const t = text.trim();
101
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(t);
95
102
  if (!m)
96
- throw new UsageError(`not a duration: ${text}`);
97
- return Math.round(Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[(m[2] ?? "ms")]);
103
+ throw new UsageError(`not a duration: "${text}": a number and its unit (ms, s, m or h), e.g. 90s`);
104
+ if (m[2] === undefined) {
105
+ if (rule.bareMs)
106
+ return Math.round(Number(m[1]));
107
+ if (rule.zeroOff && Number(m[1]) === 0)
108
+ return 0;
109
+ throw new UsageError(`not a duration: "${text}": give a unit, e.g. ${t}s or ${t}ms`);
110
+ }
111
+ return Math.round(Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[m[2]]);
112
+ }
113
+ /**
114
+ * Every duration flag of every command, with its rule. The `-ms` flags carry their unit in their name. A bare `0` turns
115
+ * off parking (`--park-threshold`), the start grace and its backoff (`--start-grace`), and the backoff alone
116
+ * (`--start-backoff-max`, the grace then stays at `--start-grace`).
117
+ */
118
+ const DURATION_FLAGS = {
119
+ "heartbeat-ms": { bareMs: true },
120
+ "lease-expiry-ms": { bareMs: true },
121
+ "lease-margin-ms": { bareMs: true },
122
+ "park-threshold": { zeroOff: true },
123
+ "park-idle": {},
124
+ "drain-timeout": {},
125
+ "stop-timeout": {},
126
+ "control-timeout": {},
127
+ "lease-expiry": {},
128
+ "stonith-timeout": {},
129
+ "start-grace": { zeroOff: true },
130
+ "start-backoff-max": { zeroOff: true },
131
+ "token-grace": {},
132
+ every: {},
133
+ };
134
+ /** A duration flag's value in milliseconds, by the flag's rule; undefined when the flag is absent. */
135
+ function durationFlag(values, name) {
136
+ const value = values[name];
137
+ return typeof value === "string" ? parseDuration(value, DURATION_FLAGS[name]) : undefined;
98
138
  }
99
139
  function parse(argv, options) {
140
+ let values;
100
141
  try {
101
- return parseArgs({ args: argv, options, allowPositionals: false, strict: true }).values;
142
+ values = parseArgs({ args: argv, options, allowPositionals: false, strict: true }).values;
102
143
  }
103
144
  catch (err) {
104
145
  throw new UsageError(err.message);
105
146
  }
147
+ // Every duration flag given is checked here, before the command does anything with any of them.
148
+ for (const name of Object.keys(DURATION_FLAGS))
149
+ durationFlag(values, name);
150
+ return values;
106
151
  }
107
152
  /** A user or group name or number, as a number (from /etc/passwd or /etc/group). */
108
153
  function idOf(value, file, fallback) {
@@ -206,7 +251,7 @@ async function runInstance(values) {
206
251
  const onSigterm = str(values["on-sigterm"], "resume");
207
252
  if (onSigterm !== "resume" && onSigterm !== "pause")
208
253
  throw new UsageError("--on-sigterm is resume or pause");
209
- const ms = (name) => (typeof values[name] === "string" ? parseDuration(values[name]) : undefined);
254
+ const ms = (name) => durationFlag(values, name);
210
255
  const lease = { heartbeatMs: ms("heartbeat-ms"), expiryMs: ms("lease-expiry-ms"), marginMs: ms("lease-margin-ms") };
211
256
  const parkMs = ms("park-threshold") || undefined;
212
257
  const drainMs = ms("drain-timeout") ?? 0;
@@ -219,7 +264,9 @@ async function runInstance(values) {
219
264
  throw new UsageError(`--serve-host ${serveHost} is not loopback: name a --serve-token-file`);
220
265
  const serveToken = tokenFile === undefined ? undefined : readServeToken(tokenFile);
221
266
  const mountRoot = str(values["mount-root"], DEFAULT_MOUNT_ROOT);
222
- const root = dir ?? join(mountRoot, runPath(ref.id));
267
+ // On a disk the id names the run's directory: an unsafe one stops the instance here (INVALID_ARGUMENT), before it serves.
268
+ if (dir === undefined)
269
+ runPath(ref.id);
223
270
  const log = (event, extra = {}) => process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event, run: ref.id, ...extra })}\n`);
224
271
  // The app's factory runs once the claim is held (`afterClaim`), so `ctx.root` is the live directory whenever app code
225
272
  // runs; what it returns opens the Harness. Serve starts before the open, with the root options the factory fills in.
@@ -398,7 +445,7 @@ export function instanceRunArgs(values) {
398
445
  return [...(typeof values.app === "string" ? ["--app", resolve(values.app)] : []), ...(values["run-arg"] ?? [])];
399
446
  }
400
447
  /** `--park-threshold` for either driver: absent keeps the driver's default, 0 turns parking off. */
401
- const parkThreshold = (values) => values["park-threshold"] === undefined ? {} : { parkThresholdMs: parseDuration(str(values["park-threshold"])) || null };
448
+ const parkThreshold = (values) => values["park-threshold"] === undefined ? {} : { parkThresholdMs: durationFlag(values, "park-threshold") || null };
402
449
  function driverFrom(values) {
403
450
  const env = Object.fromEntries((values.env ?? []).map((kv) => {
404
451
  const i = kv.indexOf("=");
@@ -425,7 +472,7 @@ function driverFrom(values) {
425
472
  dockerArgs: values["docker-arg"] ?? [],
426
473
  apparmor: apparmor === "none" ? false : apparmor,
427
474
  note: (line) => emit({ event: "docker", note: line }),
428
- stopTimeoutMs: values["stop-timeout"] ? parseDuration(str(values["stop-timeout"])) : undefined,
475
+ stopTimeoutMs: values["stop-timeout"] ? durationFlag(values, "stop-timeout") : undefined,
429
476
  ...parkThreshold(values),
430
477
  });
431
478
  }
@@ -441,7 +488,7 @@ function driverFrom(values) {
441
488
  user: str(values.user),
442
489
  group: str(values.group),
443
490
  runArgs,
444
- stopTimeoutMs: values["stop-timeout"] ? parseDuration(str(values["stop-timeout"])) : undefined,
491
+ stopTimeoutMs: values["stop-timeout"] ? durationFlag(values, "stop-timeout") : undefined,
445
492
  restart: values["no-restart"] ? false : undefined,
446
493
  logDir: str(values["log-dir"]),
447
494
  archil: str(values.archil),
@@ -474,7 +521,7 @@ async function supervise(values) {
474
521
  if (docker && (typeof values.image !== "string" || !values.image))
475
522
  throw new UsageError("--host docker needs --image");
476
523
  const disk = await cliControl(values);
477
- const controlTimeoutMs = values["control-timeout"] ? parseDuration(str(values["control-timeout"])) : CONTROL_TIMEOUT_MS;
524
+ const controlTimeoutMs = values["control-timeout"] ? durationFlag(values, "control-timeout") : CONTROL_TIMEOUT_MS;
478
525
  if (values.check) {
479
526
  const report = await checkHost({
480
527
  control: disk,
@@ -504,10 +551,10 @@ async function supervise(values) {
504
551
  const host = driverFrom(values);
505
552
  const opts = {
506
553
  control: disk,
507
- leaseExpiryMs: values["lease-expiry"] ? parseDuration(str(values["lease-expiry"])) : undefined,
508
- stonithTimeoutMs: values["stonith-timeout"] ? parseDuration(str(values["stonith-timeout"])) : undefined,
509
- startGraceMs: values["start-grace"] !== undefined ? parseDuration(str(values["start-grace"])) : undefined,
510
- startBackoffMaxMs: values["start-backoff-max"] !== undefined ? parseDuration(str(values["start-backoff-max"])) : undefined,
554
+ leaseExpiryMs: values["lease-expiry"] ? durationFlag(values, "lease-expiry") : undefined,
555
+ stonithTimeoutMs: values["stonith-timeout"] ? durationFlag(values, "stonith-timeout") : undefined,
556
+ startGraceMs: durationFlag(values, "start-grace"),
557
+ startBackoffMaxMs: durationFlag(values, "start-backoff-max"),
511
558
  tokenTtl: str(values["token-ttl"]),
512
559
  tokenPrefix: str(values["token-prefix"]),
513
560
  demand: Boolean(values.demand),
@@ -536,7 +583,7 @@ async function supervise(values) {
536
583
  prefix: str(values["token-prefix"], TOKEN_PREFIX),
537
584
  runs: values.id ? refs.map((x) => x.id) : undefined,
538
585
  expired: Boolean(values["sweep-tokens"]),
539
- graceMs: values["token-grace"] ? parseDuration(str(values["token-grace"])) : undefined,
586
+ graceMs: values["token-grace"] ? durationFlag(values, "token-grace") : undefined,
540
587
  }).catch((e) => ({ removed: [], failed: [{ identifier: "*", error: e.message }] }));
541
588
  if (r.removed.length || r.failed.length)
542
589
  emit({ event: "token-sweep", ...r });
@@ -545,7 +592,7 @@ async function supervise(values) {
545
592
  await tick();
546
593
  return failures ? 1 : 0;
547
594
  }
548
- const every = parseDuration(str(values.every));
595
+ const every = durationFlag(values, "every");
549
596
  let stopped = false;
550
597
  process.once("SIGTERM", () => (stopped = true));
551
598
  process.once("SIGINT", () => (stopped = true));
package/dist/env.js CHANGED
@@ -510,6 +510,9 @@ export function archilEnv(claim, options = {}) {
510
510
  if (runAs && ![runAs.uid, runAs.gid].every((n) => Number.isSafeInteger(n) && n >= 0)) {
511
511
  throw new EnvError("INVALID_RUN_AS", `runAs needs a numeric uid and gid, got ${JSON.stringify(runAs)}`);
512
512
  }
513
+ if (runAs?.uid === 0) {
514
+ throw new EnvError("INVALID_RUN_AS", "runAs names root (uid 0): the agent's commands would run as the owner of run.json, the store and the archil daemon");
515
+ }
513
516
  const darwin = process.platform === "darwin";
514
517
  const noNewPrivs = options.noNewPrivs ?? !darwin;
515
518
  const setpriv = noNewPrivs || runAs ? requireSetpriv(options.setprivPath ?? SETPRIV) : undefined;
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ /** EX_DATAERR: the run's data refuses to open (a store behind its seal, an unreadable `run.json`); a restart cannot help. */
2
+ export declare const EXIT_DATAERR = 65;
3
+ /** EX_SOFTWARE: this package cannot read the head of pi's store (pi's schema moved); a restart cannot help. */
4
+ export declare const EXIT_SOFTWARE = 70;
1
5
  /** EX_TEMPFAIL: the claim was revoked or its mount failed; nothing this process writes can become durable. */
2
6
  export declare const EXIT_FENCED = 75;
3
7
  /**
package/dist/errors.js CHANGED
@@ -2,6 +2,10 @@
2
2
  // another); both are terminal for the host's process supervisor, which must not restart into a run it lost.
3
3
  // A module's own errors extend FencedError or HeldError (overriding `code`) so one `instanceof` or `exitCodeFor` covers
4
4
  // them all, e.g. `class StoreFencedError extends FencedError` with `{ code: "STORE_FENCED" }`.
5
+ /** EX_DATAERR: the run's data refuses to open (a store behind its seal, an unreadable `run.json`); a restart cannot help. */
6
+ export const EXIT_DATAERR = 65;
7
+ /** EX_SOFTWARE: this package cannot read the head of pi's store (pi's schema moved); a restart cannot help. */
8
+ export const EXIT_SOFTWARE = 70;
5
9
  /** EX_TEMPFAIL: the claim was revoked or its mount failed; nothing this process writes can become durable. */
6
10
  export const EXIT_FENCED = 75;
7
11
  /**
@@ -182,7 +182,6 @@ export function sandboxName(prefix, runId, now) {
182
182
  const stamp = `${now.toString(36)}${Math.floor(Math.random() * 1296).toString(36).padStart(2, "0")}`;
183
183
  return `${prefix}${slug}-${stamp}`.slice(0, 63);
184
184
  }
185
- /** The last line of a command's output that parses as JSON, or null. */
186
185
  /**
187
186
  * What the launcher printed, for a start it gave no reason for: the last 4 KB, or that it printed nothing. It prints only
188
187
  * its state; the mount token is in a root-only file it reads, never in its output.
@@ -191,6 +190,7 @@ function launcherOutput(text) {
191
190
  const out = text.trim();
192
191
  return out ? `the launcher printed: ${out.slice(-4096)}` : "the launcher printed nothing and exited 0";
193
192
  }
193
+ /** The last line of a command's output that parses as JSON, or null. */
194
194
  function lastJson(text) {
195
195
  for (const line of text.trim().split("\n").reverse()) {
196
196
  try {
@@ -14,10 +14,10 @@
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
- import { EXIT_FENCED, EXIT_HELD, PdaError } from "../errors.js";
20
+ import { EXIT_DATAERR, EXIT_FENCED, EXIT_HELD, EXIT_SOFTWARE, PdaError } from "../errors.js";
21
21
  import { LOCAL_PARK_THRESHOLD_MS } from "../park.js";
22
22
  import { processTable, thisBoot } from "../process-table.js";
23
23
  import { attemptEnv } from "./attempt-env.js";
@@ -44,10 +44,6 @@ const FUSERMOUNT = "/usr/bin/fusermount";
44
44
  const UMOUNT = "/usr/bin/umount";
45
45
  /** How long a stat of the mountpoint may take before its daemon counts as alive but stuck (never removed). */
46
46
  const STAT_TIMEOUT_MS = 3_000;
47
- /** EX_DATAERR: a store behind its seal or a bad run.json (run.ts names it EXIT_DATAERR). */
48
- const EXIT_DATAERR = 65;
49
- /** EX_SOFTWARE: the store's head is unreadable (pi's schema moved); run.json says failed, STORE_HEAD_UNREADABLE. */
50
- const EXIT_SOFTWARE = 70;
51
47
  /** Exits a restart cannot fix: 65 data error, 70 store head unreadable, 75 fenced, 76 held. */
52
48
  export const TERMINAL_EXITS = [EXIT_DATAERR, EXIT_SOFTWARE, EXIT_FENCED, EXIT_HELD];
53
49
  // The package's own CLI, with the extension this module has: `.ts` from a checkout, `.js` once built.
@@ -77,6 +73,14 @@ export function currentBootId() {
77
73
  throw new Error("this machine's boot id cannot be read");
78
74
  return boot;
79
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
+ }
80
84
  const firstLine = (r) => (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300);
81
85
  const okRan = (r) => r.code === 0 && !r.timedOut;
82
86
  /** `systemctl show` output as a map. */
@@ -108,6 +112,35 @@ export function unitStatus(show) {
108
112
  return "unknown";
109
113
  }
110
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
+ }
111
144
  export function localHost(opts = {}) {
112
145
  const mode = opts.mode ?? "systemd";
113
146
  const mountRoot = opts.mountRoot ?? DEFAULT_MOUNT_ROOT;
@@ -130,6 +163,28 @@ export function localHost(opts = {}) {
130
163
  const procMounts = opts.procMounts ?? "/proc/self/mounts";
131
164
  const mine = (h) => h.driver === "local" && h.host === hostName;
132
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}`);
133
188
  const mounted = async (mp) => (await readFile(procMounts, "utf8")).split("\n").some((l) => l.split(" ")[1] === mp);
134
189
  const parkMs = opts.parkThresholdMs === undefined ? LOCAL_PARK_THRESHOLD_MS : opts.parkThresholdMs;
135
190
  const drainMs = Math.max(0, stopTimeoutMs - Math.min(5_000, stopTimeoutMs / 2));
@@ -141,15 +196,18 @@ export function localHost(opts = {}) {
141
196
  ];
142
197
  const holderEnv = (h) => JSON.stringify(h);
143
198
  const tokenFile = (unit) => join(TOKEN_DIR, `${unit}.mount-token`);
144
- 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) {
145
203
  if (!sudo) {
146
204
  await mkdir(TOKEN_DIR, { recursive: true, mode: 0o700 });
147
- await writeFile(path, token, { mode: 0o600 });
205
+ await writeFile(path, content, { mode: 0o600 });
148
206
  return;
149
207
  }
150
- 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 });
151
209
  if (!okRan(r))
152
- 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)}`);
153
211
  }
154
212
  /** Replace the token file by an empty one: the started instance's open fd keeps the token, the path no longer has it. */
155
213
  async function emptyTokenFile(path) {
@@ -169,10 +227,13 @@ export function localHost(opts = {}) {
169
227
  const mountpoint = mountpointOf(ref);
170
228
  const handle = { driver: "local", mode: "systemd", host: hostName, bootId, unit, mountpoint };
171
229
  const env = { ...opts.env, ...extra, PATH: SAFE_PATH, PDA_HOLDER: holderEnv(handle) };
230
+ const environment = environmentFile(env);
172
231
  const path = tokenFile(unit);
173
- await writeTokenFile(path, token);
232
+ const envPath = envFile(unit);
233
+ await writeSecretFile(path, token, "mount token file");
174
234
  let started = false;
175
235
  try {
236
+ await writeSecretFile(envPath, environment, "environment file");
176
237
  const argv = [
177
238
  SYSTEMD_RUN,
178
239
  `--unit=${unit}`,
@@ -185,7 +246,8 @@ export function localHost(opts = {}) {
185
246
  "-p", `User=${user}`,
186
247
  "-p", `Group=${group}`,
187
248
  "-p", `StandardInput=file:${path}`,
188
- ...Object.entries(env).map(([k, v]) => `--setenv=${k}=${v}`),
249
+ "-p", `EnvironmentFile=${envPath}`,
250
+ "-p", `ExecStopPost=${environmentCleanup(envPath, opts.restart !== false)}`,
189
251
  "--",
190
252
  ...command,
191
253
  ...runFlags(ref),
@@ -199,8 +261,13 @@ export function localHost(opts = {}) {
199
261
  }
200
262
  finally {
201
263
  // Type=exec: systemd-run returns after the exec, so the instance's stdin is already open on the token. The path
202
- // stays (empty) because a restart in place opens it again; stop removes it.
203
- 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(() => { });
204
271
  }
205
272
  }
206
273
  async function startChild(ref, token, extra) {
@@ -292,6 +359,17 @@ export function localHost(opts = {}) {
292
359
  kills: "process",
293
360
  async start(ref, token, attempt) {
294
361
  runPath(ref.id);
362
+ // Never as root: the instance and the agent's tools would share a uid with the archil daemon that holds the mount
363
+ // token, and own run.json and the store outright.
364
+ if (mode === "systemd" && user.trim() === "") {
365
+ 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)");
366
+ }
367
+ if (mode === "systemd" && (await uidOf(user)) === 0) {
368
+ throw new LocalHostError("INVALID_ARGUMENT", `user ${user} is root: name the unprivileged user the instance runs as (user)`);
369
+ }
370
+ if (mode === "child" && process.getuid?.() === 0) {
371
+ throw new LocalHostError("INVALID_ARGUMENT", "child mode runs the instance as this process's user, which is root: use systemd mode with a user");
372
+ }
295
373
  const extra = attemptEnv(attempt, (m) => new LocalHostError("INVALID_ARGUMENT", m));
296
374
  return mode === "systemd" ? startUnit(ref, token, extra) : startChild(ref, token, extra);
297
375
  },
@@ -317,7 +395,7 @@ export function localHost(opts = {}) {
317
395
  // real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
318
396
  // process it may signal), and without start ticks nothing tells a reused pid from the instance.
319
397
  if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
320
- return;
398
+ throw notOwnPid(h, "stopped");
321
399
  const alive = () => procStartTicks(pid) === h.startTicks;
322
400
  if (!alive())
323
401
  return;
@@ -333,22 +411,24 @@ export function localHost(opts = {}) {
333
411
  process.kill(-pid, "SIGKILL");
334
412
  return;
335
413
  }
336
- if (typeof h.unit !== "string" || !UNIT_SAFE.test(h.unit))
337
- return;
338
- const stopUnit = () => root([SYSTEMCTL, "stop", `${h.unit}.service`], { timeoutMs: stopTimeoutMs + 10_000 });
414
+ const unit = ownUnit(h);
415
+ if (unit === undefined)
416
+ throw notOwnUnit(h, "stopped");
417
+ const stopUnit = () => root([SYSTEMCTL, "stop", `${unit}.service`], { timeoutMs: stopTimeoutMs + 10_000 });
339
418
  let r = await stopUnit();
340
- if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
419
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
341
420
  // A unit that outlives SIGKILL is wedged in its mount (a FUSE request its daemon never answers): kill the
342
421
  // daemon so those requests fail and the processes can die, then stop once more.
343
- await killDaemons(h.unit);
422
+ await killDaemons(unit);
344
423
  r = await stopUnit();
345
- if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
346
- throw new LocalHostError("STOP_FAILED", `systemctl stop ${h.unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
424
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
425
+ throw new LocalHostError("STOP_FAILED", `systemctl stop ${unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
347
426
  }
348
427
  }
349
- await cleanMount(h.unit, String(h.mountpoint));
350
- await root([SYSTEMCTL, "reset-failed", `${h.unit}.service`]);
351
- await removeFile(tokenFile(h.unit));
428
+ await cleanMount(unit, String(h.mountpoint));
429
+ await root([SYSTEMCTL, "reset-failed", `${unit}.service`]);
430
+ await removeFile(tokenFile(unit));
431
+ await removeFile(envFile(unit));
352
432
  },
353
433
  async kill(h) {
354
434
  if (!mine(h) || h.bootId !== bootId)
@@ -356,7 +436,9 @@ export function localHost(opts = {}) {
356
436
  if (h.mode === "child") {
357
437
  const pid = Number(h.pid);
358
438
  // As in stop: only a real process, and only the one the handle names (its start ticks).
359
- if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number" || procStartTicks(pid) !== h.startTicks)
439
+ if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
440
+ throw notOwnPid(h, "killed");
441
+ if (procStartTicks(pid) !== h.startTicks)
360
442
  return;
361
443
  try {
362
444
  process.kill(-pid, "SIGKILL");
@@ -366,13 +448,14 @@ export function localHost(opts = {}) {
366
448
  }
367
449
  return;
368
450
  }
369
- if (typeof h.unit !== "string" || !UNIT_SAFE.test(h.unit))
370
- return;
451
+ const unit = ownUnit(h);
452
+ if (unit === undefined)
453
+ throw notOwnUnit(h, "killed");
371
454
  // Every process of the unit's control group at once, the instance's commands included. Its FUSE daemon runs in a
372
455
  // scope of its own and keeps the mount, as after an OOM kill; the unit restarts in place unless restart is off.
373
- const r = await root([SYSTEMCTL, "kill", "--signal=SIGKILL", "--kill-whom=all", `${h.unit}.service`]);
374
- if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
375
- throw new LocalHostError("KILL_FAILED", `systemctl kill ${h.unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
456
+ const r = await root([SYSTEMCTL, "kill", "--signal=SIGKILL", "--kill-whom=all", `${unit}.service`]);
457
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
458
+ throw new LocalHostError("KILL_FAILED", `systemctl kill ${unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
376
459
  }
377
460
  },
378
461
  };
package/dist/place.d.ts CHANGED
@@ -43,7 +43,6 @@ export type RunnerOrder = {
43
43
  env: Record<string, string>;
44
44
  stdin?: string;
45
45
  };
46
- /** What the place reads of the run, the way it reads it: `run.json`, and on a disk the run's delegations. */
47
46
  /**
48
47
  * What the place reads of the run, the way it reads it: `run.json`; on a disk, the run's delegations; and the boxes the
49
48
  * place made for its runners, each as a receipt (`HostBox`: provider, kind, from, to, list price per second and where
package/dist/run.d.ts CHANGED
@@ -3,17 +3,14 @@ import { Harness } from "@earendil-works/pi-durable";
3
3
  import type { HarnessOptions, Seq, Storage, ToolRegistration } from "@earendil-works/pi-durable";
4
4
  import type { SqliteDatabase, SqliteExecutor } from "@earendil-works/pi-durable/storage/sqlite";
5
5
  import { type AcquireOptions, type ArchilHost, type Claim, type RunRef } from "./claim.js";
6
- import { FencedError, PdaError } from "./errors.js";
6
+ import { EXIT_DATAERR, EXIT_SOFTWARE, FencedError, PdaError } from "./errors.js";
7
7
  import { type ArchilStore, type Profile } from "./store.js";
8
8
  import { type ClaimDir, type PersistRecord, type RunRecord, type RunStatus } from "./status.js";
9
9
  /** The same-host owner lock under the run's root. */
10
10
  export declare const OWNER_LOCK = "owner.lock";
11
11
  /** pi's store under the claim's `store/` directory. */
12
12
  export declare const STORE_FILE = "run.sqlite";
13
- /** EX_DATAERR: the run's data refuses to open (a store behind its seal, an unreadable `run.json`); a restart cannot help. */
14
- export declare const EXIT_DATAERR = 65;
15
- /** EX_SOFTWARE: this package cannot read the head of pi's store (pi's schema moved); a restart cannot help. */
16
- export declare const EXIT_SOFTWARE = 70;
13
+ export { EXIT_DATAERR, EXIT_SOFTWARE };
17
14
  export type RunErrorCode = "INVALID_ARGUMENT" | "STORE_HEAD_UNREADABLE" | "RUN_RELEASED";
18
15
  /**
19
16
  * Bad options, a call on a released run, or a store whose head cannot be read (STORE_HEAD_UNREADABLE: the run is marked
package/dist/run.js CHANGED
@@ -12,7 +12,7 @@ import { Harness } from "@earendil-works/pi-durable";
12
12
  import { acquire } from "./claim.js";
13
13
  import { reapOrRefuse } from "./commands.js";
14
14
  import { archilEnv } from "./env.js";
15
- import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
15
+ import { EXIT_DATAERR, EXIT_FENCED, EXIT_SOFTWARE, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
16
16
  import { openArchilStore } from "./store.js";
17
17
  import { LeaseWatchdog } from "./watchdog.js";
18
18
  import { currentHolder, DEFAULT_HEARTBEAT_MS, DEFAULT_LEASE_EXPIRY_MS, DEFAULT_LEASE_MARGIN_MS, keepStartMark, openClaimDir, persistRecord, readRunRecord, RUN_JSON, RunRecordError, writeRunRecord, } from "./status.js";
@@ -20,10 +20,7 @@ import { currentHolder, DEFAULT_HEARTBEAT_MS, DEFAULT_LEASE_EXPIRY_MS, DEFAULT_L
20
20
  export const OWNER_LOCK = "owner.lock";
21
21
  /** pi's store under the claim's `store/` directory. */
22
22
  export const STORE_FILE = "run.sqlite";
23
- /** EX_DATAERR: the run's data refuses to open (a store behind its seal, an unreadable `run.json`); a restart cannot help. */
24
- export const EXIT_DATAERR = 65;
25
- /** EX_SOFTWARE: this package cannot read the head of pi's store (pi's schema moved); a restart cannot help. */
26
- export const EXIT_SOFTWARE = 70;
23
+ export { EXIT_DATAERR, EXIT_SOFTWARE };
27
24
  /**
28
25
  * Bad options, a call on a released run, or a store whose head cannot be read (STORE_HEAD_UNREADABLE: the run is marked
29
26
  * `failed` and released, exit 70).
package/dist/status.js CHANGED
@@ -7,7 +7,7 @@ import { open, readFile, rename, rm, stat } from "node:fs/promises";
7
7
  import { hostname } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { Worker } from "node:worker_threads";
10
- import { FencedError, ownWriteFenced, PdaError } from "./errors.js";
10
+ import { EXIT_DATAERR, FencedError, ownWriteFenced, PdaError } from "./errors.js";
11
11
  import { fdPath, hold } from "./held-fd.js";
12
12
  import { thisBoot } from "./process-table.js";
13
13
  export const RUN_JSON = "run.json";
@@ -23,7 +23,7 @@ export const RUN_STATUSES = ["running", "sleeping", "paused", "done", "failed"];
23
23
  /** `run.json` is missing a field, has one of the wrong type, or is not JSON. Exit 65 (EX_DATAERR): a restart cannot fix it. */
24
24
  export class RunRecordError extends PdaError {
25
25
  constructor(message, options = {}) {
26
- super("RUN_JSON_INVALID", message, { cause: options.cause, exitCode: 65 });
26
+ super("RUN_JSON_INVALID", message, { cause: options.cause, exitCode: EXIT_DATAERR });
27
27
  }
28
28
  }
29
29
  const isInstant = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
@@ -18,8 +18,7 @@ export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
18
18
  * SIGKILL the instance's commands: the process group of each direct child (pi starts each command as its own group's
19
19
  * leader), or the child alone when it shares the instance's group; with `ownCgroup`, every other process of the cgroup.
20
20
  * Never the instance or its group. A process this user may not signal (sudo's archil helpers) is skipped. Returns the
21
- * number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
22
- * spaces and parentheses.
21
+ * number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup.
23
22
  */
24
23
  export declare const sweep: (instance: number, options?: SweepOptions) => number;
25
24
  /** The main thread's handle on the watchdog thread. */
package/dist/watchdog.js CHANGED
@@ -30,6 +30,8 @@ function lapsed(shared, now) {
30
30
  return true;
31
31
  }
32
32
 
33
+ // A stat line is "pid (comm) state ppid pgrp ...": its fields are read after the last ")", since comm may hold spaces and
34
+ // parentheses.
33
35
  function stat(proc, pid) {
34
36
  try {
35
37
  const text = fs.readFileSync(path.join(proc, String(pid), "stat"), "utf8");
@@ -126,8 +128,7 @@ export const lapsed = compiled.lapsed;
126
128
  * SIGKILL the instance's commands: the process group of each direct child (pi starts each command as its own group's
127
129
  * leader), or the child alone when it shares the instance's group; with `ownCgroup`, every other process of the cgroup.
128
130
  * Never the instance or its group. A process this user may not signal (sudo's archil helpers) is skipped. Returns the
129
- * number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
130
- * spaces and parentheses.
131
+ * number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup.
131
132
  */
132
133
  export const sweep = compiled.sweep;
133
134
  /** The main thread's handle on the watchdog thread. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@parcha/pi-durable-disk",
3
- "version": "0.1.0-beta.20",
4
- "description": "A durable pi host on a network disk (Archil): one owner per run, a fence, durable commits, and a supervisor.",
3
+ "version": "0.1.0-beta.23",
4
+ "description": "The agent is the workspace: its conversation and its files live together in one directory on a disk. Mount it on any Linux host or container, release it, and mount it elsewhere: the agent carries on.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-durable",