@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,226 @@
1
+ /**
2
+ * GitHub authentication for the worker: mint a per-job scoped token and resolve the harness's own
3
+ * identity ONCE at construction. Three sources -- a user PAT, the `gh` CLI, or a GitHub App -- each
4
+ * yielding the same `{ mintToken, selfId, source }` shape so the processor never branches on source.
5
+ *
6
+ * CONST-TOKEN-SCOPED-PER-JOB: the App path mints one installation token per job, scoped to the one
7
+ * repo, expiring in an hour. That short-lived, repo-scoped expiry IS the blast-radius bound for an
8
+ * exfiltrated environment (App: 1h; a single-owner fine-grained PAT: operator-set, still short).
9
+ * The per-job SCOPING claim is the App path's property alone: `gh` and `pat` hand out the
10
+ * operator-scoped credential and rely on the operator keeping that credential narrow (the
11
+ * INT-CONTAINER-RUNTIME-CONTRACT trade-off note).
12
+ *
13
+ * Money-hole invariant: `mintToken` NEVER returns "" / null. `env-allowlist.mjs` forwards the token
14
+ * as `GITHUB_TOKEN` only when truthy (`if (githubToken)`), so an empty token becomes an ABSENT
15
+ * variable -- a silent anonymous run that still spends provider money. Every mint path routes through
16
+ * `requireToken`, which throws `configError` on an empty/whitespace token instead of handing it out.
17
+ *
18
+ * All side-effecting collaborators are INJECTED (Octokit, createAppAuth, execFile, readFile, env),
19
+ * each defaulting to the real import -- the budget.mjs / identity.mjs DI convention -- so the whole
20
+ * module is testable offline with no GitHub, no `gh` binary, and no filesystem.
21
+ */
22
+
23
+ import { execFile as realExecFile } from "node:child_process";
24
+ import { readFile as realReadFile } from "node:fs/promises";
25
+ import { promisify } from "node:util";
26
+ import { createAppAuth as realCreateAppAuth } from "@octokit/auth-app";
27
+ import { Octokit as RealOctokit } from "@octokit/rest";
28
+ import { configError } from "./config.mjs";
29
+ import { resolveSelfId } from "./identity.mjs";
30
+ import { InfraRetry } from "./processor.mjs";
31
+
32
+ /**
33
+ * Build the auth surface for `cfg = { source, patVar?, appId?, installationId?, privateKeyPath? }`.
34
+ *
35
+ * Returns `{ mintToken, selfId, source }`:
36
+ * - `mintToken(job)` -> a non-empty token string (rejects, never returns empty). It takes the whole
37
+ * job, not a repo, because that is the shape every forge's auth can implement: each reads whatever
38
+ * identifies the target on its own side (here `job.repo`) without the caller knowing which forge it
39
+ * asked. A local `run.github` cron job carries no `repo`, which the App path refuses below.
40
+ * - `selfId` -> the acting identity's integer user id (resolved once here, for the bot-loop guard).
41
+ * - `source` -> the configured source, echoed back.
42
+ *
43
+ * Fails CLOSED at construction: an empty PAT, an unreadable PEM, a logged-out `gh`, or an
44
+ * unresolvable identity throws before returning, so a misconfigured worker refuses to boot.
45
+ */
46
+ export async function makeGitHubAuth(cfg, deps = {}) {
47
+ // The explicit, named opt-out for the gh-source resume refusal below (PI_SESSIONS_ALLOW_GH_SOURCE).
48
+ // Read once at construction, and strictly `=== true` so a hand-set string cannot arm it.
49
+ const allowGhResume = cfg?.allowGhResume === true;
50
+ const {
51
+ Octokit = RealOctokit,
52
+ createAppAuth = realCreateAppAuth,
53
+ execFile = realExecFile,
54
+ readFile = realReadFile,
55
+ env = process.env,
56
+ } = deps;
57
+
58
+ const source = cfg?.source;
59
+
60
+ if (source === "pat") {
61
+ const patVar = cfg.patVar ?? "GITHUB_PAT";
62
+ const token = requireToken(env[patVar], patVar);
63
+ const octokit = new Octokit({ auth: token });
64
+ const selfId = await resolveSelfId({ source: "pat", octokit });
65
+ const mintToken = async () => requireToken(token, patVar);
66
+ return { mintToken, selfId, source };
67
+ }
68
+
69
+ if (source === "gh") {
70
+ const execFileAsync = promisify(execFile);
71
+ const ghToken = () => runGhAuthToken(execFileAsync);
72
+ // Resolve identity from a token minted once here; each job mints its own below.
73
+ const octokit = new Octokit({ auth: await ghToken() });
74
+ const selfId = await resolveSelfId({ source: "gh", octokit });
75
+ const mintToken = async (job) => {
76
+ assertResumeAllowedOnGhSource(job, allowGhResume);
77
+ return ghToken();
78
+ };
79
+ return { mintToken, selfId, source };
80
+ }
81
+
82
+ if (source === "app") {
83
+ if (!cfg.appId || !cfg.installationId || !cfg.privateKeyPath) {
84
+ throw configError(
85
+ "app auth requires appId, installationId, and privateKeyPath (see .env.example)",
86
+ );
87
+ }
88
+ const privateKey = await readPem(readFile, cfg.privateKeyPath);
89
+ const auth = { appId: cfg.appId, privateKey, installationId: cfg.installationId };
90
+
91
+ // App-JWT client: resolveSelfId's app path reads GET /app then GET /users/{slug}[bot].
92
+ const octokit = new Octokit({ authStrategy: createAppAuth, auth });
93
+ const selfId = await resolveSelfId({ source: "app", octokit });
94
+
95
+ const mintToken = async (job) => {
96
+ const repo = job?.repo;
97
+ // A local run.github job carries no repo, and an installation token MUST be scoped to one
98
+ // (CONST-TOKEN-SCOPED-PER-JOB) -- refuse deterministically rather than mint broad.
99
+ if (!repo || !String(repo).trim()) {
100
+ throw configError(
101
+ "GITHUB_AUTH_SOURCE=app mints per-repo installation tokens, and a local run.github job has no repo to scope to -- use gh or pat for run.github cron triggers, or run the work as a github job",
102
+ );
103
+ }
104
+ const repositoryNames = [repoNameOf(repo)]; // scope to the ONE repo; owner stripped
105
+ let minted;
106
+ try {
107
+ const appAuth = createAppAuth(auth);
108
+ minted = await appAuth({ type: "installation", repositoryNames });
109
+ } catch (error) {
110
+ throw classifyAppMintError(error);
111
+ }
112
+ return requireToken(minted?.token, "app installation token");
113
+ };
114
+ return { mintToken, selfId, source };
115
+ }
116
+
117
+ throw configError(`makeGitHubAuth: unknown or missing source: ${JSON.stringify(source)}`);
118
+ }
119
+
120
+ /**
121
+ * Enforce the money-hole invariant: return a trimmed non-empty token, or throw `configError`.
122
+ * Handing back "" would let `env-allowlist.mjs` drop `GITHUB_TOKEN` entirely and run anonymously.
123
+ */
124
+ function requireToken(raw, what) {
125
+ const token = (raw ?? "").trim();
126
+ if (!token) {
127
+ throw configError(
128
+ `${what}: empty token -- refusing to run (an absent GITHUB_TOKEN runs anonymously on paid infra)`,
129
+ );
130
+ }
131
+ return token;
132
+ }
133
+
134
+ /** Run `gh auth token` (array args, no shell) and return the trimmed token, or throw configError. */
135
+ async function runGhAuthToken(execFileAsync) {
136
+ let stdout;
137
+ try {
138
+ ({ stdout } = await execFileAsync("gh", ["auth", "token"]));
139
+ } catch (error) {
140
+ // ENOENT (binary absent) or a non-zero exit (logged out / broken) -- both deterministic.
141
+ const detail = error?.code ?? error?.message ?? "unknown";
142
+ throw configError(`\`gh auth token\` failed (${detail}); is the gh CLI installed and logged in?`);
143
+ }
144
+ // Logged-out gh can exit 0 with empty stdout; an empty token is the money hole, so refuse it.
145
+ return requireToken(stdout, "gh auth token");
146
+ }
147
+
148
+ /** Read a PEM file as UTF-8, throwing configError on an unreadable or empty file. */
149
+ async function readPem(readFile, path) {
150
+ let pem;
151
+ try {
152
+ pem = await readFile(path, "utf8");
153
+ } catch (error) {
154
+ throw configError(`could not read private key at ${path}: ${error?.code ?? error?.message ?? "unknown"}`);
155
+ }
156
+ if (!pem || !pem.trim()) {
157
+ throw configError(`private key at ${path} is empty`);
158
+ }
159
+ return pem;
160
+ }
161
+
162
+ /** `"owner/name"` -> `"name"`. The installation token is scoped by repo NAME, not `owner/name`. */
163
+ function repoNameOf(repo) {
164
+ const name = String(repo ?? "").split("/").pop()?.trim() ?? "";
165
+ if (!name) {
166
+ throw configError(`app token mint: invalid repo, expected "owner/name", got ${JSON.stringify(repo)}`);
167
+ }
168
+ return name;
169
+ }
170
+
171
+ /**
172
+ * Map an octokit/auth-app rejection to the retry-vs-config distinction the queue depends on.
173
+ * Retryable (InfraRetry): 429, 403 + Retry-After (secondary rate limit), 5xx, and status-less
174
+ * network faults (ENOTFOUND/ECONNRESET/ETIMEDOUT). Deterministic (configError): 401 bad
175
+ * credentials, 404 unknown installation, and any other 4xx. Collapsing all 4xx to config would
176
+ * turn a transient rate-limit into a permanent failure, so the classes are kept disjoint.
177
+ */
178
+ function classifyAppMintError(error) {
179
+ const status = typeof error?.status === "number" ? error.status : undefined;
180
+ const retryAfter = error?.response?.headers?.["retry-after"];
181
+
182
+ if (status === 401) return configError("app token mint refused: bad app credentials (401)");
183
+ if (status === 404) return configError("app token mint refused: unknown installation (404)");
184
+ if (status === 429) return new InfraRetry("app token mint: rate limited (429)");
185
+ if (status === 403 && retryAfter !== undefined) {
186
+ return new InfraRetry("app token mint: secondary rate limit (403 + retry-after)");
187
+ }
188
+ if (status !== undefined && status >= 500) {
189
+ return new InfraRetry(`app token mint: upstream error (${status})`);
190
+ }
191
+ if (status !== undefined) {
192
+ return configError(`app token mint failed (${status}): ${error?.message ?? "unknown"}`);
193
+ }
194
+ // No HTTP status -> network-level fault. Retry per INT-RUNNER-EXIT-CODE-PROTOCOL.
195
+ const detail = error?.code ? `${error.code}: ` : "";
196
+ return new InfraRetry(`app token mint: network fault: ${detail}${error?.message ?? "unknown"}`);
197
+ }
198
+
199
+ /**
200
+ * Refuse to mint the `gh` source's credential for a job that will persist its transcript
201
+ * (REQ-RESUMABLE-SESSION x CONST-TOKEN-SCOPED-PER-JOB). Pure and total so the decision is testable
202
+ * offline -- the gh path itself shells out to a real `gh`, and a security gate that needs a live CLI to
203
+ * exercise is a gate nobody re-tests.
204
+ *
205
+ * Every property CONST-TOKEN-SCOPED-PER-JOB relies on assumes the credential is an ENV VALUE: it lives in
206
+ * container memory and dies with the container, which is what makes `short-lived` do real work. A
207
+ * persisted transcript is a FILE, and any command the agent ran that echoed its own authorization header
208
+ * wrote the token into it -- on host disk, replayed into the next job on that key, for as long as
209
+ * PI_SESSIONS_TTL_DAYS allows. This source is the operator's whole `gh` login: full-scope and
210
+ * NON-EXPIRING, so the one bound that would have contained that exposure is absent exactly where the
211
+ * exposure is most durable. The app and fine-grained-pat paths both carry an expiry.
212
+ *
213
+ * REFUSED rather than warned, and the asymmetry is the whole argument: a warning is read once at setup,
214
+ * the disclosure is permanent and silent, and nothing downstream ever notices. The escape hatch is
215
+ * explicit and named, following PI_GLOBAL_ALLOW_EXTENSIONS -- an operator who has read this can still take
216
+ * the trade, but has to say so rather than miss a line in SECURITY.md.
217
+ *
218
+ * Refused at MINT time, the same shape and moment as the app-source refusal above, so it costs no budget
219
+ * slot and no clone.
220
+ */
221
+ export function assertResumeAllowedOnGhSource(job, allowGhResume) {
222
+ if (job?.resume !== true || allowGhResume === true) return;
223
+ throw configError(
224
+ "GITHUB_AUTH_SOURCE=gh is a full-scope, non-expiring credential, and run.resume writes the agent's transcript to disk -- any command that echoed an auth header would persist it. Use GITHUB_AUTH_SOURCE=app or a short-expiry fine-grained PAT, drop run.resume from this trigger, or set PI_SESSIONS_ALLOW_GH_SOURCE=1 to accept the trade explicitly (docs/sessions.md, SECURITY.md)",
225
+ );
226
+ }
@@ -0,0 +1,16 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * Report a folder's git working-tree state: `true` = dirty, `false` = clean, `null` = not a usable
5
+ * git repository. Reads the WORKING TREE via `git status --porcelain` — distinct from
6
+ * flow-gate.mjs's object-store read, which reads committed content at a pinned SHA; the two are kept
7
+ * separate on purpose. `exec` is injectable for tests.
8
+ */
9
+ export function gitDirty(folder, { exec = execFileSync } = {}) {
10
+ try {
11
+ const out = exec("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
12
+ return out.trim().length > 0;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }