@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 +19 -1
- package/README.md +12 -8
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +63 -18
- package/dist/env.js +3 -0
- package/dist/hosts/local-host.js +113 -26
- package/package.json +2 -2
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
|
-
|
|
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/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
|
-
/**
|
|
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
|
@@ -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
|
-
/**
|
|
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;
|
|
@@ -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:
|
|
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"] ?
|
|
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"] ?
|
|
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"] ?
|
|
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"] ?
|
|
510
|
-
stonithTimeoutMs: values["stonith-timeout"] ?
|
|
511
|
-
startGraceMs: values
|
|
512
|
-
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"),
|
|
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"] ?
|
|
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 =
|
|
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;
|
package/dist/hosts/local-host.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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(
|
|
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(
|
|
422
|
+
await killDaemons(unit);
|
|
340
423
|
r = await stopUnit();
|
|
341
|
-
if (!okRan(r) && unitStatus(await show(
|
|
342
|
-
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)}`);
|
|
343
426
|
}
|
|
344
427
|
}
|
|
345
|
-
await cleanMount(
|
|
346
|
-
await root([SYSTEMCTL, "reset-failed", `${
|
|
347
|
-
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));
|
|
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"
|
|
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
|
-
|
|
366
|
-
|
|
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", `${
|
|
370
|
-
if (!okRan(r) && unitStatus(await show(
|
|
371
|
-
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)}`);
|
|
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.
|
|
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",
|