@edgehero/pi-dispatch 1.10.3 → 2.0.0

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.
Files changed (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +29 -2
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +363 -13
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1348 -326
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * daemon-facts.mjs -- what a container runtime's own `info` says, parsed (issue #452, gate round 3).
3
+ *
4
+ * A LEAF, importing nothing of this project's, and that is its reason to exist: the parsers the local venue decides its
5
+ * job user with (`parseDaemonFacts`) and the podman venue decides its observations with (`parsePodmanInfo`) are also
6
+ * what the network detach gate (`netns-keeper.mjs`) reads the runtime with, and that gate is used by `egress.mjs`, which
7
+ * `job-user.mjs` and `backend-podman.mjs` sit above. Moved here unchanged and re-exported from both, so every existing
8
+ * importer and test is untouched and there is still ONE copy of each rule.
9
+ */
10
+
11
+ /**
12
+ * The one daemon read. `{{json .}}` rather than a narrow template, deliberately and against `DOCKER_ENDPOINT_ARGS`'
13
+ * rule, for a measured reason: a template field one runtime lacks is a TEMPLATE ERROR on the client, exactly what "no
14
+ * daemon" and Podman's docker emulation also produce, while a missing key in a parsed body is just `null`. The body
15
+ * carries proxy settings and storage paths, so it is parsed here, reduced to the facts below, and never logged or
16
+ * returned.
17
+ */
18
+ export const DAEMON_FACTS_ARGS = Object.freeze(["info", "--format={{json .}}"]);
19
+ export const DAEMON_FACTS_MAX_BUFFER = 256 * 1024;
20
+ // Longer than the endpoint read's 5 s: `docker info` counts containers and images, and on a busy host it is the slow
21
+ // one. A per-job `unknown` retries the job, so a bound the host routinely misses would retry every job forever. Boot
22
+ // is bounded separately (`settleWithin`), and a read still in flight there is joined by the first job.
23
+ export const DAEMON_FACTS_TIMEOUT_MS = 15_000;
24
+
25
+ // Podman's compat API has hard-coded this since v2 (pkg/api/handlers/compat/info.go); Docker CE packages say
26
+ // "Community Engine" and Docker Desktop omits the key (measured). Used only to withhold credit, never to grant it.
27
+ export const PODMAN_PRODUCT_LICENSE = "Apache-2.0";
28
+
29
+ /**
30
+ * What a `docker info --format={{json .}}` answer says: `{ facts }`, `{ unreachable: true }`, or `null` when no line
31
+ * parses to either shape.
32
+ *
33
+ * NO DAEMON IS NOT A SHAPE. When the CLI's `/info` request fails, the docker CLI still exits 0 under `--format` and
34
+ * prints a Docker-shaped body with every server field empty and the error in `ServerErrors` (measured on 27.5.1
35
+ * against a missing socket). From 28.1 a CONNECTION failure exits non-zero instead, which the reader already treats
36
+ * as transient, but any other `/info` failure (an authorization plugin, an API version mismatch) still exits 0 with
37
+ * `ServerErrors` on every version (source). Read as facts, that body has no rootless marker and decides `worker`, so it is
38
+ * recognised FIRST: a non-empty `ServerErrors` is `unreachable` (the error text is never read), and a Docker-shaped
39
+ * body with no `ServerVersion` is no shape at all. A daemon that answers `/info` always sets `ServerVersion`
40
+ * (Docker and Podman's compat handler, source).
41
+ *
42
+ * TWO SHAPES, both measured. The real docker CLI against any daemon (Docker, or Podman's compat socket) prints the
43
+ * Docker shape. Podman's docker emulation (podman-docker) prints Podman's own (`host.security.rootless`,
44
+ * `host.serviceIsRemote`, `host.remoteSocket.path`), and that is the only facts source on that route, because it
45
+ * resolves no docker context.
46
+ *
47
+ * `bounds` is `null` whenever the daemon is Podman: its compat `PidsLimit` is hard-coded true and `MemoryLimit`
48
+ * follows the root cgroup's controllers, not whether a container's bounds apply (source; measured on a rootless host
49
+ * reporting both true while applying neither). CPU is never read: rootful Podman reports `CpuCfsQuota: false` while
50
+ * applying `--cpus` (measured).
51
+ */
52
+ export function parseDaemonFacts(output) {
53
+ const lines = String(output ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
54
+ for (let i = lines.length - 1; i >= 0; i--) {
55
+ let body;
56
+ try {
57
+ body = JSON.parse(lines[i]);
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (!body || typeof body !== "object" || Array.isArray(body)) continue;
62
+ if (Array.isArray(body.ServerErrors) && body.ServerErrors.length > 0) return { unreachable: true };
63
+ if (body.host && typeof body.host === "object") {
64
+ const rootless = body.host.security?.rootless;
65
+ const selinux = body.host.security?.selinuxEnabled;
66
+ const path = body.host.remoteSocket?.path;
67
+ return { facts: {
68
+ shape: "podman",
69
+ podman: true,
70
+ os: typeof body.host.os === "string" ? body.host.os : null,
71
+ rootless: typeof rootless === "boolean" ? rootless : null,
72
+ // Issue #355. Podman's own shape says it outright, so a missing or odd value is no fact rather than `false`.
73
+ selinux: typeof selinux === "boolean" ? selinux : null,
74
+ userns: false,
75
+ bounds: null,
76
+ serviceIsRemote: typeof body.host.serviceIsRemote === "boolean" ? body.host.serviceIsRemote : null,
77
+ // Only a unix path is kept, because only a unix path is ever statted. Podman fills this from the SERVICE's own
78
+ // listener (source): `unix://...` or `tcp://...` for a running service, the bare default path in-process.
79
+ remoteSocketPath: isUnixSocketPath(path) ? path : null,
80
+ serverVersion: displayVersion(body.version?.Version),
81
+ } };
82
+ }
83
+ if (typeof body.ServerVersion === "string" && body.ServerVersion !== "" && (typeof body.OperatingSystem === "string" || Array.isArray(body.SecurityOptions))) {
84
+ const options = Array.isArray(body.SecurityOptions) ? body.SecurityOptions.filter((o) => typeof o === "string") : [];
85
+ const named = (name) => options.some((o) => o.split(",").includes(`name=${name}`));
86
+ const podman = body.ProductLicense === PODMAN_PRODUCT_LICENSE;
87
+ return { facts: {
88
+ shape: "docker",
89
+ podman,
90
+ os: typeof body.OperatingSystem === "string" ? body.OperatingSystem : null,
91
+ rootless: named("rootless"),
92
+ // Issue #355, measured through rootful Podman 5.8.1's compat API on an enforcing Fedora 44 host:
93
+ // `["name=seccomp,profile=default","name=selinux"]`. Docker Engine with `selinux-enabled` says the same word,
94
+ // and that route is out of scope, so this fact alone decides nothing (`relabelsPrivateMounts`).
95
+ selinux: named("selinux"),
96
+ userns: named("userns"),
97
+ bounds: podman ? null : { pids: body.PidsLimit === true, memory: body.MemoryLimit === true },
98
+ serviceIsRemote: null,
99
+ remoteSocketPath: null,
100
+ serverVersion: displayVersion(body.ServerVersion),
101
+ } };
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * A version string fit for doctor's display line (issue #345), or `null`. Display only, never a decision: validated to a
109
+ * short run of version characters so a daemon's answer cannot put anything else on an operator's terminal. Exported for
110
+ * the podman venue's `podman info` read (issue #354), so the two runtimes' versions pass one filter.
111
+ */
112
+ export function displayVersion(value) {
113
+ return typeof value === "string" && /^[0-9A-Za-z.+~_-]{1,40}$/.test(value) ? value : null;
114
+ }
115
+
116
+ function isUnixSocketPath(path) {
117
+ return typeof path === "string" && (path.startsWith("/") || path.startsWith("unix:///"));
118
+ }
119
+
120
+
121
+ /** The one facts read. JSON, not a template: a template field one Podman lacks is an error, a missing key is `null`. */
122
+ export const PODMAN_INFO_ARGS = Object.freeze(["info", "--format", "json"]);
123
+
124
+ /**
125
+ * What `podman info --format json` says, reduced to the facts this venue reads, or `null` when the output is not a JSON
126
+ * object with a `host` object. Every field is `null` when absent or of the wrong type, never a guess: a missing
127
+ * `rootless` must not read as rootless, nor a missing `serviceIsRemote` as local. Nothing else of the body (proxy
128
+ * settings, store paths) survives this function, so nothing else can be logged.
129
+ */
130
+ export function parsePodmanInfo(stdout) {
131
+ let body;
132
+ try {
133
+ body = JSON.parse(String(stdout ?? "").trim());
134
+ } catch {
135
+ return null;
136
+ }
137
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
138
+ const host = body.host;
139
+ if (!host || typeof host !== "object" || Array.isArray(host)) return null;
140
+ const bool = (value) => (typeof value === "boolean" ? value : null);
141
+ const controllers = Array.isArray(host.cgroupControllers) ? host.cgroupControllers.filter((c) => typeof c === "string" && /^[a-z][a-z0-9_]{0,31}$/.test(c)) : null;
142
+ return {
143
+ rootless: bool(host.security?.rootless),
144
+ serviceIsRemote: bool(host.serviceIsRemote),
145
+ selinux: bool(host.security?.selinuxEnabled),
146
+ // "v2" or "v1"; anything else is no fact rather than a string an operator's terminal is handed.
147
+ cgroupVersion: typeof host.cgroupVersion === "string" && /^v[0-9]{1,2}$/.test(host.cgroupVersion) ? host.cgroupVersion : null,
148
+ // Issue #453: the cgroup manager Podman actually uses for this call. `cgroupfs` when it was configured so, and when a
149
+ // configured `systemd` could not reach the account's user manager over D-Bus (Podman then warns and falls back).
150
+ // A short lower-case word, else no fact.
151
+ cgroupManager: typeof host.cgroupManager === "string" && /^[a-z][a-z0-9_-]{0,31}$/.test(host.cgroupManager) ? host.cgroupManager : null,
152
+ // The controllers of the CALLING process's own cgroup, as `podman info` reports them. Kept for what it is and for
153
+ // nothing else: issue #453 measured it listing all five where no bound was applied, so no observation reads it.
154
+ controllers,
155
+ version: displayVersion(body.version?.Version),
156
+ // Issue #429: the container STORE this account's Podman uses (`store.graphRoot`), which moves with HOME,
157
+ // XDG_DATA_HOME or a storage.conf. Rootless `podman ps -a` over another store answers exit 0 with an EMPTY list
158
+ // (measured, Podman 5.8.1), so a sandbox opened with another store is invisible to the retention sweep; the
159
+ // sandbox and the sweep compare it with the one a run recorded. An absolute path with no control character, else
160
+ // no fact.
161
+ graphRoot: typeof body.store?.graphRoot === "string" && body.store.graphRoot.length <= 4096 && /^\/[^\u0000-\u001f\u007f]*$/.test(body.store.graphRoot) ? body.store.graphRoot : null,
162
+ // Issue #450: where Podman keeps its runtime state (`store.runRoot`, `/run/user/<uid>/containers` by default,
163
+ // measured on 5.8.1 and 4.9.3), under which Podman 5 records this account's rootless network helper. Parsed as
164
+ // graphRoot is: an absolute path with no control character, else no fact (and the live network check refuses).
165
+ runRoot: typeof body.store?.runRoot === "string" && body.store.runRoot.length <= 4096 && /^\/[^\u0000-\u001f\u007f]*$/.test(body.store.runRoot) ? body.store.runRoot : null,
166
+ };
167
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * WHICH VENUE A DEPLOYMENT RUNS, decided one way for every command that asks (issue #453): `up` since #430, and `init`
3
+ * (its next steps) and `doctor` (what it judges) since #453, which read only their shell before and so answered for a
4
+ * different venue than the service they described. The deployment's `.env` is read with `readStackKeys`, exactly as
5
+ * `service install` reads it, and a key this shell sets differently stops the command, since the service runs the file.
6
+ */
7
+ import { parseBackendList } from "./backends.mjs";
8
+ import { egressArmed, egressProxyName } from "./egress.mjs";
9
+ import { envValueShown } from "./env-file.mjs";
10
+ import { STACK_KEYS, VALKEY_SHARED_KEY, readStackKeys, readValkeyKeys } from "./podman-stack.mjs";
11
+
12
+ /** How each command says what a shell/file disagreement would make it do, the clause before "while the service ...". */
13
+ const COMMAND_SAYS = Object.freeze({
14
+ up: "up would drive the shell's venue",
15
+ init: "init's next steps would be for the shell's venue",
16
+ doctor: "doctor would judge the shell's venue",
17
+ });
18
+
19
+ /**
20
+ * The venue keys this pass decides with (D2): this shell's value where it sets one, else the deployment `.env`'s, read
21
+ * with `readStackKeys` exactly as `service install` reads them. Returns `{ env, fromFile, disagreements }` or
22
+ * `{ error }`. `env` is the whole environment with the file's keys filled in; `fromFile` is only what the file
23
+ * supplied, for doctor's layering; `disagreements` names each key both set differently.
24
+ */
25
+ export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up", loader: loaderGiven = null, keys: keysWanted = STACK_KEYS }) {
26
+ let keys = {};
27
+ const notes = [];
28
+ // The loader of the file on THIS platform, as service.mjs reads it (round 2, E6): systemd's EnvironmentFile= on
29
+ // Linux, the sh wrapper on macOS, the cmd wrapper on Windows. Off Linux the venue refuses the host anyway, so a
30
+ // line that platform's loader reads differently is noted and never stops `up`.
31
+ const linux = platform === "linux";
32
+ // A caller with a mapping of its own passes it (doctor, so every `.env` read it makes uses one loader).
33
+ const loader = loaderGiven ?? (linux ? "systemd" : platform === "darwin" ? "shell" : "cmd");
34
+ if (fs.existsSync(envPath)) {
35
+ let text = null;
36
+ try {
37
+ // Bytes, not text: `readStackKeys` checks what systemd refuses to load before it decodes (issue #447).
38
+ text = fs.readFileSync(envPath);
39
+ } catch (err) {
40
+ // Said rather than silent (round 2 nit): the venue then comes from this shell alone.
41
+ notes.push(`${envPath} could not be read (${err?.message}), so PI_BACKENDS, PI_EGRESS and PI_EGRESS_PROXY come from this shell alone`);
42
+ }
43
+ if (text !== null) {
44
+ const read = readStackKeys(text, { loader, path: envPath });
45
+ if (read.error && linux) return { error: read.error };
46
+ if (read.error) notes.push(`${read.error}; off Linux the podman venue refuses this host anyway, so this pass reads the venue from this shell`);
47
+ else keys = read.keys;
48
+ }
49
+ }
50
+ const merged = { ...env };
51
+ const fromFile = {};
52
+ const conflicts = [];
53
+ // Only the keys the caller allows are taken from the file (doctor's `SERVICE_ENV_KEYS`); all of them by default.
54
+ for (const key of STACK_KEYS.filter((k) => keysWanted.includes(k))) {
55
+ if (!Object.hasOwn(keys, key)) continue;
56
+ if (typeof env[key] === "string") {
57
+ // Compared as the worker READS them (round 3, D4), not as strings: ` podman` and `podman,podman` are the list
58
+ // `podman`, and refusing them sent an operator to reconcile two values that already agree.
59
+ if (venueKeyMeaning(key, env[key]) !== venueKeyMeaning(key, keys[key])) conflicts.push(`${key} is ${quotedShown(env[key])} in this shell and ${quotedShown(keys[key])} in ${envPath}`);
60
+ continue;
61
+ }
62
+ merged[key] = keys[key];
63
+ fromFile[key] = keys[key];
64
+ }
65
+ // A KNOWN disagreement is a refusal (round 2, E5), not a warning: this pass would stand up one venue's stack and the
66
+ // service installed next would run the other. The file is what the service runs, so it is the one to change unless
67
+ // the shell's value was a one-off.
68
+ if (conflicts.length > 0) {
69
+ return { error: `${conflicts.join("; ")}. ${COMMAND_SAYS[command] ?? COMMAND_SAYS.up} while the service runs the file's. Make them agree: change ${envPath} (what the service reads), or unset the key in this shell` };
70
+ }
71
+ return { env: merged, fromFile, notes };
72
+ }
73
+
74
+ /**
75
+ * VALKEY_URL and PI_VALKEY_SHARED as `up` must judge them (issue #464): the deployment `.env`'s, read with the loader the
76
+ * service uses (`readValkeyKeys`), because the Valkey `up` adopts or refuses is the one the SERVICE's worker will use.
77
+ * For VALKEY_URL this shell's value is taken where the file sets none (what the wizard relies on before init has
78
+ * written `.env`), and both set differently is refused, as a venue key is (`deploymentVenueEnv`), since the service
79
+ * runs the file's. PI_VALKEY_SHARED comes from the file only, a shell value named as ignored (gate round 2); a
80
+ * `.env` line the loaders read differently is refused, naming what in it is the problem, on Linux (off it the podman
81
+ * venue refuses the host anyway, so it is a note). Returns `{ env: { VALKEY_URL?, PI_VALKEY_SHARED? }, fromFile, notes }`
82
+ * or `{ error }`.
83
+ */
84
+ export function deploymentValkeyEnv({ env, fs, envPath, platform, command = "up" }) {
85
+ const linux = platform === "linux";
86
+ const loader = linux ? "systemd" : platform === "darwin" ? "shell" : "cmd";
87
+ const notes = [];
88
+ let keys = {};
89
+ if (fs.existsSync(envPath)) {
90
+ let text = null;
91
+ try {
92
+ // Bytes, not text (gate round 3): `readValkeyKeys` checks what systemd refuses to load before it decodes.
93
+ text = fs.readFileSync(envPath);
94
+ } catch (err) {
95
+ notes.push(`${envPath} could not be read (${err?.message}), so VALKEY_URL comes from this shell alone and PI_VALKEY_SHARED is unset`);
96
+ }
97
+ if (text !== null) {
98
+ const read = readValkeyKeys(text, { loader, path: envPath });
99
+ if (read.error && linux) return { error: `${read.error}. ${command} judges the Valkey the service will use, so it stops here` };
100
+ if (read.error) notes.push(read.error);
101
+ else keys = read.keys;
102
+ }
103
+ }
104
+ const merged = {};
105
+ const fromFile = {};
106
+ const conflicts = [];
107
+ {
108
+ const shell = typeof env.VALKEY_URL === "string" ? env.VALKEY_URL : undefined;
109
+ if (!Object.hasOwn(keys, "VALKEY_URL")) {
110
+ if (shell !== undefined) merged.VALKEY_URL = shell;
111
+ } else {
112
+ if (shell !== undefined && shell !== keys.VALKEY_URL) conflicts.push(`VALKEY_URL is ${quotedShown(shell)} in this shell and ${quotedShown(keys.VALKEY_URL)} in ${envPath}`);
113
+ merged.VALKEY_URL = keys.VALKEY_URL;
114
+ fromFile.VALKEY_URL = keys.VALKEY_URL;
115
+ }
116
+ }
117
+ // PI_VALKEY_SHARED from the file ONLY (gate round 2): it is the one statement that another uid's Valkey is this
118
+ // deployment's queue on purpose, and the worker, `service install` and doctor read it from `.env` alone, so a shell
119
+ // export that `up` honoured was an opt-in the service never made (measured: `up` exit 0 and a ✓ over a refused
120
+ // install). A shell value is ignored, and said.
121
+ if (Object.hasOwn(keys, VALKEY_SHARED_KEY)) {
122
+ merged[VALKEY_SHARED_KEY] = keys[VALKEY_SHARED_KEY];
123
+ fromFile[VALKEY_SHARED_KEY] = keys[VALKEY_SHARED_KEY];
124
+ }
125
+ if (typeof env[VALKEY_SHARED_KEY] === "string") notes.push(sharedShellIgnored(env[VALKEY_SHARED_KEY], envPath));
126
+ if (conflicts.length > 0) {
127
+ return { error: `${conflicts.join("; ")}. ${command} would judge the shell's Valkey while the service uses the file's. Make them agree: change ${envPath} (what the service reads), or unset the key in this shell` };
128
+ }
129
+ return { env: merged, fromFile, notes };
130
+ }
131
+
132
+ /** The sentence for a PI_VALKEY_SHARED this shell sets, which no command honours (issue #464, gate round 2). */
133
+ export function sharedShellIgnored(value, envPath) {
134
+ return `${VALKEY_SHARED_KEY} is ${quotedShown(value)} in this shell: ignored, since only ${envPath} may say a Valkey is shared on purpose (the service's worker reads it there alone)`;
135
+ }
136
+
137
+ /**
138
+ * What a venue key MEANS to the worker, for comparing two spellings of it: the parsed list for PI_BACKENDS, on or off
139
+ * for PI_EGRESS, the resolved name for PI_EGRESS_PROXY exactly as egressProxyName resolves it (empty is the default
140
+ * name; NOT trimmed, because nothing that reads the name trims it, so " x" and "x" are different proxies to the worker
141
+ * and to podman). A value the worker would refuse keeps its raw spelling, so two different unreadable values still
142
+ * disagree and the doctor below names them.
143
+ */
144
+ function venueKeyMeaning(key, value) {
145
+ try {
146
+ if (key === "PI_BACKENDS") return `list:${parseBackendList(value).join(",")}`;
147
+ if (key === "PI_EGRESS") return `egress:${egressArmed({ PI_EGRESS: value }) ? "on" : "off"}`;
148
+ return `proxy:${egressProxyName({ PI_EGRESS_PROXY: value })}`;
149
+ } catch {
150
+ return `raw:${value}`;
151
+ }
152
+ }
153
+
154
+ /** A value shown in quotes, so a leading space or an empty value is visible; control characters escaped as ever. */
155
+ function quotedShown(value) {
156
+ const shown = envValueShown(value);
157
+ return shown.startsWith('"') ? shown : `"${shown}"`;
158
+ }
@@ -20,7 +20,8 @@
20
20
  // `buildDockerRunArgs`, which never moved; the entry that names `containerSpec` is design.md's 2026-08-31
21
21
  // row, and this move is recorded in its own row rather than by leaving that pointer to rot.
22
22
  export { containerSpec, CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE } from "./container-spec.mjs";
23
- import { containerSpec } from "./container-spec.mjs";
23
+ import { isAbsolute, relative } from "node:path";
24
+ import { assertCidFile, assertJobUser, containerSpec } from "./container-spec.mjs";
24
25
 
25
26
  /** The fixed isolation flags. Not configurable -- these ARE the boundary. */
26
27
  export const ISOLATION_FLAGS = [
@@ -43,8 +44,8 @@ export const ISOLATION_FLAGS = [
43
44
  ];
44
45
 
45
46
  /**
46
- * HOW Docker spells it. The only consumer of a spec today, and the local backend's translation step:
47
- * a second backend implements this function's job against its own runtime and shares everything above it.
47
+ * HOW Docker spells it, and the local backend's translation step. Since issue #354 `podmanArgsFromSpec` below is the
48
+ * second, and it shares this function's body rather than copying it.
48
49
  */
49
50
  /**
50
51
  * Flags a `dockerExtra` may not carry, because docker resolves a repeated option LAST-WINS and this array
@@ -56,9 +57,9 @@ export const ISOLATION_FLAGS = [
56
57
  * The list covers every member of `ISOLATION_FLAGS`, the container name, and the near-synonyms that reach
57
58
  * the same effect without repeating a listed flag (`--volumes-from` for a mount, `--memory-swap` for the
58
59
  * memory bound). It is a DENY-list and therefore only as good as its coverage: docker's surface is large
59
- * and a release can add another way in. So it NARROWS the gap rather than closing it, and the backend
60
- * table's `isolation` and `ephemeral` words rest on this plus the single production caller passing fixed
61
- * literals, never on this alone.
60
+ * and a release can add another way in. So it NARROWS the gap rather than closing it; since issue #341 the
61
+ * allow-list below closes it, and the backend table's `isolation` and `ephemeral` words rest on both plus the
62
+ * callers passing fixed literals.
62
63
  *
63
64
  * Without this the two standing assertions that "every member of ISOLATION_FLAGS reaches the argv" would
64
65
  * still pass on an argv with no boundary left, because membership is not effectiveness. Nothing passes any
@@ -66,13 +67,22 @@ export const ISOLATION_FLAGS = [
66
67
  * which stay allowed -- so this closes a hole rather than changing a behaviour, and it makes "the builder
67
68
  * CANNOT DECLINE the boundary" true of the whole argv instead of of one boolean field.
68
69
  *
69
- * `--user` is DELIBERATELY NOT HERE, and it is the one that looks like it belongs. It is a documented,
70
- * tested feature -- the Linux-only `uid:gid` on a bind-mounted local folder, so files the agent writes into
71
- * an operator's own directory come back owned by the operator rather than by root. It also does not touch
72
- * this boundary: it changes which uid runs, not what that uid may do, and `--cap-drop=ALL` plus
73
- * `no-new-privileges` hold either way. What it does bear on is `nonRoot`, which the backend table already
74
- * declares `asserted` for the separate reason that the image's `USER pi` is what provides it. Denying it
75
- * here would break local-folder jobs to make a word honest that is already honest.
70
+ * `--user` and `-u` ARE here (issue #341), because the builder owns the job user now: `containerSpec`'s `user` field,
71
+ * validated non-root and emitted before `dockerExtra`. A second `--user` in `dockerExtra` would win last and could
72
+ * name uid 0, which is `nonRoot` gone. An earlier version of this comment called `--user` "a documented, tested
73
+ * feature" and left it allowed; nothing documented it and nothing passed it, which is how every job on a native
74
+ * Linux daemon whose worker uid is not 1001 came to run as a uid that cannot read its own inputs.
75
+ *
76
+ * ONE SHORT FLAG PER TOKEN, and that is a separate rule below the list: docker parses `-u0` as `-u 0`, `-iu0` as
77
+ * `-i -u 0`, `-v/:/h` as a mount and `-m1g` as a memory bound, and a list compared on the text before `=` sees none
78
+ * of those. A single-dash token longer than two characters is refused outright; every caller passes separate tokens.
79
+ *
80
+ * AND AN ALLOW-LIST CLOSES WHAT THE DENY-LIST ONLY NARROWED (issue #341, found by an adversarial pass): a bare
81
+ * positional token or `--` becomes the IMAGE, with every `-e` and `-v` after it passed to that image as arguments;
82
+ * `--annotation run.oci.keep_original_groups=1` keeps the worker's groups on Podman; `--uidmap`/`--gidmap` remap
83
+ * the user on podman-docker; `--use-api-socket` mounts the daemon socket on a recent CLI. None was listed, and the
84
+ * next release adds another. So after the refusals above, a token must be one of what the callers actually pass,
85
+ * `DOCKER_EXTRA_ALLOWED`, or it is refused. The deny-list stays for its named reasons and its tests.
76
86
  */
77
87
  export const DOCKER_EXTRA_FORBIDDEN = [
78
88
  // Each of the seven logical flags in ISOLATION_FLAGS, and the argv member beside them that the worker's
@@ -85,6 +95,9 @@ export const DOCKER_EXTRA_FORBIDDEN = [
85
95
  // last and leaves a container no sweep finds and no timeout can stop. `ephemeral` and `abortable` both
86
96
  // rest on it.
87
97
  "--name",
98
+ // Issue #345: the run's cidfile is how a container that outlived its `docker run` is found; a second `--cidfile`
99
+ // would win last and write the ID where the worker does not look.
100
+ "--cidfile",
88
101
  "--privileged",
89
102
  "--cap-add",
90
103
  "--security-opt",
@@ -118,9 +131,74 @@ export const DOCKER_EXTRA_FORBIDDEN = [
118
131
  "--uts",
119
132
  "--userns",
120
133
  "--cgroupns",
134
+ // The job user is a spec field (issue #341). Here so a repeat cannot override it with uid 0.
135
+ "--user",
136
+ "-u",
121
137
  ];
122
138
 
139
+ /**
140
+ * Everything a `dockerExtra` may say. Bare flags stand alone; a valued flag takes the NEXT token, and that token must
141
+ * match its pattern: an entrypoint is a command name, never a flag, and a published port is bound to loopback
142
+ * (`parsePublish` never builds anything else). The sandbox passes `-i -t --entrypoint bash -p 127.0.0.1:<h>:<c>`;
143
+ * the live probes pass `-d --entrypoint sleep` and `-d`.
144
+ */
145
+ export const DOCKER_EXTRA_ALLOWED = Object.freeze({
146
+ bare: Object.freeze(["-i", "-t", "-d"]),
147
+ valued: Object.freeze({ "--entrypoint": /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/, "-p": /^127\.0\.0\.1:\d{1,5}:\d{1,5}$/ }),
148
+ });
149
+
123
150
  export function dockerArgsFromSpec(spec) {
151
+ // Issue #354. REFUSED, never dropped: the docker CLI rejects `--userns=keep-id` client-side (exit 125, measured in
152
+ // issue #345), and silently omitting it would run the job under the daemon's own mapping, where the "<uid>:<gid>"
153
+ // the caller chose for bind-mount ownership names a different host identity. `undefined` is a hand-built spec that
154
+ // predates the field, which asked for nothing.
155
+ if (spec?.userns !== null && spec?.userns !== undefined) {
156
+ throw new Error(`docker run: refusing a spec with userns ${JSON.stringify(spec.userns)}: the docker CLI has no --userns=keep-id, and only a podman venue builds one`);
157
+ }
158
+ return argsFromSpec(spec, { userns: null });
159
+ }
160
+
161
+ /**
162
+ * HOW rootless Podman spells the same box (issue #354): `dockerArgsFromSpec`'s argv with `--userns=keep-id` immediately
163
+ * after `--user=`, from ONE private builder, so the boundary flags, the `dockerExtra` allow-list and the mount rendering
164
+ * cannot drift between the two runtimes. Podman accepts every other token here as docker does (`--pull=never`,
165
+ * `--cidfile`, `-v host:ctr:ro,Z`: measured on 5.8.1).
166
+ *
167
+ * `keep-id` is REQUIRED, and so is a user. Without keep-id a rootless container's uid N is the host's subordinate uid, so
168
+ * a job run as the worker's uid cannot read `/job`. With keep-id and NO `--user`, the container runs as the IMAGE's user
169
+ * with the worker's gid only as a supplementary group, and `/job` is unreadable again (both measured on Fedora 44,
170
+ * Podman 5.8.1). Either argv would start, spend its slot and fail inside the container, so both are refused here.
171
+ */
172
+ export function podmanArgsFromSpec(spec) {
173
+ if (spec?.userns !== "keep-id") {
174
+ throw new Error(`podman run: refusing a spec whose userns is not "keep-id": ${JSON.stringify(spec?.userns ?? null)} (a rootless job without it cannot read its own mounts)`);
175
+ }
176
+ if (spec.user === null || spec.user === undefined) {
177
+ throw new Error("podman run: refusing a keep-id spec with no job user: the image's own user would run with /job unreadable");
178
+ }
179
+ return argsFromSpec(spec, { userns: "keep-id" });
180
+ }
181
+
182
+ /**
183
+ * The namespaces and inheritances a rootless Podman argv pins, because the account's own containers.conf can set every
184
+ * container's defaults for them: `pidns` (PID), `ipcns` (IPC), `utsns` (UTS), `cgroupns` (cgroup), `env_host` and `http_proxy`. (dockerd's
185
+ * daemon.json can default the cgroup and IPC modes too, and the docker argv pins neither: a gap older than this venue.) Measured on Podman 5.8.1 with a user containers.conf of `pidns = "host"` and `env_host = true`: an
186
+ * unpinned job ran outside its own PID namespace and received the worker's environment (the provider key with it);
187
+ * with these flags it got its own namespaces and nothing. `http_proxy` is on by default, which copies the worker's proxy
188
+ * variables into every job. A job's network is pinned the same way where it has none of its own (`--network=private`,
189
+ * Podman's word for the rootless default), or `netns = "host"` would put it on the host's. What cannot be pinned is
190
+ * that network's OPTIONS: containers.conf `pasta_options` come before the command line's in pasta's argv, and a conf
191
+ * `-T <port>` survives even `--network=pasta:--map-host-loopback,none` (measured), so no flag here could close it. It is
192
+ * REFUSED instead, not left open: the venue refuses to run while the account's containers.conf sets `pasta_options`, `network_cmd_options`, `annotations`, `env`, `helper_binaries_dir`, `network_cmd_path`, `default_sysctls`, `default_ulimits`, `seccomp_profile`, `init_path`, `dns_servers`, `dns_options`, `dns_searches`, `base_hosts_file`, `oom_score_adj`, `privileged`, `label`, `cgroup_conf`, `host_containers_internal_ip`, `runtimes`, `conmon_path`, `cgroups` or `umask` at all, the list
193
+ * `PODMAN_WIDENING_KEYS` in backends.mjs (`podmanConfWidening` in backend-podman.mjs, issues #428 and #450).
194
+ */
195
+ export const PODMAN_PINNED_FLAGS = Object.freeze(["--pid=private", "--ipc=private", "--uts=private", "--cgroupns=private", "--env-host=false", "--http-proxy=false"]);
196
+
197
+ /**
198
+ * The shared body of both builders. `userns` is the caller's, never the spec's: each public builder has already decided
199
+ * what its runtime may say, and reading the spec here would let a docker argv carry a flag its CLI refuses.
200
+ */
201
+ function argsFromSpec(spec, { userns }) {
124
202
  // The builder CANNOT DECLINE the boundary. `containerSpec` cannot produce anything but `true`, so this
125
203
  // only ever fires on a hand-built spec -- and a hand-built spec that forgot the field is exactly the
126
204
  // case that must fail loudly rather than quietly emit a container with no isolation flags at all.
@@ -129,7 +207,9 @@ export function dockerArgsFromSpec(spec) {
129
207
  // Same refusal, one level down. `dockerExtra` is raw Docker flags by design, and it lands after the
130
208
  // boundary where a repeat supersedes it -- so the escape hatch is bounded by what it may not say.
131
209
  // Split on `=` so `--network=foo` is caught alongside `--network foo`.
132
- for (const flag of spec.dockerExtra ?? []) {
210
+ const extra = spec.dockerExtra ?? [];
211
+ for (let i = 0; i < extra.length; i++) {
212
+ const flag = extra[i];
133
213
  // REFUSED, not skipped. Skipping a non-string still PUSHED it into the argv below, so
134
214
  // `new String("--privileged")` and `{ toString: () => "--privileged" }` walked past the check and
135
215
  // then reached docker as the flag they stringify to.
@@ -139,7 +219,21 @@ export function dockerArgsFromSpec(spec) {
139
219
  if (DOCKER_EXTRA_FORBIDDEN.includes(flag.split("=", 1)[0])) {
140
220
  throw new Error(`docker run: refusing a dockerExtra flag that would supersede the isolation boundary: ${flag}`);
141
221
  }
222
+ // Fused short flags (see the list's header). After the list check, so a listed flag keeps its own refusal.
223
+ if (/^-[^-]/.test(flag) && flag.length > 2) {
224
+ throw new Error(`docker run: refusing a dockerExtra flag that would supersede the isolation boundary: ${flag} (one short flag per token)`);
225
+ }
226
+ if (DOCKER_EXTRA_ALLOWED.bare.includes(flag)) continue;
227
+ const valuePattern = Object.hasOwn(DOCKER_EXTRA_ALLOWED.valued, flag) ? DOCKER_EXTRA_ALLOWED.valued[flag] : null;
228
+ if (valuePattern && typeof extra[i + 1] === "string" && valuePattern.test(extra[i + 1])) {
229
+ i++;
230
+ continue;
231
+ }
232
+ throw new Error(`docker run: refusing a dockerExtra token outside what the builder's callers pass (-i, -t, -d, --entrypoint <command>, -p 127.0.0.1:<host>:<container>): ${JSON.stringify(flag)}`);
142
233
  }
234
+ // Re-checked here for a hand-built spec, the same reason `isolated` is.
235
+ assertJobUser(spec.user);
236
+ assertCidFile(spec.cidFile);
143
237
 
144
238
  // `--network` sits HERE, beside --memory and --cpus, and deliberately NOT inside ISOLATION_FLAGS.
145
239
  // That array is the LITERAL, value-free, unconditional set, and two separate places assert every member
@@ -153,6 +247,14 @@ export function dockerArgsFromSpec(spec) {
153
247
  // before this feature existed. Same shape as the sessionDir/outboxDir/globalPiDir mounts below.
154
248
  const args = ["run", `--name=${spec.name}`, ...ISOLATION_FLAGS, `--memory=${spec.memory}`, `--cpus=${spec.cpus}`];
155
249
  if (spec.network) args.push(`--network=${spec.network}`);
250
+ else if (userns) args.push("--network=private");
251
+ // null => ABSENT, so a job the image's own USER runs has an argv byte-identical to one built before issue #341.
252
+ if (spec.user) args.push(`--user=${spec.user}`);
253
+ // Issue #354: IMMEDIATELY after `--user=`, which it qualifies, and before `dockerExtra`, where `--userns` is refused.
254
+ if (userns) args.push(`--userns=${userns}`, ...PODMAN_PINNED_FLAGS);
255
+ // null => ABSENT, so every argv but a job's is byte-identical to one built before issue #345. BEFORE `dockerExtra`, and
256
+ // `--cidfile` is refused there, so no later token can move where the ID lands and turn the detached check off.
257
+ if (spec.cidFile) args.push(`--cidfile=${spec.cidFile}`);
156
258
  args.push(...(spec.dockerExtra ?? []));
157
259
 
158
260
  // Explicit env allowlist. Each entry is `-e NAME=VALUE`, built from the closed map -- so a
@@ -166,7 +268,16 @@ export function dockerArgsFromSpec(spec) {
166
268
  // `-v` and its value stay TWO argv elements rather than one `--volume=` token. Not cosmetic: the mount
167
269
  // assertions across this suite extract mounts by adjacency (`args[i - 1] === "-v"`), so collapsing the
168
270
  // pair would make those filters return nothing and turn several exact-array checks vacuously green.
169
- for (const m of spec.mounts ?? []) args.push("-v", `${m.host}:${m.container}${m.readOnly ? ":ro" : ""}`);
271
+ //
272
+ // The option list after the container path is `ro`, `Z`, or `ro,Z` (issue #355): one list, comma-joined, which is
273
+ // how both the docker CLI and Podman read it. `Z` only for a mount the spec marks `relabel: "private"`, so a spec
274
+ // without that field renders exactly the strings it always did. Anything else in that field is refused rather than
275
+ // dropped, because a mount that silently lost its label fails in the container with nothing pointing back here.
276
+ for (const m of spec.mounts ?? []) {
277
+ if (m.relabel !== undefined && m.relabel !== "private") throw new Error(`docker run: refusing a mount relabel other than "private": ${JSON.stringify(m.relabel)}`);
278
+ const options = [...(m.readOnly ? ["ro"] : []), ...(m.relabel === "private" ? ["Z"] : [])];
279
+ args.push("-v", `${m.host}:${m.container}${options.length > 0 ? `:${options.join(",")}` : ""}`);
280
+ }
170
281
 
171
282
  args.push(spec.image);
172
283
  return args;
@@ -182,3 +293,23 @@ export function dockerArgsFromSpec(spec) {
182
293
  export function buildDockerRunArgs(opts) {
183
294
  return dockerArgsFromSpec(containerSpec(opts));
184
295
  }
296
+
297
+ /**
298
+ * The `podman run` argv (excluding the leading "podman"), issue #354. `userns` is not the caller's to choose: a podman
299
+ * job runs keep-id or not at all (see `podmanArgsFromSpec`), so an opts bag carrying another value is overridden rather
300
+ * than read. A null `user` is refused by the builder, before anything spawns.
301
+ */
302
+ export function buildPodmanRunArgs(opts) {
303
+ return podmanArgsFromSpec(containerSpec({ ...opts, userns: "keep-id" }));
304
+ }
305
+
306
+ /**
307
+ * Whether `inner` is strictly inside `outer` (issue #355). The one containment rule that decides a workspace is the
308
+ * worker's own and may carry a private SELinux label: the job path (run-container) and a reopened sandbox both ask it, here beside the builder that renders the label,
309
+ * and it fails CLOSED, on an empty or non-string path, on `outer` itself and on anything that climbs out of it.
310
+ */
311
+ export function insideDir(outer, inner) {
312
+ if (typeof outer !== "string" || typeof inner !== "string" || outer === "" || inner === "") return false;
313
+ const rel = relative(outer, inner);
314
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
315
+ }