@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0-beta.26 (2026-10-24)
6
+
7
+ - Pin the Docker image, planned-move example and live-test sandbox setup to Archil client 0.10.0, with SHA-256 checks for each downloaded package. The client verifies server certificates against the operating system trust store and rejects certificates from the compromised eMudhra intermediate authorities. Linux hosts need client 0.10.0 or later and the operating system CA bundle installed (`ca-certificates` on Debian/Ubuntu); rebuild Docker images and sandbox snapshots to use the new client.
8
+ - Archil's latest SDK: the package depends on the `disk` SDK 1.9.0 (was 1.7.0). The SDK calls the claim, lease and supervisor make are the same in 1.9.0, which adds disk branches and sandbox mounts. The client verbs the package runs take the same arguments in client 0.10.0, whose `mount` adds `--kernel-read-ahead-kb`. A host that installs `disk` itself installs 1.9.0, so npm keeps one copy.
9
+ - `archilPlace({ unheld })` takes a hook that runs in each start's window while nobody holds the run: after ensureRunning's revoke and start mark and after the run's directories are made, before the driver starts anything, once per start. It gets the start's `attempt` and, when the place hands labels over, exactly the `HOST_BOX` that start carries (`start.box`). It also gets the run's own files: `read`, `list`, and `put(path, text)`, which creates a file the run user owns (0644, parents 0755) and refuses a path already there with `EXISTS`. What it returns is added to that start's environment. It needs `ensure.create` and a control that lists. New types `UnheldHook`, `UnheldRun` and `UnheldStart`, and new `unheldTimeoutMs` (default 30 s).
10
+ - A put the hook does not await still settles inside the window: the driver starts only once every put has settled, within the hook's bound.
11
+ - `put` is a check, then a write, not a conditional create, so it is not exclusive between two supervisors in one window: put names unique to the start, and bind a shared name in the process that holds the run. It is for non-secret run files; a secret never goes through it.
12
+ - A start fails, and the driver starts no box, when a put was refused because the run is held (`WINDOW_CLOSED`, even if the hook caught it), when the hook throws a control failure (`CONTROL_API_FAILED`), when it throws anything else or overruns its bound (`UNHELD_FAILED`), or when its environment has a name the start already sets, a name that is not a variable name, a key-shaped name (a `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL` or `CREDENTIALS` segment), or a value that is not a string without NUL (`INVALID_ARGUMENT`, naming the variable, never its value). The caller sees `HOST_START_FAILED` with that `PlaceError` as its cause. `RunFileErrorCode` gains `EXISTS` and `WINDOW_CLOSED`; `PlaceErrorCode` gains `WINDOW_CLOSED`, `CONTROL_API_FAILED` and `UNHELD_FAILED`.
13
+ - Fix to merged behavior: a box the place already started and its driver adopts again (`adopted: true`, the same attempt started twice) keeps its key in `status().boxes`. New optional `HostDriver.instanceOf(handle)` names the instance a handle is for, the same for its fresh and its adopted handle; `dockerHost` (daemon and container name), `localHost` (host, boot, unit or tag) and `daytonaHost` (sandbox id) implement it, and without it no two handles are one box. It used to get a second entry: a key no runner claimed under `HOST_BOX`, the box counted twice in the receipts, and stopped twice at release.
14
+ - `readRunFile(control, ref, path)` and `listRunFiles(control, ref, dir)` read a run's own files over the disk's control (S3 GET, HEAD and LIST): they never mount, write, or take or wait on the claim, so they read a run another host holds. `readRunFile` returns the bytes, or `null` for a file not written yet; `listRunFiles` returns the names directly under a directory, sorted, names only. Refusals are `RunFileError`: `INVALID_PATH` (before any request: absolute, `.`/`..`, unsafe characters, `.archil/`), `RUN_MISSING`, `NOT_A_FILE` (a directory, or a symlink, decided on Archil's 501 for it, never from a listing, where a symlink looks like an empty file), and `CONTROL_API_FAILED`. Both take a `signal`.
15
+ - A place offers the same reads of its own run: `Place.readRunFile(path)` and `Place.listRunFiles(dir)`, optional members that `archilPlace` implements over its control and `localPlace` over its run directory on this machine, where a symlink anywhere on the way is `NOT_A_FILE`, never followed. A missing file is `null`; a directory not there yet lists `[]`; every other failure throws.
16
+ - `INVALID_PATH` names what is wrong with a path (empty, absolute, a `.` or `..` segment, an unsafe character at an index, under `.archil/`), never the path itself.
17
+ - `archilPlace` tells each host box's runner which label to claim: when its driver makes boxes and names their provider (new optional `HostDriver.provider`; `daytonaHost` names `daytona`) and the place can read the label back (its control lists), each start puts `HOST_BOX` (`{ provider, key, run }`: the box's index in `status().boxes`, the order's `RUN_LABEL`) in that order's environment. With it, `status()` fails (`STATUS_FAILED`) on a box its driver gives no receipt for, instead of shifting the keys of the boxes after it.
18
+
19
+ ## 0.1.0-beta.23 (2026-10-22)
20
+
21
+ - 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).
22
+ - Flags named -ms keep bare milliseconds (`--heartbeat-ms`, `--lease-expiry-ms`, `--lease-margin-ms`): their name carries the unit, and units are still accepted.
23
+ - 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.
24
+ - **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.
25
+
26
+ - 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.
27
+ - **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.
28
+ - **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.**
29
+ - 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.
30
+ - 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.
31
+ - 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`.
32
+ - 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.
33
+ - 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
34
+ one directory on a disk, which any Linux host or container can mount, release and mount somewhere else, and the agent carries on
35
+ where the last host released it. The second paragraph says what opening and releasing a run do on an Archil disk (mount and
36
+ claim, then seal, give up the claim and unmount) and on a plain directory (no mount).
37
+
5
38
  ## 0.1.0-beta.21 (2026-10-21)
6
39
 
7
40
  - `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 +546,6 @@ published.
513
546
  - Unchanged, because they name the Archil disk: `bin/archil-scoped` (and the `./bin/archil-scoped` export), `ARCHIL_API_KEY`, `archilEnv`,
514
547
  `ArchilCodingTools`, the claim's internals and the `archil` client it runs.
515
548
 
516
-
517
549
  **A holder the control API lists without a path**
518
550
  - 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
551
  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
@@ -4,7 +4,7 @@ export type RunRef = {
4
4
  region: string;
5
5
  id: string;
6
6
  };
7
- /** The control-plane calls the claim uses; an archil `Disk` (SDK `disk` 1.7.0) satisfies it. Supervisor side only. */
7
+ /** The control-plane calls the claim uses; an archil `Disk` (SDK `disk` 1.9.0) satisfies it. Supervisor side only. */
8
8
  export interface ControlApi {
9
9
  putObject(key: string, body: string, options: {
10
10
  uid: number;
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;
@@ -1,3 +1,12 @@
1
1
  import type { StartAttempt } from "../supervise.js";
2
+ /** A name an environment variable can have. */
3
+ export declare const ENV_NAME: RegExp;
4
+ /**
5
+ * Whether `name` is a secret's name: one of its `_`-separated segments, in any case, is KEY, TOKEN, SECRET, PASSWORD,
6
+ * PASSWD, CREDENTIAL or CREDENTIALS. Segments, not suffixes, so MODAL_TOKEN_ID is one too; CACHE_KEY_PREFIX is refused
7
+ * as well, and renamed, which is the cheaper error. A start's environment is readable in the instance, so a secret never
8
+ * goes there.
9
+ */
10
+ export declare function keyShaped(name: string): boolean;
2
11
  /** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
3
12
  export declare function attemptEnv(attempt: StartAttempt | undefined, refuse: (message: string) => Error): Record<string, string>;
@@ -1,5 +1,16 @@
1
- const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1
+ /** A name an environment variable can have. */
2
+ export const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
3
  const DRIVER_KEYS = new Set(["PATH", "PDA_HOLDER"]);
4
+ const KEY_SEGMENTS = new Set(["KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS"]);
5
+ /**
6
+ * Whether `name` is a secret's name: one of its `_`-separated segments, in any case, is KEY, TOKEN, SECRET, PASSWORD,
7
+ * PASSWD, CREDENTIAL or CREDENTIALS. Segments, not suffixes, so MODAL_TOKEN_ID is one too; CACHE_KEY_PREFIX is refused
8
+ * as well, and renamed, which is the cheaper error. A start's environment is readable in the instance, so a secret never
9
+ * goes there.
10
+ */
11
+ export function keyShaped(name) {
12
+ return name.split("_").some((segment) => KEY_SEGMENTS.has(segment.toUpperCase()));
13
+ }
3
14
  /** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
4
15
  export function attemptEnv(attempt, refuse) {
5
16
  const env = attempt?.env ?? {};
@@ -158,7 +158,7 @@ export interface DaytonaHostOptions {
158
158
  export declare function sandboxStatus(state: string | undefined): HostStatus | "check";
159
159
  /** A Daytona-safe box name for a run: lowercase, at most 63 characters, unique per call. */
160
160
  export declare function sandboxName(prefix: string, runId: string, now: number): string;
161
- export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
161
+ export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills" | "instanceOf">> & {
162
162
  readonly fleet: string;
163
163
  readonly mountRoot: string;
164
164
  };
@@ -293,6 +293,8 @@ export function daytonaHost(opts) {
293
293
  fleet,
294
294
  mountRoot,
295
295
  kills: "box",
296
+ provider: "daytona",
297
+ instanceOf: (h) => (h.driver === "daytona" && typeof h.sandboxId === "string" ? `daytona\0${h.sandboxId}` : undefined),
296
298
  async start(ref, mountToken, attempt) {
297
299
  runPath(ref.id);
298
300
  if (!mountToken)
@@ -95,7 +95,7 @@ export type ContainerInfo = {
95
95
  startedAt: string | null;
96
96
  finishedAt: string | null;
97
97
  };
98
- export declare function dockerHost(opts?: DockerHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
98
+ export declare function dockerHost(opts?: DockerHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills" | "instanceOf">> & {
99
99
  readonly fleet: string;
100
100
  readonly mountRoot: string;
101
101
  describe(handle: HostHandle): Promise<ContainerInfo | null>;
@@ -272,6 +272,7 @@ export function dockerHost(opts = {}) {
272
272
  fleet,
273
273
  mountRoot,
274
274
  kills: "box",
275
+ instanceOf: (h) => (h.driver === "docker" && typeof h.daemon === "string" && typeof h.name === "string" ? `docker\0${h.daemon}\0${h.name}` : undefined),
275
276
  async start(ref, token, attemptInfo) {
276
277
  runPath(ref.id);
277
278
  if (!opts.image)
@@ -66,7 +66,7 @@ export declare function currentBootId(): string;
66
66
  export declare function parseShow(text: string): Record<string, string>;
67
67
  /** A unit's state as a host status: loaded and active (or restarting) is running; unloaded is gone. */
68
68
  export declare function unitStatus(show: Record<string, string>): HostStatus;
69
- export declare function localHost(opts?: LocalHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
69
+ export declare function localHost(opts?: LocalHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills" | "instanceOf">> & {
70
70
  readonly hostName: string;
71
71
  readonly mountRoot: string;
72
72
  };