@parcha/pi-durable-disk 0.1.0-beta.21 → 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,25 @@
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
+
5
24
  ## 0.1.0-beta.21 (2026-10-21)
6
25
 
7
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.
@@ -513,7 +532,6 @@ published.
513
532
  - Unchanged, because they name the Archil disk: `bin/archil-scoped` (and the `./bin/archil-scoped` export), `ARCHIL_API_KEY`, `archilEnv`,
514
533
  `ArchilCodingTools`, the claim's internals and the `archil` client it runs.
515
534
 
516
-
517
535
  **A holder the control API lists without a path**
518
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
519
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/cli.d.ts CHANGED
@@ -20,8 +20,17 @@ export declare function watchdogOwnsCgroup(holder: Record<string, unknown>, opti
20
20
  owns: boolean;
21
21
  warning?: string;
22
22
  };
23
- /** "30s", "500ms", "2m", "1h", or plain milliseconds. */
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
@@ -50,7 +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]`;
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`;
54
56
  /**
55
57
  * Whether the lease watchdog may kill every other process in the instance's cgroup: only inside its own systemd unit,
56
58
  * whose cgroup holds nothing but the instance and its commands. Elsewhere the cgroup may be shared (a login session, an
@@ -89,20 +91,63 @@ export function watchdogOwnsCgroup(holder, options = {}) {
89
91
  return refuse(`the parent shares cgroup ${own}, so this process only inherited the handle`);
90
92
  return { owns: true };
91
93
  }
92
- /** "30s", "500ms", "2m", "1h", or plain milliseconds. */
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;
@@ -400,7 +445,7 @@ export function instanceRunArgs(values) {
400
445
  return [...(typeof values.app === "string" ? ["--app", resolve(values.app)] : []), ...(values["run-arg"] ?? [])];
401
446
  }
402
447
  /** `--park-threshold` for either driver: absent keeps the driver's default, 0 turns parking off. */
403
- 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 };
404
449
  function driverFrom(values) {
405
450
  const env = Object.fromEntries((values.env ?? []).map((kv) => {
406
451
  const i = kv.indexOf("=");
@@ -427,7 +472,7 @@ function driverFrom(values) {
427
472
  dockerArgs: values["docker-arg"] ?? [],
428
473
  apparmor: apparmor === "none" ? false : apparmor,
429
474
  note: (line) => emit({ event: "docker", note: line }),
430
- stopTimeoutMs: values["stop-timeout"] ? parseDuration(str(values["stop-timeout"])) : undefined,
475
+ stopTimeoutMs: values["stop-timeout"] ? durationFlag(values, "stop-timeout") : undefined,
431
476
  ...parkThreshold(values),
432
477
  });
433
478
  }
@@ -443,7 +488,7 @@ function driverFrom(values) {
443
488
  user: str(values.user),
444
489
  group: str(values.group),
445
490
  runArgs,
446
- stopTimeoutMs: values["stop-timeout"] ? parseDuration(str(values["stop-timeout"])) : undefined,
491
+ stopTimeoutMs: values["stop-timeout"] ? durationFlag(values, "stop-timeout") : undefined,
447
492
  restart: values["no-restart"] ? false : undefined,
448
493
  logDir: str(values["log-dir"]),
449
494
  archil: str(values.archil),
@@ -476,7 +521,7 @@ async function supervise(values) {
476
521
  if (docker && (typeof values.image !== "string" || !values.image))
477
522
  throw new UsageError("--host docker needs --image");
478
523
  const disk = await cliControl(values);
479
- 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;
480
525
  if (values.check) {
481
526
  const report = await checkHost({
482
527
  control: disk,
@@ -506,10 +551,10 @@ async function supervise(values) {
506
551
  const host = driverFrom(values);
507
552
  const opts = {
508
553
  control: disk,
509
- leaseExpiryMs: values["lease-expiry"] ? parseDuration(str(values["lease-expiry"])) : undefined,
510
- stonithTimeoutMs: values["stonith-timeout"] ? parseDuration(str(values["stonith-timeout"])) : undefined,
511
- startGraceMs: values["start-grace"] !== undefined ? parseDuration(str(values["start-grace"])) : undefined,
512
- 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"),
513
558
  tokenTtl: str(values["token-ttl"]),
514
559
  tokenPrefix: str(values["token-prefix"]),
515
560
  demand: Boolean(values.demand),
@@ -538,7 +583,7 @@ async function supervise(values) {
538
583
  prefix: str(values["token-prefix"], TOKEN_PREFIX),
539
584
  runs: values.id ? refs.map((x) => x.id) : undefined,
540
585
  expired: Boolean(values["sweep-tokens"]),
541
- graceMs: values["token-grace"] ? parseDuration(str(values["token-grace"])) : undefined,
586
+ graceMs: values["token-grace"] ? durationFlag(values, "token-grace") : undefined,
542
587
  }).catch((e) => ({ removed: [], failed: [{ identifier: "*", error: e.message }] }));
543
588
  if (r.removed.length || r.failed.length)
544
589
  emit({ event: "token-sweep", ...r });
@@ -547,7 +592,7 @@ async function supervise(values) {
547
592
  await tick();
548
593
  return failures ? 1 : 0;
549
594
  }
550
- const every = parseDuration(str(values.every));
595
+ const every = durationFlag(values, "every");
551
596
  let stopped = false;
552
597
  process.once("SIGTERM", () => (stopped = true));
553
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;
@@ -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) {
@@ -288,6 +359,17 @@ export function localHost(opts = {}) {
288
359
  kills: "process",
289
360
  async start(ref, token, attempt) {
290
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
+ }
291
373
  const extra = attemptEnv(attempt, (m) => new LocalHostError("INVALID_ARGUMENT", m));
292
374
  return mode === "systemd" ? startUnit(ref, token, extra) : startChild(ref, token, extra);
293
375
  },
@@ -313,7 +395,7 @@ export function localHost(opts = {}) {
313
395
  // real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
314
396
  // process it may signal), and without start ticks nothing tells a reused pid from the instance.
315
397
  if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
316
- return;
398
+ throw notOwnPid(h, "stopped");
317
399
  const alive = () => procStartTicks(pid) === h.startTicks;
318
400
  if (!alive())
319
401
  return;
@@ -329,22 +411,24 @@ export function localHost(opts = {}) {
329
411
  process.kill(-pid, "SIGKILL");
330
412
  return;
331
413
  }
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 });
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 });
335
418
  let r = await stopUnit();
336
- if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
419
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
337
420
  // A unit that outlives SIGKILL is wedged in its mount (a FUSE request its daemon never answers): kill the
338
421
  // daemon so those requests fail and the processes can die, then stop once more.
339
- await killDaemons(h.unit);
422
+ await killDaemons(unit);
340
423
  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)}`);
424
+ if (!okRan(r) && unitStatus(await show(unit)) === "running") {
425
+ throw new LocalHostError("STOP_FAILED", `systemctl stop ${unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
343
426
  }
344
427
  }
345
- await cleanMount(h.unit, String(h.mountpoint));
346
- await root([SYSTEMCTL, "reset-failed", `${h.unit}.service`]);
347
- 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));
348
432
  },
349
433
  async kill(h) {
350
434
  if (!mine(h) || h.bootId !== bootId)
@@ -352,7 +436,9 @@ export function localHost(opts = {}) {
352
436
  if (h.mode === "child") {
353
437
  const pid = Number(h.pid);
354
438
  // 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)
439
+ if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
440
+ throw notOwnPid(h, "killed");
441
+ if (procStartTicks(pid) !== h.startTicks)
356
442
  return;
357
443
  try {
358
444
  process.kill(-pid, "SIGKILL");
@@ -362,13 +448,14 @@ export function localHost(opts = {}) {
362
448
  }
363
449
  return;
364
450
  }
365
- if (typeof h.unit !== "string" || !UNIT_SAFE.test(h.unit))
366
- return;
451
+ const unit = ownUnit(h);
452
+ if (unit === undefined)
453
+ throw notOwnUnit(h, "killed");
367
454
  // Every process of the unit's control group at once, the instance's commands included. Its FUSE daemon runs in a
368
455
  // 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)}`);
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)}`);
372
459
  }
373
460
  },
374
461
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@parcha/pi-durable-disk",
3
- "version": "0.1.0-beta.21",
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",