@edgehero/pi-dispatch 4.3.0 → 5.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 (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +199 -30
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -33,9 +33,17 @@
33
33
  * `jobs` (issue #599, phase 2) is the one field that carries a list: JSON of the jobs this host runs now, ids and
34
34
  * integers only, built and read back by `live-jobs.mjs`, whose header argues each field against this rule. The reader
35
35
  * below parses it through that module's allowlist, so a row's `jobs` is an array (or null), never the raw string.
36
+ *
37
+ * VALKEY'S CLOCK IS USED ONLY AS A DURATION (issue #737). The reader asks each row's `PTTL` beside its `HGETALL`, and
38
+ * `ttlMs - PTTL` is how long ago the row's last PEXPIRE landed, counted by the server that holds it. That is a length
39
+ * of time, never an instant: no field here is ever compared with Valkey's own time of day. Set beside `beatAt` (an
40
+ * instant on the HOST's clock) and the reader's own `now()`, it gives a third party's estimate of how far the two
41
+ * machines' clocks disagree (`skewMs`), and it says when a row has no expiry at all (`noExpiry`), which is a row the TTL
42
+ * will never remove.
36
43
  */
37
44
 
38
45
  import { parseJobsMore, parseLiveJobs } from "./live-jobs.mjs";
46
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
39
47
 
40
48
  /** The index. A SET cannot expire its members, so the leak is handled by the reader, as `wait:held` does. */
41
49
  export const HOST_SET = "host:live";
@@ -54,6 +62,14 @@ export const HOST_BEAT_MS = 15_000;
54
62
  */
55
63
  export const HOST_TTL_MS = 90_000;
56
64
 
65
+ /**
66
+ * The smallest `skewMs` a reader may call a clock skew (issue #737). TWO BEATS, and the margin is not decoration:
67
+ * `skewMs` is measured from the row's last PEXPIRE, and a read landing between a beat's HSET (the new `beatAt`) and its
68
+ * PEXPIRE sees the previous beat's expiry, so the estimate can be off by one beat period, and by a round trip besides.
69
+ * Below this, a difference is noise a reader must not report.
70
+ */
71
+ export const HOST_SKEW_CLAIM_MS = 30_000;
72
+
57
73
  /**
58
74
  * Build the registry accessor. `redis` is the client the budget and the wait state already share.
59
75
  *
@@ -109,7 +125,8 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
109
125
  for (const [k, v] of Object.entries(fields)) {
110
126
  let resolved;
111
127
  try {
112
- resolved = typeof v === "function" ? v() : v;
128
+ // A dependent display fact can bind to this row's generation without guessing another clock.
129
+ resolved = typeof v === "function" ? v(fields) : v;
113
130
  } catch {
114
131
  resolved = ""; // a fact that cannot be computed is absent, never a reason to skip the beat
115
132
  }
@@ -275,8 +292,22 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
275
292
  * `{ unreachable }` and `[]` are DIFFERENT ANSWERS and must never be collapsed by a caller, even where
276
293
  * one treats them alike: "there are no other hosts" and "I could not find out" differ, and a panel that
277
294
  * renders the second as the first tells an operator their fleet is gone when Valkey merely blinked.
295
+ *
296
+ * Each row also carries three fields read off the key's `PTTL` (issue #737), all null where the client has no `pttl`,
297
+ * where the PTTL failed or timed out, or where the key went between the two reads (-2):
298
+ * - `ttlAgeMs`: how long ago the row's last PEXPIRE landed, `ttlMs - PTTL` (never below 0), on Valkey's clock as a
299
+ * duration. `ttlMs` is the TTL the writer sets, `HOST_TTL_MS` for every worker.
300
+ * - `skewMs`: `(now() - beatAt) - ttlAgeMs`, the reader's clock minus the host's, as a third party measures it.
301
+ * Positive: the host's clock is behind. A round trip and up to one beat period of noise; a reader claims a skew only
302
+ * past `HOST_SKEW_CLAIM_MS`. Null without a readable `beatAt`.
303
+ * - `noExpiry`: true when the key has no expiry at all (-1), which the TTL will never remove; false when it has one.
304
+ * A reader walks `host:live`, and a beat SADDs only after its PEXPIRE, so a reader meets it only under a name an
305
+ * EARLIER beat indexed: for a moment when a beat re-creates a row that expired while its name stayed, and for good
306
+ * when the process died between that HSET and its PEXPIRE.
307
+ * The PTTL goes out beside the HGETALL, in the same tick and each under its own bound, so it costs no round trip; a
308
+ * failed PTTL costs these three fields and never the row.
278
309
  */
279
- export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS, prune = true } = {}) {
310
+ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS, prune = true, ttlMs = HOST_TTL_MS } = {}) {
280
311
  let names;
281
312
  try {
282
313
  names = await bounded(redis.smembers(HOST_SET), timeoutMs);
@@ -287,9 +318,23 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
287
318
  return { unreachable: err?.message ?? "registry unreadable" };
288
319
  }
289
320
  const hosts = [];
321
+ const withTtl = typeof redis?.pttl === "function";
290
322
  for (const member of names ?? []) {
323
+ // A name outside the worker name charset (`WORKER_NAME_RE`: a letter or a digit, then letters, digits, `.`, `_` and
324
+ // `-`, at most 64 in all) is no host's: anything with the Valkey's URL can SADD one, and a reader that took it would
325
+ // print it wherever rows are drawn (a 3,000-character member widened the console's page to 43,000 px). The charset
326
+ // alone, looser than the boot rule, which also refuses a `.json` or `.log` tail on `PI_WORKER_NAME` (`config.mjs`).
327
+ // Skipped before its row is even read, and never pruned: removing what someone else wrote is not a reader's call,
328
+ // and the member costs only its place in the set.
329
+ if (typeof member !== "string" || !WORKER_NAME_RE.test(member)) continue;
291
330
  try {
292
- const row = await bounded(redis.hgetall(hostKey(member)), timeoutMs);
331
+ const key = hostKey(member);
332
+ // Both issued before either is awaited. The PTTL's own failure (a throw, a rejection, a hang past its bound)
333
+ // is null here, so only the HGETALL decides whether the row is read at all.
334
+ const [row, pttl] = await Promise.all([
335
+ bounded(redis.hgetall(key), timeoutMs),
336
+ withTtl ? bounded((async () => redis.pttl(key))(), timeoutMs).catch(() => null) : null,
337
+ ]);
293
338
  if (!row || Object.keys(row).length === 0) {
294
339
  // `prune: false` for a reader that promises to write nothing (the capacity report, issue #599): the stale member
295
340
  // is skipped the same, and left for the next pruning reader.
@@ -297,9 +342,13 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
297
342
  continue;
298
343
  }
299
344
  const beatAt = typeof row.beatAt === "string" && row.beatAt.trim() !== "" ? Number(row.beatAt) : NaN;
345
+ const nowMs = now();
300
346
  // The running jobs through the allowlist (issue #599, phase 2): an array, or null where the row publishes none (a
301
347
  // worker from before the field) or none that parses. A listed entry that does not read is a running job not
302
348
  // counted, so it joins `jobsMore` rather than vanishing.
349
+ // The row's own `name` stands for the member only when it holds to the same rule, else the row is no host's.
350
+ const name = row.name || member;
351
+ if (typeof name !== "string" || !WORKER_NAME_RE.test(name)) continue;
303
352
  const live = parseLiveJobs(row.jobs);
304
353
  const more = parseJobsMore(row.jobsMore);
305
354
  hosts.push({
@@ -309,11 +358,12 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
309
358
  // A row that HAS a `jobs` value that is not a list: how many it runs is unknown, which a reader must say
310
359
  // rather than read as a worker from before the field.
311
360
  jobsUnreadable: live.jobs === null && typeof row.jobs === "string" && row.jobs !== "",
312
- name: row.name || member,
361
+ name,
313
362
  // Derived rather than stored, so the panel can say "stale 2m" about a row that still lives.
314
363
  // A row whose clock is AHEAD of ours reads as 0 rather than negative: the difference is the
315
- // skew between two machines, which is its own signal and not this field's to report.
316
- staleMs: Number.isFinite(beatAt) ? Math.max(0, now() - beatAt) : null,
364
+ // skew between two machines, which is its own signal and not this field's to report (`skewMs` is).
365
+ staleMs: Number.isFinite(beatAt) ? Math.max(0, nowMs - beatAt) : null,
366
+ ...expiryFields(pttl, { ttlMs, beatAt, nowMs }),
317
367
  });
318
368
  } catch {
319
369
  // One unreadable row degrades that row, never the listing.
@@ -322,3 +372,13 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
322
372
  hosts.sort((a, b) => String(a.name).localeCompare(String(b.name)));
323
373
  return { hosts };
324
374
  }
375
+
376
+ /** `readLiveHosts`'s three PTTL fields (see there). `pttl` is the reply, or null where none was read. */
377
+ function expiryFields(pttl, { ttlMs, beatAt, nowMs }) {
378
+ if (pttl === -1) return { ttlAgeMs: null, skewMs: null, noExpiry: true };
379
+ // -2 (the key went between the two reads), a failed read, or a reply that is not a whole number: nothing is known.
380
+ if (!Number.isSafeInteger(pttl) || pttl < 0) return { ttlAgeMs: null, skewMs: null, noExpiry: null };
381
+ const ttl = Number.isSafeInteger(ttlMs) && ttlMs > 0 ? ttlMs : HOST_TTL_MS;
382
+ const ttlAgeMs = Math.max(0, ttl - pttl);
383
+ return { ttlAgeMs, skewMs: Number.isFinite(beatAt) ? nowMs - beatAt - ttlAgeMs : null, noExpiry: false };
384
+ }
@@ -0,0 +1,92 @@
1
+ /** Host self-description only: no consumer may use these facts to admit or refuse a job (#748). */
2
+ import { BACKEND_NAMES } from "./backends.mjs";
3
+ import { LIVE_JOB_ID_RE, publishedJobId } from "./live-jobs.mjs";
4
+ import { createHash } from "node:crypto";
5
+ import { imageRefProblem } from "./image-ref.mjs";
6
+ import { RUN_KINDS } from "./forges.mjs";
7
+
8
+ export const SANDBOXES_MAX = 32;
9
+ export const SANDBOX_READ_MS = 60_000;
10
+ export const CPU_RESERVE_WORDS = Object.freeze(["held", "no-parent", "unmanaged", "budget-unknown", "unreadable", "unset", "differs"]);
11
+ const integer = (v) => Number.isSafeInteger(v) && v >= 0;
12
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
13
+ const decoded = (raw) => {
14
+ if (typeof raw !== "string") return raw;
15
+ if (raw.length > 32 * 1024) return null;
16
+ try { return JSON.parse(raw); } catch { return null; }
17
+ };
18
+
19
+ /** Unknown and unreadable are null, never an invented empty inventory. */
20
+ export function parseSandboxes(raw) {
21
+ const list = decoded(raw);
22
+ if (!Array.isArray(list) || list.length > SANDBOXES_MAX) return null;
23
+ // Corruption must not turn a nonempty inventory into a false claim that no workspace remains.
24
+ if (list.some((r) => !object(r) || typeof r.id !== "string" || !LIVE_JOB_ID_RE.test(r.id) || !BACKEND_NAMES.includes(r.v) || !(r.until === null || integer(r.until)) || typeof r.pin !== "boolean")) return null;
25
+ if (list.some((r) => (r.prepared !== undefined && typeof r.prepared !== "boolean") || (r.canOpen !== undefined && typeof r.canOpen !== "boolean") || (r.generation !== undefined && !/^[a-f0-9]{64}$/.test(r.generation)))) return null;
26
+ if (list.some((r) => (r.image !== undefined && imageRefProblem(r.image)) || (r.kind !== undefined && !RUN_KINDS.includes(r.kind)))) return null;
27
+ return list.map(({ id, v, until, pin, prepared, canOpen, generation, image, kind }) => ({ id, v, until, pin, ...(prepared !== undefined ? { prepared } : {}), ...(canOpen !== undefined ? { canOpen } : {}), ...(generation !== undefined ? { generation } : {}), ...(image !== undefined ? { image } : {}), ...(kind !== undefined ? { kind } : {}) }));
28
+ }
29
+
30
+ /** An opaque incarnation binds a confirmation to the workspace, without publishing its host path. */
31
+ export function sandboxManifestGeneration(m) {
32
+ if (typeof m?.jobId !== "string" || typeof m?.image !== "string" || typeof m?.workspace !== "string" || typeof m?.createdAt !== "string") return null;
33
+ return createHash("sha256").update(JSON.stringify([m.jobId, m.image, m.workspace, m.createdAt, m.backend ?? "local", m.baseSha ?? null, m.prepared?.requestDigest ?? null])).digest("hex");
34
+ }
35
+
36
+ export function parseBudgetFrom(raw) {
37
+ const r = decoded(raw);
38
+ if (!object(r)) return null;
39
+ const out = {};
40
+ for (const key of ["memMode", "cpuMode"]) {
41
+ if (!["auto", "value", "off"].includes(r[key])) return null;
42
+ out[key] = r[key];
43
+ }
44
+ for (const key of ["memTotalMiB", "memReserveMiB", "cpuTotalCenti", "cpuReserveCenti"]) {
45
+ if (r[key] !== null && !integer(r[key])) return null;
46
+ out[key] = r[key];
47
+ }
48
+ for (const key of ["memFloored", "cpuFloored"]) {
49
+ if (typeof r[key] !== "boolean") return null;
50
+ out[key] = r[key];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function parseCpuReserve(raw) {
56
+ const list = decoded(raw);
57
+ if (!Array.isArray(list)) return null;
58
+ const seen = new Set();
59
+ return list.filter((r) => object(r) && BACKEND_NAMES.includes(r.venue) && CPU_RESERVE_WORDS.includes(r.status) && !seen.has(r.venue) && seen.add(r.venue)).map((r) => ({ venue: r.venue, status: r.status, wantCenti: integer(r.wantCenti) ? r.wantCenti : null, quotaCenti: integer(r.quotaCenti) ? r.quotaCenti : null }));
60
+ }
61
+
62
+ /** Cache filesystem work, not the heartbeat. The caller supplies the store's actual deadline rule. */
63
+ export function makeSandboxTelemetry({ list, deadline, retentionHours, canOpen = null, now = () => Date.now(), log = () => {} }) {
64
+ let readAt = null;
65
+ let unreadable = false;
66
+ let cached = { sandboxes: "", sandboxesMore: "", sandboxRetentionHours: String(retentionHours) };
67
+ return () => {
68
+ const at = now();
69
+ if (readAt !== null && at >= readAt && at - readAt < SANDBOX_READ_MS) return cached;
70
+ readAt = at;
71
+ try {
72
+ const all = list().map((m) => {
73
+ const id = publishedJobId(m.jobId);
74
+ const venue = Object.hasOwn(m, "backend") ? m.backend : "local";
75
+ if (!id || !BACKEND_NAMES.includes(venue)) return null;
76
+ // Old manifests may lack these facts. Corrupt present facts make the inventory unknown, never invented.
77
+ if ((m.image !== undefined && imageRefProblem(m.image)) || (m.kind !== undefined && !RUN_KINDS.includes(m.kind))) throw new Error("invalid sandbox image or kind");
78
+ const d = deadline(m, retentionHours);
79
+ const generation = sandboxManifestGeneration(m);
80
+ return { id, v: venue, until: integer(d.until) ? d.until : null, pin: d.source === "pin", ...(m.origin === "prepared" ? { prepared: true } : {}), ...(generation ? { generation } : {}), ...(typeof canOpen === "function" ? { canOpen: canOpen(m) === true } : {}), ...(m.image !== undefined ? { image: m.image } : {}), ...(m.kind !== undefined ? { kind: m.kind } : {}), created: Date.parse(m.createdAt) || 0 };
81
+ }).filter(Boolean).sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
82
+ unreadable = false;
83
+ cached = { sandboxes: JSON.stringify(all.slice(0, SANDBOXES_MAX).map(({ created, ...r }) => r)), sandboxesMore: String(Math.max(0, all.length - SANDBOXES_MAX)), sandboxRetentionHours: String(retentionHours) };
84
+ } catch {
85
+ // A failed read is absent telemetry, never a claim that the host has no retained workspaces.
86
+ if (!unreadable) log("host_sandboxes_unreadable", { reason: "read-failed" });
87
+ unreadable = true;
88
+ cached = { sandboxes: "", sandboxesMore: "", sandboxRetentionHours: String(retentionHours) };
89
+ }
90
+ return cached;
91
+ };
92
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The forge-neutral half of a webhook endpoint: read a bounded raw body, recognise a JSON content type,
3
+ * and write a small JSON response.
4
+ *
5
+ * Extracted from verify.mjs when a second forge arrived (issue #42), and extracted rather than copied for
6
+ * one reason: `readRawBody`'s per-chunk accounting is a memory bound on attacker-controlled input. Two
7
+ * copies of a security limit drift -- one gets the fix, the other keeps the bug -- and the copy that keeps
8
+ * it is the one nobody is looking at. Verification itself is NOT here; each forge's gate owns its own,
9
+ * because that is exactly the part that differs.
10
+ *
11
+ * Nothing here reads a header that identifies a forge, and nothing here decides whether a request is
12
+ * trusted. A caller must still verify before parsing a single field (CONST-HMAC-OVER-RAW-BODY).
13
+ *
14
+ * Moved from the receiver to the worker's exports (issue #640) for the same reason it was extracted: the operator
15
+ * console reads request bodies too, and the console must not import the receiver (its only export is `./start`, and an
16
+ * admin surface on the receiver is the rejected option `DES-OPERATOR-CONSOLE` keeps). `respond` sets no security
17
+ * headers; the console wraps every response in its own header set and never calls it directly.
18
+ */
19
+
20
+ /** Accept `application/json`, ignoring any parameters and case. */
21
+ export function isJsonContentType(contentType) {
22
+ return String(contentType ?? "").split(";")[0].trim().toLowerCase() === "application/json";
23
+ }
24
+
25
+ /**
26
+ * Collect the request stream into a Buffer, bounded by `limit`. Accumulation is checked on every
27
+ * chunk so the buffer can never grow past the cap: exceeding it destroys the request and rejects
28
+ * with `code: "E_PAYLOAD_TOO_LARGE"` rather than reading an unbounded body into memory.
29
+ */
30
+ export function readRawBody(req, limit) {
31
+ return new Promise((resolve, reject) => {
32
+ const chunks = [];
33
+ let size = 0;
34
+ let settled = false;
35
+
36
+ const cleanup = () => {
37
+ req.removeListener("data", onData);
38
+ req.removeListener("end", onEnd);
39
+ req.removeListener("error", onError);
40
+ };
41
+ const onData = (chunk) => {
42
+ if (settled) return;
43
+ size += chunk.length;
44
+ if (size > limit) {
45
+ settled = true;
46
+ cleanup();
47
+ req.destroy();
48
+ const err = new Error("payload too large");
49
+ err.code = "E_PAYLOAD_TOO_LARGE";
50
+ reject(err);
51
+ return;
52
+ }
53
+ chunks.push(chunk);
54
+ };
55
+ const onEnd = () => {
56
+ if (settled) return;
57
+ settled = true;
58
+ cleanup();
59
+ resolve(Buffer.concat(chunks));
60
+ };
61
+ const onError = (err) => {
62
+ if (settled) return;
63
+ settled = true;
64
+ cleanup();
65
+ reject(err);
66
+ };
67
+
68
+ req.on("data", onData);
69
+ req.on("end", onEnd);
70
+ req.on("error", onError);
71
+ });
72
+ }
73
+
74
+ /** Write a small JSON body with an explicit status. `writeHead` sets the status code on the response. */
75
+ export function respond(res, status, body) {
76
+ res.writeHead(status, { "content-type": "application/json" });
77
+ res.end(JSON.stringify(body));
78
+ }
@@ -16,10 +16,9 @@ import { FORGE_MCP_SERVER_NAME } from "./mcp-config.mjs";
16
16
  */
17
17
 
18
18
  /**
19
- * The image this job runs in: the job's own `image` when it carries one, otherwise the deployment default
20
- * (`PI_JOB_IMAGE`). Today nothing sets the per-job field, so every job resolves the default; the seam exists
21
- * because the preflight and the argv builder must never disagree about which tag they mean, and one function
22
- * is how that is guaranteed rather than hoped for.
19
+ * The image this job runs in: the job's own `image` when it carries one (a trigger's `run.image`, the CLI's `--image`),
20
+ * otherwise the deployment default (`PI_JOB_IMAGE`). The preflight and the argv builder must never disagree about which
21
+ * tag they mean, and one function is how that is guaranteed rather than hoped for.
23
22
  */
24
23
  export function resolveJobImage(job, defaultImage) {
25
24
  const image = job?.image ?? defaultImage;
@@ -45,8 +44,9 @@ export function resolveJobImage(job, defaultImage) {
45
44
  * { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
46
45
  * { <gate.result>, declared } -- present, but does not declare a capability this job needs, one key per
47
46
  * CAPABILITY_GATES row (replicaUnsupported, commandUnsupported,
48
- * excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
49
- * forgeMcpUnsupported)
47
+ * excludeToolsUnsupported, loadExclusionsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
48
+ * forgeMcpUnsupported, awaitInputUnsupported, progressUnsupported, recoverUnsupported,
49
+ * steerUnsupported)
50
50
  * => POLICY
51
51
  *
52
52
  * A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
@@ -60,10 +60,19 @@ export function resolveJobImage(job, defaultImage) {
60
60
  * `unavailable` and retry; if it comes up between them we report `missing` on an image that is genuinely
61
61
  * absent. Nothing is cached, deliberately -- see the note at the call site in start.mjs.
62
62
  */
63
- export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
63
+ export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker", copies = false, brokered = false }) {
64
+ // Issue #683: `copies` is the venue's, not the job's: a venue that copies the job's files into the box (`binds:
65
+ // false`, `openshell`) needs the `sandboxLayout` row below on EVERY job, and a binding venue never does. A boolean
66
+ // and nothing else, so a truthy string from a hand-built bundle cannot arm a gate it did not mean.
67
+ if (typeof copies !== "boolean") throw new TypeError("makeImagePreflight: `copies` must be a boolean");
68
+ // Issue #730: `brokered` is the venue's too: a venue whose every connection is mediated by a broker that reads the
69
+ // caller's memory (both OpenShell venues, copy and bind) needs the `observableRunner` row below on EVERY job. A
70
+ // boolean, for the same reason.
71
+ if (typeof brokered !== "boolean") throw new TypeError("makeImagePreflight: `brokered` must be a boolean");
64
72
  // Issue #354: `bin` is the venue's CLI. Both probes go through it, because a `podman image inspect` answered by one
65
73
  // store and an `info` answered by another daemon would tell "absent" from "unavailable" about two different hosts.
66
- return async function imagePreflight(job) {
74
+ return async function imagePreflight(job, { subscriptionAuthRequired = false } = {}) {
75
+ if (typeof subscriptionAuthRequired !== "boolean") throw new TypeError("imagePreflight: subscriptionAuthRequired must be a boolean");
67
76
  const wanted = resolveJobImage(job, image);
68
77
  // --format keeps docker from serialising the whole image manifest to a pipe we ignore.
69
78
  //
@@ -103,13 +112,16 @@ export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
103
112
  // the job actually carries the feature it guards. The gates, their order and why each exists are
104
113
  // the CAPABILITY_GATES table; the first one this job needs and the image lacks refuses.
105
114
  for (const gate of CAPABILITY_GATES) {
106
- if (gate.needed(job) && !(capabilities ?? []).includes(gate.token)) {
115
+ if (gate.needed(job, { copies, brokered, subscriptionAuthRequired }) && !(capabilities ?? []).includes(gate.token)) {
107
116
  return { [gate.result]: wanted, declared: capabilities ?? [] };
108
117
  }
109
118
  }
110
119
  // `capabilities` rides the ok result for issue #341's job-user gate, which needs `anyUid` and runs after
111
120
  // this preflight; an image that declares nothing reads as [].
112
- return { ok: true, image: wanted, piVersion, imageDigest, capabilities: capabilities ?? [] };
121
+ // `fromJob`: a run on an image its job named says nothing of the image this host runs everything else on, which only
122
+ // this function can tell, since it alone holds both (the record's `imageFromJob`, loaded-record.mjs). A job naming this
123
+ // host's own default ran on this host's own image, so it is no such run.
124
+ return { ok: true, image: wanted, piVersion, imageDigest, capabilities: capabilities ?? [], ...(wanted !== image ? { fromJob: true } : {}) };
113
125
  }
114
126
  if ((await runDocker(bin, spawnFn, ["info"])).code === 0) return { missing: wanted };
115
127
  return { unavailable: wanted };
@@ -124,7 +136,8 @@ export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
124
136
  * row whose `needed(job)` holds and whose `token` the image does not declare, before any spend.
125
137
  *
126
138
  * token the capability the image must declare
127
- * needed whether this job carries the feature (a job that does not pays nothing for the row)
139
+ * needed whether this job carries the feature (a job that does not pays nothing for the row), given the job and
140
+ * the venue's `{ copies }` (issue #683: the one row a venue rather than a trigger arms)
128
141
  * result the key the preflight result names the image under, which the processor branches on
129
142
  * reason the run record's reason for the refusal
130
143
  * event the worker log event
@@ -135,8 +148,7 @@ export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
135
148
  * A new image-gated feature (`costCap`, `modelPolicy`) is a row here, not another branch in two files. Order
136
149
  * is the order the branches had, so a job carrying several features is refused for the same one as before.
137
150
  */
138
- export const CAPABILITY_GATES = Object.freeze([
139
- // REQ-REPLICA-RUNS: an image built before it bakes a HARD_RULES.md whose rule 3 hard-codes `pi/issue-<n>`
151
+ export const CAPABILITY_GATES = Object.freeze([ // REQ-REPLICA-RUNS: an image built before it bakes a HARD_RULES.md whose rule 3 hard-codes `pi/issue-<n>`
140
152
  // as a SYSTEM rule, which the model treats as authoritative over the user prompt naming `pi/issue-<n>-r2`.
141
153
  // Both replicas would push to one branch: the push race the feature exists to avoid, two runs billed.
142
154
  Object.freeze({
@@ -225,6 +237,107 @@ export const CAPABILITY_GATES = Object.freeze([
225
237
  event: "refused_image_await_input_unsupported",
226
238
  comment: (image, declared) => `Refused: the job image "${image}" does not declare support for stopping to wait for an answer (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.awaitInput\`. Rebuild the image from a version that has this feature. Not run.`,
227
239
  }),
240
+ // Issue #644: a runner that predates run.progress reads no PI_REPORT_PROGRESS, so the agent is never offered the
241
+ // report_progress tool and the trigger's phases never appear, on a run that otherwise reads as fine. Refused for the
242
+ // awaitInput row's reason: the trigger asked for something this image cannot give.
243
+ Object.freeze({
244
+ token: "reportProgress",
245
+ needed: (job) => job?.progress === true,
246
+ result: "progressUnsupported",
247
+ reason: "job-image-progress-unsupported",
248
+ event: "refused_image_progress_unsupported",
249
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for reporting progress (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.progress\`. Rebuild the image from a version that has this feature. Not run.`,
250
+ }),
251
+ // Issue #645: a runner that predates run.recover reads no PI_RECOVER and keeps no checkpoint, so a run the worker
252
+ // loses would fail exactly as an unarmed one does while the trigger promised it would continue. Refused for the
253
+ // awaitInput row's reason: the trigger asked for something this image cannot give, and saying so costs nothing.
254
+ Object.freeze({
255
+ token: "recover",
256
+ needed: (job) => job?.recover === true,
257
+ result: "recoverUnsupported",
258
+ reason: "job-image-recover-unsupported",
259
+ event: "refused_image_recover_unsupported",
260
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for continuing a lost run from its checkpoint (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.recover\`. Rebuild the image from a version that has this feature. Not run.`,
261
+ }),
262
+ // Issue #646: a runner that predates run.steer reads no PI_STEER and never looks at /inbox, so every message an
263
+ // operator sent would be accepted and then never reach the agent. Refused for the recover row's reason: the trigger
264
+ // asked for something this image cannot give, and saying so costs nothing.
265
+ Object.freeze({
266
+ token: "steer",
267
+ needed: (job) => job?.steer === true,
268
+ result: "steerUnsupported",
269
+ reason: "job-image-steer-unsupported",
270
+ event: "refused_image_steer_unsupported",
271
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for receiving messages while it runs (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.steer\`. Rebuild the image from a version that has this feature. Not run.`,
272
+ }),
273
+ // Issue #683: on a venue that copies the job's files (`openshell`), a runner that predates PI_JOB_LAYOUT looks for
274
+ // /job, finds nothing in a sandbox whose files sit under /sandbox, and exits "missing job input" after the sandbox
275
+ // was made and the budget reserved; and without /opt/pi-dispatch/pi-landlock its /job would be writable by the
276
+ // agent. Armed by the VENUE, on every job there, never by a trigger, so a binding venue never reads the token.
277
+ Object.freeze({
278
+ token: "sandboxLayout",
279
+ needed: (_job, { copies } = {}) => copies === true,
280
+ result: "sandboxLayoutUnsupported",
281
+ reason: "job-image-sandbox-layout-unsupported",
282
+ event: "refused_image_sandbox_layout_unsupported",
283
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for a venue that copies the job's files into its sandbox (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would not find them, and could not keep the agent from rewriting them. Rebuild the image from a version that has this feature. Not run.`,
284
+ }),
285
+ // Issue #730: on a venue whose connections are mediated by a broker that must read the caller's memory (OpenShell,
286
+ // both modes), a runner started from the exec-only node cannot be read, so its own provider call and every tool's
287
+ // connection below it are refused: the job would reserve, start, and fail its first model call. An image that has
288
+ // the readable copy and the entrypoint that chooses it declares `observableRunner`. Armed by the VENUE, on every job
289
+ // there, never by a trigger.
290
+ Object.freeze({
291
+ token: "observableRunner",
292
+ needed: (_job, { brokered } = {}) => brokered === true,
293
+ result: "observableRunnerUnsupported",
294
+ reason: "job-image-observable-runner-unsupported",
295
+ event: "refused_image_observable_runner_unsupported",
296
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for a venue whose sandbox must observe the runner to let it reach the network (\`dev.pi-dispatch.capabilities\` ${declared}), so the job could reach neither its model nor anything else. Rebuild the image from a version that has this feature. Not run.`,
297
+ }),
298
+ Object.freeze({
299
+ token: "fork",
300
+ needed: (job) => job?.fork !== undefined || job?.thinkingLevel !== undefined,
301
+ result: "forkUnsupported",
302
+ reason: "job-image-fork-unsupported",
303
+ event: "refused_image_fork_unsupported",
304
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare managed session forks (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore the selected entry or thinking level. Rebuild the image from a version that has this feature. Not run.`,
305
+ }),
306
+ Object.freeze({
307
+ token: "approvalGates",
308
+ needed: (job) => Array.isArray(job?.gates) && job.gates.length > 0,
309
+ result: "approvalGatesUnsupported",
310
+ reason: "job-image-approval-gates-unsupported",
311
+ event: "refused_image_approval_gates_unsupported",
312
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare approval gates (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.gates\`. Rebuild the image from a version that has this feature. Not run.`,
313
+ }),
314
+
315
+ Object.freeze({
316
+ token: "modelFailover",
317
+ needed: (job) => job?.fallbackModels !== undefined,
318
+ result: "modelFailoverUnsupported",
319
+ reason: "job-image-model-failover-unsupported",
320
+ event: "refused_image_model_failover_unsupported",
321
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare model-failover support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner cannot report the authenticated failure and replay evidence this job requires. Rebuild the image from a version that has this feature. Not run.`,
322
+ }),
323
+ Object.freeze({ token: "modelRoles", needed: (job) => job?.modelRoles !== undefined, result: "modelRolesUnsupported", reason: "job-image-model-roles-unsupported", event: "refused_image_model_roles_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare modelRoles support (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot resolve reviewed roles before child calls. Rebuild the image from a version that has this feature. Not run.` }),
324
+ Object.freeze({ token: "watch", needed: (job) => Boolean(job?.watch), result: "watchUnsupported", reason: "job-image-watch-unsupported", event: "refused_image_watch_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare watch isolation (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot enforce read-only investigations. Rebuild the image from a version that has this feature. Not run.` }),
325
+ Object.freeze({ token: "questionGrouping", needed: (job) => Boolean(job?.questionGrouping), result: "questionGroupingUnsupported", reason: "job-image-question-grouping-unsupported", event: "refused_image_question_grouping_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare isolated question grouping (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot run a classifier without loading an agent. Rebuild the image from a version that has this feature. Not run.` }),
326
+ Object.freeze({ token: "answerDraft", needed: (job) => Boolean(job?.answerDraft), result: "answerDraftUnsupported", reason: "job-image-answer-draft-unsupported", event: "refused_image_answer_draft_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare restricted answer drafts (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot enforce source-only drafting without posting tools. Rebuild the image from a version that has this feature. Not run.` }),
327
+ // Only trusted host connection policy arms this gate. Queued input cannot claim or suppress authentication.
328
+ Object.freeze({ token: "subscriptionAuth", needed: (_job, { subscriptionAuthRequired = false } = {}) => subscriptionAuthRequired === true, result: "subscriptionAuthUnsupported", reason: "job-image-subscription-auth-unsupported", event: "refused_image_subscription_auth_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare subscription authentication (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot verify the admitted access snapshot, community adapter or final request cost. Rebuild the image from a version that has this feature. Not run.` }),
329
+ // Issue #800: a runner that predates them reads no PI_EXCLUDE_EXTENSIONS and no PI_EXCLUDE_EXTENSION_TOOLS, so the overlay extension loads, the tool
330
+ // stays, and the job records a clean exit. A permission quietly not enforced, as the `excludeTools` row says. A package the worker left out of
331
+ // PI_PACKAGES stays out on any image, but the worker cannot know a name is only that, so either field arms this.
332
+ // `run.excludeTools` beside a role map arms it too. That runner reads PI_EXCLUDE_TOOLS for its own session, so the `excludeTools` row above passes, and then
333
+ // starts a role child on pi's CLI with no `--exclude-tools`, so the child keeps what the trigger removed and the job reads clean. `job.modelRoles` is what says
334
+ // a child can start: `resolveModelRoles` set it at this pickup, a queued one never survives that, and with no map there is no `subagent` tool. An image with no
335
+ // role machinery at all never reaches this for that reason: the `modelRoles` row stands above and answers first, in its own words. A list of only a server's
336
+ // names arms it as well, though a child connects no server: the row's words hold of it, the fix is the same rebuild, and this module holds no table of those names.
337
+ // One sentence for all of these. REJECTED: naming the keys and the role child, which said "would start a role child" of a job with no role map on an image with no role machinery.
338
+ Object.freeze({ token: "loadExclusions", needed: (job) => job?.excludeExtensions !== undefined || job?.excludeExtensionTools !== undefined || (job?.excludeTools !== undefined && job?.modelRoles !== undefined), result: "loadExclusionsUnsupported", reason: "job-image-load-exclusions-unsupported", event: "refused_image_load_exclusions_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare support for this job's exclusions (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would not honour them. Rebuild the image from a version that has this feature. Not run.` }),
339
+ // Issue #796: an image too old to report whether a canary's candidate loaded could only ever read "not reported", on a paid run.
340
+ Object.freeze({ token: "packageCanary", needed: (job) => job?.packageCanary !== undefined, result: "packageCanaryUnsupported", reason: "job-image-package-canary-unsupported", event: "refused_image_package_canary_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare package canaries (\`dev.pi-dispatch.capabilities\` ${declared}), so it could not report whether the candidate loaded. Rebuild the image from a version that has this feature. Not run.` }),
228
341
  ]);
229
342
 
230
343
  /**
@@ -235,6 +348,36 @@ export const CAPABILITY_GATES = Object.freeze([
235
348
  */
236
349
  export const EXIT_AUTH_CAPABILITY = "exitAuth";
237
350
 
351
+ /**
352
+ * The capability an image declares when its runner mirrors the session file over stderr as records signed with the exit
353
+ * key, on PI_TRANSCRIPT=stderr (issue #686, image/runner/src/transcript-mirror.mjs). NOT a gate, like `exitAuth`: no job
354
+ * is refused for lacking it. On a venue that copies the job's files (`openshell`) it is what the live transcript, run
355
+ * progress and the status comment come from; an image without it runs there without them (`live_transcript_skipped`,
356
+ * reason `no-capability`). A binding venue never reads it.
357
+ */
358
+ export const TRANSCRIPT_STDERR_CAPABILITY = "transcriptStderr";
359
+
360
+ /**
361
+ * The capability an image declares when its runner reads steer messages as MAC'd frames on stdin, on
362
+ * PI_EXIT_AUTH=stdin-frames (issue #686, image/runner/src/steer-frames.mjs): the key as frame 1, read before pi loads,
363
+ * the stream moved off fd 0 so a tool child inherits /dev/null. NOT a gate, like `transcriptStderr`: `steer` stays the
364
+ * gate a `run.steer` job needs. On a venue that copies the job's files (`openshell`) it is what steering comes over; an
365
+ * image without it runs there unsteered (`steerSkipped: "venue-copies"`, as before it existed). A binding venue never
366
+ * reads it: it keeps the read-only `/inbox` mount, which the agent cannot withdraw a message from.
367
+ */
368
+ export const STEER_STDIN_CAPABILITY = "steerStdin";
369
+
370
+ /**
371
+ * The capability an image declares when its runner reads PI_BUNDLE_ENV=1: a project secret bundle's plain variables
372
+ * from `bundle-env.json` in the job directory into its own environment (issue #704 part 3, image/runner/src/
373
+ * bundle-env.mjs). A gate on the OpenShell venues only, judged by their bundle gate rather than a CAPABILITY_GATES row
374
+ * (the need is known only once the bundle is read, which is after those rows run): a job there that would carry plain
375
+ * bundle variables on an image without it is refused before spend (`openshell-bundle-image-too-old`), since an older
376
+ * runner would ignore the file and the job would run without them. Every other venue hands the values as env and never
377
+ * reads it.
378
+ */
379
+ export const BUNDLE_ENV_FILE_CAPABILITY = "bundleEnvFile";
380
+
238
381
  /**
239
382
  * Exported so the test cannot drift from the format string above. `|` because an image id is
240
383
  * `sha256:<hex>` and a version is a version, so neither can contain one -- and because a literal control