@edgehero/pi-dispatch 0.1.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 (69) hide show
  1. package/.env.example +160 -0
  2. package/deploy/com.pi-dispatch.worker.plist +66 -0
  3. package/deploy/nssm-install.cmd +59 -0
  4. package/deploy/receiver.service +36 -0
  5. package/deploy/worker-env-wrapper.cmd +50 -0
  6. package/deploy/worker-env-wrapper.sh +63 -0
  7. package/deploy/worker.service +55 -0
  8. package/package.json +83 -0
  9. package/src/azure-auth.mjs +61 -0
  10. package/src/azure-host.mjs +236 -0
  11. package/src/azure-identity.mjs +63 -0
  12. package/src/azure-prompt.mjs +118 -0
  13. package/src/branch.mjs +80 -0
  14. package/src/budget.mjs +179 -0
  15. package/src/cli.mjs +208 -0
  16. package/src/config.mjs +329 -0
  17. package/src/connection.mjs +40 -0
  18. package/src/cron.mjs +94 -0
  19. package/src/docker-run.mjs +119 -0
  20. package/src/doctor.mjs +1127 -0
  21. package/src/env-allowlist.mjs +198 -0
  22. package/src/env-file.mjs +153 -0
  23. package/src/exit-code.mjs +32 -0
  24. package/src/flow-gate.mjs +82 -0
  25. package/src/forgejo-auth.mjs +77 -0
  26. package/src/forgejo-host.mjs +172 -0
  27. package/src/forgejo-identity.mjs +74 -0
  28. package/src/forgejo-prompt.mjs +123 -0
  29. package/src/forges.mjs +148 -0
  30. package/src/get-token.mjs +226 -0
  31. package/src/git-dirty.mjs +16 -0
  32. package/src/github-app-setup.mjs +517 -0
  33. package/src/github-host.mjs +159 -0
  34. package/src/github-prompt.mjs +286 -0
  35. package/src/gitlab-auth.mjs +72 -0
  36. package/src/gitlab-host.mjs +200 -0
  37. package/src/gitlab-identity.mjs +61 -0
  38. package/src/gitlab-prompt.mjs +123 -0
  39. package/src/identity.mjs +57 -0
  40. package/src/image-preflight.mjs +180 -0
  41. package/src/import-pi.mjs +451 -0
  42. package/src/index.mjs +177 -0
  43. package/src/init.mjs +77 -0
  44. package/src/job-id.mjs +100 -0
  45. package/src/materialize.mjs +138 -0
  46. package/src/outbox.mjs +179 -0
  47. package/src/packages.mjs +188 -0
  48. package/src/pause-windows.mjs +218 -0
  49. package/src/prepare-github.mjs +260 -0
  50. package/src/prepare-local.mjs +76 -0
  51. package/src/prepare.mjs +199 -0
  52. package/src/pricing.mjs +168 -0
  53. package/src/processor.mjs +360 -0
  54. package/src/queue.mjs +152 -0
  55. package/src/run-container.mjs +133 -0
  56. package/src/run-history.mjs +534 -0
  57. package/src/runtime-settings.mjs +188 -0
  58. package/src/sandbox-cli.mjs +156 -0
  59. package/src/sandbox-store.mjs +269 -0
  60. package/src/sandbox.mjs +171 -0
  61. package/src/scheduler-stall-guard.mjs +67 -0
  62. package/src/schedules.mjs +62 -0
  63. package/src/service.mjs +677 -0
  64. package/src/session-key.mjs +108 -0
  65. package/src/session-store.mjs +249 -0
  66. package/src/start.mjs +502 -0
  67. package/src/subscriptions.mjs +208 -0
  68. package/src/triggers.mjs +491 -0
  69. package/src/up.mjs +315 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * `pi-dispatch import-pi` — stage a CREDENTIAL-FREE copy of the host's `pi` setup into a global overlay
3
+ * dir, so every job can reuse it (REQ-GLOBAL-PI-OVERLAY). Point `PI_GLOBAL_PI_DIR` at the result and the
4
+ * worker mounts it `:ro` into each container, layered UNDER each repo's own `.pi/`.
5
+ *
6
+ * The overlay must never carry a secret (CONST-TOKEN-SCOPED-PER-JOB): the provider key stays in the host's
7
+ * `auth.json` / env and reaches the container through the env allowlist, never a mounted file. So this
8
+ * command copies only the safe subset and REFUSES a `models.json` that embeds a literal key.
9
+ *
10
+ * Copied: models.json (definitions only, sanitized), skills/, APPEND_SYSTEM.md, and extensions/ (verbatim;
11
+ * the admin extension is hard-blocked). Extensions come along BY DEFAULT -- staging is the vetting
12
+ * step, and an overlay missing the operator's own extensions is not the setup they asked for --
13
+ * with `--no-extensions` as the escape hatch. Every extension staged is PRINTED by name, because
14
+ * this is the moment the operator can still see what is about to run inside every job container.
15
+ * Staged: packages/ — only under --with-packages — pinned third-party pi packages, installed here on the
16
+ * host so a job container can load them from the overlay with NO network access (issue #58).
17
+ * Never: auth.json, settings.json, sessions/, themes/, prompts/, tools/.
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync, renameSync, rmSync } from "node:fs";
20
+ import { execFile } from "node:child_process";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { promisify } from "node:util";
24
+ import { PACKAGES_SUBDIR, STAGE_MANIFEST, parsePackagesFile } from "./packages.mjs";
25
+
26
+ /** A valid skill/extension entry name: lowercase kebab/underscore, no dots (no "..") and no slashes. */
27
+ export const ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
28
+ /** The admin extension — never duplicated into a job overlay (it can enqueue paid jobs: a recursion vector). */
29
+ export const ADMIN_RE = /pi-dispatch|dispatch-admin/i;
30
+
31
+ /** The pi resource kinds a package may contribute by convention dir, when it carries no `pi` manifest. */
32
+ const RESOURCE_DIRS = ["extensions", "skills", "prompts", "themes"];
33
+
34
+ const execFileAsync = promisify(execFile);
35
+
36
+ /**
37
+ * The default package-stager runner. ARRAY argv, never a shell string -- a package name from a config file
38
+ * must never be able to become shell syntax on the operator's host. See `npmExecOptions` for the one
39
+ * platform on which `shell: true` is nonetheless unavoidable, and why it is safe there.
40
+ */
41
+ function defaultExec(file, args, options) {
42
+ return execFileAsync(file, args, options);
43
+ }
44
+
45
+ // Resolve the host's pi agent dir the way pi's getAgentDir() does (env override, else ~/.pi/agent).
46
+ // Custom: the worker CLI depends on @earendil-works/pi-ai/compat, not the whole pi-coding-agent SDK;
47
+ // importing the SDK just to read one well-known path is not worth the weight.
48
+ function defaultFrom(env) {
49
+ return env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
50
+ }
51
+
52
+ /** A config value that defers to the environment/a command rather than embedding a literal secret. */
53
+ function isIndirection(v) {
54
+ return typeof v === "string" && (v.startsWith("$") || v.startsWith("!"));
55
+ }
56
+
57
+ /**
58
+ * Find a literal secret in a parsed models.json. Returns a human-readable location, or null if clean.
59
+ * A provider `apiKey`, or an auth-ish header, that is a plain string (not `$ENV` / `!cmd`) is a literal.
60
+ */
61
+ export function findLiteralSecret(models) {
62
+ const providers = models?.providers;
63
+ if (!providers || typeof providers !== "object") return null;
64
+ for (const [name, cfg] of Object.entries(providers)) {
65
+ if (typeof cfg?.apiKey === "string" && !isIndirection(cfg.apiKey)) return `providers.${name}.apiKey`;
66
+ const headers = cfg?.headers;
67
+ if (headers && typeof headers === "object") {
68
+ for (const [h, val] of Object.entries(headers)) {
69
+ if (/auth|api[-_]?key|token|secret|bearer/i.test(h) && typeof val === "string" && !isIndirection(val)) {
70
+ return `providers.${name}.headers.${h}`;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ export async function runImportPi(argv = [], deps = {}) {
79
+ const {
80
+ env = process.env,
81
+ cwd = process.cwd(),
82
+ out = (s) => process.stdout.write(s),
83
+ fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync, renameSync, rmSync },
84
+ exec = defaultExec,
85
+ // Injected like `fs`/`exec`/`out` so the win32 npm branch below is reachable from a test on any host;
86
+ // it is the branch that was dead on arrival precisely because nothing could exercise it here.
87
+ platform = process.platform,
88
+ } = deps;
89
+
90
+ // Extensions are copied unless the operator says otherwise. `--with-extensions` is still accepted and is
91
+ // now a no-op, so an existing setup script keeps working and keeps meaning what it always meant.
92
+ const withExtensions = !argv.includes("--no-extensions");
93
+ const withPackages = argv.includes("--with-packages");
94
+ const from = flagValue(argv, "--from") ?? defaultFrom(env);
95
+ const to = flagValue(argv, "--to") ?? join(cwd, "pi-global");
96
+ const packagesFile = flagValue(argv, "--packages-file") ?? env.PI_PACKAGES_FILE ?? join(cwd, "pi-packages.json");
97
+
98
+ if (!fs.existsSync(from)) {
99
+ out(`error: no pi setup found at ${from}\n Is pi installed and configured? Set PI_CODING_AGENT_DIR or pass --from <dir>.\n`);
100
+ return 1;
101
+ }
102
+
103
+ // Pre-flight the one hard security gate BEFORE writing anything: a literal key in models.json aborts the
104
+ // whole import so no half-overlay is produced and no secret is ever written to the overlay.
105
+ const modelsSrc = join(from, "models.json");
106
+ let modelsText;
107
+ if (fs.existsSync(modelsSrc)) {
108
+ modelsText = fs.readFileSync(modelsSrc, "utf8");
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(modelsText);
112
+ } catch {
113
+ modelsText = null; // malformed: skip it with a warning rather than abort the whole import
114
+ }
115
+ if (parsed) {
116
+ const leak = findLiteralSecret(parsed);
117
+ if (leak) {
118
+ out(
119
+ `error: ${modelsSrc} embeds a literal secret at ${leak}.\n` +
120
+ ` The overlay is mounted into an adversarial-input container, so it must be credential-free.\n` +
121
+ ` Move the key to auth.json (\`pi\` login) or reference the environment (e.g. "$MY_KEY"), then re-run.\n`,
122
+ );
123
+ return 1;
124
+ }
125
+ }
126
+ }
127
+
128
+ const results = [];
129
+ fs.mkdirSync(to, { recursive: true });
130
+
131
+ // models.json — definitions only, already proven literal-secret-free above.
132
+ if (modelsText) {
133
+ fs.writeFileSync(join(to, "models.json"), modelsText);
134
+ results.push(["models.json", "custom model/provider definitions (credential-free)"]);
135
+ } else if (fs.existsSync(modelsSrc)) {
136
+ results.push(["models.json", "SKIPPED — not valid JSON"]);
137
+ }
138
+
139
+ // skills/ — copy each named skill dir (SKILL.md + its files), skipping symlinks and odd names.
140
+ const skillsCount = copyNamedDirs(fs, join(from, "skills"), join(to, "skills"), out);
141
+ if (skillsCount > 0) results.push(["skills/", `${skillsCount} skill${skillsCount === 1 ? "" : "s"}`]);
142
+
143
+ // APPEND_SYSTEM.md — the operator's global persona.
144
+ if (fs.existsSync(join(from, "APPEND_SYSTEM.md"))) {
145
+ fs.copyFileSync(join(from, "APPEND_SYSTEM.md"), join(to, "APPEND_SYSTEM.md"));
146
+ results.push(["APPEND_SYSTEM.md", "global persona (layers under each repo's persona)"]);
147
+ }
148
+
149
+ // extensions/ -- the sharp edge, and copied BY DEFAULT: the operator staged this overlay to be their own
150
+ // setup, and one they have to arm twice more is not that. The admin extension stays hard-blocked (it can
151
+ // enqueue paid jobs from inside a container: a recursion vector, not part of this relaxation).
152
+ //
153
+ // Every copied name is listed, not just counted. What lands here runs in every job container from the
154
+ // next job onward, so the operator gets ONE moment to read the actual names -- a bare "3 extensions" row
155
+ // would leave them re-deriving the list from a directory they cannot see from the worker host.
156
+ const extSrc = join(from, "extensions");
157
+ if (withExtensions && fs.existsSync(extSrc)) {
158
+ const { copied, blocked } = copyExtensions(fs, extSrc, join(to, "extensions"), out);
159
+ if (copied.length > 0) {
160
+ results.push(["extensions/", `${copied.length} extension${copied.length === 1 ? "" : "s"} -- these LOAD in every job; VET THESE`]);
161
+ // Note-less rows: the printer emits them bare, so the names read as a list under the count above.
162
+ for (const name of copied) results.push([` - ${name}`, ""]);
163
+ }
164
+ for (const name of blocked) out(` blocked extension "${name}" — the admin extension must never run inside a job.\n`);
165
+ out(
166
+ "\n⚠ Extensions run code against adversarial input with open network egress and are NOT scanned for\n" +
167
+ " secrets. They load in every job as staged -- review every one listed below, and set\n" +
168
+ " PI_GLOBAL_ALLOW_EXTENSIONS=0 in .env if you need them off.\n",
169
+ );
170
+ } else if (fs.existsSync(extSrc)) {
171
+ results.push(["extensions/", "skipped -- --no-extensions was passed (nothing from extensions/ reaches a job)"]);
172
+ }
173
+
174
+ // packages/ — pinned third-party pi packages, staged from npm on THIS host so the job container never
175
+ // needs the network (issue #58). All-or-nothing: a failure leaves no half-staged set to load.
176
+ if (withPackages) {
177
+ const staged = await stagePackages({ fs, exec, out, packagesFile, to, platform });
178
+ if (staged.error) {
179
+ out(`error: ${staged.error}\n`);
180
+ return 1;
181
+ }
182
+ const n = staged.packages.length;
183
+ results.push(["packages/", `${n} package${n === 1 ? "" : "s"} -- third-party code, VET THESE`]);
184
+ for (const warn of staged.warnings) results.push([`packages/${warn.dir}`, `WARN: ${warn.reason}`]);
185
+ } else if (fs.existsSync(join(to, PACKAGES_SUBDIR))) {
186
+ results.push(["packages/", "kept -- re-run with --with-packages to refresh"]);
187
+ }
188
+
189
+ out(`Imported the credential-free subset of ${from} → ${to}\n\n`);
190
+ // A note-less row is a list item under the row above it (the extension names), so it prints bare rather
191
+ // than padded out to a column that has nothing to hold.
192
+ for (const [name, note] of results) out(note ? ` ${name.padEnd(18)} ${note}\n` : ` ${name}\n`);
193
+ out(`\n (auth.json, settings.json, sessions/ are never copied — your credential stays in env/auth.json.)\n`);
194
+ out(nextSteps(to, withExtensions, withPackages));
195
+ return 0;
196
+ }
197
+
198
+ /** Copy `<src>/<name>/**` for each valid, non-symlink child dir. Returns the count copied. */
199
+ function copyNamedDirs(fs, src, dst, out) {
200
+ if (!fs.existsSync(src)) return 0;
201
+ let n = 0;
202
+ for (const name of fs.readdirSync(src)) {
203
+ if (!ENTRY_NAME_RE.test(name)) {
204
+ out(` skipped "${name}" — unexpected name\n`);
205
+ continue;
206
+ }
207
+ const childSrc = join(src, name);
208
+ if (fs.statSync(childSrc).isSymbolicLink?.() || !fs.statSync(childSrc).isDirectory()) continue;
209
+ copyTree(fs, childSrc, join(dst, name));
210
+ n++;
211
+ }
212
+ return n;
213
+ }
214
+
215
+ /**
216
+ * Like copyNamedDirs but reports the admin extension it refuses to copy. Returns the NAMES copied, not a
217
+ * count: the caller prints them, so the operator sees exactly what is now going into job containers.
218
+ */
219
+ function copyExtensions(fs, src, dst, out) {
220
+ const copied = [];
221
+ const blocked = [];
222
+ for (const name of fs.readdirSync(src)) {
223
+ if (ADMIN_RE.test(name)) {
224
+ blocked.push(name);
225
+ continue;
226
+ }
227
+ if (!ENTRY_NAME_RE.test(name)) {
228
+ out(` skipped "${name}" — unexpected name\n`);
229
+ continue;
230
+ }
231
+ const childSrc = join(src, name);
232
+ const st = fs.statSync(childSrc);
233
+ if (st.isSymbolicLink?.()) continue;
234
+ if (st.isDirectory()) copyTree(fs, childSrc, join(dst, name));
235
+ else fs.copyFileSync(childSrc, join(dst, name));
236
+ copied.push(name);
237
+ }
238
+ return { copied, blocked };
239
+ }
240
+
241
+ /** Recursively copy a directory tree, skipping symlinks (a symlink could point outside the source). */
242
+ function copyTree(fs, src, dst) {
243
+ fs.mkdirSync(dst, { recursive: true });
244
+ for (const entry of fs.readdirSync(src)) {
245
+ const s = join(src, entry);
246
+ const st = fs.statSync(s);
247
+ if (st.isSymbolicLink?.()) continue;
248
+ if (st.isDirectory()) copyTree(fs, s, join(dst, entry));
249
+ else fs.copyFileSync(s, join(dst, entry));
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Stage every package pinned in `packagesFile` into `<to>/packages/<dir>` and write the stage manifest.
255
+ * Returns `{ packages, warnings }`, or `{ error }` -- ALL-OR-NOTHING, because a half-staged set is worse
256
+ * than none: pi would load the packages that made it and silently skip the rest (issue #58).
257
+ *
258
+ * Each package is installed into a private `.staging-<i>` dir, asserted there, and only renamed into place
259
+ * once EVERY package has passed. A staged dir must be SELF-CONTAINED (`package.json` + its own
260
+ * `node_modules/`) because at job time it is resolved from a read-only mount with no network and no
261
+ * install step -- so every assertion below is about that property.
262
+ */
263
+ async function stagePackages({ fs, exec, out, packagesFile, to, platform = process.platform }) {
264
+ if (!fs.existsSync(packagesFile)) {
265
+ return { error: `--with-packages needs a packages file, none at ${packagesFile}\n Run \`pi-dispatch init\` to scaffold one, or pass --packages-file <path>.` };
266
+ }
267
+
268
+ let entries;
269
+ try {
270
+ entries = parsePackagesFile(fs.readFileSync(packagesFile, "utf8"), packagesFile);
271
+ } catch (error) {
272
+ // Refused before a single directory is created, so a bad file stages nothing at all.
273
+ return { error: error.message };
274
+ }
275
+
276
+ const packagesRoot = join(to, PACKAGES_SUBDIR);
277
+ const rootExisted = fs.existsSync(packagesRoot);
278
+ fs.mkdirSync(packagesRoot, { recursive: true });
279
+
280
+ const npmBin = platform === "win32" ? "npm.cmd" : "npm";
281
+ const stagingDirs = [];
282
+ const prepared = [];
283
+ const renamed = [];
284
+ const warnings = [];
285
+
286
+ try {
287
+ for (const [index, entry] of entries.entries()) {
288
+ const staging = join(packagesRoot, `.staging-${index}`);
289
+ fs.rmSync(staging, { recursive: true, force: true }); // a crashed earlier run may have left one
290
+ stagingDirs.push(staging);
291
+ fs.mkdirSync(staging, { recursive: true });
292
+ // A private root package.json pins npm's idea of "the project" to the staging dir, so it cannot
293
+ // walk up and install into (or read config from) the operator's own checkout.
294
+ fs.writeFileSync(join(staging, "package.json"), `${JSON.stringify({ name: "pi-dispatch-staging", private: true }, null, 2)}\n`);
295
+
296
+ // ARRAY argv, never a shell string: the name and version come from a config file and must never be
297
+ // able to become shell syntax on the operator's host. The install target is the exec's `cwd`, NOT a
298
+ // `--prefix <staging>` pair -- npm installs into the cwd's node_modules by default, and dropping the
299
+ // flag removes the only filesystem PATH from argv. What is left is nothing but literal flags and one
300
+ // `name@version` token already validated against NPM_NAME_RE + EXACT_VERSION_RE; that is the property
301
+ // npmExecOptions relies on below.
302
+ //
303
+ // --ignore-scripts is load-bearing: without it the lifecycle scripts of this package AND of every
304
+ // transitive dependency would run as the operator, on the operator's host, at stage time.
305
+ // --omit=peer because pi aliases its own packages for extensions at load time, so a staged peer
306
+ // copy is ignored dead weight -- and a floating pi version at that (CONST-PI-VERSION-PINNED).
307
+ // --install-strategy=nested asks npm to keep every dependency inside the package dir; step 4 below
308
+ // ASSERTS the result rather than trusting the flag, whose name and default have moved across npm
309
+ // versions.
310
+ const args = [
311
+ "install",
312
+ `${entry.name}@${entry.version}`,
313
+ "--omit=dev",
314
+ "--omit=peer",
315
+ "--omit=optional",
316
+ "--ignore-scripts",
317
+ "--install-strategy=nested",
318
+ "--no-audit",
319
+ "--no-fund",
320
+ "--loglevel=error",
321
+ ];
322
+ out(` staging ${entry.name}@${entry.version} -> packages/${entry.dir}\n`);
323
+ try {
324
+ await exec(npmBin, args, npmExecOptions(platform, staging));
325
+ } catch (error) {
326
+ const detail = String(error?.stderr ?? error?.message ?? "").trim();
327
+ throw new Error(`npm install failed for ${entry.name}@${entry.version}: ${detail}`);
328
+ }
329
+
330
+ const source = join(staging, "node_modules", entry.name);
331
+ let pkg;
332
+ try {
333
+ pkg = JSON.parse(fs.readFileSync(join(source, "package.json"), "utf8"));
334
+ } catch {
335
+ throw new Error(`${entry.name}@${entry.version}: npm reported success but there is no readable package.json at ${join(source, "package.json")}`);
336
+ }
337
+ if (pkg.version !== entry.version) {
338
+ throw new Error(`${entry.name}: npm staged version ${JSON.stringify(pkg.version)}, not the pinned ${JSON.stringify(entry.version)} (CONST-PI-VERSION-PINNED)`);
339
+ }
340
+
341
+ // Dependency completeness -- catches hoisting whatever npm's flag defaults do this month. A
342
+ // hoisted dependency would only surface as an import failure inside a job, hours later.
343
+ for (const dep of Object.keys(pkg.dependencies ?? {})) {
344
+ if (!fs.existsSync(join(source, "node_modules", dep))) {
345
+ throw new Error(`${entry.name}: dependency "${dep}" is not inside the package dir -- npm hoisted it out, so the staged copy could not import it at run time (no network, no install)`);
346
+ }
347
+ }
348
+
349
+ // A package that contributes no pi resources loads as a silent no-op; staging exists to turn that
350
+ // run-time nothing into a stage-time error the operator can act on.
351
+ const manifest = pkg.pi !== null && typeof pkg.pi === "object" ? pkg.pi : null;
352
+ const hasResourceDir = RESOURCE_DIRS.some((name) => fs.existsSync(join(source, name)));
353
+ if (!manifest && !hasResourceDir) {
354
+ throw new Error(`${entry.name} is not a pi package -- no "pi" manifest in package.json and none of ${RESOURCE_DIRS.join("/")}; it would load as a silent no-op`);
355
+ }
356
+
357
+ // Containment: manifest entries are resolved relative to the package dir at job time, so one that
358
+ // climbs out of it would reach the rest of the read-only overlay.
359
+ const escaping = manifest && findEscapingEntry(manifest);
360
+ if (escaping) {
361
+ throw new Error(`${entry.name}: pi manifest entry ${JSON.stringify(escaping)} leaves the package dir (no ".." segment, no leading "/")`);
362
+ }
363
+
364
+ // Warn, do not refuse: --ignore-scripts means a build/postinstall step did NOT run and an optional
365
+ // dependency was NOT fetched, so such a package is staged INCOMPLETE and may fail at run time.
366
+ const scriptKeys = ["install", "preinstall", "postinstall"].filter((key) => typeof pkg.scripts?.[key] === "string");
367
+ const hasOptional = Object.keys(pkg.optionalDependencies ?? {}).length > 0;
368
+ if (scriptKeys.length > 0 || hasOptional) {
369
+ const declares = [...scriptKeys.map((key) => `scripts.${key}`), ...(hasOptional ? ["optionalDependencies"] : [])].join(", ");
370
+ warnings.push({ dir: entry.dir, reason: `${entry.name} declares ${declares} -- staged with --ignore-scripts, so it is INCOMPLETE and may fail at run time` });
371
+ }
372
+
373
+ prepared.push({ entry, source });
374
+ }
375
+
376
+ // Renames happen only after EVERY package has passed, so a failure on the last one cannot leave the
377
+ // earlier ones swapped in beside a stale manifest.
378
+ for (const { entry, source } of prepared) {
379
+ const dest = join(packagesRoot, entry.dir);
380
+ fs.rmSync(dest, { recursive: true, force: true }); // replace a previous stage of the same package
381
+ // renameSync, never copyTree: copyTree's symlink guard uses statSync, which FOLLOWS links, so it
382
+ // would copy the target of every node_modules/.bin symlink instead of skipping it.
383
+ fs.renameSync(source, dest);
384
+ renamed.push(dest);
385
+ }
386
+ } catch (error) {
387
+ for (const dest of renamed) fs.rmSync(dest, { recursive: true, force: true });
388
+ for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
389
+ if (!rootExisted) fs.rmSync(packagesRoot, { recursive: true, force: true });
390
+ return { error: `${error.message}\n Nothing was staged -- fix ${packagesFile} (or the package) and re-run with --with-packages.` };
391
+ }
392
+
393
+ for (const staging of stagingDirs) fs.rmSync(staging, { recursive: true, force: true });
394
+
395
+ const stageManifest = { stagedAt: new Date().toISOString(), packages: entries.map(({ name, version, dir }) => ({ name, version, dir })) };
396
+ fs.writeFileSync(join(packagesRoot, STAGE_MANIFEST), `${JSON.stringify(stageManifest, null, 2)}\n`);
397
+ return { packages: entries, warnings };
398
+ }
399
+
400
+ /**
401
+ * The exec options for one `npm install`: always `cwd: <staging>` (that IS the install target now that
402
+ * `--prefix` is gone), plus `shell: true` on win32 and nowhere else.
403
+ *
404
+ * WHY shell:true is REQUIRED on win32: npm ships there as `npm.cmd`, and since Node 18.20.2 / 20.12.2
405
+ * (CVE-2024-27980) spawning a `.cmd`/`.bat` WITHOUT a shell throws EINVAL outright. This package floors at
406
+ * Node >=22.19, so every Node it can run on has that behaviour -- without this, `--with-packages` fails on
407
+ * every Windows host with a misleading "spawn npm.cmd EINVAL" and the branch above is dead on arrival.
408
+ *
409
+ * WHY shell:true is SAFE HERE SPECIFICALLY, which it would NOT be in general: with `--prefix` replaced by
410
+ * `cwd`, argv holds no filesystem path at all -- only literal flags this file spells out, plus the single
411
+ * `name@version` token, and BOTH halves of that token were validated before anything was created (packages.mjs
412
+ * rejects any name failing NPM_NAME_RE and any version failing EXACT_VERSION_RE, neither of which admits a
413
+ * space, quote, or cmd metacharacter). So no operator-supplied string that could survive as shell syntax ever
414
+ * reaches the command line. Re-introducing a path -- or loosening either regex -- breaks that argument, so
415
+ * this option must be revisited together with them.
416
+ */
417
+ function npmExecOptions(platform, staging) {
418
+ return platform === "win32" ? { cwd: staging, shell: true } : { cwd: staging };
419
+ }
420
+
421
+ /** The first string anywhere in a `pi` manifest that leaves the package dir, or null when all are contained. */
422
+ function findEscapingEntry(value) {
423
+ if (typeof value === "string") {
424
+ return /^[\\/]/.test(value) || value.split(/[\\/]/).includes("..") ? value : null;
425
+ }
426
+ if (Array.isArray(value) || (value !== null && typeof value === "object")) {
427
+ for (const child of Object.values(value)) {
428
+ const hit = findEscapingEntry(child);
429
+ if (hit) return hit;
430
+ }
431
+ }
432
+ return null;
433
+ }
434
+
435
+ function flagValue(argv, flag) {
436
+ const i = argv.indexOf(flag);
437
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
438
+ }
439
+
440
+ function nextSteps(to, withExtensions, withPackages) {
441
+ const steps = [`Set PI_GLOBAL_PI_DIR=${to} in .env`, "pi-dispatch doctor # verifies the overlay is credential-free"];
442
+ // The vetting step is no longer a switch to flip -- it already happened by staging. What is left is the
443
+ // off switch, named here so an operator who does not want the extensions is not left hunting for it.
444
+ if (withExtensions) steps.push("Vet the extensions listed above -- they load in every job; set PI_GLOBAL_ALLOW_EXTENSIONS=0 in .env to disable them");
445
+ // Same inversion for packages: staging is what loads them, so the step worth naming is how to withhold
446
+ // them from a trigger that should not run third-party code.
447
+ if (withPackages) steps.push('Staged packages load in every job -- set `run.packages: false` on any trigger in triggers.json that must not load them');
448
+ return `
449
+ Next:
450
+ ${steps.map((step, i) => ` ${i + 1}. ${step}\n`).join("")}`;
451
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,177 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { DelayedError, UnrecoverableError, Worker } from "bullmq";
4
+ import { InfraRetry, runJob } from "./processor.mjs";
5
+
6
+ const exec = promisify(execFile);
7
+
8
+ export const QUEUE = "pi-jobs";
9
+ export const JOB_TIMEOUT_MS = 30 * 60 * 1000; // REQ-JOB-TIMEOUT-30M
10
+
11
+ /**
12
+ * Build the BullMQ processor.
13
+ *
14
+ * It MUST declare exactly three parameters (job, token, signal). BullMQ only allocates an
15
+ * AbortController when `processor.length >= 3` (it inspects the function's arity at construction),
16
+ * so dropping the unused `token` would silently disable BOTH the 30-minute timeout and the shutdown
17
+ * abort -- with no error. A test asserts the arity precisely because the failure is silent.
18
+ *
19
+ * Dependencies are injected so this is testable without a live queue: `cancelJob` (fired by the
20
+ * timeout), `stopContainer` (fired by the abort), and the orchestration deps.
21
+ *
22
+ * Once per job, before runJob, it resolves the runtime-settings overlay via `getSettings`
23
+ * (INT-CONFIG-OVERLAY-CONTRACT). A present-but-invalid overlay resolves to a POLICY refusal RETURNED
24
+ * (never thrown), so BullMQ marks the job completed without a retry (CONST-RETRY-INFRA-ONLY). A valid
25
+ * overlay fills the effective `provider`/`model`/`maxTurns`/`dailyCap`/`weeklyCap`/`monthlyCap`/`softHoldPct`
26
+ * under `job.data > overlay > env` precedence and re-binds the worker slot count via `applyConcurrency`.
27
+ * The overlay changes which values the spend caps take, never when they are checked -- reserveBudget still
28
+ * runs inside runJob against the freshly passed caps (CONST-BUDGET-BEFORE-TOKENS).
29
+ */
30
+ export function makeProcessor({ cancelJob, stopContainer, redis, getSettings, applyConcurrency = () => {}, pauseUntil = () => null, deps, recordRun = () => {}, timeoutMs = JOB_TIMEOUT_MS, now = () => Date.now() }) {
31
+ return async function processor(job, token, signal) {
32
+ // Scoped pause windows (REQ-SCOPED-PAUSE-WINDOWS): if this job's folder/repo is inside an active pause
33
+ // window, DEFER it to the window end via BullMQ's delayed set -- the job keeps its identity/dedup and
34
+ // auto-resumes when re-picked. This is FIRST, before the kill timer, the settings read, and the budget
35
+ // reservation, so a deferred job arms no timer, reserves no slot, and spends nothing
36
+ // (CONST-BUDGET-BEFORE-TOKENS). `moveToDelayed` needs the worker's `token`; the `> now + 1s` guard keeps
37
+ // a boundary tick from busy-deferring. A thrown `DelayedError` is how BullMQ learns the job was deferred
38
+ // (worker.js recognises it) rather than completed or failed.
39
+ // One clock snapshot for both the window lookup and the guard, so they cannot disagree across the
40
+ // call (and a test can inject a fixed clock). `now` defaults to the real wall clock in production.
41
+ const nowMs = now();
42
+ const until = pauseUntil(job.data, nowMs);
43
+ if (until && until > nowMs + 1000) {
44
+ await job.moveToDelayed(until, token);
45
+ throw new DelayedError();
46
+ }
47
+
48
+ const startedAt = new Date().toISOString();
49
+ const name = `pi-job-${job.id}`;
50
+ const timer = setTimeout(() => {
51
+ // BullMQ has no per-job kill timer; this is ours. cancelJob raises the AbortSignal.
52
+ Promise.resolve(cancelJob(job.id, "job-timeout-30m")).catch(() => {});
53
+ }, timeoutMs);
54
+
55
+ // Abort (timeout OR shutdown) => stop the container. docker stop sends SIGTERM then SIGKILL
56
+ // after the grace period; the runner exits and runContainer returns/throws.
57
+ const onAbort = () => {
58
+ Promise.resolve(stopContainer(name)).catch(() => {});
59
+ };
60
+ signal.addEventListener("abort", onAbort, { once: true });
61
+
62
+ try {
63
+ const settings = await getSettings();
64
+ if (settings.invalid) {
65
+ // A present-but-invalid overlay is a POLICY refusal, RETURNED (never thrown) so BullMQ marks the
66
+ // job completed and does not retry a file that can never parse (CONST-RETRY-INFRA-ONLY). Resolved
67
+ // before runJob, so no budget slot is reserved and no container starts (CONST-BUDGET-BEFORE-TOKENS).
68
+ // recordRun leaves the durable settings-overlay-invalid trace for the admin extension.
69
+ // No provider/model here, alone among the terminal results: the overlay is the thing that failed to parse, so no honest effective value exists -- buildRecord defaults both null.
70
+ const result = { outcome: "policy", reason: "settings-overlay-invalid", exitCode: null, turns: null, tokens: null, budgetReserved: false };
71
+ recordRun({ job, result, startedAt, endedAt: new Date().toISOString() });
72
+ return result;
73
+ }
74
+
75
+ // Re-bind the worker slot count to the effective concurrency before the run (no-op default when
76
+ // unwired, e.g. a bare makeProcessor under test).
77
+ applyConcurrency(settings.concurrency);
78
+
79
+ // Fill the effective job settings under `job.data > overlay > env` precedence: an explicit per-job
80
+ // field wins; an omitted one takes the overlay value, else env, resolved at this job's start
81
+ // (INT-CONFIG-OVERLAY-CONTRACT). Receiver GitHub jobs carry no provider/model/maxTurns, so this fill
82
+ // supplies the provider the container env allowlist requires -- absent it, the allowlist refuses a job
83
+ // only after its budget slot is reserved. The `caps`/`softHoldPct` passed to runJob change which
84
+ // values reserveBudget checks, never when it runs.
85
+ const effectiveJob = {
86
+ ...job.data,
87
+ provider: job.data.provider ?? settings.provider,
88
+ model: job.data.model ?? settings.model,
89
+ maxTurns: job.data.maxTurns ?? settings.maxTurns,
90
+ maxTokens: job.data.maxTokens ?? settings.maxTokens, // optional per-job token budget (issue #25); null => runner meter only
91
+ };
92
+
93
+ const result = await runJob(effectiveJob, {
94
+ redis,
95
+ // The three spend windows (week/month null when disabled) and the soft-hold band, resolved this
96
+ // job-start under overlay > env. reserveBudget checks them before the container.
97
+ caps: { day: settings.dailyCap, week: settings.weeklyCap, month: settings.monthlyCap },
98
+ softHoldPct: settings.softHoldPct,
99
+ // The daily TOKEN cap (issue #25), same overlay > env resolution. Check-AFTER, so it gates the
100
+ // NEXT job on prior recorded spend; null => the daily token counter is disabled.
101
+ tokenCap: settings.dailyTokenCap,
102
+ ...deps,
103
+ runContainer: (ctx) => deps.runContainer({ ...ctx, name, signal }),
104
+ // collectChain (INT-OUTBOX-CONTRACT) reads the completed parent's REAL BullMQ job: its `.id`
105
+ // (the parent id children carry) and `.data` (kind/chainDepth). runJob's own `job` is the
106
+ // effectiveJob -- a spread of job.data with no `.id`/`.data` -- so inject the real wrapper here,
107
+ // mirroring the name/signal injection above. Omitted when unwired so a bare processor falls back
108
+ // to runJob's no-op default (a chain fault can never flip a completed outcome either way).
109
+ ...(deps.collectChain ? { collectChain: (ctx) => deps.collectChain({ ...ctx, job }) } : {}),
110
+ // prepareWorkspace needs the REAL BullMQ job's `.id` to derive a cron job's scheduled-for
111
+ // instant from the deterministic repeat:<id>:<millis> jobId (DES-CRON-VIA-BULLMQ-SCHEDULER)
112
+ // for the local /job/event.json. runJob's own `job` is the effectiveJob -- a spread of
113
+ // job.data with no `.id` -- and the real wrapper is only in scope here, so inject it as
114
+ // `queueJobId`, mirroring the collectChain injection above. Omitted when unwired so a bare
115
+ // processor keeps runJob's plain (job, token) call.
116
+ ...(deps.prepareWorkspace ? { prepareWorkspace: (j, t) => deps.prepareWorkspace(j, t, { queueJobId: job.id }) } : {}),
117
+ });
118
+ recordRun({ job, result, startedAt, endedAt: new Date().toISOString() });
119
+ return result;
120
+ } catch (error) {
121
+ recordRun({ job, error, startedAt, endedAt: new Date().toISOString() });
122
+ if (error instanceof InfraRetry) throw error; // retryable: BullMQ retries per attempts
123
+ // A non-retryable, non-infra error (our bug) must not retry forever. UnrecoverableError
124
+ // records it as failed-and-distinct in the queue's failed set without a retry.
125
+ throw new UnrecoverableError(error.message);
126
+ } finally {
127
+ clearTimeout(timer);
128
+ signal.removeEventListener("abort", onAbort);
129
+ }
130
+ };
131
+ }
132
+
133
+ export function createWorker({ connection, concurrency, getSettings, redis, deps, recordRun, limiter, pauseUntil, extraClosers = [] }) {
134
+ let worker; // referenced by cancelJob/applyConcurrency before assignment; only called later, so the TDZ is fine
135
+ const processor = makeProcessor({
136
+ cancelJob: (id, reason) => worker.cancelJob(id, reason),
137
+ stopContainer: (name) => exec("docker", ["stop", "-t", "5", name]),
138
+ redis,
139
+ getSettings,
140
+ // Late-bound over `worker`: an overlay concurrency change re-binds the live slot count at the next
141
+ // job start. Guarded so only an integer that actually differs touches the property.
142
+ applyConcurrency: (n) => {
143
+ if (Number.isInteger(n) && worker.concurrency !== n) worker.concurrency = n;
144
+ },
145
+ pauseUntil,
146
+ deps,
147
+ recordRun,
148
+ });
149
+
150
+ worker = new Worker(QUEUE, processor, {
151
+ // maxRetriesPerRequest: null is REQUIRED for BullMQ's blocking connections, or it throws.
152
+ connection: { ...connection, maxRetriesPerRequest: null },
153
+ concurrency,
154
+ maxStalledCount: 0, // a stalled paid job FAILS, never silently re-runs (verified live)
155
+ ...(limiter ? { limiter } : {}),
156
+ });
157
+
158
+ const shutdown = async () => {
159
+ // Abort active jobs (=> docker stop via onAbort), then close. Without the cancel,
160
+ // worker.close() would wait up to 30 minutes for the container.
161
+ await Promise.resolve(worker.cancelAllJobs?.("shutdown")).catch(() => {});
162
+ await worker.close();
163
+ // Close auxiliary resources (e.g. a cron scheduler) after the worker drains. Per-item catch
164
+ // so one failing or absent closer never strands the others or blocks exit -- matches the
165
+ // swallow posture on cancelAllJobs above.
166
+ await Promise.all(extraClosers.map((c) => Promise.resolve(c.close?.()).catch(() => {})));
167
+ process.exit(0);
168
+ };
169
+ process.once("SIGTERM", shutdown);
170
+ process.once("SIGINT", shutdown);
171
+ // Windows never delivers an external SIGTERM. nssm's console-stop delivers Ctrl-C => SIGINT
172
+ // (handled above); SIGBREAK covers console-close. Route it to the same shutdown so a stopped
173
+ // worker still aborts in-flight jobs and docker-stops their containers rather than orphaning them.
174
+ if (process.platform === "win32") process.once("SIGBREAK", shutdown);
175
+
176
+ return worker;
177
+ }