@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
@@ -1,4 +1,129 @@
1
- import { Redis } from "ioredis";
1
+ import { AbstractConnector, Command, Redis } from "ioredis";
2
+ import { Socket, connect as netConnect } from "node:net";
3
+ import { connect as tlsConnect } from "node:tls";
4
+ import { valkeyAuthRefusal } from "./valkey-auth.mjs";
5
+ import { valkeySchemeOf } from "./podman-stack.mjs";
6
+ import { decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
7
+
8
+ // Re-exported for the receiver and the admin, which import this module from the package (issues #464 and #468).
9
+ export { killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyUrlFor, valkeyUrlProblem };
10
+
11
+ /**
12
+ * The VALKEY_URL a CLI command uses from `cwd` (`valkeyUrlFor`), with a disagreement or an unreadable `.env` written as
13
+ * one warning line through `warn`. The one resolver every CLI verb and `service restart --drain` share.
14
+ */
15
+ export function cliValkeyUrl(env, { cwd, warn = (line) => process.stderr.write(line) } = {}) {
16
+ const resolved = valkeyUrlFor(valkeyClientContext({ env, ...(cwd ? { cwd } : {}) }));
17
+ if (resolved.note) warn(`warning: ${resolved.note}\n`);
18
+ return resolved.url;
19
+ }
20
+
21
+ /**
22
+ * An error a Valkey client reports, without the command it failed on (issue #468, PR #475's review). ioredis hangs
23
+ * `command: { name, args }` on every reply error, and for a failed AUTH the args ARE the password: after a rotation,
24
+ * a client still sending the old one emitted `WRONGPASS` with it attached, and BullMQ, whose Queue and Worker had no
25
+ * `error` listener, fell back to `console.error(err)`, which prints the whole object, password included (measured
26
+ * against a real Redis with CONFIG SET requirepass and CLIENT KILL). The args also carry job data (task text) for any
27
+ * other failed command. So only the command's NAME is kept. Mutates and returns `err`; anything else passes through.
28
+ */
29
+ export function scrubValkeyError(err) {
30
+ if (err && typeof err === "object" && err.command && typeof err.command === "object") {
31
+ try {
32
+ err.command = { name: err.command.name };
33
+ } catch {
34
+ // A frozen error: nothing this client made.
35
+ }
36
+ }
37
+ return err;
38
+ }
39
+
40
+ /**
41
+ * WHERE the scrub runs (PR #475's review, round 2 corrected the first version of this comment, which claimed the emit
42
+ * hook alone covered every path). ioredis hands an error to three kinds of consumer, and a failed AUTH reaches all
43
+ * three with the password in `command.args`:
44
+ * - a listener, through `emit("error")`;
45
+ * - nobody, through `silentEmit("error")`, which SKIPS `emit()` when the client has no `error` listener (the worker's
46
+ * shared client, a CLI probe): ioredis then prints only `error.stack`, but the same object was already handed
47
+ * - to every command waiting on the connection, through that command's `reject` (`flushQueue`, `abortError`, a
48
+ * reply error): an awaited or unhandled rejection that anything may print whole (measured by the review: two
49
+ * occurrences of the password from a listener-less client after a rotation).
50
+ * So the hook sits where each of them receives the object: `Redis.prototype.emit` and `silentEmit` for the events, and
51
+ * every `Command`'s `reject` (wrapped as `initPromise` makes it) for the rejections. BullMQ and this module share the
52
+ * one ioredis, so its clients are covered by the same hooks.
53
+ */
54
+ const SCRUBS = Symbol.for("pi-dispatch.valkey-error-scrub");
55
+ if (!Redis.prototype[SCRUBS]) {
56
+ const emit = Redis.prototype.emit;
57
+ Redis.prototype.emit = function emitScrubbed(event, ...args) {
58
+ if (event === "error") scrubValkeyError(args[0]);
59
+ return emit.call(this, event, ...args);
60
+ };
61
+ const silentEmit = Redis.prototype.silentEmit;
62
+ Redis.prototype.silentEmit = function silentEmitScrubbed(event, arg, ...rest) {
63
+ if (event === "error") scrubValkeyError(arg);
64
+ return silentEmit.call(this, event, arg, ...rest);
65
+ };
66
+ const initPromise = Command.prototype.initPromise;
67
+ Command.prototype.initPromise = function initPromiseScrubbed(...args) {
68
+ const out = initPromise.apply(this, args);
69
+ const reject = this.reject;
70
+ if (typeof reject === "function") this.reject = (err) => reject(scrubValkeyError(err));
71
+ return out;
72
+ };
73
+ Redis.prototype[SCRUBS] = true;
74
+ }
75
+
76
+ /**
77
+ * A database the server refuses is a REFUSAL, never database 0 (gate round 2 of PR #478). ioredis 5.11.1 applies `db`
78
+ * with a SELECT in its connect handler (redis/event_handler.js), before the ready check, and on a failure only
79
+ * `silentEmit`s the error and carries on: the client went ready on database 0, so `status`, `cancel`, the worker and
80
+ * doctor used database 0 of a Valkey whose `/16` does not exist, possibly another deployment's queue (measured against
81
+ * Valkey: "ERR DB index is out of range", then a write through the `/16` client read back from database 0).
82
+ *
83
+ * Here, where that error surfaces, the client is stopped before its ready check can finish: the refusal
84
+ * (`valkeyRefusal`, a configuration error naming the index) rejects every command queued so far, the client closes
85
+ * without reconnecting, `JudgedConnector.check` fails the ready check that was already in flight, and a later connect
86
+ * rejects with the same refusal. Every client of the project is built on that connector, so none goes ready on
87
+ * another database. The start judgements (`judgeValkeyAtStart`, the worker's `refuseValkeyAuth`) and doctor read the
88
+ * server's `databases` count for their sentence (`valkeyAuthState`).
89
+ */
90
+ export const DB_REFUSED = Symbol.for("pi-dispatch.valkey-db-refused");
91
+ const SELECT_HOOK = Symbol.for("pi-dispatch.valkey-select-refusal");
92
+ /** Whether `err` is a server's refusal of a SELECT for a database it does not have. */
93
+ function isSelectRangeError(err) {
94
+ return Boolean(err) && typeof err === "object" && String(err.command?.name ?? "").toLowerCase() === "select" && /DB index is out of range|invalid DB index/i.test(String(err.message ?? ""));
95
+ }
96
+ if (!Redis.prototype[SELECT_HOOK]) {
97
+ const silentEmit = Redis.prototype.silentEmit;
98
+ Redis.prototype.silentEmit = function silentEmitSelectRefused(event, arg, ...rest) {
99
+ if (event === "error" && isSelectRangeError(arg)) {
100
+ if (!this[DB_REFUSED]) {
101
+ const refusal = Object.assign(valkeyRefusal(valkeyDbRangeSentence(this.options?.[JUDGE]?.url ?? "", this.condition?.select ?? this.options?.db)), { valkeyDbRange: true, cause: scrubValkeyError(arg) });
102
+ this[DB_REFUSED] = refusal;
103
+ if (this.connector) this.connector[DB_REFUSED] = refusal;
104
+ try {
105
+ this.flushQueue(refusal);
106
+ } catch {}
107
+ this.disconnect();
108
+ }
109
+ // Said to a listener only: without one, every command of the client already rejects with the refusal, and
110
+ // ioredis' own "Unhandled error event" print would add a stack trace to that sentence.
111
+ return this.listeners("error").length > 0 ? silentEmit.call(this, event, this[DB_REFUSED], ...rest) : false;
112
+ }
113
+ return silentEmit.call(this, event, arg, ...rest);
114
+ };
115
+ Redis.prototype[SELECT_HOOK] = true;
116
+ }
117
+
118
+ /**
119
+ * The `error` listener every BullMQ Queue and Worker of the project carries (issue #468): without one, BullMQ prints
120
+ * the whole error object with `console.error`. This writes one line, the message only (scrubbed of any command), and
121
+ * `what` naming whose error it is.
122
+ */
123
+ export function onValkeyError(emitter, what, write = (line) => process.stderr.write(line)) {
124
+ emitter.on?.("error", (err) => write(`[pi-dispatch] Valkey error (${what}): ${String(scrubValkeyError(err)?.message ?? err)}\n`));
125
+ return emitter;
126
+ }
2
127
 
3
128
  /**
4
129
  * Connection helpers for BullMQ and the budget's raw Redis client, both from one VALKEY_URL.
@@ -15,14 +140,39 @@ import { Redis } from "ioredis";
15
140
  * error in a couple of seconds with a clear message, not hang forever. The long-running WORKER
16
141
  * uses the default (persistent) options -- it should ride out a Valkey restart, not give up.
17
142
  */
18
- export function parseConnection(url, { failFast = false } = {}) {
143
+ export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false } = {}) {
19
144
  const u = new URL(url);
145
+ // PR #478's gate: a path that is not a database number was `db: NaN`, and the SELECT ioredis then sent failed outside
146
+ // every caller's await (an unhandled rejection). Refused here, where every client of the project is made, as the
147
+ // configuration refusal it is; `judgeValkeyAtStart` says it first at each start.
148
+ const dbProblem = valkeyUrlProblem(url);
149
+ if (dbProblem) throw valkeyRefusal(dbProblem);
150
+ const where = context ?? defaultValkeyContext();
151
+ // Issue #468: the password this client sends, by `valkeyPasswordFor`'s rule (the URL's own, else VALKEY_PASSWORD from
152
+ // the environment, else from the deployment `.env` for a loopback host). Here and nowhere else, so every client of the
153
+ // project (the worker, the CLI, the receiver, the admin panel, doctor) authenticates the same way. The URL's userinfo
154
+ // is percent-decoded: it was handed over raw before, so a password with `@` written `%40` never matched.
155
+ const { password } = valkeyPasswordFor(url, where, { withoutPassword });
20
156
  return {
21
- host: u.hostname || "127.0.0.1",
157
+ // Issue #464 (gate round 2 follow-up): EVERY client made from these options connects through `JudgedConnector`,
158
+ // which judges and pins the address (valkey-endpoint.mjs) before each connect; `host` below is only what ioredis
159
+ // reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given.
160
+ Connector: JudgedConnector,
161
+ // `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given).
162
+ [JUDGE]: { url, context: where, ...(judge ? { judge } : {}) },
163
+ // WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name
164
+ // "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on
165
+ // Fedora 44: ETIMEDOUT). The address is the part inside them.
166
+ host: u.hostname.replace(/^\[(.*)\]$/, "$1") || "127.0.0.1",
22
167
  port: Number(u.port || 6379),
23
- ...(u.password ? { password: u.password } : {}),
24
- ...(u.username ? { username: u.username } : {}),
168
+ ...(password ? { password } : {}),
169
+ ...(u.username && !withoutPassword ? { username: decodedUserinfo(u.username) } : {}),
25
170
  ...(u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {}),
171
+ // TLS for `rediss:` (issue #464, gate round 2). Host and port alone dropped it, so a `rediss:` VALKEY_URL reached
172
+ // BullMQ as plaintext. `servername` is the name a certificate is checked against when the worker connects to a
173
+ // pinned address rather than to that name (`pinnedValkeyUrl`).
174
+ // `valkeys:` is `rediss:`'s alias (`valkeySchemeOf`, gate round 3): TLS as well.
175
+ ...(valkeySchemeOf(u.protocol) === "rediss:" ? { tls: servername ? { servername } : {} } : {}),
26
176
  maxRetriesPerRequest: null, // required for BullMQ blocking connections
27
177
  ...(failFast
28
178
  ? {
@@ -34,7 +184,223 @@ export function parseConnection(url, { failFast = false } = {}) {
34
184
  };
35
185
  }
36
186
 
37
- /** A raw ioredis client for the budget's INCR/EXPIRE. */
38
- export function makeRedisClient(url) {
39
- return new Redis(url, { maxRetriesPerRequest: null });
187
+ /**
188
+ * A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
189
+ * `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
190
+ * `failFast` as ioredis and `parseConnection` read them.
191
+ */
192
+ export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false } = {}) {
193
+ return new Redis({ ...parseConnection(url, { failFast, servername, context, judge, withoutPassword }), ...(lazyConnect ? { lazyConnect: true } : {}) });
194
+ }
195
+
196
+ /**
197
+ * How the Valkey at `url` answers this client's credential (issue #468), through a fail-fast client built like every
198
+ * other: `{ state }`, one of
199
+ * "ok" PING answered PONG with what this client sends (a password or none);
200
+ * "noauth" it requires a password and this client sent none (NOAUTH);
201
+ * "wrongpass" it refused the password this client sent (WRONGPASS, or "invalid password");
202
+ * "unreachable" anything else (nothing answers, a refused owner), with its `error` text.
203
+ * `withoutPassword` asks as a client that sends none would: "ok" then means this Valkey answers ANY local account.
204
+ * The password itself never leaves this function; the answer names only which state it is. Never throws.
205
+ */
206
+ export async function valkeyAuthState(url, { context = null, servername = null, withoutPassword = false, timeoutMs = 5000, makeClient = makeRedisClient } = {}) {
207
+ const seen = [];
208
+ let client;
209
+ try {
210
+ client = makeClient(url, { failFast: true, lazyConnect: true, context, servername, withoutPassword });
211
+ } catch (err) {
212
+ return { state: "unreachable", error: err?.message ?? String(err) };
213
+ }
214
+ // ioredis reports an AUTH or ready-check refusal as an "error" event and then reconnects (bounded by failFast), while
215
+ // connect() itself may reject with only "Connection is closed.", so the events are kept and read with the reply.
216
+ client.on("error", (err) => seen.push(err?.message ?? String(err)));
217
+ let timer;
218
+ try {
219
+ const reply = await Promise.race([
220
+ client.connect().then(() => client.ping()),
221
+ new Promise((_, reject) => {
222
+ timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs);
223
+ }),
224
+ ]);
225
+ return reply === "PONG" ? { state: "ok" } : { state: "unreachable", error: `PING answered ${String(reply).slice(0, 40)}` };
226
+ } catch (err) {
227
+ if (client[DB_REFUSED]) return { state: "dbrange", error: await dbRangeSentenceFor(url, client, { context, servername, makeClient, timeoutMs }) };
228
+ const all = [err?.message ?? String(err), ...seen].join("\n");
229
+ if (/\bWRONGPASS\b|invalid password/i.test(all)) return { state: "wrongpass" };
230
+ if (/\bNOAUTH\b/.test(all)) return { state: "noauth" };
231
+ return { state: "unreachable", error: err?.message ?? String(err) };
232
+ } finally {
233
+ clearTimeout(timer);
234
+ client.disconnect();
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The refusal sentence for a client whose SELECT was refused, with the server's `databases` count read by a second
240
+ * client on database 0 (`CONFIG GET databases`; null where that is refused, a managed Valkey say). The second client
241
+ * only reads that setting and never touches a key.
242
+ */
243
+ async function dbRangeSentenceFor(url, client, { context, servername, makeClient, timeoutMs }) {
244
+ let databases = null;
245
+ let probe;
246
+ let timer;
247
+ try {
248
+ const u = new URL(url);
249
+ u.pathname = "";
250
+ probe = makeClient(u.toString(), { failFast: true, lazyConnect: true, context, servername });
251
+ probe.on?.("error", () => {});
252
+ const reply = await Promise.race([
253
+ probe.connect().then(() => probe.config("GET", "databases")),
254
+ new Promise((_, reject) => {
255
+ timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
256
+ }),
257
+ ]);
258
+ const n = Array.isArray(reply) ? Number(reply[1]) : NaN;
259
+ databases = Number.isInteger(n) && n > 0 ? n : null;
260
+ } catch {
261
+ // Unreadable: the sentence says so.
262
+ } finally {
263
+ clearTimeout(timer);
264
+ probe?.disconnect?.();
265
+ }
266
+ return valkeyDbRangeSentence(url, client.options?.db ?? client.condition?.select, databases);
267
+ }
268
+
269
+ /**
270
+ * `judgeValkeyAtStart` of valkey-endpoint.mjs (the owner judgement, retried while nothing answers), then this client's
271
+ * credential asked once (issue #468): a Valkey that refuses it is a refusal (a configError, exit 2, like a refused
272
+ * owner), since a restart sends the same credential, and names VALKEY_PASSWORD without its value. Nothing answering
273
+ * stays what it was: the command's own connect says it could not reach Valkey. `checkAuth` is a seam, the real check
274
+ * unless the caller injected its own `judge` (a test standing in for the host, which has no Valkey to ask).
275
+ */
276
+ export async function judgeValkeyAtStart(url, context, opts = {}) {
277
+ const endpoint = await judgeEndpointAtStart(url, context, opts);
278
+ const checkAuth = opts.checkAuth !== undefined ? opts.checkAuth : opts.judge ? null : valkeyAuthState;
279
+ if (checkAuth) {
280
+ const where = context ?? defaultValkeyContext();
281
+ const { state, error } = await checkAuth(url, { context: where, servername: endpoint?.servername ?? null });
282
+ // Gate round 2 of PR #478: a database the server does not have is a refusal too, never database 0.
283
+ if (state === "dbrange") throw Object.assign(new Error(error), { piDispatchConfig: true, valkeyRefused: true, valkeyDbRange: true });
284
+ const refusal = state === "noauth" || state === "wrongpass" ? authRefusalFor(state, url, where) : null;
285
+ if (refusal) throw Object.assign(new Error(refusal), { piDispatchConfig: true, valkeyRefused: true, valkeyAuth: state });
286
+ }
287
+ return endpoint;
288
+ }
289
+
290
+ /** The refusal sentence for an auth state, naming where the password would come from (never its value). */
291
+ export function authRefusalFor(state, url, context) {
292
+ const { password, from } = valkeyPasswordFor(url, context);
293
+ return valkeyAuthRefusal(new Error(state === "noauth" ? "NOAUTH" : "WRONGPASS"), { passwordSet: Boolean(password), from, envPath: context?.envPath ?? "the deployment's .env" });
294
+ }
295
+
296
+ /**
297
+ * The key under which a client's options carry what `JudgedConnector` judges: the URL as written and the context. A
298
+ * string, not a Symbol: ioredis merges its options with lodash `defaults`, which copies string keys only.
299
+ */
300
+ const JUDGE = "piDispatchValkeyJudge";
301
+
302
+ /**
303
+ * ioredis' connector for every client of this project (issue #464, gate round 2 follow-up): on each connect it asks
304
+ * `judgedEndpoint` for the address, then dials that literal (with a `rediss:` URL's name kept as the TLS servername),
305
+ * exactly as ioredis' own StandaloneConnector dials a host.
306
+ *
307
+ * A judgement that throws (nothing answers while the Valkey restarts, a refusal, a name that does not resolve) must NOT
308
+ * reject `connect()`: ioredis 5.11.1 then sets the client's status to "end" and never retries, so one Valkey restart
309
+ * killed every client for good (gate round 3, measured: "Connection is closed." on the next job, never retried). So
310
+ * `connect()` resolves a socket already destroyed, with the error as `firstError`: ioredis' own branch for a stream that
311
+ * failed before it was handed over, which reports that error and takes the close path, retrying by the client's
312
+ * strategy and judging again on the next connect. A client with a bounded strategy (the CLI's failFast) then ends with
313
+ * that error. Destroyed before it is returned, never on a later tick: a socket that is neither connecting nor destroyed
314
+ * when ioredis looks is taken as connected, and its ready check then fails on a dead stream (measured on the VMs:
315
+ * "Stream isn't writeable" printed beside every failed judgement).
316
+ */
317
+ export class JudgedConnector extends AbstractConnector {
318
+ constructor(options) {
319
+ super(options.disconnectTimeout);
320
+ this.options = options;
321
+ }
322
+
323
+ /** ioredis' ready check: failed for a client whose SELECT was refused (`DB_REFUSED`), so it never goes ready. */
324
+ check() {
325
+ return !this[DB_REFUSED];
326
+ }
327
+
328
+ connect() {
329
+ const { options } = this;
330
+ // A database the server refused stays refused: no reconnect goes ready on another one.
331
+ if (this[DB_REFUSED]) return Promise.reject(this[DB_REFUSED]);
332
+ this.connecting = true;
333
+ const judge = options[JUDGE];
334
+ if (!judge) return Promise.reject(new Error("a Valkey client was built without parseConnection's judged options"));
335
+ return (judge.judge ?? judgedEndpoint)(judge.url, judge.context).then(
336
+ (endpoint) => {
337
+ if (!this.connecting) throw new Error("Connection is closed.");
338
+ const target = { host: endpoint.host, port: options.port, ...(options.family != null ? { family: options.family } : {}) };
339
+ if (options.tls) Object.assign(target, options.tls, endpoint.servername ? { servername: endpoint.servername } : {});
340
+ this.stream = options.tls ? JudgedConnector.dial.tls(target) : JudgedConnector.dial.net(target);
341
+ this.stream.once("error", (err) => {
342
+ this.firstError = err;
343
+ });
344
+ return this.stream;
345
+ },
346
+ (err) => {
347
+ if (!this.connecting) throw new Error("Connection is closed.");
348
+ const stream = new Socket();
349
+ this.stream = stream;
350
+ this.firstError = err;
351
+ // The destroyed socket's own "error" event comes on a later tick; ioredis reports firstError instead.
352
+ stream.on("error", () => {});
353
+ stream.destroy(err);
354
+ return stream;
355
+ },
356
+ );
357
+ }
358
+ }
359
+
360
+ /** How `JudgedConnector` dials, net and tls: a seam, so a test sees the options a pinned connect is made with. */
361
+ JudgedConnector.dial = { net: netConnect, tls: tlsConnect };
362
+
363
+ /**
364
+ * Whether `connection` (options for BullMQ, or an ioredis client) connects through `JudgedConnector`. `makeQueue` and the
365
+ * worker refuse any other, so no Queue or Worker of this project can be built on an unjudged connection.
366
+ */
367
+ export function isJudgedConnection(connection) {
368
+ const options = connection?.options ?? connection;
369
+ return options?.Connector === JudgedConnector && Boolean(options?.[JUDGE]);
370
+ }
371
+
372
+ /** Throws unless `connection` is judged (`isJudgedConnection`). */
373
+ export function assertJudgedConnection(connection) {
374
+ if (!isJudgedConnection(connection)) throw new TypeError("a Valkey connection must be built by parseConnection (connection.mjs), which judges and pins the address it dials (issue #464)");
375
+ }
376
+
377
+ /**
378
+ * Record, then read back, which deployment folder the queue in a Valkey belongs to (PR #475's review, the volume gap):
379
+ * `SET pi-dispatch:owner <folder> NX`, then `GET`. Docker volume labels cannot be added after creation, so a volume made
380
+ * before the label (adopted with consent) carries its owner here, inside the data it describes, and every later start
381
+ * on it checks this key. Retried for `waitMs` while a Valkey that was just started comes up. `{ owner, claimed }` where
382
+ * `owner` is what the key holds (this folder, or another one's), or `{ error }` when it could not be read.
383
+ */
384
+ export async function claimValkeyOwner(url, folder, { context = null, waitMs = 15000, makeClient = makeRedisClient, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
385
+ const { OWNER_MARKER_KEY } = await import("./valkey-auth.mjs");
386
+ const until = Date.now() + waitMs;
387
+ let last = "no attempt";
388
+ for (;;) {
389
+ let client;
390
+ try {
391
+ client = makeClient(url, { failFast: true, lazyConnect: true, context });
392
+ client.on("error", () => {});
393
+ await client.connect();
394
+ const set = await client.set(OWNER_MARKER_KEY, folder, "NX");
395
+ const owner = await client.get(OWNER_MARKER_KEY);
396
+ return { owner, claimed: set === "OK" };
397
+ } catch (err) {
398
+ last = err?.message ?? String(err);
399
+ // NOAUTH and WRONGPASS will not change by waiting.
400
+ if (/\bNOAUTH\b|\bWRONGPASS\b/.test(last) || Date.now() >= until) return { error: last };
401
+ } finally {
402
+ client?.disconnect();
403
+ }
404
+ await sleep(500);
405
+ }
40
406
  }
@@ -45,6 +45,59 @@ export const CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
45
45
  export const CONTAINER_SESSION_DIR = "/session";
46
46
  export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
47
47
 
48
+ /**
49
+ * The job image's home, and the uid its `USER` directive runs as (issue #341, `DES-JOB-USER-INFERRED-READ-BACK-ON-
50
+ * REQUEST`). A job run under `--user` needs `HOME=CONTAINER_HOME` beside it, because such a uid has no passwd entry in
51
+ * the image: Docker gives it `HOME=/`, Podman `HOME=/workspace` (both measured). The builder does not pair them; a job
52
+ * or sandbox path that passes a `user` must. `SHIPPED_IMAGE_UID` is the one uid that needs no `--user` at all, since
53
+ * the image already runs as it; a test pins it against `image/Dockerfile`'s `useradd` so the two cannot drift.
54
+ */
55
+ export const CONTAINER_HOME = "/home/pi";
56
+ export const SHIPPED_IMAGE_UID = 1001;
57
+
58
+ // "<uid>:<gid>", both non-zero decimal. A bare uid is refused because docker then takes the group from the image's
59
+ // passwd, which is gid 0 for a uid it has no entry for; a name is refused because the image decides what it means.
60
+ const JOB_USER_RE = /^[1-9]\d{0,9}:[1-9]\d{0,9}$/;
61
+
62
+ /**
63
+ * The host path `docker run` writes the container's ID to (issue #345), or `null`. Absolute (a POSIX path, or a Windows
64
+ * drive path for a worker on Windows), no `..` segment and no control byte, because it rides a single
65
+ * `--cidfile=<path>` token (the CLI splits on the first `=`) and names a file the worker later reads and removes. The worker puts it BESIDE the job
66
+ * directory, never inside the `/job:ro` mount.
67
+ */
68
+ export function assertCidFile(cidFile) {
69
+ if (cidFile === null || cidFile === undefined) return;
70
+ const absolute = typeof cidFile === "string" && (cidFile.startsWith("/") || /^[A-Za-z]:[\\/]/.test(cidFile));
71
+ if (!absolute || cidFile.split(/[\\/]/).includes("..") || /[\u0000-\u001f\u007f]/.test(cidFile)) {
72
+ throw new Error(`docker run: refusing a cidfile that is not a plain absolute path: ${JSON.stringify(cidFile)}`);
73
+ }
74
+ }
75
+
76
+ /** Throws unless `user` is null/undefined or a non-root "<uid>:<gid>" (`nonRoot`, issue #341). */
77
+ export function assertJobUser(user) {
78
+ if (user === null || user === undefined) return;
79
+ if (typeof user !== "string" || !JOB_USER_RE.test(user)) {
80
+ throw new Error(`docker run: refusing a job user that is not "<uid>:<gid>" with both parts non-zero: ${JSON.stringify(user)}`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The user-namespace modes a spec may ask for (issue #354): `null`, the runtime's default, or `"keep-id"`, which maps the
86
+ * worker's own uid and gid to the SAME ids inside a rootless Podman container, so a job run as the worker's own
87
+ * "<uid>:<gid>" owns its bind mounts exactly as the worker does. A CLOSED set, and refused rather than passed through,
88
+ * because the value reaches an argv as one token and every other spelling Podman accepts (`host`, `auto`, `nomap`, a
89
+ * path to another process's namespace) changes which uids a job can reach on the host.
90
+ */
91
+ export const USERNS_MODES = Object.freeze(["keep-id"]);
92
+
93
+ /** Throws unless `userns` is null/undefined or a member of `USERNS_MODES` (issue #354). */
94
+ export function assertUserns(userns) {
95
+ if (userns === null || userns === undefined) return;
96
+ if (typeof userns !== "string" || !USERNS_MODES.includes(userns)) {
97
+ throw new Error(`container spec: refusing a userns other than null or ${USERNS_MODES.map((m) => JSON.stringify(m)).join(", ")}: ${JSON.stringify(userns)}`);
98
+ }
99
+ }
100
+
48
101
  /**
49
102
  * WHAT the box is, with no Docker vocabulary in it.
50
103
  *
@@ -57,8 +110,8 @@ export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
57
110
  * strings, because the flattening IS the Docker part: a runtime that does not bind-mount has to be able to
58
111
  * see which host path becomes which container path, and what may be written.
59
112
  *
60
- * `dockerExtra` is named for what it is. It carries raw Docker flags (`-i -t --entrypoint bash`, a
61
- * Linux-only `--user`), so it is the one field a non-Docker consumer must refuse rather than translate.
113
+ * `dockerExtra` is named for what it is. It carries raw Docker flags (`-i -t --entrypoint bash`), so it is the one
114
+ * field a non-Docker consumer must refuse rather than translate. The job user is NOT in it: it is `user`, a field.
62
115
  * Calling it `extraFlags` at the boundary would have hidden that.
63
116
  *
64
117
  * @param image pinned job image tag/digest
@@ -73,7 +126,18 @@ export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
73
126
  * @param memory e.g. "4g"; cpus e.g. "2"
74
127
  * @param network the per-job egress network this container joins (REQ-EGRESS-ALLOWLIST); null = the
75
128
  * docker default bridge, which is what every job did before that requirement existed
76
- * @param extraFlags escape hatch for a Linux-only --user uid:gid on a bind-mounted local folder
129
+ * @param user "<uid>:<gid>" the job runs as (issue #341), or null for the image's own USER. Portable: it says WHO
130
+ * runs the box, which a non-docker runtime must honour too.
131
+ * @param userns null (the runtime's own default) or "keep-id" (issue #354): how the job user's ids map to the host's.
132
+ * Portable in meaning, but only one runtime spells it: `dockerArgsFromSpec` REFUSES a non-null one,
133
+ * because the docker CLI rejects `--userns=keep-id` client-side (exit 125, measured in issue #345), and
134
+ * dropping it instead would run the job under a uid mapping nobody asked for.
135
+ * @param extraFlags raw docker flags for the few callers that need them (the sandbox's -i -t --entrypoint bash)
136
+ * @param relabel true where the daemon confines containers with SELinux (`relabelsPrivateMounts`, issue #355): the
137
+ * worker's own per-job mounts then carry `relabel: "private"`. Portable: it says WHICH host paths the
138
+ * runtime may re-own for this one container, which a non-docker runtime must honour or refuse.
139
+ * @param workspaceOwned true when `workspace` is the worker's own per-job directory (a forge job's clone, a sandbox's
140
+ * retained copy), false when it is the operator's folder. Read only when `relabel` is true.
77
141
  */
78
142
  export function containerSpec({
79
143
  image,
@@ -87,21 +151,41 @@ export function containerSpec({
87
151
  memory = "4g",
88
152
  cpus = "2",
89
153
  network = null,
154
+ user = null,
155
+ userns = null,
156
+ cidFile = null,
90
157
  extraFlags = [],
158
+ relabel = false,
159
+ workspaceOwned = false,
91
160
  }) {
92
161
  if (!image) throw new Error("docker run: image is required");
162
+ assertJobUser(user);
163
+ assertUserns(userns);
164
+ assertCidFile(cidFile);
93
165
  if (!name) throw new Error("docker run: container name is required");
94
166
  if (!workspace) throw new Error("docker run: workspace mount is required");
167
+ // Booleans, strictly: a truthy string from a caller that forwarded an option bag must not re-own host directories.
168
+ if (typeof relabel !== "boolean") throw new Error(`docker run: relabel must be a boolean; got ${typeof relabel}`);
169
+ if (typeof workspaceOwned !== "boolean") throw new Error(`docker run: workspaceOwned must be a boolean; got ${typeof workspaceOwned}`);
95
170
 
171
+ // Issue #355. A SPREAD rather than a `relabel: null` field, so a spec built without it has mount objects byte-identical
172
+ // to every one built before this feature existed. PRIVATE, never shared: each of these directories is made for this
173
+ // one container, and the shared form (`:z`) would leave it readable by every other container on the host, which is
174
+ // the job-to-job reach the per-job directory exists to deny.
175
+ const own = relabel ? { relabel: "private" } : {};
96
176
  const mounts = [];
97
177
  // The WHOLE /job dir is read-only (INT-CONTAINER-JOB-INPUTS): it holds prompt.md and pi/, and
98
178
  // the agent cannot rewrite any of it. /workspace is the only writable mount.
99
- if (jobDir) mounts.push({ host: jobDir, container: "/job", readOnly: true });
100
- mounts.push({ host: workspace, container: "/workspace", readOnly: false });
179
+ if (jobDir) mounts.push({ host: jobDir, container: "/job", readOnly: true, ...own });
180
+ // A local job's /workspace IS the operator's folder, and `:Z` would re-own it for this one container: measured, a
181
+ // second container mounting it afterwards is denied, and the label an operator chose is gone. So only a workspace the
182
+ // worker made is relabelled; an operator's folder needs the `semanage fcontext` fix doctor names, and the runner
183
+ // refuses one it cannot read before any spend.
184
+ mounts.push({ host: workspace, container: "/workspace", readOnly: false, ...(workspaceOwned ? own : {}) });
101
185
  // Local jobs get a writable /outbox host bind, the same host-bind mechanism as /workspace
102
186
  // (DES-WORKER-ON-HOST). github jobs pass no outboxDir, so the request channel does not exist for
103
187
  // them -- an untrusted issue author cannot chain (INT-OUTBOX-CONTRACT).
104
- if (outboxDir) mounts.push({ host: outboxDir, container: "/outbox", readOnly: false });
188
+ if (outboxDir) mounts.push({ host: outboxDir, container: "/outbox", readOnly: false, ...own });
105
189
 
106
190
  // This job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION, INT-SESSION-STORE-CONTRACT).
107
191
  // Writable, because pi appends to it as the agent works -- and per-job, exactly like jobDir, which is
@@ -110,11 +194,14 @@ export function containerSpec({
110
194
  // read and rewrite every other branch's and every other repository's transcripts, which is not a
111
195
  // weakening of that constraint but its inversion. Absent unless the trigger armed run.resume AND a key
112
196
  // resolved, so an unarmed job's argv is byte-identical to one built before this feature existed.
113
- if (sessionDir) mounts.push({ host: sessionDir, container: CONTAINER_SESSION_DIR, readOnly: false });
197
+ if (sessionDir) mounts.push({ host: sessionDir, container: CONTAINER_SESSION_DIR, readOnly: false, ...own });
114
198
 
115
199
  // The operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY): custom models, global skills, a global
116
200
  // persona, layered UNDER each repo's own .pi/. Read-only -- it is operator-authored deploy-time config,
117
201
  // the same trust class as the baked floor, but the agent still must not rewrite it. Both job kinds.
202
+ // NEVER relabelled (issue #355): every job mounts this one directory, so a private label would lock each job out of
203
+ // it in turn, and a shared one would re-own an operator's directory on every run. It needs the operator's
204
+ // `semanage fcontext` fix, which doctor names.
118
205
  if (globalPiDir) mounts.push({ host: globalPiDir, container: CONTAINER_GLOBAL_PI_DIR, readOnly: true });
119
206
 
120
207
  return {
@@ -123,6 +210,14 @@ export function containerSpec({
123
210
  memory,
124
211
  cpus,
125
212
  network,
213
+ user,
214
+ // Issue #354. Always present and `null` by default (the parameter default turns an `undefined` into it, so a spec
215
+ // has ONE spelling of "the runtime's default"), beside `user` because it qualifies it: the same "<uid>:<gid>" names
216
+ // a different host identity under a different mapping.
217
+ userns,
218
+ // Issue #345: where the runtime writes the container ID once it creates one, so a CLI that exits as if nothing
219
+ // started (a lost API connection) can be told apart from a container that runs on without it. null = absent.
220
+ cidFile,
126
221
  // UNCONDITIONALLY true, and there is deliberately no parameter that can unset it. The boundary is
127
222
  // not a thing a caller opts into -- CONST-ISOLATION-CONTAINER-PER-JOB is why every other flag here
128
223
  // exists -- so the spec is simply unable to describe an unisolated container, and the builder in