@edgehero/pi-dispatch 0.3.0 → 1.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.
package/src/doctor.mjs CHANGED
@@ -46,15 +46,18 @@
46
46
  */
47
47
  import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
48
48
  import { homedir, tmpdir } from "node:os";
49
- import { join } from "node:path";
49
+ import { dirname, join } from "node:path";
50
50
  import { fileURLToPath } from "node:url";
51
51
  import { spawn as nodeSpawn } from "node:child_process";
52
52
  import { defaultSandboxDir, globalExtensionsEnabled } from "./config.mjs";
53
53
  import { isForgeKind } from "./forges.mjs";
54
54
  import { findLiteralSecret, ADMIN_RE } from "./import-pi.mjs";
55
55
  import { agentDirFrom, readHostPi } from "./host-pi.mjs";
56
- import { PACKAGES_SUBDIR, readStageManifest } from "./packages.mjs";
56
+ import { PACKAGES_SUBDIR, readStagedSkills, readStageManifest } from "./packages.mjs";
57
57
  import { copySkillTree } from "./copy-tree.mjs";
58
+ import { SKILL_NAME_RE } from "./flow-gate.mjs";
59
+ import { DEFAULT_EGRESS_PROXY, egressArmed } from "./egress.mjs";
60
+ import { installedUnitPaths, readUnitSeam } from "./service.mjs";
58
61
  import { parseTriggers } from "./triggers.mjs";
59
62
 
60
63
  const NODE_FLOOR = [22, 19]; // pi's engine floor (22.19.0)
@@ -99,8 +102,13 @@ export async function runDoctor(env = process.env, deps = {}) {
99
102
  // default is a real path in the developer's home directory and the host comparison may spawn their
100
103
  // package manager -- neither belongs in a unit test, and "no network, no Docker" is the same rule.
101
104
  agentDir = agentDirFrom(env),
105
+ // Where the service manager's units live, and which formats to read them in (issue #216). Seams
106
+ // rather than bare process.platform/homedir() because the --env-setup check has to be exercised
107
+ // for all three unit formats, and only one of them exists on whichever host runs the suite.
108
+ platform = process.platform,
109
+ home = homedir(),
102
110
  } = deps;
103
- const seams = { cwd, out, spawn, probeValkey, fileExists, nodeVersion, mkdir, chmod, rm, agentDir };
111
+ const seams = { cwd, out, spawn, probeValkey, fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home };
104
112
 
105
113
  let checks = await collectChecks(env, seams);
106
114
  let failed = render(checks, out);
@@ -202,7 +210,7 @@ export async function defaultPromptFn(question, { input = process.stdin, output
202
210
  * a comment.
203
211
  */
204
212
  export async function collectChecks(env, seams) {
205
- const { cwd, spawn, probeValkey, fileExists, nodeVersion, agentDir = agentDirFrom(env) } = seams;
213
+ const { cwd, spawn, probeValkey, fileExists, nodeVersion, platform, agentDir = agentDirFrom(env) } = seams;
206
214
 
207
215
  const jobImage = env.PI_JOB_IMAGE ?? "pi-job:latest";
208
216
  const valkeyUrl = env.VALKEY_URL ?? "redis://127.0.0.1:6379";
@@ -240,6 +248,10 @@ export async function collectChecks(env, seams) {
240
248
  },
241
249
  });
242
250
 
251
+ // Right after `.env present`, because it answers the same question that check raises: where DOES this
252
+ // deployment's environment come from. [] unless a seam is configured (issue #216).
253
+ checks.push(...(await envSetupChecks(env, seams)));
254
+
243
255
  const dockerCode = await runCmd(spawn, "docker", ["info"]);
244
256
  checks.push({
245
257
  ok: dockerCode === 0,
@@ -251,7 +263,17 @@ export async function collectChecks(env, seams) {
251
263
  // image checks just below, and `optingOut`/`requiring` colour the staged-packages lines further down.
252
264
  // `optingOut` counts the only value that withholds the staged set; `requiring` counts an explicit
253
265
  // run.packages: true, which arms nothing any more but is still an operator statement of intent.
254
- const { requiring, optingOut, resuming, replicating, instructing, images, skillsDirs, forges, repositories } = readTriggerFacts(env, fileExists, cwd);
266
+ const { requiring, optingOut, resuming, replicating, instructing, commands, images, skillsDirs, forges, repositories, flows, parseError, path: triggersFilePath } = readTriggerFacts(env, fileExists, cwd);
267
+ // FIRST, and fail rather than warn: every check below this line reads counts that a parse failure
268
+ // zeroed, so a green run here would be reporting on a file nobody could read. The receiver loads this
269
+ // file unconditionally and refuses to start without it, which is the consequence worth naming.
270
+ if (parseError) {
271
+ checks.push({
272
+ ok: false,
273
+ label: `triggers file does not parse -- the receiver will refuse to start: ${parseError}`,
274
+ fix: `fix ${triggersFilePath} so it loads (the message above names the entry and the reason), then re-run doctor -- every trigger-derived check below is skipped until it parses`,
275
+ });
276
+ }
255
277
 
256
278
  // Only meaningful if docker itself responds; otherwise the image check is noise on top of a down daemon.
257
279
  const imageCode = dockerCode === 0 ? await runCmd(spawn, "docker", ["image", "inspect", jobImage]) : null;
@@ -325,6 +347,91 @@ export async function collectChecks(env, seams) {
325
347
  }
326
348
  }
327
349
 
350
+ // REQ-PER-TRIGGER-SKILLS (issue #189). One line per distinct (flow, folder, skillsDir, packages)
351
+ // question: does the flow this trigger names resolve in ANY tier this host can see? Probed in the
352
+ // loader's own precedence order (repo > injected > overlay > staged packages), first hit wins the
353
+ // line. ⚠ and NEVER ✗ when nothing resolves -- a forge trigger's repo is not on this host and
354
+ // mid-setup is legal -- and no fixAction (triggers content is the never tier). The runner's
355
+ // flow_not_loaded line is the exact, in-container half of the same answer
356
+ // (DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS): these probes read dir names, and a frontmatter
357
+ // `name:` rename is invisible to them, so a ⚠ here can be wrong in only the loud direction.
358
+ // A deployment with no triggers adds no lines at all, so its output is byte-identical.
359
+ if (flows.length > 0) {
360
+ // One stage read for the whole run; each tuple's staged probe is a lookup on its result.
361
+ const staged = readStagedSkills({ globalPiDir: env.PI_GLOBAL_PI_DIR, readFile: (p) => readFileSync(p, "utf8"), fileExists });
362
+ const groups = new Map();
363
+ for (const f of flows) {
364
+ const key = JSON.stringify([f.flow, f.folder, f.skillsDir, f.packages]);
365
+ if (!groups.has(key)) groups.set(key, { ...f, labels: [] });
366
+ groups.get(key).labels.push(f.label);
367
+ }
368
+ for (const g of groups.values()) {
369
+ const at = g.labels.join(", ");
370
+ // The charset pre-check doubles as the interpolation guard for the git probe below, and it is
371
+ // a finding of its own: a name the skill charset refuses can never materialise in ANY tier
372
+ // (materialize and copy-tree enforce the same RE on the way in).
373
+ if (!SKILL_NAME_RE.test(g.flow)) {
374
+ checks.push({
375
+ ok: false,
376
+ warn: true,
377
+ label: `Trigger flow ${JSON.stringify(g.flow)} fails the skill name charset (${at})`,
378
+ fix: "a flow name must match the skill charset (lowercase alphanumerics, - and _, 64 max), or no tier can ever hold it -- fix run.flow",
379
+ });
380
+ continue;
381
+ }
382
+ let resolved = null;
383
+ const checked = [];
384
+ const unknown = [];
385
+ if (g.folder) {
386
+ const state = await repoFlowAtHead(spawn, g.folder, g.flow);
387
+ if (state === "present") resolved = `repo .pi/skills at HEAD of ${g.folder}`;
388
+ else if (state === "absent") checked.push("repo .pi/skills at HEAD");
389
+ else unknown.push(`repo (${g.folder} is not readable as a git repo here)`);
390
+ } else {
391
+ unknown.push("repo (a forge clone, not on this host)");
392
+ }
393
+ if (!resolved && g.skillsDir) {
394
+ if (fileExists(join(g.skillsDir, g.flow, "SKILL.md"))) resolved = `injected run.skillsDir ${g.skillsDir}`;
395
+ else checked.push("injected run.skillsDir");
396
+ }
397
+ if (!resolved && env.PI_GLOBAL_PI_DIR) {
398
+ if (fileExists(join(env.PI_GLOBAL_PI_DIR, "skills", g.flow, "SKILL.md"))) resolved = "the overlay skills/";
399
+ else checked.push("overlay skills/");
400
+ }
401
+ if (!resolved) {
402
+ if (!g.packages) {
403
+ checked.push("staged packages (withheld: run.packages false)");
404
+ } else {
405
+ const hit = staged.skills.find((s) => s.name === g.flow);
406
+ if (hit) resolved = `staged package ${hit.package}`;
407
+ else if (staged.unenumerable.length > 0) unknown.push(`staged package(s) ${staged.unenumerable.join(", ")} (manifest patterns, not enumerable here)`);
408
+ else checked.push("staged packages");
409
+ }
410
+ }
411
+ if (resolved) {
412
+ checks.push({ ok: true, label: `Trigger flow "${g.flow}" resolves (${at}: ${resolved})` });
413
+ } else {
414
+ checks.push({
415
+ ok: false,
416
+ warn: true,
417
+ label: `Trigger flow "${g.flow}" resolves in NO tier visible here (${at})`,
418
+ fix: `checked: ${checked.join(", ") || "nothing checkable"}${unknown.length > 0 ? `; not checkable here: ${unknown.join(", ")}` : ""} -- commit .pi/skills/${g.flow}/SKILL.md, add the skill to run.skillsDir or the overlay skills/, or stage a package shipping it; a job of this trigger runs without the flow it names (the runner logs flow_not_loaded) and still exits 0`,
419
+ });
420
+ }
421
+ }
422
+ }
423
+
424
+ // run.command triggers (issue #189): ONE advisory line, deliberately WITHOUT the per-tier probes the
425
+ // flow block above runs. A command is registered by extension CODE at pi startup -- repo .pi/, the
426
+ // overlay and staged packages all contribute, and none is enumerable host-side without executing the
427
+ // extension, which doctor must never do. The honest line names where the real check lives instead;
428
+ // unlike a missing flow, the failure there is LOUD (a refusal, not a clean exit 0), which is why this
429
+ // is advisory and carries no fixAction (triggers content is the never tier). A deployment with no
430
+ // command triggers adds no line at all, so its output is byte-identical.
431
+ if (commands > 0) {
432
+ checks.push({ ok: true, label: `${commands} command trigger(s): a command is only verifiable in-container -- the runner refuses an unregistered one pre-spend (command-unregistered)` });
433
+ }
434
+
328
435
  // Issue #41: every DISTINCT image a trigger names in run.image, minus the deployment default already
329
436
  // checked above. Two silent-failure modes, and both used to be impossible because there was one image.
330
437
  // 1. the image was never built -- a job that refuses pre-spend at 03:00 in a log nobody is reading, and
@@ -353,6 +460,11 @@ export async function collectChecks(env, seams) {
353
460
  }
354
461
  }
355
462
 
463
+ // REQ-EGRESS-ALLOWLIST (issue #202). [] when PI_EGRESS=0, so a deployment that declined it gets
464
+ // byte-identical output. Gated on docker and the image, because two of these checks run a container and
465
+ // the rest are noise on top of a down daemon.
466
+ checks.push(...(await egressChecks(env, seams, { dockerCode, imageCode, jobImage })));
467
+
356
468
  // The receiver itself, when the triggers file names ANY forge (issue #80). Only forge deliveries need
357
469
  // the receiver at all, so a cron/local-only deployment gets no receiver noise here. WARNS rather than
358
470
  // fails, same doctrine as the gitlab block below: a deployment can legitimately be mid-setup (or run
@@ -558,16 +670,43 @@ export async function collectChecks(env, seams) {
558
670
  fix: setupFix,
559
671
  });
560
672
  }
673
+ // Two ways to supply the key, exactly one of them at a time (issue #208): a path to a file, or the
674
+ // PEM itself in GITHUB_APP_PRIVATE_KEY for a deployment whose environment comes from a secrets
675
+ // manager. The hygiene pass below belongs to the PATH variant -- there is no mode to check and no
676
+ // file to stat on a value that only ever exists in this process's environment, and whoever supplies
677
+ // that environment owns its hygiene. What survives for both is the shape sniff, and the rule that
678
+ // nothing from the key reaches output.
561
679
  const keyPath = env.GITHUB_APP_PRIVATE_KEY_PATH;
562
- if (!keyPath) {
563
- checks.push({ ok: false, warn: true, label: "GITHUB_AUTH_SOURCE=app but GITHUB_APP_PRIVATE_KEY_PATH is unset -- the worker will refuse to boot", fix: setupFix });
680
+ const inlineKey = (env.GITHUB_APP_PRIVATE_KEY ?? "").trim();
681
+ if (inlineKey !== "" && keyPath) {
682
+ checks.push({
683
+ ok: false,
684
+ warn: true,
685
+ label: "GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are both set -- the worker will refuse to boot",
686
+ fix: "unset one of them: the inline value for a deployment fed by a secrets manager, the path for a key on disk (docs/secrets.md)",
687
+ });
688
+ } else if (inlineKey !== "") {
689
+ // A flattened key still starts with the header -- the `\n` escapes come after it -- so one sniff
690
+ // covers both accepted forms.
691
+ if (inlineKey.startsWith("-----BEGIN")) {
692
+ checks.push({ ok: true, label: "GitHub App private key supplied inline (GITHUB_APP_PRIVATE_KEY)" });
693
+ } else {
694
+ checks.push({
695
+ ok: false,
696
+ warn: true,
697
+ label: `GITHUB_APP_PRIVATE_KEY does not look like a PEM (does not begin "-----BEGIN ...") -- the worker will refuse to boot; contents not shown`,
698
+ fix: "check for a truncated paste, or point GITHUB_APP_PRIVATE_KEY_PATH at the key file instead (docs/secrets.md)",
699
+ });
700
+ }
701
+ } else if (!keyPath) {
702
+ checks.push({ ok: false, warn: true, label: "GITHUB_AUTH_SOURCE=app but neither GITHUB_APP_PRIVATE_KEY_PATH nor GITHUB_APP_PRIVATE_KEY is set -- the worker will refuse to boot", fix: setupFix });
564
703
  } else if (!fileExists(keyPath)) {
565
704
  checks.push({ ok: false, warn: true, label: `GITHUB_APP_PRIVATE_KEY_PATH does not exist (${keyPath})`, fix: setupFix });
566
705
  } else {
567
706
  checks.push({ ok: true, label: `GitHub App private key present (${keyPath})` });
568
707
  // POSIX mode only -- on win32 stat modes are synthetic (0666-ish for everything), so a warn
569
708
  // there would fire on every healthy deployment and teach operators to ignore it.
570
- if (process.platform !== "win32") {
709
+ if (platform !== "win32") {
571
710
  try {
572
711
  const loose = statSync(keyPath).mode & 0o077;
573
712
  if (loose !== 0) {
@@ -603,6 +742,26 @@ export async function collectChecks(env, seams) {
603
742
  } catch {
604
743
  checks.push({ ok: false, warn: true, label: `the App private key at ${keyPath} exists but is not readable by this user`, fix: setupFix });
605
744
  }
745
+ // Mode 0600 protects the key from other users on this host; it does nothing once the file is in
746
+ // a commit. `setup github` writes the key into the DEPLOYMENT FOLDER, and a deployment folder is
747
+ // very often a checkout -- so the last thing between an App signing key and a public repository
748
+ // can be one `git add -A`. This repo's own .gitignore covers *.pem; the operator's may not, and
749
+ // a key they renamed or brought themselves is the same accident.
750
+ //
751
+ // Exit 1 is the ONLY case that warns: git says "this is a work tree, and that path is not
752
+ // ignored". 0 means covered, 128 means no work tree at all, and null means git could not be
753
+ // launched. Every one of those is silence, because a check nobody can silence must never cry
754
+ // wolf -- the cost of a missed warning here is one operator reading the doc, and the cost of a
755
+ // false one is every operator learning to scroll past doctor.
756
+ const ignoreCode = await runCmd(spawn, "git", [...GIT_READ_FLAGS, "-C", dirname(keyPath), "check-ignore", "-q", keyPath]);
757
+ if (ignoreCode === 1) {
758
+ checks.push({
759
+ ok: false,
760
+ warn: true,
761
+ label: `the App private key at ${keyPath} is inside a git work tree that does not ignore it`,
762
+ fix: `move it outside that repo, or ignore it there (\`*.pem\`) -- one \`git add -A\` commits the App's signing key, which can mint a token for every repository the App is installed on, and mode 0600 does not survive a commit`,
763
+ });
764
+ }
606
765
  }
607
766
  }
608
767
 
@@ -1124,9 +1283,11 @@ function nodeCheck(version) {
1124
1283
  * Parsed with the SHARED `parseTriggers`, so doctor counts exactly the entries the worker and receiver will
1125
1284
  * act on -- a truthy `"true"` string is rejected there and therefore never counted here.
1126
1285
  *
1127
- * Swallows ANY error to zeroes -- a missing, unreadable, or malformed triggers file already fails LOUD at
1128
- * worker boot (config.mjs, schedules.mjs), so re-reporting the parse failure here would only bury doctor's
1129
- * own findings under a second copy of a diagnosis the operator already gets.
1286
+ * A missing file still reads as zeroes and says nothing: that is an ordinary cron-less deployment. A file
1287
+ * that EXISTS and does not parse is reported instead, with the reason. The old justification here -- that
1288
+ * such a file "already fails LOUD at worker boot" -- was false for the deployment that needs doctor most:
1289
+ * the worker reads this file only when PI_TRIGGERS_FILE is set, so on a receiver-only host nothing else
1290
+ * says a word, while the zeroes quietly disarm every forge, image and flow check below.
1130
1291
  */
1131
1292
  /** lstat, so a symlinked skillsDir is judged on its own inode -- copy-tree.mjs's rule, restated. */
1132
1293
  function dirExists(dir) {
@@ -1191,8 +1352,37 @@ function aiTriggerNames(dir) {
1191
1352
  return names;
1192
1353
  }
1193
1354
 
1355
+ /**
1356
+ * Does `.pi/skills/<flow>/SKILL.md` exist at HEAD of a local folder? "present" | "absent" | "unknown".
1357
+ *
1358
+ * Deliberately NOT readFlowGate: that module answers WHO may fire a flow (the ai-trigger frontmatter,
1359
+ * at a caller-pinned sha) and its catch collapses ANY git failure into deny -- fail-closed is right
1360
+ * for a gate and exactly wrong here, where deny-because-git-broke would print a confident wrong
1361
+ * answer on an advisory line. Doctor resolving HEAD itself is also fine: the gate's no-ref rule
1362
+ * defends against an agent self-authorizing mid-run, and a host-side preflight has no agent. What IS
1363
+ * the gate's, verbatim, is the ls-tree read, the 100644-blob requirement and the hardening flags --
1364
+ * copied so the two readers cannot disagree about what "a committed skill file" means, and so a
1365
+ * hostile repo config cannot run code during the read (flow-gate.mjs's defaultGit, restated).
1366
+ */
1367
+ const GIT_READ_FLAGS = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager"];
1368
+ async function repoFlowAtHead(spawn, folder, flow) {
1369
+ if (!SKILL_NAME_RE.test(flow)) return "unknown"; // the caller pre-checks; belt against interpolation
1370
+ const head = await runCmdCapture(spawn, "git", [...GIT_READ_FLAGS, "-C", folder, "rev-parse", "HEAD"]);
1371
+ const sha = head.code === 0 ? head.output.trim() : null;
1372
+ if (!sha || !/^[0-9a-f]{40,64}$/.test(sha)) return "unknown";
1373
+ const tree = await runCmdCapture(spawn, "git", [...GIT_READ_FLAGS, "-C", folder, "ls-tree", "-z", sha, `.pi/skills/${flow}/SKILL.md`]);
1374
+ if (tree.code !== 0) return "unknown";
1375
+ const record = tree.output.split("\0").find((r) => r);
1376
+ if (!record) return "absent"; // valid sha, path absent at that commit
1377
+ const tab = record.indexOf("\t");
1378
+ const [mode, type] = tab === -1 ? [] : record.slice(0, tab).split(/\s+/);
1379
+ // A symlink/gitlink entry is "absent" for this question too: the gate would refuse it, and the
1380
+ // materialiser never copies it, so nothing downstream treats it as a skill file.
1381
+ return mode === "100644" && type === "blob" ? "present" : "absent";
1382
+ }
1383
+
1194
1384
  function readTriggerFacts(env, fileExists, cwd) {
1195
- const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, images: [], skillsDirs: [], forges: [], repositories: [] };
1385
+ const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, commands: 0, images: [], skillsDirs: [], forges: [], repositories: [], flows: [], parseError: null, path: null };
1196
1386
  try {
1197
1387
  // Unset falls back to ./triggers.json in cwd, MIRRORING the receiver's own default
1198
1388
  // (receiver/src/config.mjs) -- the two must read the same file, or doctor preflights a deployment
@@ -1209,6 +1399,10 @@ function readTriggerFacts(env, fileExists, cwd) {
1209
1399
  // REQ-REPLICA-RUNS. `> 1` rather than `!== undefined` because the loader already refuses anything
1210
1400
  // else -- this counts triggers that will actually multiply spend, which is the only reason to say so.
1211
1401
  replicating: triggers.filter((t) => t.run.replicas > 1).length,
1402
+ // run.command triggers (issue #189), counted for the one advisory line below. The `flows`
1403
+ // tuple list already filters to `typeof f.flow === "string"`, so a command trigger drops out
1404
+ // of the flow-tier probes naturally -- no exclusion needed there.
1405
+ commands: triggers.filter((t) => typeof t.run.command === "string").length,
1212
1406
  optingOut: triggers.filter((t) => t.run.packages === false).length,
1213
1407
  images: [...new Set(triggers.map((t) => t.run.image).filter((i) => typeof i === "string"))].sort(),
1214
1408
  // REQ-PER-TRIGGER-SKILLS. The distinct host directories the file names, deduped like `images`,
@@ -1224,10 +1418,306 @@ function readTriggerFacts(env, fileExists, cwd) {
1224
1418
  // would report all-green and never mention that the credential it needs was never looked for.
1225
1419
  forges: [...new Set(triggers.map((t) => t.run.kind).filter(isForgeKind))].sort(),
1226
1420
  repositories: [...new Set(triggers.filter((t) => t.run.kind === "github" && typeof t.run.repository === "string").map((t) => t.run.repository))].sort(),
1421
+ // REQ-PER-TRIGGER-SKILLS (issue #189). Per-trigger TUPLES, unlike every deduped set above,
1422
+ // because a flow-resolution answer depends on the trigger's own folder/skillsDir/packages --
1423
+ // two triggers naming the same flow with different skillsDirs are two different questions.
1424
+ // The label is how a line names its trigger: cron entries by their id, id-less webhook
1425
+ // entries by raw file position (the admin's trigger:<index> identity).
1426
+ flows: triggers
1427
+ .map((t, index) => ({
1428
+ label: t.on.type === "cron" ? `cron "${t.on.id}"` : `${t.on.type} trigger #${index}`,
1429
+ flow: t.run.flow,
1430
+ kind: t.run.kind,
1431
+ folder: typeof t.run.folder === "string" ? t.run.folder : null,
1432
+ skillsDir: typeof t.run.skillsDir === "string" ? t.run.skillsDir : null,
1433
+ packages: t.run.packages !== false,
1434
+ }))
1435
+ .filter((f) => typeof f.flow === "string"),
1227
1436
  };
1437
+ } catch (e) {
1438
+ // REPORTED, not swallowed. This catch used to justify itself with "a malformed triggers file already
1439
+ // fails LOUD at worker boot", and that premise does not hold: the worker reads the file only when
1440
+ // PI_TRIGGERS_FILE is set, so a receiver-only deployment gets no loud failure anywhere. Worse, the
1441
+ // zeroes below silently disarm the WEBHOOK_SECRET check, every per-forge credential check, the
1442
+ // per-image checks and the flow-tier probes -- so doctor came back GREENER than a healthy
1443
+ // deployment, which is the one direction a preflight must never fail in.
1444
+ //
1445
+ // The counts stay zero, because every downstream check reads them and a half-parsed file has no
1446
+ // honest counts to give. What changes is that the reason travels with them.
1447
+ // Only a TAGGED config refusal is reported. parseTriggers throws `piDispatchConfig` errors; anything
1448
+ // else here is an fs failure on a path the guard above already said existed (a race, a permission,
1449
+ // a directory), which is not a statement about the file's CONTENT and has no fix an operator can act
1450
+ // on from this line. Those keep the old silent zeroes.
1451
+ if (e?.piDispatchConfig !== true) return none;
1452
+ return { ...none, parseError: e.message, path: triggersPath(env, cwd) };
1453
+ }
1454
+ }
1455
+
1456
+ /** The triggers path doctor would have read, so a parse failure can name it. */
1457
+ function triggersPath(env, cwd) {
1458
+ return env.PI_TRIGGERS_FILE ?? join(cwd, "triggers.json");
1459
+ }
1460
+
1461
+ /**
1462
+ * The `--env-setup` script (issue #216). `pi-dispatch service render|install --env-setup <path>` names a
1463
+ * script the service manager SOURCES at every boot, as the service user, with the deployment's
1464
+ * environment -- and after that nothing ever looks at it again. resolveEnvSetup checked it existed once,
1465
+ * at render time, on a host that may not be this one.
1466
+ *
1467
+ * doctor has to DISCOVER the path before it can check it, because --env-setup is a render-time flag and
1468
+ * the rendered unit is the only place it lives. Two sources, in this order:
1469
+ *
1470
+ * 1. The installed units for THIS deployment -- the file that actually boots, and so the honest
1471
+ * answer. A unit whose WorkingDirectory names some other folder belongs to some other deployment on
1472
+ * the same host and is deliberately skipped: doctor is this deployment's preflight, and warning
1473
+ * about a neighbour's unit would fire forever on a host that runs two.
1474
+ * 2. PI_ENV_SETUP in doctor's OWN environment, and only when (1) found nothing. That is what launchd
1475
+ * and nssm put in front of the wrapper, so it is the right answer for a doctor run through the same
1476
+ * environment the service gets. It is a different question from (1), which is why every line below
1477
+ * names the source it came from rather than blurring the two.
1478
+ *
1479
+ * Everything here is warn-tier and nothing carries a `fixAction` -- the never tier
1480
+ * (REQ-DEPLOYMENT-BOOTSTRAP): doctor does not chmod an operator's file and does not move it. Nor does it
1481
+ * ever OPEN the script. The script holds no secret by design, but what it holds is the commands that
1482
+ * fetch them, and a preflight that echoed those would be publishing the map instead of the treasure.
1483
+ *
1484
+ * Returns [] when no seam is configured, so a deployment that does not use one gets byte-identical
1485
+ * output.
1486
+ */
1487
+ /**
1488
+ * REQ-EGRESS-ALLOWLIST. What the shipped egress policy actually is on this host, read back from docker
1489
+ * rather than assumed from the compose file that was supposed to create it.
1490
+ *
1491
+ * Returns [] when `PI_EGRESS=0`, so a deployment that declined the policy gets byte-identical output --
1492
+ * the same convention envSetupChecks follows one feature over. Armed is the DEFAULT, so most deployments
1493
+ * see these lines.
1494
+ *
1495
+ * TIERING, and it is the whole editorial judgement here. The proxy's PRESENCE is a hard failure when the
1496
+ * policy is armed: the worker refuses every job pre-spend without it, so a ✓ would be a lie and a ⚠ would
1497
+ * under-report a deployment that cannot run anything. Everything that needs the NETWORK to answer is
1498
+ * warn-tier, on doctor's own rule that a ✗ is reserved for certainties: a custom provider base URL, a
1499
+ * corporate egress path or a transient provider blip each make a red here a false alarm, and an operator
1500
+ * who learns to scroll past doctor costs more than a missed warning does.
1501
+ *
1502
+ * NOTHING here carries a `fixAction` -- the never tier (REQ-DEPLOYMENT-BOOTSTRAP). One candidate was
1503
+ * considered and refused: a prompt-tier offer to start the proxy, on the Valkey precedent. That offer
1504
+ * starts a QUEUE, whose failure mode is that nothing runs. This one would stand up a SECURITY CONTROL
1505
+ * whose allowlist the operator has not written yet, turning "no policy" into "a policy that fails every
1506
+ * job inside a paid container". It is also not one argv but a compose profile and a file that must already
1507
+ * exist, and doctor "never guesses a semantic env value".
1508
+ */
1509
+ async function egressChecks(env, seams, { dockerCode, imageCode, jobImage }) {
1510
+ const { spawn } = seams;
1511
+ // The SAME parse the worker boots with (egress.mjs), never a second `=== "1"`: doctor reporting a
1512
+ // policy that is off, or nothing about one that is on, is worse than doctor not checking at all.
1513
+ // A malformed value is the worker's boot failure to report, not doctor's to guess at, so it reads as
1514
+ // armed here and the `.env` check above is what fails.
1515
+ let armed;
1516
+ try {
1517
+ armed = egressArmed(env);
1228
1518
  } catch {
1229
- return none;
1519
+ armed = true;
1520
+ }
1521
+ if (!armed) return [];
1522
+ const proxy = env.PI_EGRESS_PROXY || DEFAULT_EGRESS_PROXY;
1523
+ const checks = [];
1524
+
1525
+ if (dockerCode !== 0) {
1526
+ checks.push({
1527
+ ok: false,
1528
+ warn: true,
1529
+ label: "Egress policy: not checked (the Docker daemon did not answer)",
1530
+ fix: "start Docker, then re-run doctor -- the policy lives in docker's own networks and containers, so none of it can be read from here",
1531
+ });
1532
+ return checks;
1533
+ }
1534
+
1535
+ // `docker inspect` on the container, not `ps`: it answers present-vs-absent and running-vs-stopped in
1536
+ // one call, and those are two different fixes. The FIELD_SEP habit is image-preflight.mjs's -- neither
1537
+ // a boolean nor a health word can contain "|".
1538
+ const state = await runCmdCapture(spawn, "docker", ["inspect", `--format={{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}`, proxy]);
1539
+ const [running, health] = state.code === 0 ? state.output.trim().split("|") : [];
1540
+ const up = running === "true";
1541
+ checks.push({
1542
+ ok: up,
1543
+ label: up ? `Egress proxy running (${proxy})` : state.code === 0 ? `Egress proxy is stopped (${proxy})` : `Egress proxy is not on this host (${proxy})`,
1544
+ fix: "docker compose -f deploy/docker-compose.yml --profile egress up -d -- the egress policy refuses every job pre-spend while this is down, which costs no budget but runs nothing (PI_EGRESS=0 opts out)",
1545
+ });
1546
+ if (!up) return checks;
1547
+
1548
+ // Advisory on purpose, and deliberately NOT what the money gate reads. A healthcheck can flap, and a
1549
+ // pre-spend gate that refuses on a flapping signal drops real work while one that retries on it burns
1550
+ // the second budget slot this whole requirement exists to save. Here a human is reading, so it is worth
1551
+ // saying: a squid that parsed its config and then wedged looks identical to a healthy one from outside.
1552
+ if (health && health !== "none") {
1553
+ checks.push({
1554
+ ok: health === "healthy",
1555
+ warn: true,
1556
+ label: `Egress proxy health: ${health}`,
1557
+ fix: `docker logs ${proxy} -- the container is up but its listener is not answering, so jobs will start and then fail to reach anything`,
1558
+ });
1559
+ }
1560
+
1561
+ // The end-to-end probe, and the only place in this codebase that proves the policy rather than
1562
+ // inspecting it. Two containers, on a throwaway network built exactly like a job's, gated on the image
1563
+ // being present because it uses the job image's own node -- which is the point: it proves the operator's
1564
+ // OWN image honours NODE_USE_ENV_PROXY, the property a stale image would silently lack and the one
1565
+ // whose absence turns the whole policy into an outage.
1566
+ //
1567
+ // Credential-free by construction: `api.anthropic.com` answers 401 to an unauthenticated request, so
1568
+ // reaching the provider and being refused for the key proves the entire path and costs nothing. That is
1569
+ // docs/egress.md's own method, promoted from prose to a check.
1570
+ if (imageCode !== 0) return checks;
1571
+ const net = `pi-dispatch-egress-doctor-${process.pid}`;
1572
+ if ((await runCmd(spawn, "docker", ["network", "create", "--internal", net])) !== 0) return checks;
1573
+ try {
1574
+ if ((await runCmd(spawn, "docker", ["network", "connect", net, proxy])) !== 0) return checks;
1575
+ for (const [slug, host, url, want] of [
1576
+ ["provider", "the provider", "https://api.anthropic.com/v1/messages", true],
1577
+ ["unlisted", "an unlisted host", "https://pi-dispatch-not-on-your-allowlist.example/", false],
1578
+ ]) {
1579
+ const probe = await runCmdCapture(spawn, "docker", [
1580
+ "run",
1581
+ "--rm",
1582
+ // Named, and outside the boot reaper's `pi-job-` filter by construction. `--rm` disposes of it,
1583
+ // so the name exists for the operator watching `docker ps` during a doctor run and for the one
1584
+ // reading `ps` afterwards to find out what a wedged probe was doing.
1585
+ "--name",
1586
+ `pi-dispatch-egress-probe-${slug}`,
1587
+ "--pull=never",
1588
+ `--network=${net}`,
1589
+ "-e",
1590
+ `HTTPS_PROXY=http://${proxy}:3128`,
1591
+ "-e",
1592
+ "NODE_USE_ENV_PROXY=1",
1593
+ "--entrypoint",
1594
+ "node",
1595
+ jobImage,
1596
+ "-e",
1597
+ // The URL rides ARGV, not the spawn env, and the difference from the in-image `gh` probe is
1598
+ // deliberate: that one carries a TOKEN, which must never be visible in `ps`. This carries a
1599
+ // public hostname, so argv is the honest place for it -- an operator reading `ps` during a
1600
+ // doctor run can see exactly which host is being probed.
1601
+ `fetch(${JSON.stringify(url)},{method:"POST"}).then(r=>{console.log("reached",r.status);process.exit(0)},e=>{console.log("blocked",e.cause?.code??e.message);process.exit(3)})`,
1602
+ ]);
1603
+ const reached = probe.code === 0;
1604
+ checks.push({
1605
+ ok: reached === want,
1606
+ warn: true,
1607
+ label: reached === want
1608
+ ? want
1609
+ ? `Egress policy reaches the provider (api.anthropic.com answered, so the whole path works and no key was spent)`
1610
+ : `Egress policy denies ${host} (the deny direction is the half an allowlist can silently lose)`
1611
+ : want
1612
+ ? `Egress policy does NOT reach the provider (api.anthropic.com)`
1613
+ : `Egress policy ALLOWS ${host} that is not on your allowlist`,
1614
+ fix: want
1615
+ ? `add api.anthropic.com to egress-allowlist.conf and restart the proxy -- until then every job starts, fails at its first turn, and spends two budget slots proving it (docs/egress.md)`
1616
+ : `check egress-allowlist.conf: a rule wider than you meant (a bare domain where you wanted a subdomain) lets a job reach hosts you did not list`,
1617
+ });
1618
+ }
1619
+ } finally {
1620
+ await runCmd(spawn, "docker", ["network", "disconnect", "-f", net, proxy]);
1621
+ await runCmd(spawn, "docker", ["network", "rm", net]);
1622
+ }
1623
+ return checks;
1624
+ }
1625
+
1626
+ async function envSetupChecks(env, seams) {
1627
+ const { cwd, spawn, fileExists, platform, home } = seams;
1628
+ const sources = new Map(); // setup path -> how doctor learned it; the first source to name it wins
1629
+
1630
+ if (platform === "win32") {
1631
+ for (const which of ["worker", "receiver"]) {
1632
+ const service = `pi-dispatch-${which}`;
1633
+ const got = await runCmdCapture(spawn, "nssm", ["get", service, "AppEnvironmentExtra"]);
1634
+ // Not installed, or nssm not on PATH: silence. Same doctrine as check-ignore below -- a check
1635
+ // nobody can silence must never cry wolf, and "could not ask" is not "misconfigured".
1636
+ if (got.code !== 0) continue;
1637
+ // No deployment match here: nssm keeps the folder in a SEPARATE AppDirectory property, and there
1638
+ // is exactly one machine-scoped service per name for it to be confused with.
1639
+ const { setup } = readUnitSeam(got.output, "win32");
1640
+ if (setup && !sources.has(setup)) sources.set(setup, `${service}'s AppEnvironmentExtra`);
1641
+ }
1642
+ } else {
1643
+ for (const { path } of installedUnitPaths(platform, home)) {
1644
+ if (!fileExists(path)) continue;
1645
+ let seam;
1646
+ try {
1647
+ seam = readUnitSeam(readFileSync(path, "utf8"), platform);
1648
+ } catch {
1649
+ continue; // a system-scope unit this user may not read: which deployment it serves is unknowable
1650
+ }
1651
+ if (!seam.setup || seam.deployDir !== cwd) continue;
1652
+ if (!sources.has(seam.setup)) sources.set(seam.setup, path);
1653
+ }
1654
+ }
1655
+
1656
+ const fromEnv = (env.PI_ENV_SETUP ?? "").trim();
1657
+ if (sources.size === 0 && fromEnv) sources.set(fromEnv, "PI_ENV_SETUP in this environment");
1658
+
1659
+ const checks = [];
1660
+ for (const [setup, source] of sources) {
1661
+ if (!fileExists(setup)) {
1662
+ checks.push({
1663
+ ok: false,
1664
+ warn: true,
1665
+ label: `the env-setup script at ${setup} does not exist (named by ${source})`,
1666
+ fix: "restore it, or re-render without --env-setup -- the service manager sources it at every boot, so until it is back the unit exits 1 in a restart loop and the worker never starts (docs/secrets.md)",
1667
+ });
1668
+ continue;
1669
+ }
1670
+ checks.push({ ok: true, label: `env-setup script present (${setup}, named by ${source})` });
1671
+
1672
+ // WRITABILITY, not readability -- deliberately `& 0o022` and not the App key's `& 0o077`. This file
1673
+ // is EXECUTED (sourced) by the account that holds the provider key and the forge token, so anyone
1674
+ // who can edit it owns the worker. That it is READABLE is fine: it holds no secret by design.
1675
+ // POSIX only, for the same reason the App key's mode check skips win32 -- stat modes are synthetic
1676
+ // there, so this would warn on every healthy Windows deployment and teach operators to scroll past.
1677
+ if (platform !== "win32") {
1678
+ try {
1679
+ if ((statSync(setup).mode & 0o022) !== 0) {
1680
+ checks.push({
1681
+ ok: false,
1682
+ warn: true,
1683
+ label: `the env-setup script at ${setup} is group/world-writable`,
1684
+ fix: `chmod go-w ${setup} -- the service manager sources it at every boot as the account that holds the provider key and the forge token, so whoever can edit it owns the worker`,
1685
+ });
1686
+ }
1687
+ } catch {
1688
+ // stat raced a deletion or an exotic fs: the presence line above already covered existence.
1689
+ }
1690
+ const dir = dirname(setup);
1691
+ try {
1692
+ const mode = statSync(dir).mode;
1693
+ // Sticky (0o1000) is exempt and must stay exempt: in a sticky directory a non-owner cannot
1694
+ // rename or delete someone else's file, so "anyone can replace it" would simply be false there.
1695
+ if ((mode & 0o022) !== 0 && (mode & 0o1000) === 0) {
1696
+ checks.push({
1697
+ ok: false,
1698
+ warn: true,
1699
+ label: `the directory holding the env-setup script (${dir}) is group/world-writable`,
1700
+ fix: `chmod go-w ${dir} -- the script's own mode does not help when anyone can replace the file, and the manager sources whatever is there at the next boot`,
1701
+ });
1702
+ }
1703
+ } catch {
1704
+ // an unreadable parent directory: nothing to claim either way.
1705
+ }
1706
+ }
1707
+
1708
+ // The #211 question, asked of a different file. Exit 1 is again the ONLY case that speaks: 0 means
1709
+ // ignored, 128 means no work tree, null means git could not be launched, and all three are silence.
1710
+ const ignoreCode = await runCmd(spawn, "git", [...GIT_READ_FLAGS, "-C", dirname(setup), "check-ignore", "-q", setup]);
1711
+ if (ignoreCode === 1) {
1712
+ checks.push({
1713
+ ok: false,
1714
+ warn: true,
1715
+ label: `the env-setup script at ${setup} is inside a git work tree that does not ignore it`,
1716
+ fix: "move it outside that repo, or ignore it there -- it holds no secret by design, but it holds the commands that FETCH them (client and project ids, a manager address, sometimes a path to a credential file), which is a map to every secret this deployment uses",
1717
+ });
1718
+ }
1230
1719
  }
1720
+ return checks;
1231
1721
  }
1232
1722
 
1233
1723
  /**