@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 +23 -1
- package/README.md +12 -8
- package/dist/claim.d.ts +5 -5
- package/dist/claim.js +7 -7
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +67 -20
- package/dist/env.js +3 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +4 -0
- package/dist/hosts/daytona.js +1 -1
- package/dist/hosts/local-host.js +114 -31
- package/dist/place.d.ts +0 -1
- package/dist/run.d.ts +2 -5
- package/dist/run.js +2 -5
- package/dist/status.js +2 -2
- package/dist/watchdog.d.ts +1 -2
- package/dist/watchdog.js +3 -2
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => (
|
|
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
|
-
|
|
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:
|
|
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"] ?
|
|
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"] ?
|
|
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"] ?
|
|
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"] ?
|
|
508
|
-
stonithTimeoutMs: values["stonith-timeout"] ?
|
|
509
|
-
startGraceMs: values
|
|
510
|
-
startBackoffMaxMs: values
|
|
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"] ?
|
|
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 =
|
|
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
|
/**
|
package/dist/hosts/daytona.js
CHANGED
|
@@ -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 {
|
package/dist/hosts/local-host.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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(
|
|
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(
|
|
422
|
+
await killDaemons(unit);
|
|
344
423
|
r = await stopUnit();
|
|
345
|
-
if (!okRan(r) && unitStatus(await show(
|
|
346
|
-
throw new LocalHostError("STOP_FAILED", `systemctl stop ${
|
|
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(
|
|
350
|
-
await root([SYSTEMCTL, "reset-failed", `${
|
|
351
|
-
await removeFile(tokenFile(
|
|
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"
|
|
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
|
-
|
|
370
|
-
|
|
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", `${
|
|
374
|
-
if (!okRan(r) && unitStatus(await show(
|
|
375
|
-
throw new LocalHostError("KILL_FAILED", `systemctl kill ${
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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));
|
package/dist/watchdog.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|