@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,108 @@
1
+ import { createHash } from "node:crypto";
2
+ import { issueBranch } from "./branch.mjs";
3
+ import { isForgeKind } from "./forges.mjs";
4
+
5
+ /**
6
+ * session-key.mjs -- which persisted transcript, if any, a job runs on.
7
+ *
8
+ * PURE and TOTAL, no I/O, no throw. That is the same discipline filter.mjs holds, and for the same
9
+ * reason: the cross-repo and cross-PR safety of this whole feature lives in this one function, and a
10
+ * security-critical decision that needs a network to test is a decision nobody re-tests.
11
+ *
12
+ * DERIVED, NEVER LOOKED UP (DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED). There is no index mapping "PR #123"
13
+ * to "the job that opened it" and there will not be one: an index is a query surface, and this project
14
+ * deliberately has no database (INT-RUN-HISTORY-FILE-CONTRACT). The key is computed from what the job
15
+ * already carries, so a mismatch is unrepresentable rather than merely unlikely.
16
+ *
17
+ * WHAT THE KEY ACTUALLY BINDS, stated honestly because the flattering version is wrong. It is tempting
18
+ * to say issue numbers never recycle, so `pi/issue-<n>` names one issue's lineage forever. It does not.
19
+ * `pi/issue-<n>` is asked for by a prompt and by a hard rule; nothing verifies that a pull request is
20
+ * actually on it, and a branch -- unlike an issue number -- can be deleted and re-created by anyone who
21
+ * can push. The key is a NAME, and its namespace is the base repository's push-access population. That
22
+ * is one step wider than the population CONST-NO-CONTEXT-FILES-MANDATORY already trusts. The residual
23
+ * is OQ-014; what bounds it is here, in the two rules below.
24
+ *
25
+ * Rule one: the repository half is always the BASE repo. A fork's `full_name` is attacker-chosen and
26
+ * must never select a directory. Rule two: a fork resolves NO KEY AT ALL -- returning null rather than
27
+ * setting a flag a later stage has to remember to check. Without it, a stranger who forks, names a
28
+ * branch `pi/issue-7` and gets a collaborator to act on the pull request would be handed issue 7's
29
+ * transcript.
30
+ *
31
+ * The returned string is a HASH, not a path built from the branch. A ref is attacker-influenced free
32
+ * text and validating it into a safe path segment is the losing half of that argument; hashing makes
33
+ * traversal unreachable. It also keeps a directory listing PII-free by construction, the same property
34
+ * `local:<basename>` gives the run record.
35
+ */
36
+
37
+ /** Long enough that a collision is not a thing to reason about, short enough to read in a log line. */
38
+ const KEY_LENGTH = 32;
39
+
40
+ /**
41
+ * @param {object} job - The job data (`kind`, `repo`, `target`, `trigger`).
42
+ * @param {object} [resolved] - `{ headRef, headRepo }` for a pull/merge-request target, from the FORGE
43
+ * API rather than the webhook payload. Absent or incomplete means no key, which is a cold start.
44
+ * @returns {string|null} A hex key, or `null` when this job has no durable identity to resume against.
45
+ */
46
+ export function sessionKeyFor(job, resolved = {}) {
47
+ const parts = keyParts(job, resolved);
48
+ if (parts === null) return null;
49
+ // NUL-delimited so ("a", "bc") and ("ab", "c") cannot collide -- the same reasoning job-id.mjs uses.
50
+ return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, KEY_LENGTH);
51
+ }
52
+
53
+ /**
54
+ * The key's material, before hashing. Exported for tests and for the operator-facing `doctor` line: the
55
+ * hash is deliberately unreadable, so the one place that can explain a directory is the function that
56
+ * built it.
57
+ */
58
+ export function keyParts(job, resolved = {}) {
59
+ const kind = job?.kind;
60
+
61
+ // Keyed on "is this a forge job", never on a list of forge names. An enumeration that forgot one would
62
+ // not throw: every job on that forge would resolve no key and cold-start forever, which looks exactly
63
+ // like a deployment that never armed run.resume. `local` is handled below, on its own terms.
64
+ if (isForgeKind(kind)) {
65
+ const repo = job?.repo;
66
+ if (typeof repo !== "string" || repo === "") return null;
67
+
68
+ if (job?.target?.type === "pull_request") {
69
+ const { headRef, headRepo } = resolved;
70
+ if (typeof headRef !== "string" || headRef === "") return null;
71
+ // THE FORK GATE. Absence of a key, never a boolean: there is no later stage that could forget
72
+ // to check this, because there is nothing to check -- the job simply has no session.
73
+ if (typeof headRepo !== "string" || headRepo !== repo) return null;
74
+ return [kind, repo, headRef];
75
+ }
76
+
77
+ // An issue-triggered job's branch is the one its own envelope names, minted by the same function
78
+ // (branch.mjs) so the two cannot drift. issueBranch throws on a non-positive-integer number; this
79
+ // function is total, so that becomes "no key" rather than a failed job.
80
+ //
81
+ // ONE ARGUMENT, DELIBERATELY, and it must stay that way (REQ-REPLICA-RUNS). `issueBranch` takes an
82
+ // optional replica index and a replica job's envelope names `pi/issue-<n>-r<i>`, so this call is
83
+ // knowingly resolving a key for a branch that job was not told to push to. That is safe only because
84
+ // `triggers.mjs` refuses `run.replicas` together with `run.resume`: a replica job never resumes, so
85
+ // the key it would resolve is never read. Relax that refusal and every replica of one issue resolves
86
+ // the SAME key -- one transcript, N writers, fighting the store's one-writer lock. If the refusal
87
+ // ever goes, the replica index has to arrive here too. The coupling is stated in branch.mjs as well.
88
+ try {
89
+ return [kind, repo, issueBranch(job?.target?.number)];
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ if (kind === "local") {
96
+ // A cron job's scheduler id is the strongest key here and the only one chosen by nobody untrusted:
97
+ // it is operator-authored, [A-Za-z0-9._-]+, unique across the triggers file, and stable across
98
+ // fires. A nightly job that remembers last night is the safest instance of this feature.
99
+ const id = job?.trigger?.id;
100
+ if (typeof id !== "string" || id === "") return null;
101
+ return ["local", "", id];
102
+ }
103
+
104
+ // Everything else -- a CLI `pi-dispatch run`, a chained /outbox child -- has no trigger entry that
105
+ // could have armed run.resume, so there is nothing to honour and no stable identity to key on. A job
106
+ // kind that is neither a forge nor local lands here too, which is the safe direction: no session.
107
+ return null;
108
+ }
@@ -0,0 +1,249 @@
1
+ import {
2
+ copyFileSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ openSync,
6
+ closeSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ renameSync,
10
+ rmSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { sessionKeyFor } from "./session-key.mjs";
16
+
17
+ /**
18
+ * session-store.mjs -- the host side of a resumable session (INT-SESSION-STORE-CONTRACT).
19
+ *
20
+ * THE CANONICAL STORE IS NEVER MOUNTED. A job gets a per-job COPY, under its own jobDir, and only a
21
+ * `completed` run's output is promoted back. Three properties fall out of that one decision, and each
22
+ * would need its own mechanism otherwise:
23
+ *
24
+ * - CONST-RETRY-INFRA-ONLY survives. A policy or infra exit discards the container's writes entirely,
25
+ * so attempt 2 starts from exactly what attempt 1 did. Promote on every exit and "retry" quietly
26
+ * stops meaning re-run and starts meaning continue.
27
+ * - CONST-ISOLATION-CONTAINER-PER-JOB's "every mount operator- or worker-supplied, none host-wide"
28
+ * stays true verbatim: /session is per-job exactly as /job is. A container can name its own copy and
29
+ * nothing else, so a compromised agent that computes another repo's key still cannot reach it -- the
30
+ * mount is the capability, and the hash is not one.
31
+ * - The validation happens host-side, on both edges, where the agent cannot influence it.
32
+ *
33
+ * NEVER THROWS. Every path returns `{ resume, reason, ... }` or null-ish, because a disk fault must not
34
+ * fail a prepare that only asked whether there was a transcript -- the posture makeFindPreviousRun
35
+ * already sets. The one fail-CLOSED case lives in the processor, not here: a trigger that armed
36
+ * run.resume while PI_SESSIONS_DIR is unset is a pre-spend policy refusal, because running it silently
37
+ * without persistence is the failure validatePackagesFlag's own comment describes.
38
+ */
39
+
40
+ /** Container-side name, fixed. Nothing key-derived crosses the boundary -- see makeSessionStore. */
41
+ export const SESSION_FILE_NAME = "current.jsonl";
42
+ const PI_VERSION_FILE = "pi-version";
43
+ const LOCK_FILE = "lock";
44
+
45
+ /**
46
+ * Read-path outcomes. Every one is a named cold start rather than a bare `false`: a feature that fails
47
+ * open is otherwise indistinguishable from a feature nobody switched on, which is precisely how "we
48
+ * never resumed once in three months" goes unnoticed.
49
+ */
50
+ const COLD = (reason) => ({ resume: false, reason, bytes: null });
51
+
52
+ export function makeSessionStore({
53
+ sessionsDir,
54
+ ttlDays,
55
+ maxBytes,
56
+ log = () => {},
57
+ now = () => Date.now(),
58
+ fs = { copyFileSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync },
59
+ }) {
60
+ /**
61
+ * Stage this job's /session directory and decide whether it resumes.
62
+ *
63
+ * @param {object} job - the job data.
64
+ * @param {object} opts - `{ jobDir, resolved, piVersion }`. `resolved` is `{ headRef, headRepo }` from
65
+ * the FORGE API for a pull/merge-request target; `piVersion` is the job image's declared version.
66
+ * @returns {object|null} `{ hostDir, resume, reason, bytes, key }`, or `null` when this job gets no
67
+ * /session mount at all (unarmed, or no key) -- which is byte-identical to a pre-feature job.
68
+ */
69
+ function resolveSession(job, { jobDir, resolved = {}, piVersion = null } = {}) {
70
+ try {
71
+ if (!sessionsDir) return null; // feature unavailable; the processor refuses armed triggers earlier
72
+ const key = sessionKeyFor(job, resolved);
73
+ // No key is not a failure and not a degradation: this job has no durable identity (a fork PR, a
74
+ // CLI run, an unresolvable head ref), so it gets no mount and no transcript on disk.
75
+ if (key === null) return null;
76
+
77
+ const hostDir = join(jobDir, "session");
78
+ const staged = join(hostDir, SESSION_FILE_NAME);
79
+ fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 });
80
+
81
+ const verdict = readCanonical(key, piVersion);
82
+ if (verdict.resume) {
83
+ fs.copyFileSync(canonicalFile(key), staged);
84
+ } else {
85
+ // A 0-BYTE FILE, not an absent one. pi's setSessionFile then takes its empty-file branch and
86
+ // writes its own header at this exact path, which marks the manager flushed -- so _persist
87
+ // never reaches its openSync(path, "wx"), and the EEXIST race stops being a race. The host
88
+ // never has to know pi's file format to get that.
89
+ fs.writeFileSync(staged, "");
90
+ }
91
+ log("session_resolved", { key, resume: verdict.resume, reason: verdict.reason });
92
+ return { hostDir, key, ...verdict };
93
+ } catch (err) {
94
+ // A history fault must never fail the prepare that asked.
95
+ log("session_store_failed", { phase: "resolve", reason: err?.message });
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Promote the container's transcript back into the store. Called ONLY for a `completed` exit.
102
+ *
103
+ * Validates the agent's output before it becomes an input to a future job, then swaps it in under an
104
+ * exclusive per-key lock. A job that cannot take the lock discards rather than clobbers: two jobs on
105
+ * one key is a real shape (REQ-QUEUE-BURST-NO-DROP), and last-write-wins there would interleave two
106
+ * agents' turns into one transcript.
107
+ */
108
+ function promoteSession(session, { piVersion = null } = {}) {
109
+ if (!session?.key) return { promoted: false, reason: "no-key" };
110
+ try {
111
+ const staged = join(session.hostDir, SESSION_FILE_NAME);
112
+ const check = inspectFile(staged);
113
+ if (!check.ok) {
114
+ log("session_promote_skipped", { key: session.key, reason: check.reason });
115
+ return { promoted: false, reason: check.reason };
116
+ }
117
+
118
+ const dir = keyDir(session.key);
119
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
120
+
121
+ const lock = join(dir, LOCK_FILE);
122
+ let fd;
123
+ try {
124
+ fd = fs.openSync(lock, "wx"); // exclusive create IS the lock; no daemon, no lease
125
+ } catch {
126
+ log("session_promote_skipped", { key: session.key, reason: "locked" });
127
+ return { promoted: false, reason: "locked" };
128
+ }
129
+ try {
130
+ // Atomic swap: a reader either sees the old file or the new one, never a half-written one.
131
+ const tmp = `${canonicalFile(session.key)}.incoming`;
132
+ fs.copyFileSync(staged, tmp);
133
+ fs.renameSync(tmp, canonicalFile(session.key));
134
+ fs.writeFileSync(join(dir, PI_VERSION_FILE), String(piVersion ?? ""));
135
+ } finally {
136
+ fs.closeSync(fd);
137
+ try {
138
+ fs.unlinkSync(lock);
139
+ } catch {
140
+ // A leaked lock file wedges this key until the reaper sweeps it. Logged, never thrown.
141
+ log("session_lock_stuck", { key: session.key });
142
+ }
143
+ }
144
+ log("session_promoted", { key: session.key, bytes: check.bytes });
145
+ return { promoted: true, reason: "promoted", bytes: check.bytes };
146
+ } catch (err) {
147
+ log("session_store_failed", { phase: "promote", reason: err?.message });
148
+ return { promoted: false, reason: "promote-failed" };
149
+ }
150
+ }
151
+
152
+ function keyDir(key) {
153
+ return join(sessionsDir, key);
154
+ }
155
+ function canonicalFile(key) {
156
+ return join(keyDir(key), SESSION_FILE_NAME);
157
+ }
158
+
159
+ /** The read path, gate by gate. The FIRST miss wins and names itself. */
160
+ function readCanonical(key, piVersion) {
161
+ const file = canonicalFile(key);
162
+ const check = inspectFile(file);
163
+ if (!check.ok) return COLD(check.reason);
164
+
165
+ if (ttlDays > 0 && now() - check.mtimeMs > ttlDays * 86400000) return COLD("expired");
166
+
167
+ // A transcript outlives the pi that wrote it, and pi's own docs record what then breaks: an older
168
+ // session's stored tool-call arguments may no longer match the current tool schema. We cannot
169
+ // repair that mid-run, so a version change is a cold start rather than a mid-run failure. An
170
+ // image that declares no version never resumes -- the safe direction, never "assume it matches".
171
+ if (piVersion === null) return COLD("pi-version-changed");
172
+ let stamped = null;
173
+ try {
174
+ stamped = String(fs.readFileSync(join(keyDir(key), PI_VERSION_FILE), "utf8")).trim();
175
+ } catch {
176
+ return COLD("pi-version-changed");
177
+ }
178
+ if (stamped !== piVersion) return COLD("pi-version-changed");
179
+
180
+ // Cheapest real shape check, and the last one: the first line must be a pi session header. Anything
181
+ // else the runner would throw on, so refusing here keeps the container's degrade path for genuine
182
+ // surprises rather than for a file we could already tell was wrong.
183
+ try {
184
+ const head = String(fs.readFileSync(file, "utf8")).split("\n", 1)[0];
185
+ if (JSON.parse(head)?.type !== "session") return COLD("unparseable");
186
+ } catch {
187
+ return COLD("unparseable");
188
+ }
189
+ return { resume: true, reason: "resumed", bytes: check.bytes };
190
+ }
191
+
192
+ /**
193
+ * lstat, REGULAR FILES ONLY -- and this is the one line in the file that is load-bearing security
194
+ * rather than hygiene.
195
+ *
196
+ * The agent owns /session. A symlink it plants there resolves on the HOST when we read it back, so a
197
+ * plain `stat` + `readFileSync` would hand the next job on this key the contents of any file the
198
+ * worker user can read. INT-CONTAINER-JOB-INPUTS already documents this attack in the other direction
199
+ * (`fs.readFile` off the clone following a symlink into a worker-host file). The repo's own habit is
200
+ * the wrong one here: makeLogReaper uses statSync, which follows.
201
+ */
202
+ function inspectFile(file) {
203
+ let st;
204
+ try {
205
+ st = fs.lstatSync(file);
206
+ } catch {
207
+ return { ok: false, reason: "absent" };
208
+ }
209
+ if (!st.isFile()) return { ok: false, reason: "not-a-regular-file" };
210
+ if (st.size === 0) return { ok: false, reason: "absent" }; // a staged-but-unwritten transcript
211
+ if (maxBytes > 0 && st.size > maxBytes) return { ok: false, reason: "too-large" };
212
+ return { ok: true, bytes: st.size, mtimeMs: st.mtimeMs };
213
+ }
214
+
215
+ /**
216
+ * Boot-time sweep. A SIBLING of makeLogReaper rather than a widening of it: that one's `.log`/`.json`
217
+ * filter and logsDir scope are a documented contract, and these files have a different retention
218
+ * policy and a different PII class. Same never-throws shape, same `0 = keep forever` sentinel.
219
+ *
220
+ * Age at boot is the smaller half. The gate that matters is the one in readCanonical, which runs at
221
+ * OPEN -- a worker that never restarts would otherwise keep resuming a transcript indefinitely, and a
222
+ * stale transcript is a live input to a future job rather than debris (OQ-007).
223
+ */
224
+ function reapSessions() {
225
+ if (!sessionsDir || ttlDays === 0) return;
226
+ const cutoff = now() - ttlDays * 86400000;
227
+ let names;
228
+ try {
229
+ names = fs.readdirSync(sessionsDir);
230
+ } catch (err) {
231
+ log("session_reaper_skipped", { reason: err?.message });
232
+ return;
233
+ }
234
+ for (const name of names) {
235
+ try {
236
+ const dir = join(sessionsDir, name);
237
+ const st = fs.lstatSync(join(dir, SESSION_FILE_NAME));
238
+ if (st.mtimeMs < cutoff) {
239
+ fs.rmSync(dir, { recursive: true, force: true });
240
+ log("reaped_session", { key: name });
241
+ }
242
+ } catch (err) {
243
+ log("session_reaper_skipped", { key: name, reason: err?.message });
244
+ }
245
+ }
246
+ }
247
+
248
+ return { resolveSession, promoteSession, reapSessions };
249
+ }