@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
package/src/init.mjs ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `pi-dispatch init` — scaffold a deployment's config files in the current folder.
3
+ *
4
+ * Idempotent and non-destructive: an existing file is reported and left as-is, so re-running init
5
+ * never overwrites operator edits. The scaffolds mirror the empty templates the worker validates
6
+ * against — an empty triggers list disables cron/label/comment/PR, an empty windows list means no
7
+ * scoped pauses, an empty packages list stages nothing, an empty subscriptions list declares no plan
8
+ * prices — so a fresh deployment starts inert and is opted into feature by feature.
9
+ */
10
+ import { existsSync, copyFileSync, writeFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { join } from "node:path";
13
+
14
+ const EMPTY_TRIGGERS = `${JSON.stringify({ triggers: [] }, null, 2)}\n`;
15
+ const EMPTY_PAUSE_WINDOWS = `${JSON.stringify({ windows: [] }, null, 2)}\n`;
16
+ // Pinned third-party pi packages staged into the global overlay (issue #58). Empty by default: staging
17
+ // runs third-party code inside jobs, so it is opted into package by package, never scaffolded populated.
18
+ const EMPTY_PACKAGES = `${JSON.stringify({ packages: [] }, null, 2)}\n`;
19
+ // Operator-declared subscription plans (issue #53), read by the admin extension only — never at job
20
+ // time. Versioned because a newer file must fail loud, and that cannot be retrofitted into a v1 reader.
21
+ const EMPTY_SUBSCRIPTIONS = `${JSON.stringify({ version: 1, subscriptions: [] }, null, 2)}\n`;
22
+
23
+ export function runInit(cwd = process.cwd(), deps = {}) {
24
+ const { fs = { existsSync, copyFileSync, writeFileSync }, out = (s) => process.stdout.write(s) } = deps;
25
+ const results = [];
26
+
27
+ // .env from the example. Prefer the copy in cwd (the clone's repo root); fall back to the copy
28
+ // SHIPPED with the worker package (worker/.env.example, kept byte-identical to the root example by
29
+ // worker/test/publish.test.mjs) so init works both from elsewhere in a checkout and from an npm
30
+ // install, where the repo root does not exist.
31
+ const envPath = join(cwd, ".env");
32
+ if (fs.existsSync(envPath)) {
33
+ results.push(["kept", ".env", "already exists — left untouched"]);
34
+ } else {
35
+ const cwdExample = join(cwd, ".env.example");
36
+ const source = fs.existsSync(cwdExample)
37
+ ? cwdExample
38
+ : fileURLToPath(new URL("../.env.example", import.meta.url));
39
+ fs.copyFileSync(source, envPath);
40
+ results.push(["created", ".env", "from .env.example — set your provider key next"]);
41
+ }
42
+
43
+ scaffold(fs, results, join(cwd, "triggers.json"), EMPTY_TRIGGERS, "empty triggers list");
44
+ scaffold(fs, results, join(cwd, "pause-windows.json"), EMPTY_PAUSE_WINDOWS, "empty pause-windows list");
45
+ scaffold(fs, results, join(cwd, "pi-packages.json"), EMPTY_PACKAGES, "empty pi package list (stage with import-pi --with-packages)");
46
+ scaffold(fs, results, join(cwd, "subscriptions.json"), EMPTY_SUBSCRIPTIONS, "empty subscription list (declare plan prices for the admin's cost analytics)");
47
+
48
+ for (const [verb, name, note] of results) {
49
+ out(`${verb.padEnd(7)} ${name.padEnd(20)} ${note}\n`);
50
+ }
51
+ out(nextSteps());
52
+ return 0;
53
+ }
54
+
55
+ function scaffold(fs, results, path, content, note) {
56
+ const name = path.split(/[\\/]/).pop();
57
+ if (fs.existsSync(path)) {
58
+ results.push(["kept", name, "already exists — left untouched"]);
59
+ } else {
60
+ fs.writeFileSync(path, content);
61
+ results.push(["created", name, note]);
62
+ }
63
+ }
64
+
65
+ function nextSteps() {
66
+ return `
67
+ Next:
68
+ 1. docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest
69
+ # the prebuilt job image (or build image/Dockerfile)
70
+ 2. docker compose -f deploy/docker-compose.yml up -d # the durable queue (Valkey)
71
+ 3. edit .env # set ANTHROPIC_API_KEY (or your provider's key)
72
+ 4. pi-dispatch doctor # verify Docker, Valkey, image, and key
73
+ 5. pi-dispatch worker # drain the queue
74
+
75
+ Operator panel (optional): pi install npm:@edgehero/pi-dispatch-admin then /dispatch
76
+ `;
77
+ }
package/src/job-id.mjs ADDED
@@ -0,0 +1,100 @@
1
+ import { createHash } from "node:crypto";
2
+ import { configError } from "./config.mjs";
3
+ import { forgeSpec } from "./forges.mjs";
4
+
5
+ /**
6
+ * A deterministic jobId for a local job. BullMQ's dedup is `EXISTS jobId`, so a double-invoke of
7
+ * the same task within the same minute produces the same id and the duplicate is ignored -- the
8
+ * local equivalent of REQ-DEDUP-BY-DELIVERY-GUID, guarding against a hasty second Enter
9
+ * double-spending, without blocking a deliberate re-run a minute later.
10
+ *
11
+ * Kept free of any bullmq import so the dedup logic is testable everywhere, not only where the
12
+ * queue's dependencies are installed.
13
+ */
14
+ export function localJobId({ folder, flow, task, minute }) {
15
+ // NUL-delimited so {folder:'a',task:'bc'} and {folder:'ab',task:'c'} cannot collide.
16
+ const digest = createHash("sha256").update([folder, flow ?? "", task ?? "", minute].join("\0")).digest("hex");
17
+ return `local-${digest.slice(0, 16)}`;
18
+ }
19
+
20
+ /**
21
+ * The retry-idempotent jobId for a chained (outbox-requested) child job: `parent id + content-hash of
22
+ * (flow, task)`, with NO time component. BullMQ's dedup is `EXISTS jobId`, so a retried parent
23
+ * re-collects its outbox and re-enqueues IDENTICAL child ids -- the duplicate follow-up is rejected,
24
+ * so a retry cannot fan out extra paid jobs (INT-OUTBOX-CONTRACT, DES-JOB-OUTBOX-CHAINING).
25
+ *
26
+ * localJobId's minute component is deliberately ABSENT here: a retry crossing a minute boundary would
27
+ * otherwise mint a fresh id and double-chain. `parentJobId` is folded INTO the hash so two different
28
+ * parents requesting the same flow+task resolve to distinct ids and cannot collide; the `chain-`
29
+ * prefix carries no parent info of its own because the hash already binds it.
30
+ *
31
+ * Kept free of any bullmq import (mirrors localJobId/deliveryJobId) so the dedup key is derivable
32
+ * everywhere, not only where the queue's dependencies are installed.
33
+ */
34
+ export function chainedJobId({ parentJobId, flow, task }) {
35
+ // NUL-delimited (localJobId's idiom) so distinct field splits cannot collide.
36
+ const digest = createHash("sha256").update([String(parentJobId), flow ?? "", task ?? ""].join("\0")).digest("hex");
37
+ return `chain-${digest.slice(0, 16)}`;
38
+ }
39
+
40
+ /**
41
+ * The deterministic jobId for a GitHub-triggered job: the `X-GitHub-Delivery` GUID, prefixed.
42
+ * BullMQ's dedup is `EXISTS jobId`, so a redelivered webhook (GitHub retries on timeout) carries
43
+ * the same GUID, resolves to the same id, and the duplicate paid run is rejected --
44
+ * REQ-DEDUP-BY-DELIVERY-GUID.
45
+ *
46
+ * Kept free of any bullmq import (mirrors localJobId) so the dedup key is derivable everywhere, not
47
+ * only where the queue's dependencies are installed. A missing GUID is a caller bug -- the receiver
48
+ * rejects a missing deliveryId before enqueue (D2) -- so this throws rather than inventing a random
49
+ * id, which would silently defeat dedup and let a redelivery double-spend.
50
+ */
51
+ export function deliveryJobId(guid) {
52
+ return forgeDeliveryJobId("github", guid);
53
+ }
54
+
55
+ /**
56
+ * The same thing for a GitLab-triggered job: the `webhook-id` (or its older name `Idempotency-Key`),
57
+ * prefixed `gl-`. GitLab keeps that value CONSTANT across its own retries, which is the exact property
58
+ * REQ-DEDUP-BY-DELIVERY-GUID needs, so the guarantee is the same one -- and retention-bounded in the same
59
+ * way.
60
+ */
61
+ export function gitlabDeliveryJobId(id) {
62
+ return forgeDeliveryJobId("gitlab", id);
63
+ }
64
+
65
+ /**
66
+ * The general form the two above are now spellings of: a forge kind plus that forge's own per-delivery
67
+ * id, prefixed from the forge table.
68
+ *
69
+ * The prefix is not decoration -- it keeps every forge's id space disjoint, so a delivery id that happened
70
+ * to collide across two forges could never silently suppress the other's job. One body rather than one per
71
+ * forge because the *policy* here (a missing id throws rather than inventing a random one, which would
72
+ * defeat dedup and let a redelivery double-spend) is a property of REQ-DEDUP-BY-DELIVERY-GUID, not of any
73
+ * one forge, and four copies of a policy is four chances to weaken it in three places.
74
+ *
75
+ * An unknown kind throws for a different reason than an empty id, and says so: reaching here with one
76
+ * means a forge was added to the trigger schema and not to the table.
77
+ *
78
+ * `replica` is the ONE seam that lets a single delivery become more than one job (REQ-REPLICA-RUNS). It is
79
+ * appended, so `gh-<guid>` becomes `gh-<guid>-r2`, and the dedup guarantee above survives intact: a
80
+ * REDELIVERY of that same GUID still resolves `-r2` for replica 2 and is still rejected, while replica 1
81
+ * and replica 2 can never suppress each other. Everything the harness keys off the jobId -- the container
82
+ * name, `PI_JOB_ID`, the `.log` and `.json` sidecars -- becomes replica-distinct for free. `-` is inside
83
+ * `sanitizeJobId`'s `[A-Za-z0-9._-]` allowlist (run-history.mjs) and inside docker's container-name
84
+ * grammar, so the longer id needs no new escaping anywhere. Absent leaves the id byte-identical.
85
+ */
86
+ export function forgeDeliveryJobId(kind, id, replica) {
87
+ const spec = forgeSpec(kind);
88
+ if (!spec) {
89
+ throw configError(`forgeDeliveryJobId: ${JSON.stringify(kind)} is not a known forge -- add it to FORGES in worker/src/forges.mjs`);
90
+ }
91
+ if (typeof id !== "string" || id === "") {
92
+ throw configError(`forgeDeliveryJobId requires a non-empty ${spec.deliveryIdName} for a ${kind} delivery`);
93
+ }
94
+ const base = `${spec.jobIdPrefix}${id}`;
95
+ if (replica === undefined) return base;
96
+ if (!Number.isInteger(replica) || replica <= 0) {
97
+ throw configError(`forgeDeliveryJobId: replica must be a positive integer when present (got ${JSON.stringify(replica)})`);
98
+ }
99
+ return `${base}-r${replica}`;
100
+ }
@@ -0,0 +1,138 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const exec = promisify(execFile);
7
+
8
+ /**
9
+ * Materialise a serviced repo's `.pi/` (its persona and skills) from a pinned commit into a
10
+ * read-only `/job/pi/` directory the container mounts.
11
+ *
12
+ * This is security-critical: the content becomes the agent's SYSTEM PROMPT, and the repo is only
13
+ * trusted at maintainer level. Three properties, each PROVEN with a hostile fixture in the tests:
14
+ *
15
+ * 1. NO SYMLINK FOLLOWING. A `.pi/APPEND_SYSTEM.md` symlinked to the worker's `.env` or
16
+ * `/etc/passwd` must never pull a host file into the prompt. We enumerate with `git ls-tree`
17
+ * and REJECT any entry that is not a regular blob (mode 100644): symlinks are 120000,
18
+ * submodules 160000. We never touch the working tree, so there is no link to follow.
19
+ * 2. NO PATH TRAVERSAL. Every output path is re-derived from the git tree path and asserted to
20
+ * stay under the destination root; a crafted entry cannot escape `/job/pi/`.
21
+ * 3. NO EXECUTION. `git cat-file blob <oid>` dumps raw bytes by object id -- no working-tree
22
+ * checkout, no smudge/clean filters, no hooks, no diff drivers. Nothing in the repo runs.
23
+ *
24
+ * The SHA is an input, resolved by the caller from a fresh default-branch API call -- NEVER a
25
+ * webhook field, and NEVER the triggering (possibly fork) branch.
26
+ */
27
+
28
+ const PI_DIR = ".pi";
29
+ const APPEND_SYSTEM = `${PI_DIR}/APPEND_SYSTEM.md`;
30
+ // A skill directory name: lowercase kebab/underscore, 1-64 chars, no dots (so no "..") and no
31
+ // slashes. This is what makes a traversal name impossible at the source. Matched against the
32
+ // CAPTURED segment only, never the whole path.
33
+ const SKILL_PATH_RE = /^\.pi\/skills\/([a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?)\/SKILL\.md$/;
34
+ const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
35
+
36
+ /**
37
+ * Classify a git tree path into the destination we will WRITE, or null to reject.
38
+ *
39
+ * Critically, the returned `outRel` is built from a FIXED TEMPLATE using the validated skill name,
40
+ * never from the raw git path. gitshow-research proved `git ls-tree` can emit path strings
41
+ * containing literal `../` segments (git does not sanitise tree-entry names), so deriving the
42
+ * output path from git's string is unsafe even behind a containment check. The name is the only
43
+ * attacker-influenced input, and it is validated to a charset that cannot express traversal.
44
+ */
45
+ export function classifyPiPath(path) {
46
+ if (path === APPEND_SYSTEM) return { outRel: "pi/APPEND_SYSTEM.md" };
47
+ const m = SKILL_PATH_RE.exec(path);
48
+ if (!m) return null;
49
+ const name = m[1];
50
+ if (!SKILL_NAME_RE.test(name)) return null; // redundant with the capture group; belt and braces
51
+ return { outRel: `pi/skills/${name}/SKILL.md` };
52
+ }
53
+
54
+ /** Back-compat predicate used by callers/tests that only care whether a path is accepted. */
55
+ export function isAllowedPiPath(path) {
56
+ return classifyPiPath(path) !== null;
57
+ }
58
+
59
+ /**
60
+ * Parse `git ls-tree -r -z` output into entries, keeping ONLY regular blobs (100644) at allowed
61
+ * paths, each carrying its template-derived output path. Symlinks (120000), submodules (160000),
62
+ * executables (100755), and anything outside the allowlist are dropped here -- the single choke
63
+ * point for the reject-by-mode rule.
64
+ */
65
+ export function selectEntries(lsTreeZ) {
66
+ const entries = [];
67
+ for (const record of lsTreeZ.split("\0")) {
68
+ if (!record) continue;
69
+ // "<mode> <type> <oid>\t<path>"
70
+ const tab = record.indexOf("\t");
71
+ if (tab === -1) continue;
72
+ const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
73
+ const path = record.slice(tab + 1);
74
+ if (mode !== "100644" || type !== "blob") continue; // rejects symlink/submodule/exec
75
+ const classified = classifyPiPath(path);
76
+ if (!classified) continue;
77
+ entries.push({ oid, path, outRel: classified.outRel });
78
+ }
79
+ return entries;
80
+ }
81
+
82
+ /**
83
+ * Assert a resolved output path stays under root. Defence in depth behind the path allowlist.
84
+ * Uses path.relative rather than string-prefix so it is correct on Windows too -- the worker is
85
+ * cross-platform and destDir may be a Windows path with backslash separators.
86
+ */
87
+ function safeJoin(root, ...segments) {
88
+ const resolved = join(root, ...segments);
89
+ const rel = relative(root, resolved);
90
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
91
+ throw new Error(`path escapes destination: ${segments.join("/")}`);
92
+ }
93
+ return resolved;
94
+ }
95
+
96
+ /**
97
+ * Materialise `.pi/` at `sha` from the clone at `gitDir` into `destDir` (which becomes /job/pi).
98
+ * Returns the list of relative paths written (under `pi/`), for logging.
99
+ *
100
+ * `git` is injected for tests; defaults to a thin wrapper over the real binary.
101
+ */
102
+ export async function materializePiDir({ gitDir, sha, destDir, git = defaultGit }) {
103
+ const lsTreeZ = await git(gitDir, ["ls-tree", "-r", "-z", sha, `${PI_DIR}/`]);
104
+ const entries = selectEntries(lsTreeZ);
105
+
106
+ const written = [];
107
+ for (const { oid, outRel } of entries) {
108
+ const content = await git(gitDir, ["cat-file", "blob", oid], { raw: true });
109
+ // outRel is TEMPLATE-derived from a validated name, never the raw git path -- so it cannot
110
+ // contain traversal. safeJoin re-checks containment as defence in depth, and splits the posix
111
+ // outRel into host path segments so it is correct on Windows too.
112
+ const out = safeJoin(destDir, ...outRel.split("/"));
113
+ mkdirSync(dirname(out), { recursive: true });
114
+ writeFileSync(out, content, { mode: 0o444 });
115
+ // Report posix-style: this names a CONTAINER path (/job/pi/...), stable across host OSes.
116
+ written.push(outRel);
117
+ }
118
+ return written;
119
+ }
120
+
121
+ async function defaultGit(gitDir, args, { raw = false } = {}) {
122
+ // -c protecting against a hostile repo config: no hooks, no external filters, no pager.
123
+ const hardened = [
124
+ "-c",
125
+ "core.hooksPath=/dev/null",
126
+ "-c",
127
+ "core.fsmonitor=false",
128
+ "--no-pager",
129
+ "-C",
130
+ gitDir,
131
+ ...args,
132
+ ];
133
+ const { stdout } = await exec("git", hardened, {
134
+ encoding: raw ? "buffer" : "utf8",
135
+ maxBuffer: 16 * 1024 * 1024,
136
+ });
137
+ return stdout;
138
+ }
package/src/outbox.mjs ADDED
@@ -0,0 +1,179 @@
1
+ import * as nodeFs from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readFlowGate as realReadFlowGate } from "./flow-gate.mjs";
4
+ import { chainedJobId } from "./job-id.mjs";
5
+ import { enqueueLocalJob } from "./queue.mjs";
6
+
7
+ /**
8
+ * The outbox chain collector: the host-side reader of a completed LOCAL parent's `/outbox`, the
9
+ * container's ONLY signal channel back to the worker (INT-OUTBOX-CONTRACT, DES-JOB-OUTBOX-CHAINING).
10
+ * The worker is the only enqueuer; the container never learns the queue exists. Every field in a
11
+ * `request-<n>.json` is agent-authored and untrusted, so each is allowlist-validated host-side before
12
+ * a child is enqueued, the child folder is FORCED to the parent's own folder (the outbox `folder`
13
+ * field is ignored -- same-folder-only), and depth is HOST-computed, never read from the outbox.
14
+ *
15
+ * Fault isolation is the whole contract. The parent has already COMPLETED its own result before this
16
+ * runs; a throw here would flip a completed parent to failed/retryable and pay TWICE for one answer
17
+ * (CONST-RETRY-INFRA-ONLY). Therefore the entire body is wrapped in one try/catch and `collectChain`
18
+ * NEVER throws: any internal error is logged and the running `{ enqueued, refused }` counts are
19
+ * returned. This is a PRODUCER-only path -- no budget reserve, no container -- and the enqueued
20
+ * children pass `reserveBudget` consumer-side like any local job (CONST-BUDGET-BEFORE-TOKENS).
21
+ *
22
+ * Logs are PII-free by construction: every line carries `{ jobId, reason, index }` only, never the
23
+ * agent-authored `task`/`flow` content or file bytes (`no-pii-in-logs`). The `task` text is DATA that
24
+ * reaches the child's prompt.md but never a log line or the run-history record.
25
+ */
26
+
27
+ // The skill-name charset: lowercase kebab/underscore, 1-64 chars, no dots (so no "..") and no
28
+ // slashes. Mirrors flow-gate.mjs / materialize.mjs SKILL_NAME_RE -- keep in sync. Rejecting a bad
29
+ // `flow` here, BEFORE the gate, is the traversal choke point: an untrusted name never reaches git.
30
+ const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
31
+
32
+ // One request file: `request-<n>.json`. The captured `<n>` orders the files numerically so the count
33
+ // cap deterministically keeps the lowest-numbered requests.
34
+ const REQUEST_RE = /^request-(\d+)\.json$/;
35
+
36
+ // Per-file size cap (4 KiB), enforced BEFORE any read so a hostile file cannot inflate parse cost.
37
+ const MAX_REQUEST_BYTES = 4096;
38
+
39
+ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGate = realReadFlowGate, config, fs = nodeFs, log = () => {} }) {
40
+ return async function collectChain({ job, prepared }) {
41
+ let enqueued = 0;
42
+ let refused = 0;
43
+ const refuse = (reason, index) => {
44
+ refused++;
45
+ log(reason, { jobId: job?.id, reason, index });
46
+ };
47
+
48
+ try {
49
+ // Belt-and-suspenders: a github parent has no /outbox mount at all (the request channel does not
50
+ // exist for it), but guard here too so a stray dir cannot chain off an untrusted issue author.
51
+ if (job?.data?.kind !== "local") return { enqueued: 0, refused: 0 };
52
+
53
+ const outboxDir = join(prepared.jobDir, "outbox");
54
+ let names;
55
+ try {
56
+ names = fs.readdirSync(outboxDir);
57
+ } catch {
58
+ return { enqueued: 0, refused: 0 }; // no outbox dir is the normal no-request case
59
+ }
60
+
61
+ const requests = names
62
+ .map((name) => {
63
+ const m = REQUEST_RE.exec(name);
64
+ return m ? { name, n: Number(m[1]) } : null;
65
+ })
66
+ .filter((r) => r !== null)
67
+ .sort((a, b) => a.n - b.n);
68
+
69
+ // Depth is host-computed from the parent's own chainDepth, NEVER read from the outbox. A
70
+ // chainDepthMax of 0 is the kill-switch: childDepth is always >= 1, so every request refuses.
71
+ const childDepth = (job.data.chainDepth ?? 0) + 1;
72
+
73
+ for (let i = 0; i < requests.length; i++) {
74
+ // Count cap first: only the lowest-numbered `chainMaxPerJob` files are ever read, so parse
75
+ // cost is bounded before any file is opened.
76
+ if (i >= config.chainMaxPerJob) {
77
+ refuse("chain-count-cap", i);
78
+ continue;
79
+ }
80
+
81
+ const filePath = join(outboxDir, requests[i].name);
82
+
83
+ // Size cap before reading/parsing.
84
+ let size;
85
+ try {
86
+ size = fs.statSync(filePath).size;
87
+ } catch {
88
+ refuse("chain-not-regular-file", i);
89
+ continue;
90
+ }
91
+ if (size > MAX_REQUEST_BYTES) {
92
+ refuse("chain-oversize", i);
93
+ continue;
94
+ }
95
+
96
+ // Regular-file-only: lstat (NOT stat) so a symlink is rejected on its own inode, not followed.
97
+ let isFile;
98
+ try {
99
+ isFile = fs.lstatSync(filePath).isFile();
100
+ } catch {
101
+ refuse("chain-not-regular-file", i);
102
+ continue;
103
+ }
104
+ if (!isFile) {
105
+ refuse("chain-not-regular-file", i);
106
+ continue;
107
+ }
108
+
109
+ // Read + JSON parse + object-root check.
110
+ let req;
111
+ try {
112
+ req = JSON.parse(fs.readFileSync(filePath, "utf8"));
113
+ } catch {
114
+ refuse("chain-parse-error", i);
115
+ continue;
116
+ }
117
+ if (typeof req !== "object" || req === null || Array.isArray(req)) {
118
+ refuse("chain-parse-error", i);
119
+ continue;
120
+ }
121
+
122
+ // Explicit property reads ONLY -- `req` is never spread into job data.
123
+ const flow = req.flow;
124
+ const task = req.task;
125
+
126
+ // Flow-name charset, BEFORE the gate: a bad name never reaches git.
127
+ if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) {
128
+ refuse("chain-bad-flow-name", i);
129
+ continue;
130
+ }
131
+
132
+ // Host-computed depth cap.
133
+ if (childDepth > config.chainDepthMax) {
134
+ refuse("chain-depth-cap", i);
135
+ continue;
136
+ }
137
+
138
+ // ai-trigger gate at the parent's pre-agent SHA: only an exact `allow` passes.
139
+ const { gate } = await readFlowGate({ folder: prepared.workspace, flow, sha: prepared.sha });
140
+ if (gate !== "allow") {
141
+ refuse("chain-gate-deny", i);
142
+ continue;
143
+ }
144
+
145
+ // Enqueue an ordinary local job on the parent's OWN folder (folder is forced, not read from
146
+ // the outbox). The child id is retry-idempotent so a retried parent dedups instead of fanning out.
147
+ await enqueue(queue, {
148
+ folder: prepared.workspace,
149
+ flow,
150
+ task,
151
+ // The child runs the parent's OWN folder, so it needs the parent's toolchain by definition -- and
152
+ // this is where `image` differs from provider/model, which are deliberately NOT inherited. A
153
+ // fallback provider still runs the flow; a fallback IMAGE gives a child that cannot find its tools,
154
+ // writes a plausible report and exits 0, which is the queue-reports-success failure class arriving
155
+ // by the back door. Read off `job.data` -- the parent's own validated job data -- and NEVER off
156
+ // `req`: the agent cannot choose its child's image any more than it can choose its folder or depth
157
+ // (INT-OUTBOX-CONTRACT's explicit-property-reads rule). Undefined stays undefined, so a parent with
158
+ // no image chains a child whose data is byte-identical to today's.
159
+ image: job.data?.image,
160
+ chainDepth: childDepth,
161
+ parentJobId: job.id,
162
+ // chainedJobId deliberately does NOT take the image: the child's identity is (parent, flow, task).
163
+ // Folding the image in would let an operator's triggers.json edit fan out a duplicate paid child
164
+ // from a retried parent. The image is derived from the parent's own data, so it is already stable
165
+ // across that parent's retries.
166
+ jobId: chainedJobId({ parentJobId: job.id, flow, task }),
167
+ });
168
+ enqueued++;
169
+ log("chain-enqueued", { jobId: job.id, reason: "chain-enqueued", index: i });
170
+ }
171
+ } catch {
172
+ // Never propagate: a throw would flip a completed parent to retryable and double-spend. The
173
+ // reason is a fixed enum, never the raw error message, so no host path can leak into a log line.
174
+ log("chain-collect-error", { jobId: job?.id, reason: "chain-collect-error" });
175
+ }
176
+
177
+ return { enqueued, refused };
178
+ };
179
+ }