@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,198 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { findEnvKeys } from "@earendil-works/pi-ai/compat";
5
+ import { forgeSpec } from "./forges.mjs";
6
+
7
+ function configError(message) {
8
+ const error = new Error(message);
9
+ error.piDispatchConfig = true;
10
+ return error;
11
+ }
12
+
13
+ /**
14
+ * Build the EXACT environment a job container receives. Never a pass-through.
15
+ *
16
+ * `no-broad-env-into-container` is a BLOCKER, and for good reason: `ANTHROPIC_OAUTH_TOKEN`
17
+ * silently outranks `ANTHROPIC_API_KEY`, so one stray host variable would redirect which
18
+ * credential every job spends, with no error and no log line. So we forward a closed set.
19
+ *
20
+ * The provider key variable is DERIVED from pi's own table, not hardcoded. `findEnvKeys(provider,
21
+ * env)` returns the provider's key variable names that are actually PRESENT in `env`, in
22
+ * precedence order (OAuth before API key). Deriving it means:
23
+ * - any of pi's ~30 providers works with no code change here;
24
+ * - the list cannot drift when pi adds a provider (a hand-copied table would);
25
+ * - `undefined` return === "this provider is not configured on this host" === refuse the job
26
+ * BEFORE spending, rather than launch a container that will fail auth on the first call.
27
+ *
28
+ * `getApiKeyEnvVars` (the full candidate list) is intentionally NOT exported by pi; `findEnvKeys`
29
+ * against our own process.env is the right tool anyway, because we only ever forward keys we have.
30
+ */
31
+ export function providerKeyVars(provider, hostEnv) {
32
+ return findEnvKeys(provider, hostEnv);
33
+ }
34
+
35
+ /**
36
+ * Resolve the provider credential(s) to inject, as `{ VAR_NAME: value }`.
37
+ *
38
+ * Primary source: the worker's own environment, by pi's expected variable name(s) (`findEnvKeys`).
39
+ * Fallback (ON by default; `PI_AUTH_FROM_PI=0` forces env-only): when the env has none, read the credential
40
+ * from the host's pi `auth.json`. This is a HOST-SIDE read of a host-held secret, injected via env exactly
41
+ * like the env path — never a credential file mounted into the container (`CONST-TOKEN-SCOPED-PER-JOB`).
42
+ * API-key credentials only; an OAuth/subscription login is refused (it expires, the container cannot refresh
43
+ * it, and it is not the credential for an unattended service). Throws a config-tagged error (pre-spend
44
+ * refusal) when neither source yields a credential.
45
+ */
46
+ export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync }) {
47
+ const envNames = providerKeyVars(provider, hostEnv);
48
+ if (envNames && envNames.length > 0) {
49
+ return Object.fromEntries(envNames.map((name) => [name, hostEnv[name]]));
50
+ }
51
+ if (authFromPi) {
52
+ const { name, value } = credentialFromPiAuth(provider, agentDir ?? defaultAgentDir(hostEnv), readFile);
53
+ return { [name]: value };
54
+ }
55
+ throw configError(`provider ${provider} has no configured credential in the worker environment`);
56
+ }
57
+
58
+ function defaultAgentDir(hostEnv) {
59
+ // pi's getAgentDir() default, resolved without importing the whole pi SDK into the worker.
60
+ return hostEnv.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
61
+ }
62
+
63
+ function credentialFromPiAuth(provider, agentDir, readFile) {
64
+ const path = join(agentDir, "auth.json");
65
+ let auth;
66
+ try {
67
+ auth = JSON.parse(readFile(path, "utf8"));
68
+ } catch {
69
+ throw configError(`no credential for provider "${provider}": not in the worker environment, and no pi login at ${path} — set the key in .env, or run \`pi login\``);
70
+ }
71
+ const cred = auth?.[provider];
72
+ if (!cred) throw configError(`no credential for provider "${provider}": not in the worker environment, and ${path} has no "${provider}" login — set the key in .env, or run \`pi login\``);
73
+ if (cred.type === "oauth") {
74
+ throw configError(
75
+ `the pi login for "${provider}" is an OAuth/subscription token, which cannot power an unattended service (it expires and the container cannot refresh it). Configure an API key — with a provider-side spend limit — instead.`,
76
+ );
77
+ }
78
+ if (cred.type !== "api_key" || !cred.key) throw configError(`unsupported pi credential for "${provider}" in ${path} — set an API key in .env`);
79
+ const name = resolveEnvName(provider, cred);
80
+ if (!name) {
81
+ throw configError(`could not determine the environment variable pi expects for provider "${provider}" — set it in the worker environment manually`);
82
+ }
83
+ return { name, value: cred.key };
84
+ }
85
+
86
+ /**
87
+ * The env var name pi reads this provider's key from. Discovered through pi's OWN `findEnvKeys` (the
88
+ * oracle) rather than a hand-maintained provider→var table that would drift: try the credential's own
89
+ * `env` hint plus the conventional `<PROVIDER>_API_KEY`/`_KEY`, and forward the one pi recognizes.
90
+ */
91
+ function resolveEnvName(provider, cred) {
92
+ const upper = provider.toUpperCase().replace(/[^A-Z0-9]/g, "_");
93
+ const candidates = [cred.env, `${upper}_API_KEY`, `${upper}_KEY`].filter((s) => typeof s === "string" && s.length > 0);
94
+ const synthetic = Object.fromEntries(candidates.map((name) => [name, cred.key]));
95
+ const recognized = findEnvKeys(provider, synthetic);
96
+ return recognized?.[0] ?? null;
97
+ }
98
+
99
+ /**
100
+ * Assemble the container env. `hostEnv` is the worker's process.env; `job` carries the resolved
101
+ * config and the per-job scoped token (GitHub-backed jobs, and local cron jobs that opted in via
102
+ * run.github).
103
+ *
104
+ * Throws if the provider is not configured -- a deterministic misconfiguration the caller maps to
105
+ * a pre-spend refusal, never a launched-then-failed container.
106
+ *
107
+ * `packagePaths` is the operator-staged pi package set for THIS job: already-resolved absolute
108
+ * CONTAINER paths under the :ro overlay, empty for a job whose trigger opted OUT (or when nothing is staged).
109
+ *
110
+ * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
111
+ * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
112
+ */
113
+ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], sessionFile = null, authFromPi = false, agentDir, readFile = readFileSync }) {
114
+ // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
115
+ // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
116
+ // neither source yields one, which the processor turns into a pre-spend refusal.
117
+ const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile });
118
+
119
+ const env = {
120
+ PI_PROVIDER: provider,
121
+ PI_MODEL: model,
122
+ PI_MAX_TURNS: String(maxTurns),
123
+ // The optional per-job token budget (issue #25). Absent/null => variable omitted (docker-run skips
124
+ // undefined), so the runner attaches a pure meter with no cap. Never an empty string.
125
+ PI_MAX_TOKENS: maxTokens === null || maxTokens === undefined ? undefined : String(maxTokens),
126
+ PI_JOB_ID: jobId,
127
+ // Baked into the image, but harmless to restate; kept here so the container contract is
128
+ // visible in one place. INT-CONTAINER-RUNTIME-CONTRACT.
129
+ PLAYWRIGHT_BROWSERS_PATH: "/ms-playwright",
130
+ PLAYWRIGHT_MCP_BROWSER: "chromium",
131
+ PLAYWRIGHT_MCP_SANDBOX: "false",
132
+ // The overlay's extensions load in the runner unless the operator opted out (REQ-GLOBAL-PI-OVERLAY).
133
+ // ABSENT means LOAD on both sides of the mount, so this variable is emitted ONLY to carry the explicit
134
+ // "0" opt-out -- the one reading a container must never have to infer. Both halves agree on the same
135
+ // canonical string, so the container env is legible against the operator's own .env line.
136
+ PI_GLOBAL_ALLOW_EXTENSIONS: allowGlobalExtensions ? undefined : "0",
137
+ // ":"-delimited ABSOLUTE CONTAINER paths of the operator-staged pi packages (REQ-GLOBAL-PI-OVERLAY).
138
+ // The caller has already applied the per-trigger opt-out, so an empty list here means "this job loads
139
+ // none" -- either nothing is staged, or its trigger set run.packages:false. Empty emits no -e at all,
140
+ // never PI_PACKAGES=. The delimiter is ":" because these are CONTAINER (POSIX) paths -- never the
141
+ // host's path.delimiter, which is ";" on Windows.
142
+ PI_PACKAGES: packagePaths.length > 0 ? packagePaths.join(":") : undefined,
143
+ // The persisted transcript inside the /session mount (REQ-RESUMABLE-SESSION). Emitted ONLY when
144
+ // this job actually has one -- absent means the runner builds pi's ephemeral in-memory session,
145
+ // which is every job before this feature and every job whose trigger did not arm run.resume. Never
146
+ // an empty string, for PI_PACKAGES' reason: an empty value is a third state neither side reads the
147
+ // same way, and the one reading a container must not have to infer which was meant.
148
+ PI_SESSION_FILE: sessionFile || undefined,
149
+ // Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
150
+ // to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
151
+ // writable in the container. We emit only local paths, so nothing should reach that branch -- this
152
+ // makes it UNREACHABLE rather than merely unused. It is a narrowing, never a capability, which is why
153
+ // it is not gated on the opt-in.
154
+ PI_OFFLINE: "1",
155
+ };
156
+
157
+ // The provider credential(s), under pi's expected variable name(s), so pi's own auth resolution finds them.
158
+ Object.assign(env, credEnv);
159
+
160
+ // Operator-declared extra vars (PI_FORWARD_ENV), forwarded by EXACT name -- the allowlist
161
+ // no-broad-env-into-container prescribes, not a host pass-through. This is how a CUSTOM provider's
162
+ // key (one pi's findEnvKeys table does not know) reaches the container. A name whose value is unset
163
+ // on the host is skipped, never forwarded as empty.
164
+ for (const name of forwardEnv) {
165
+ if (hostEnv[name] !== undefined) env[name] = hostEnv[name];
166
+ }
167
+
168
+ // Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
169
+ // no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into BOTH of its forge's variables because
170
+ // each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
171
+ // -- and mirroring forecloses any precedence surprise inside the container.
172
+ //
173
+ // The token goes ONLY into its own forge's names. A GitLab credential exported as GITHUB_TOKEN would
174
+ // be sent by `gh` to github.com on the agent's first tab-complete: a working credential handed to the
175
+ // wrong host, which is how a scoped token stops being scoped.
176
+ //
177
+ // This assignment deliberately sits AFTER the PI_FORWARD_ENV loop so a forwarded name can never
178
+ // override the mint (and loadConfig refuses those names at load anyway).
179
+ // Absent token => absent variable, never an empty one.
180
+ //
181
+ // The forge is looked UP, never fallen back to. This was an `if gitlab / else github`, and the `else`
182
+ // was the whole hazard: a job of any kind the table did not name -- a new forge wired up everywhere but
183
+ // here, a typo that survived validation -- got its credential exported as GITHUB_TOKEN and GH_TOKEN,
184
+ // which is precisely the "working credential handed to the wrong host" the paragraph above describes.
185
+ // A local cron job that opted in via run.github has kind "local" and genuinely wants GitHub's names, so
186
+ // it is mapped explicitly rather than inheriting them from a default.
187
+ if (githubToken) {
188
+ const spec = forgeSpec(forgeKind === "local" ? "github" : forgeKind);
189
+ if (!spec) {
190
+ throw configError(`buildContainerEnv: no token variable names for job kind ${JSON.stringify(forgeKind)} -- add it to FORGES in worker/src/forges.mjs rather than letting it inherit another forge's`);
191
+ }
192
+ for (const name of spec.tokenVars) env[name] = githubToken;
193
+ const host = forgeHosts?.[forgeKind];
194
+ if (spec.hostVar && host) env[spec.hostVar] = host;
195
+ }
196
+
197
+ return env;
198
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Single-key edits to a dotenv-style file, in two disciplines.
3
+ *
4
+ * `setEnvKeyIfEmpty` exists for `pi-dispatch up`, which fills WEBHOOK_SECRET into a scaffolded .env
5
+ * without asking the operator to hand-run `openssl rand -hex 32`. It is init's contract applied to a
6
+ * single key instead of a whole file: init never overwrites an existing file, and this never
7
+ * overwrites an existing VALUE — a key the operator already set is sacrosanct, because a tool that
8
+ * "helpfully" rotates a live webhook secret breaks every configured forge hook at once, silently.
9
+ *
10
+ * `setEnvKey` is the CONSENTED-overwrite sibling, added for `pi-dispatch setup github` (issue #81),
11
+ * whose whole point is replacing values like GITHUB_AUTH_SOURCE=gh with the App credentials the
12
+ * operator just minted and approved line-by-line. The consent lives at the CALLER, never here.
13
+ *
14
+ * Both return the input text UNCHANGED (byte-identical, same object) when there is nothing to do, so
15
+ * callers can compare identity to know nothing happened.
16
+ *
17
+ * Deliberately dependency-free (node:fs only, and only in the thin wrapper): it must stay importable
18
+ * from any future setup command without dragging worker config or queue deps along.
19
+ */
20
+ import { chmodSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
21
+
22
+ /**
23
+ * Pure transform over .env TEXT: set `key` to `value` only where nothing is set yet.
24
+ *
25
+ * - `KEY=` (empty value, whitespace-only counts) → that line becomes `KEY=value`, in place
26
+ * - no set line, but a commented `# KEY=…` line → the comment becomes `KEY=value`, in place
27
+ * - no KEY line at all → `KEY=value` appended at the end
28
+ * - `KEY=something` (any non-empty value, anywhere) → text returned UNCHANGED
29
+ *
30
+ * Every other byte is preserved: lines are only ever replaced whole, CRLF endings survive on the
31
+ * replaced line, and the untouched remainder is never re-serialized. Ambiguity resolves to "do not
32
+ * touch" — a value like `KEY= # tbd` trims to a non-empty string and therefore counts as set, because
33
+ * the cost of wrongly leaving a key alone (operator sets it by hand) is a fraction of the cost of
34
+ * wrongly overwriting one.
35
+ */
36
+ export function setEnvKeyIfEmpty(text, key, value) {
37
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
+ const setRe = new RegExp(`^\\s*${escaped}\\s*=(.*)$`);
39
+ const commentRe = new RegExp(`^\\s*#\\s*${escaped}\\s*=`);
40
+
41
+ const lines = text.split("\n");
42
+ // Replace a line wholesale, keeping a CRLF file's trailing \r so the file stays one convention.
43
+ const replaceLine = (i) => {
44
+ lines[i] = `${key}=${value}${lines[i].endsWith("\r") ? "\r" : ""}`;
45
+ return lines.join("\n");
46
+ };
47
+
48
+ // Pass 1: set lines. ANY non-empty value anywhere means the key is set — return the input text
49
+ // itself (not a copy) so callers can detect "unchanged" by identity. Otherwise remember the FIRST
50
+ // empty set line; a later commented duplicate must not win over it.
51
+ let firstEmpty = -1;
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
54
+ const m = line.match(setRe);
55
+ if (!m) continue;
56
+ if (m[1].trim() !== "") return text;
57
+ if (firstEmpty === -1) firstEmpty = i;
58
+ }
59
+ if (firstEmpty !== -1) return replaceLine(firstEmpty);
60
+
61
+ // Pass 2: a commented-out `# KEY=` line (only reachable when no set line exists at all).
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
64
+ if (commentRe.test(line)) return replaceLine(i);
65
+ }
66
+
67
+ // Pass 3: no trace of the key — append at the end, on its own line.
68
+ const base = text === "" || text.endsWith("\n") ? text : `${text}\n`;
69
+ return `${base}${key}=${value}\n`;
70
+ }
71
+
72
+ /**
73
+ * Pure transform over .env TEXT: set `key` to `value`, REPLACING whatever is there. The overwrite
74
+ * sibling of `setEnvKeyIfEmpty`, with the same line mechanics and the same first-match position rules:
75
+ *
76
+ * - a set line `KEY=anything` (empty or not, whitespace tolerated) → becomes `KEY=value`, in place;
77
+ * the FIRST set line wins over later duplicates and over any commented line
78
+ * - no set line, but a commented `# KEY=…` line → the comment becomes `KEY=value`
79
+ * - no KEY line at all → `KEY=value` appended at the end
80
+ * - the first set line is already exactly `KEY=value` → text returned UNCHANGED
81
+ * (the input string object itself, so callers detect the no-op by identity, like the sibling)
82
+ *
83
+ * THE CONFIRM GATE LIVES AT THE CALLER. This function is mechanical and will happily replace a live
84
+ * credential; the wizard that calls it shows the exact lines it is about to write and collects an
85
+ * explicit y/N first (github-app-setup.mjs). Nothing in here asks, because a transform that sometimes
86
+ * prompts is untestable and a prompt that sometimes doesn't fire is not a gate. Every other byte is
87
+ * preserved exactly as in the sibling: whole-line replacement only, CRLF survives on the replaced
88
+ * line, the untouched remainder is never re-serialized. A matched line with stray whitespace
89
+ * (`KEY = old`) is normalised to canonical `KEY=value` — it is being rewritten anyway.
90
+ */
91
+ export function setEnvKey(text, key, value) {
92
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
93
+ const setRe = new RegExp(`^\\s*${escaped}\\s*=`);
94
+ const commentRe = new RegExp(`^\\s*#\\s*${escaped}\\s*=`);
95
+
96
+ const lines = text.split("\n");
97
+ const replaceLine = (i) => {
98
+ lines[i] = `${key}=${value}${lines[i].endsWith("\r") ? "\r" : ""}`;
99
+ return lines.join("\n");
100
+ };
101
+
102
+ // Pass 1: the FIRST set line, whatever its value — this is the overwrite discipline. Already
103
+ // exactly `KEY=value` (modulo the CRLF tail) → the input object back, so the wrapper skips the write.
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
106
+ if (!setRe.test(line)) continue;
107
+ if (line === `${key}=${value}`) return text;
108
+ return replaceLine(i);
109
+ }
110
+
111
+ // Pass 2: a commented-out `# KEY=` line (only reachable when no set line exists at all).
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
114
+ if (commentRe.test(line)) return replaceLine(i);
115
+ }
116
+
117
+ // Pass 3: no trace of the key — append at the end, on its own line.
118
+ const base = text === "" || text.endsWith("\n") ? text : `${text}\n`;
119
+ return `${base}${key}=${value}\n`;
120
+ }
121
+
122
+ /**
123
+ * Read → transform → write back ATOMICALLY (tmp + rename, the same shape as the admin's
124
+ * writeTriggers), so a watcher or a concurrent reader never sees a half-written .env. When the
125
+ * transform is a no-op the file is not touched at all — no tmp, no rename, no mtime churn — and
126
+ * `{ changed: false }` is returned so callers can say so.
127
+ *
128
+ * `overwrite` picks the transform: false (the default, and the only behaviour that existed before
129
+ * issue #81) applies `setEnvKeyIfEmpty`'s never-clobber discipline; true applies `setEnvKey`, for
130
+ * callers that have already shown the operator the exact line and collected consent — the gate is
131
+ * theirs, this wrapper stays mechanical either way.
132
+ *
133
+ * Mode: a .env at 0o600 (an operator who locked their secrets down) stays 0o600 — chmod on the tmp
134
+ * BEFORE the rename, so no window exists where the secret-bearing file is wider than it was. Any
135
+ * other mode is left to the platform default; this helper preserves a hardening choice, it does not
136
+ * impose one.
137
+ */
138
+ export function updateEnvFile(path, key, value, deps = {}) {
139
+ const { fs = { readFileSync, writeFileSync, renameSync, statSync, chmodSync }, overwrite = false } = deps;
140
+ const text = fs.readFileSync(path, "utf8");
141
+ const next = (overwrite ? setEnvKey : setEnvKeyIfEmpty)(text, key, value);
142
+ if (next === text) return { changed: false };
143
+ const tmp = `${path}.tmp`;
144
+ fs.writeFileSync(tmp, next);
145
+ try {
146
+ if ((fs.statSync(path).mode & 0o777) === 0o600) fs.chmodSync(tmp, 0o600);
147
+ } catch {
148
+ // The file vanished between read and write, or the fs cannot stat: leave the tmp's default
149
+ // mode rather than failing an edit that is otherwise sound.
150
+ }
151
+ fs.renameSync(tmp, path);
152
+ return { changed: true };
153
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * INT-RUNNER-EXIT-CODE-PROTOCOL, worker side.
3
+ *
4
+ * The container's exit code is the ONLY channel telling "the agent ran and concluded something"
5
+ * from "the container died". The worker turns that into BullMQ's throw-vs-return, which IS
6
+ * CONST-RETRY-INFRA-ONLY: a thrown processor is retried, a returned one is not.
7
+ */
8
+ export const EXIT_COMPLETED = 0; // agent ran, INCLUDING "I cannot fix this" -- a determinate success
9
+ export const EXIT_INFRA = 1; // container died, network, provider 5xx/429 -- the only retryable class
10
+ export const EXIT_POLICY = 2; // budget/turn cap/config -- a determinate refusal, never retried
11
+
12
+ /**
13
+ * Decide whether the processor should RETURN (BullMQ records success, no retry) or THROW (BullMQ
14
+ * retries per `attempts`). Returns `{ retry }`; the caller returns on false and throws on true.
15
+ *
16
+ * Only exit 1 is retryable. 0 and 2 are both determinate outcomes -- the agent's verdict (or our
17
+ * own budget refusal) is the product, not a failure to paper over by paying for it again. An
18
+ * unknown code is treated as infra: a runner that exits with something we do not recognise is a
19
+ * runner we cannot reason about, and retrying-then-alerting beats silently accepting it as done.
20
+ */
21
+ export function decideRetry(exitCode) {
22
+ switch (exitCode) {
23
+ case EXIT_COMPLETED:
24
+ return { retry: false, outcome: "completed" };
25
+ case EXIT_POLICY:
26
+ return { retry: false, outcome: "policy" };
27
+ case EXIT_INFRA:
28
+ return { retry: true, outcome: "infra" };
29
+ default:
30
+ return { retry: true, outcome: `unknown-exit-${exitCode}` };
31
+ }
32
+ }
@@ -0,0 +1,82 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const exec = promisify(execFile);
5
+
6
+ /**
7
+ * Read a flow's AI-trigger opt-in from the git OBJECT STORE at a pinned commit.
8
+ *
9
+ * A flow is AI-triggerable only if `.pi/skills/<flow>/SKILL.md` at `sha` carries `ai-trigger: allow`
10
+ * in its YAML frontmatter. `sha` is a REQUIRED input captured BEFORE the in-container agent runs, so
11
+ * it is agent-uninfluenceable: an agent cannot self-authorize by committing its own `SKILL.md`,
12
+ * because any `allow` it writes lands in a commit later than the pinned `sha` and is never consulted.
13
+ * This module therefore resolves NO ref — no HEAD, no rev-parse, no ref primitive of any kind — the
14
+ * caller supplies the SHA. (DES-AI-TRIGGER-FLOW-GATE, INT-OUTBOX-CONTRACT.)
15
+ *
16
+ * The read is object-store-only, mirroring materialize.mjs's blob-only discipline: enumerate the one
17
+ * exact path with `git ls-tree` at `sha`, require a single regular blob (type `blob`, mode `100644`
18
+ * — rejecting a 120000 symlink or 160000 gitlink), then `git cat-file blob <oid>`. The working tree
19
+ * is never read, so a frontmatter symlinked at a token file cannot escape the tree.
20
+ *
21
+ * Fail-closed everywhere — only an exact `ai-trigger: allow` yields `allow`:
22
+ * - missing/empty `sha` -> deny (never falls back to HEAD)
23
+ * - `flow` failing the skill charset -> deny (never interpolated; traversal choke)
24
+ * - SKILL.md absent at that path/sha -> no-skill
25
+ * - entry present but non-blob / wrong mode -> deny
26
+ * - unparseable / absent `---` frontmatter -> deny
27
+ * - `ai-trigger` key absent -> deny
28
+ * - `ai-trigger` present but not exactly `allow`-> deny
29
+ * - git error / bad sha / binary failure -> deny (caught; this function never throws)
30
+ */
31
+
32
+ // The skill name charset: lowercase kebab/underscore, 1-64 chars, no dots (so no "..") and no
33
+ // slashes. Mirrors materialize.mjs SKILL_NAME_RE (not exported there) — keep in sync. Validating
34
+ // `flow` against it BEFORE building any path is the traversal choke point: a bad name is denied,
35
+ // never interpolated into a git path.
36
+ const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
37
+
38
+ export async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
39
+ // `sha` is REQUIRED and never defaulted: a missing SHA fails closed rather than resolving HEAD,
40
+ // which would let a self-authored commit open the gate. See DES-AI-TRIGGER-FLOW-GATE.
41
+ if (typeof sha !== "string" || sha.trim() === "") return { gate: "deny" };
42
+ // Charset-guard `flow` BEFORE it reaches any git path (traversal choke point).
43
+ if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) return { gate: "deny" };
44
+
45
+ const path = `.pi/skills/${flow}/SKILL.md`;
46
+ try {
47
+ const lsTreeZ = await git(folder, ["ls-tree", "-z", sha, path]);
48
+ const record = lsTreeZ.split("\0").find((r) => r);
49
+ if (!record) return { gate: "no-skill" }; // valid sha, path absent at that commit
50
+ // "<mode> <type> <oid>\t<path>"
51
+ const tab = record.indexOf("\t");
52
+ if (tab === -1) return { gate: "deny" };
53
+ const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
54
+ if (mode !== "100644" || type !== "blob") return { gate: "deny" }; // symlink/gitlink/exec
55
+ const content = await git(folder, ["cat-file", "blob", oid], { raw: true });
56
+ return { gate: aiTriggerAllows(content) ? "allow" : "deny" };
57
+ } catch {
58
+ // bad sha, git failure, binary error: fail closed, never crash the producer/collector.
59
+ return { gate: "deny" };
60
+ }
61
+ }
62
+
63
+ // Custom: no YAML parser in worker deps; a single ai-trigger boolean does not justify js-yaml.
64
+ // Strict leading-frontmatter scan: strip a BOM, normalise CRLF, isolate the leading `---`..`---`
65
+ // block, and require an exact `ai-trigger: allow` line (an optional surrounding double quote).
66
+ function aiTriggerAllows(buf) {
67
+ const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
68
+ const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
69
+ if (!block) return false; // no parseable frontmatter -> deny
70
+ return /^ai-trigger:\s*("?)allow\1\s*$/m.test(block[1]);
71
+ }
72
+
73
+ async function defaultGit(gitDir, args, { raw = false } = {}) {
74
+ // hardening flags mirror materialize.mjs defaultGit — keep in sync. No hooks, no fsmonitor, no
75
+ // pager, so a hostile repo config cannot run code or corrupt output during a read.
76
+ const hardened = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager", "-C", gitDir, ...args];
77
+ const { stdout } = await exec("git", hardened, {
78
+ encoding: raw ? "buffer" : "utf8",
79
+ maxBuffer: 16 * 1024 * 1024,
80
+ });
81
+ return stdout;
82
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Forgejo/Gitea authentication for the worker: the counterpart of get-token.mjs and gitlab-auth.mjs,
3
+ * yielding the identical `{ mintToken, selfId, source }` shape so nothing downstream branches on which
4
+ * forge a job belongs to.
5
+ *
6
+ * CONST-TOKEN-SCOPED-PER-JOB, stated honestly. Forgejo has no App, no installation token, and no per-job
7
+ * mint. What it does have -- and this is the interesting part -- is a REPO-SCOPED access token, which
8
+ * GitLab's project token also gives but GitHub's classic PAT does not. So Forgejo and Azure DevOps fail
9
+ * OPPOSITE halves of this constraint, and it is worth being precise about which:
10
+ *
11
+ * - repo-scoped YES, and better than GitLab's. A "specific repositories" token reaches only the
12
+ * repositories the operator selected.
13
+ * - minimally-permissioned YES, and this is the good news: such a token may carry ONLY
14
+ * `read:repository`, `write:repository`, `read:issue` and `write:issue`. There is
15
+ * no all-or-nothing `api` scope to fall back to, as there is on GitLab.
16
+ * - host-held YES; it lives in the worker's env and reaches a container only as an env value.
17
+ * - env-injected YES; never written to /workspace, .git/config, argv, or a log.
18
+ * - not merge-capable YES in practice; branch protection is the barrier and no merge API is called.
19
+ * - short-lived NO. The operator mints it by hand. Forgejo offers no installation-token
20
+ * equivalent, so there is no path to a bounded expiry at all.
21
+ *
22
+ * That last row is the whole exception, and it is the one the constitution names as the blast-radius
23
+ * bound -- "a when, not an if" for an agent induced to exfiltrate its environment. It is inherited from
24
+ * the accepted `gh`/`pat` gap rather than being a new class, but unlike GitHub there is no stronger path
25
+ * to prefer, so the docs must say so where an operator chooses.
26
+ *
27
+ * The narrow scope has one consequence that is NOT cosmetic: a repo-scoped token cannot call `GET /user`,
28
+ * so the bot-loop guard's identity has to come from `FORGEJO_BOT_ID` instead. See forgejo-identity.mjs --
29
+ * and note that an unresolved identity throws here rather than degrading, because a receiver that cannot
30
+ * recognise its own comments turns them into more paid jobs.
31
+ *
32
+ * All side-effecting collaborators are INJECTED, so the module is testable offline with no Forgejo.
33
+ */
34
+
35
+ import { configError } from "./config.mjs";
36
+ import { resolveForgejoSelfId } from "./forgejo-identity.mjs";
37
+
38
+ /**
39
+ * Build the auth surface for `cfg = { source, apiUrl, tokenVar, botId }`.
40
+ *
41
+ * Fails CLOSED at construction: an empty token or an unresolvable identity throws before returning, so a
42
+ * misconfigured worker refuses to boot rather than running jobs it cannot report on.
43
+ */
44
+ export async function makeForgejoAuth(cfg, deps = {}) {
45
+ const { env = process.env, fetchFn = fetch } = deps;
46
+ const source = cfg?.source;
47
+ if (source !== "pat") {
48
+ // `app` is the value most likely to arrive here, from an operator copying the GitHub block. It is
49
+ // refused by name rather than ignored, because an App is not merely unconfigured on Forgejo -- it
50
+ // does not exist, and a worker that quietly fell back to `pat` would hide that.
51
+ throw configError(`makeForgejoAuth: unknown or missing source: ${JSON.stringify(source)} (only "pat" is supported -- Forgejo has no App or installation-token equivalent)`);
52
+ }
53
+
54
+ const tokenVar = cfg.tokenVar ?? "FORGEJO_TOKEN";
55
+ const token = requireToken(env[tokenVar], tokenVar);
56
+ const selfId = await resolveForgejoSelfId({ apiUrl: cfg.apiUrl, token, botId: cfg.botId ?? null, fetchFn });
57
+
58
+ // Ignores the job by design: one operator-supplied token serves every repository this deployment
59
+ // services, exactly as the gitlab and github `pat` sources do. The parameter exists so the shape matches.
60
+ const mintToken = async () => requireToken(token, tokenVar);
61
+ return { mintToken, selfId, source };
62
+ }
63
+
64
+ /**
65
+ * The money-hole invariant, mirrored from get-token.mjs: return a trimmed non-empty token, or throw.
66
+ *
67
+ * An empty credential would reach env-allowlist's truthiness check as falsy, the token would be OMITTED
68
+ * from the container env entirely, and the job would run anonymously -- a silent, paid, useless run rather
69
+ * than an error.
70
+ */
71
+ function requireToken(raw, what) {
72
+ const token = typeof raw === "string" ? raw.trim() : "";
73
+ if (token === "") {
74
+ throw configError(`${what} is empty or unset; refusing to hand a job an empty Forgejo credential`);
75
+ }
76
+ return token;
77
+ }