@edgehero/pi-dispatch 2.0.0 → 3.0.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 (80) hide show
  1. package/.env.example +44 -7
  2. package/README.md +14 -6
  3. package/deploy/com.pi-dispatch.worker.plist +1 -1
  4. package/deploy/docker-compose.yml +12 -0
  5. package/deploy/egress-proxy.conf +28 -3
  6. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  7. package/deploy/worker-env-wrapper.cmd +1 -1
  8. package/deploy/worker-env-wrapper.sh +3 -3
  9. package/package.json +9 -2
  10. package/src/allocation.mjs +731 -0
  11. package/src/backends.mjs +243 -0
  12. package/src/budget.mjs +40 -4
  13. package/src/cli.mjs +222 -11
  14. package/src/config.mjs +126 -5
  15. package/src/daemon-facts.mjs +3 -0
  16. package/src/deployment-venue.mjs +1 -0
  17. package/src/doctor.mjs +2316 -183
  18. package/src/dollar-budget.mjs +373 -0
  19. package/src/dollar-fingerprint.mjs +83 -0
  20. package/src/egress-cli.mjs +316 -0
  21. package/src/egress-proxy-state.mjs +35 -5
  22. package/src/egress.mjs +16 -3
  23. package/src/env-allowlist.mjs +142 -18
  24. package/src/env-file.mjs +194 -25
  25. package/src/envelope.mjs +413 -0
  26. package/src/exit-code.mjs +22 -0
  27. package/src/fleet-lease.mjs +85 -25
  28. package/src/get-token.mjs +16 -5
  29. package/src/git-dirty.mjs +67 -0
  30. package/src/github-app-setup.mjs +6 -3
  31. package/src/github-host.mjs +5 -3
  32. package/src/host-pi.mjs +19 -3
  33. package/src/identity.mjs +2 -1
  34. package/src/image-preflight.mjs +98 -24
  35. package/src/image-ref.mjs +37 -0
  36. package/src/import-pi.mjs +4 -2
  37. package/src/index.mjs +407 -62
  38. package/src/init.mjs +18 -0
  39. package/src/job-id.mjs +26 -3
  40. package/src/live-probes.mjs +24 -9
  41. package/src/model-catalog.mjs +297 -0
  42. package/src/model-endpoints.mjs +649 -0
  43. package/src/model-ref.mjs +151 -0
  44. package/src/models-json.mjs +262 -0
  45. package/src/money.mjs +144 -0
  46. package/src/octokit-log.mjs +65 -0
  47. package/src/outbox-plan.mjs +218 -0
  48. package/src/outbox.mjs +29 -9
  49. package/src/output-cap.mjs +157 -0
  50. package/src/packages.mjs +2 -2
  51. package/src/pause-windows.mjs +81 -2
  52. package/src/pi-model-loader.mjs +77 -0
  53. package/src/podman-stack.mjs +16 -3
  54. package/src/portfolio-snapshot.mjs +304 -0
  55. package/src/prepare-local.mjs +247 -12
  56. package/src/prepare.mjs +35 -3
  57. package/src/pricing.mjs +9 -5
  58. package/src/priorities.mjs +569 -0
  59. package/src/processor.mjs +603 -173
  60. package/src/project-id.mjs +17 -0
  61. package/src/projects.mjs +238 -0
  62. package/src/provider-key.mjs +32 -7
  63. package/src/provider-steering.mjs +214 -59
  64. package/src/queue.mjs +111 -6
  65. package/src/reserved-env.mjs +30 -0
  66. package/src/run-container.mjs +59 -5
  67. package/src/run-history.mjs +379 -24
  68. package/src/run-mirror.mjs +30 -0
  69. package/src/runtime-settings.mjs +104 -9
  70. package/src/schedules.mjs +33 -1
  71. package/src/scoped-limits.mjs +447 -27
  72. package/src/secrets.mjs +2 -1
  73. package/src/service.mjs +15 -4
  74. package/src/session-store.mjs +131 -6
  75. package/src/start.mjs +528 -40
  76. package/src/subscriptions.mjs +7 -3
  77. package/src/triggers-file.mjs +65 -4
  78. package/src/triggers.mjs +140 -9
  79. package/src/up.mjs +308 -34
  80. package/src/valkey-endpoint.mjs +3 -2
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { readFileSync, rmSync } from "node:fs";
3
4
  import { DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
4
5
  import { CONTAINER_HOME } from "./container-spec.mjs";
@@ -61,14 +62,24 @@ export function makeRunContainer({
61
62
  // `log` names a teardown the gate refused, which leaves the job's network for the boot reaper.
62
63
  teardownRuntime = null,
63
64
  log = () => {},
65
+ // Issue #545: the per-job exit-line key, 32 random bytes as hex. A seam so a test can name the key it verifies with.
66
+ mintExitKey = () => randomBytes(32).toString("hex"),
64
67
  }) {
65
68
  // async so a synchronous throw (e.g. buildContainerEnv on an unconfigured provider) surfaces as
66
69
  // a rejection, uniformly awaitable by the processor and by tests.
67
70
  // `relabel` (issue #355) is the processor's, off the same job-user answer as `user`: true where the daemon confines
68
71
  // containers with SELinux, so the worker's own per-job mounts carry `:Z`. Defaults off, so a caller that predates it
69
72
  // builds exactly the argv it always did.
70
- return async function runContainer({ job, token, prepared, secrets = {}, name, signal, user = null, home = null, relabel = false }) {
73
+ // `modelEndpoints` (issue #503) is the pickup's endpoint snapshot, the one the free credential gate decided on, so a
74
+ // keyless provider's PI_DISPATCH_KEYLESS is written from the same declaration and never from a second read.
75
+ // `exitAuth` (issue #545) is the processor's, off the image preflight: true when the job image declares `exitAuth`, so
76
+ // its runner reads a key from stdin and signs its exit line with it. Defaults off, so a caller that predates it, and
77
+ // every image that does not declare it, runs exactly as before and is read exactly as before.
78
+ return async function runContainer({ job, token, prepared, secrets = {}, name, signal, user = null, home = null, relabel = false, modelEndpoints = null, exitAuth = false }) {
71
79
  if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }; // killed before it could start
80
+ // Minted per attempt, held in this closure and the sink's, and handed to the container on its stdin only: never in
81
+ // the argv (a host `ps` shows it), never in the env (the container's /proc/1/environ shows it), never logged.
82
+ const exitKey = exitAuth === true ? mintExitKey() : null;
72
83
  // Issue #341. `user` and `home` travel as a PAIR: a uid with no passwd entry in the image gets `HOME=/` from
73
84
  // Docker and `HOME=/workspace` from Podman (measured), so a `--user` without this HOME is refused here rather
74
85
  // than started. The builder does not insist, because `doctor --live`'s probes run `--user` with no environment.
@@ -87,6 +98,7 @@ export function makeRunContainer({
87
98
  model: job.model,
88
99
  maxTurns: job.maxTurns,
89
100
  maxTokens: job.maxTokens, // optional per-job token budget (issue #25); undefined => runner meter only
101
+ maxCostMicros: job.maxCostMicros ?? null, // issue #501: effectiveJobOf's min of trigger and deployment; null => no cap
90
102
  jobId: name,
91
103
  githubToken: token ?? undefined,
92
104
  // Which forge minted it, so the token lands in that forge's own variable names and no other.
@@ -121,12 +133,17 @@ export function makeRunContainer({
121
133
  // non-empty validated array; the guard is the same defensive shape `flow` above wears, so a
122
134
  // hand-built job with junk in the field emits no variable rather than an empty one.
123
135
  excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
136
+ // Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
137
+ // the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
138
+ allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
124
139
  authFromPi, // source the provider key from pi's auth.json when the env has none
125
140
  // REQ-TRIGGER-SECRETS: this trigger's resolved secrets, fetched by the processor BEFORE anything
126
141
  // spent. Off the call bag rather than off `job` or the closure: it is neither a per-job fact the
127
142
  // record may carry nor a deployment setting, it is a live credential, and `token` is its precedent.
128
143
  secrets,
129
144
  home: user !== null ? home : null, // issue #341: HOME only beside --user, assigned after the forward loops
145
+ modelEndpoints, // issue #503: the keyless branch of the credential, from the gate's own snapshot
146
+ exitAuth: exitKey !== null, // issue #545: PI_EXIT_AUTH=stdin, only when a key is actually handed over below
130
147
  });
131
148
 
132
149
  // `-net` on this container's own name (egress.mjs). null when no policy is armed, and docker-run's
@@ -168,6 +185,9 @@ export function makeRunContainer({
168
185
  // Both facts, the kind the preparers branch on AND containment in this job's own directory, so a kind added later
169
186
  // that works on a folder in place fails closed rather than relabelling it.
170
187
  workspaceOwned: job?.kind !== "local" && insideDir(prepared.jobDir, prepared.workspace),
188
+ // Issue #545: `-i`, so the container's stdin is the pipe the key is written to. Absent otherwise, and the argv is
189
+ // byte-identical to one built before the signed exit line.
190
+ ...(exitKey !== null ? { extraFlags: ["-i"] } : {}),
171
191
  });
172
192
 
173
193
  // REQ-EGRESS-ALLOWLIST. This job's own --internal network, created here rather than at boot because
@@ -184,10 +204,17 @@ export function makeRunContainer({
184
204
 
185
205
  // Host-side per-job log sink, teed off `onOutput`. `name` is `pi-job-<jobId>`; the sink
186
206
  // sanitizes internally. No container mount, no env var -- the sink lives on this side only.
187
- const sink = openJobLog(name);
207
+ const sink = openJobLog(name, { exitKey });
188
208
 
189
209
  const run = new Promise((resolve, reject) => {
190
- const child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
210
+ const child = spawnFn(bin, args, { stdio: [exitKey !== null ? "pipe" : "ignore", "pipe", "pipe"] });
211
+ if (exitKey !== null) {
212
+ // Written and closed at once: the runner drains stdin to EOF before anything else runs, so the key is out of
213
+ // the pipe before any tool exists. A CLI that died first makes this EPIPE, which is the run failing, reported
214
+ // by `error` or `close` below; the listener only keeps the write's own error from crashing the worker.
215
+ child.stdin?.on("error", () => {});
216
+ child.stdin?.end(`${exitKey}\n`);
217
+ }
191
218
  // A throwing sink.write is swallowed so a misbehaving sink cannot break the tee or hang the run.
192
219
  const tee = (chunk) => {
193
220
  onOutput(chunk);
@@ -216,12 +243,19 @@ export function makeRunContainer({
216
243
  // RUNNER_POLICY_REASONS set and to a line that itself said code 2. The processor still decides
217
244
  // the retry class from `code` alone; this only picks the label inside exit 2.
218
245
  let exitReason = null;
246
+ // Issue #501 (PR #542's review, round 3): the LAST exit line's own `code`, which the dollar settlement
247
+ // compares with the container's real exit code before it trusts that line's cost.
248
+ let exitLineCode = null;
249
+ // Issue #507: the cost guard's rule on a `cost-cap` line, already filtered by parseExitWhy to COST_CAP_WHYS.
250
+ let exitWhy = null;
251
+ // Issue #545: null (no key issued), "verified" or "unverified" (a key, and no exit line carried it).
252
+ let exitAuthResult = null;
219
253
  try {
220
254
  // `context = null` is a DEFAULT rather than a plain destructure: an injected sink that
221
255
  // predates the field returns no such key, and `undefined` would then reach the record's
222
256
  // shape where every other absence is spelled `null`.
223
257
  // `exitReason` defaults the same way, for the same reason.
224
- ({ turns, tokens, session, usage, context = null, exitReason = null } = await sink.close());
258
+ ({ turns, tokens, session, usage, context = null, exitReason = null, exitLineCode = null, exitWhy = null, exitAuth: exitAuthResult = null } = await sink.close());
225
259
  } catch {
226
260
  turns = null;
227
261
  tokens = null;
@@ -229,8 +263,28 @@ export function makeRunContainer({
229
263
  usage = null;
230
264
  context = null;
231
265
  exitReason = null;
266
+ exitLineCode = null;
267
+ exitWhy = null;
268
+ // The sink could not say, and a key was issued: nothing it returned was verified.
269
+ exitAuthResult = exitKey !== null ? "unverified" : null;
270
+ }
271
+ // A sink that predates the field returned none; with a key issued, that is still nothing verified.
272
+ if (exitKey !== null && exitAuthResult !== "verified") {
273
+ exitAuthResult = "unverified";
274
+ turns = null;
275
+ tokens = null;
276
+ session = null;
277
+ usage = null;
278
+ context = null;
279
+ exitReason = null;
280
+ exitLineCode = null;
281
+ exitWhy = null;
232
282
  }
233
- resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context, exitReason } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context, exitReason });
283
+ // Spread only when a key was issued, so a run without one resolves the very object it always did.
284
+ const auth = exitKey !== null ? { exitAuth: exitAuthResult } : {};
285
+ // `exitWhy` only when the line named one, so every other run resolves the object it always did.
286
+ const why = exitWhy !== null ? { exitWhy } : {};
287
+ resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context, exitReason, exitLineCode, ...why, ...auth } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context, exitReason, exitLineCode, ...why, ...auth });
234
288
  });
235
289
  });
236
290