@edgehero/pi-dispatch 4.3.1 → 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 (256) hide show
  1. package/.env.example +84 -3
  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 +190 -29
  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 +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
package/src/backends.mjs CHANGED
@@ -61,13 +61,14 @@
61
61
  *
62
62
  * `OQ-012` already draws this line for images -- "a required OCI label proves INTENT, not conformance" --
63
63
  * and the same is true one level out. The value of the table is not that a vendor is verified; it is that a
64
- * MISMATCH becomes a refusal instead of a silent downgrade. `backend-conformance.mjs` verifies THREE of the
65
- * thirteen -- exit-code fidelity, the abort flag's independence from the code, and the read-only downgrade a
66
- * copying runtime takes -- plus the shape of a bundle and the internal consistency of its declaration. EIGHT more
64
+ * MISMATCH becomes a refusal instead of a silent downgrade. `backend-conformance.mjs` verifies FOUR of the
65
+ * fourteen -- exit-code fidelity, the abort flag's independence from the code, the read-only downgrade a
66
+ * copying runtime takes, and (issue #645) that a venue claiming `pausable` carries the verbs, driven through a freeze
67
+ * only when the adapter supplies a probe -- plus the shape of a bundle and the internal consistency of its declaration. EIGHT more
67
68
  * are read back off a live container (issues #278 and #344): for `local` by `doctor --live` (`live-probes.mjs`), for
68
69
  * any other runtime through the harness's `readBack` probe, which the adapter supplies. The remaining TWO the harness
69
70
  * names with what each would take rather than passing them in silence. So a backend can still declare all of this
70
- * and do most of it: these words are a contract with three of them checked and eight readable, which is more than
71
+ * and do most of it: these words are a contract with four of them checked and eight readable, which is more than
71
72
  * none and less than proof -- the read-back is of a fixture with the job image, not of every job.
72
73
  *
73
74
  * THE PROPERTIES ARE NOT A RENDERING OF `ISOLATION_FLAGS`, and must not become one. That array is the
@@ -233,6 +234,17 @@ const PROPERTIES_TABLE = {
233
234
  question: "a local folder can be bind-mounted and edited in place (DES-WORKER-ON-HOST finding 2)",
234
235
  armedBy: null,
235
236
  },
237
+ pausable: {
238
+ // Issue #645. OPTIONAL in the sense that matters: nothing a job needs rests on it, so `absent` is an honest word for
239
+ // a venue that cannot freeze and costs that venue nothing but a live (unfrozen) checkpoint read. Pausing uses the
240
+ // cgroup freezer (`docker pause`, `podman pause`): every process in the container stops where it stands and
241
+ // nothing is killed. It exists so the HOST can read a job's bind-mounted workspace as one consistent state while
242
+ // nothing in the container runs; it grants the container nothing, and no mount, flag or network changes
243
+ // (CONST-ISOLATION-CONTAINER-PER-JOB is untouched by it). The bundle's `pause(name)` / `unpause(name)` resolve
244
+ // `{ ok }` and never throw, so a freeze that does not take on one host degrades the read, never the job.
245
+ question: "a running job can be frozen by the cgroup freezer and thawed again, so the host reads its workspace while nothing in the container runs",
246
+ armedBy: null,
247
+ },
236
248
  };
237
249
 
238
250
  /**
@@ -392,6 +404,15 @@ export const PODMAN_ADDS_NO_MOUNTS = "podmanAddsNoMounts";
392
404
  */
393
405
  export const PODMAN_SERVICE_LOCAL = "podmanServiceLocal";
394
406
 
407
+ /**
408
+ * The observation that the OpenShell gateway this worker's CLI talks to listens on this host (issue #683,
409
+ * `CONST-VENUE-EGRESS-LAYER`'s third condition): `openshell status -o json` names a server on a loopback address or a
410
+ * unix socket. Every job's provider key and forge token reach that gateway, so a remote one is where they would travel.
411
+ * Unlike the docker and podman observations it is not only a floor's business: the openshell venue REFUSES a job on a
412
+ * gateway it does not observe local, whatever the floor says, because the constitution admits a layer venue only so.
413
+ */
414
+ export const OPENSHELL_GATEWAY_LOCAL = "openshellGatewayLocal";
415
+
395
416
  /**
396
417
  * The podman venue's refusal of an account containers.conf that widens every job (issue #428), and the run record's
397
418
  * `reason` for a job it refuses. Here, in the leaf, rather than in backend-podman.mjs beside the check, so the processor
@@ -551,6 +572,7 @@ export const OBSERVATIONS = Object.freeze({
551
572
  [PODMAN_BOUNDS_DELEGATED]: "this worker's rootless Podman runs on cgroup v2 with a systemd user manager running for its account, the pids, memory and cpu controllers delegated to that manager, and Podman putting containers under it, and no containers.conf it reads sets `cgroups`",
552
573
  [PODMAN_ADDS_NO_MOUNTS]: "this worker's rootless Podman adds no mounts of its own to a job container",
553
574
  [PODMAN_SERVICE_LOCAL]: "the podman CLI this worker spawns runs containers on this host, not through a remote service",
575
+ [OPENSHELL_GATEWAY_LOCAL]: "the OpenShell gateway this worker's openshell CLI talks to listens on a loopback address or a unix socket on this host",
554
576
  });
555
577
 
556
578
  /** What to do about each observation that a floor needed and did not get, keyed like `OBSERVATIONS` and pinned to it. */
@@ -561,6 +583,7 @@ export const OBSERVATION_FIX = Object.freeze({
561
583
  [PODMAN_BOUNDS_DELEGATED]: "Turn on linger for the worker account (`loginctl enable-linger <account>`, which keeps its systemd user manager running with no one logged in) and run the worker as a systemd user service (`pi-dispatch service install`), or give Podman the account's user bus so it uses the systemd cgroup manager; delegate the cpu, memory and pids controllers to that manager on cgroup v2 (a systemd `Delegate=` drop-in for user@.service), remove any `cgroups` key from its containers.conf (written in plain ASCII with no escaped key or multi-line string, which the check refuses rather than guesses at), or lower that entry to `asserted`: `pi-dispatch doctor --live` reads pids.max and memory.max off a real container.",
562
584
  [PODMAN_ADDS_NO_MOUNTS]: "As the worker account, create an empty ~/.config/containers/mounts.conf (or, when it has none, an empty /etc/containers/mounts.conf), remove any `volumes`, `mounts`, `devices` or `hooks_dir` key from every containers.conf it reads (and write those in plain ASCII with no escaped key or multi-line string, which the check refuses rather than guesses at) and any OCI hook, or lower that entry to `asserted`.",
563
585
  [PODMAN_SERVICE_LOCAL]: "Unset CONTAINER_HOST and any containers.conf service destination for the worker account so `podman info` reports serviceIsRemote false, or lower that entry to `asserted`.",
586
+ [OPENSHELL_GATEWAY_LOCAL]: "Run the OpenShell gateway on this host and register it on loopback (`openshell gateway add https://127.0.0.1:17670 --local`); a remote gateway is refused, not lowered.",
564
587
  });
565
588
 
566
589
  const BACKENDS_TABLE = {
@@ -572,6 +595,14 @@ const BACKENDS_TABLE = {
572
595
  */
573
596
  describe: "the Docker daemon on this worker's host",
574
597
  remote: false,
598
+ // Issue #683 (`DES-CONTAINER-BACKEND-REGISTRY`, `CONST-VENUE-EGRESS-LAYER`): the provider key and the forge token sit
599
+ // in the job's own container, as `-e NAME=VALUE` the agent can read. Not a word on the three-word scale: who holds
600
+ // the credentials is a closed choice (`CREDENTIAL_CUSTODY`), and `PI_BACKEND_FLOOR` does not compare it.
601
+ credentialCustody: "container",
602
+ // Issue #712: what the run-time features this venue serves, in words (`SERVES`). The bundle binds the job's
603
+ // directories (`binds: true`) and its transcript is a file on the host, so live transcript, progress and `/inbox`
604
+ // steering work as built; `run.recover` snapshots the host workspace; `pi-dispatch sandbox` has a docker launcher.
605
+ serves: { liveTranscript: "yes", steering: "yes", recovery: "yes", sandboxShell: "yes" },
575
606
  declares: {
576
607
  // Built by this worker's own argv, in `ISOLATION_FLAGS`, and asserted back member-by-member
577
608
  // against the imported array by two tests. `dockerArgsFromSpec` additionally refuses a
@@ -640,6 +671,11 @@ const BACKENDS_TABLE = {
640
671
  credentialTransit: ENFORCED,
641
672
  // The whole reason `DES-WORKER-ON-HOST` reversed the containerised worker.
642
673
  localFolders: ENFORCED,
674
+ // `docker pause` on the name (`makePauseVerbs`), the freezer of whichever cgroup version the daemon runs. The
675
+ // one host this does not hold on is a ROOTLESS Docker on cgroup v1, which cannot freeze; there the CLI's own
676
+ // non-zero exit comes back as `{ ok: false }` and the read goes unfrozen, rather than the word being lowered
677
+ // for every rootful host because of it.
678
+ pausable: ENFORCED,
643
679
  },
644
680
  /**
645
681
  * WHO is asserting each `asserted` property. Required for every property this backend declares
@@ -682,6 +718,10 @@ const BACKENDS_TABLE = {
682
718
  // The containers run on this host, as this account. A remote service is refused rather than declared remote, so
683
719
  // the venue is never one; `credentialTransit`'s observation is what notices a CLI that tries.
684
720
  remote: false,
721
+ // `local`'s: the credentials are `-e` values in the job's own container.
722
+ credentialCustody: "container",
723
+ // `local`'s, for `local`'s reasons: the bundle binds, the transcript is a host file, and SANDBOX_LAUNCHERS has a podman row.
724
+ serves: { liveTranscript: "yes", steering: "yes", recovery: "yes", sandboxShell: "yes" },
685
725
  declares: {
686
726
  // The same `ISOLATION_FLAGS` through the same builder (`buildPodmanRunArgs` shares `argsFromSpec`), read back on
687
727
  // a live rootless container: CapEff 0, NoNewPrivs 1, seccomp 2, pids.max 512, memory.max 4 GiB. AND ONLY WHILE
@@ -728,6 +768,12 @@ const BACKENDS_TABLE = {
728
768
  // A bind mount of the operator's folder, as on `local`, never relabelled (on an SELinux host it needs the
729
769
  // `semanage fcontext` doctor names), and writable because the job runs as the account that owns it.
730
770
  localFolders: ENFORCED,
771
+ // `podman pause` on the name, AND ONLY ON CGROUP V2: rootless Podman cannot freeze on v1 (the freezer needs a
772
+ // cgroup the user owns, which v1 never delegates). The venue already reads which version from `podman info`
773
+ // (`isolation`'s observation needs v2 too), so the bundle answers `{ ok: false, reason: "cgroup-v1-rootless" }`
774
+ // there without spawning (`podmanPausable`), and doctor says so. Not an `observedBy` entry: that machinery
775
+ // lowers a word to `asserted`, which would name an asserter for a freeze nobody provides.
776
+ pausable: ENFORCED,
731
777
  },
732
778
  // Nothing asserted: every word above is this worker's own argv or an observation of this host.
733
779
  asserts: {},
@@ -739,6 +785,152 @@ const BACKENDS_TABLE = {
739
785
  credentialTransit: PODMAN_SERVICE_LOCAL,
740
786
  },
741
787
  },
788
+ openshell: {
789
+ /**
790
+ * An NVIDIA OpenShell sandbox on a gateway this deployment owns (issue #683, `DES-OPENSHELL-VENUE`), driven through
791
+ * the real `openshell` CLI with `-o json`, on its Docker or Podman compute driver only. The vendor builds the box:
792
+ * an agent container with networking off beside a supervisor container that decides every connection, under
793
+ * Landlock and seccomp. So every word this worker cannot build or read back itself is `asserted`, with its asserter
794
+ * named, where `local` says `enforced`; `PI_BACKEND_FLOOR` lets an operator refuse that trade at boot. Measured on
795
+ * OpenShell 0.1.4, Docker driver, ubuntu-24.04 (Linux 6.17), 2026-10-19.
796
+ *
797
+ * BLESSABLE SINCE ISSUE #684, which shipped the policy layer `CONST-VENUE-EGRESS-LAYER` asks of a layer venue: the
798
+ * per-job network policy generated from the squid venues' own sources, and the per-job credential provider, with
799
+ * placeholders in the agent. What the venue does not serve yet (live transcript, progress, steering, recovery) is
800
+ * refused or skipped by name until issue #686, which serves the first three here and recovery in bind mode only
801
+ * (`openshell-bind`, below).
802
+ */
803
+ describe: "an NVIDIA OpenShell sandbox on this host's gateway, through the openshell CLI",
804
+ // The gateway is refused unless it is on this host (`OPENSHELL_GATEWAY_LOCAL`), so the venue is never remote.
805
+ remote: false,
806
+ // `CONST-VENUE-EGRESS-LAYER`: the venue's own layer holds the credentials, placeholders in the agent (issue #684):
807
+ // a per-job provider holds the values and the supervisor swaps them in for a request to a host the policy admits.
808
+ credentialCustody: "layer",
809
+ // Issue #712: copy mode COPIES the job's files (`binds: false`), so the live transcript comes over the exec's stderr
810
+ // and steering over its stdin, each only when the job image declares the capability (`transcriptStderr`; for
811
+ // steering `steerStdin` AND `transcriptStderr`, whose stream carries the acks) and an exit key: `image`. `run.recover` is refused before spend (`openshell-recover-unsupported`)
812
+ // and refused in the triggers file for a trigger naming this venue (`recoverVenueProblem`). No sandbox launcher.
813
+ serves: { liveTranscript: "image", steering: "image", recovery: "no", sandboxShell: "no" },
814
+ declares: {
815
+ // The agent container and its bounds are the compute driver's (`cap_drop ALL`, `no-new-privileges`, networking
816
+ // off, a pids limit of 2048, `--cpu` and `--memory`), not an argv this worker builds.
817
+ isolation: ASSERTED,
818
+ // The worker creates one sandbox per job and deletes it in a `finally`, and the reaper deletes a crashed one;
819
+ // that the delete really removes the agent and supervisor containers is the gateway's.
820
+ ephemeral: ASSERTED,
821
+ // No mount is the worker's: the job's files are uploaded and downloaded, and what the driver mounts beside them
822
+ // (its runtime, the supervisor's socket) is the vendor's.
823
+ mountSet: ASSERTED,
824
+ // The layer's per-job policy (issue #684), deny-all but for the hosts the allowlist, the model endpoints and the
825
+ // forge admit. The boot refuses PI_EGRESS=0 with this venue blessed: the layer has no "off" (`config.mjs`).
826
+ egress: ASSERTED,
827
+ // Each sandbox's agent has networking off and reaches only its own supervisor, so another job is not on any
828
+ // network it can see: the driver's construction.
829
+ jobToJobIsolation: ASSERTED,
830
+ // The image is resolved by the gateway's driver (`image_pull_policy`, `if_not_present` by default), not by a
831
+ // `--pull=never` this worker passes.
832
+ imagePinning: ASSERTED,
833
+ // `sandbox exec` returns the command's own code and 128+n for a signal (measured: 0, 1, 2, 3, 137, 255, 143
834
+ // and 137), but through the vendor's relay; a CLI failure also exits 1.
835
+ exitCodes: ASSERTED,
836
+ // `sandbox delete` on the name ends the exec (measured: 143), and the abort FLAG is the discriminator as on
837
+ // every venue; the delete itself is the gateway's.
838
+ abortable: ASSERTED,
839
+ // Not a `:ro` bind: the uploaded `/sandbox/job` is read-only because the worker starts the runner through the
840
+ // image's `pi-landlock`, AFTER the upload (`DES-OPENSHELL-VENUE`, corrected in #683).
841
+ readOnlyJobInputs: ASSERTED,
842
+ // OpenShell refuses a root workload, and the worker's policy names the image's uid as `run_as_user`.
843
+ nonRoot: ASSERTED,
844
+ // The same resolver and record as `local`, but the values reach a vendor's gateway and supervisor.
845
+ secretsCustody: ASSERTED,
846
+ // ENFORCED, AND ONLY WHILE OBSERVED, for `local`'s reason: the values reach the gateway the CLI talks to, and the
847
+ // worker reads where that is (`openshellGatewayLocal`). A gateway not observed local is refused outright.
848
+ credentialTransit: ENFORCED,
849
+ // Copy mode edits a copy, not the operator's folder: a local or cron folder job is refused at load
850
+ // (`refusesLocalWorkspace`). Bind mode, which can, is its own venue, `openshell-bind` (issue #686 part 3).
851
+ localFolders: ABSENT,
852
+ // OpenShell has no pause verb, so live checkpoints are off (`DES-RUN-CHECKPOINT-PAIRED`).
853
+ pausable: ABSENT,
854
+ },
855
+ asserts: {
856
+ isolation: "OpenShell's compute driver (the agent container it creates with every capability dropped, no new privileges, networking off and a pids limit, under Landlock and seccomp)",
857
+ ephemeral: "the OpenShell gateway, which removes a sandbox's containers when the worker deletes it",
858
+ mountSet: "OpenShell's compute driver, which mounts its own runtime and the supervisor's socket and nothing of the job's",
859
+ egress: "OpenShell's supervisor, which decides every connection by the sandbox's policy",
860
+ jobToJobIsolation: "OpenShell's compute driver, which gives each sandbox's agent no network beside its own supervisor",
861
+ imagePinning: "the OpenShell gateway's image pull policy on its compute driver",
862
+ exitCodes: "OpenShell's exec relay, which returns the command's own code and 128 plus the signal",
863
+ abortable: "the OpenShell gateway, whose sandbox delete ends the running exec",
864
+ readOnlyJobInputs: "the job image's /opt/pi-dispatch/pi-landlock (the sandboxLayout capability), which the worker starts the runner through after the upload",
865
+ nonRoot: "OpenShell, which refuses a root workload, with the image's uid as the policy's run_as_user",
866
+ secretsCustody: "the OpenShell gateway and supervisor, which hold the job's environment while it runs",
867
+ },
868
+ observedBy: {
869
+ credentialTransit: OPENSHELL_GATEWAY_LOCAL,
870
+ },
871
+ },
872
+ "openshell-bind": {
873
+ /**
874
+ * The OpenShell venue in BIND MODE (issue #686 part 3, `DES-OPENSHELL-VENUE`'s "Bind mode"): the same gateway, the
875
+ * same driver, the same per-job network policy and credential provider as `openshell`, but the job's host
876
+ * directories are BIND-MOUNTED into the sandbox, as `local` mounts them, through the compute driver's
877
+ * `--driver-config-json` mounts. Its own venue rather than a setting of `openshell`, so a floor, a trigger's
878
+ * `run.backend` and the run record each name which of the two a job ran in: the words below differ where it
879
+ * matters. Opt-in per host: the gateway's gateway.toml must allow driver config and bind mounts and turn resource
880
+ * admission off, and upstream warns that a bind "can bypass workspace isolation and filesystem policy".
881
+ * Measured on OpenShell 0.1.4, Docker driver, 2026-10-19. Every word not commented is `openshell`'s, for its reason.
882
+ */
883
+ describe: "an NVIDIA OpenShell sandbox on this host's gateway with the job's directories bind-mounted, through the openshell CLI",
884
+ remote: false,
885
+ // `CONST-VENUE-EGRESS-LAYER`, UNCHANGED: the provider, the policy and the placeholders are copy mode's exactly.
886
+ credentialCustody: "layer",
887
+ // Issue #712: the binds make it `local`'s for the transcript (a host file), `/inbox` steering and recovery (the host
888
+ // workspace is salvaged and snapshotted at exit); no sandbox launcher, as in copy mode.
889
+ serves: { liveTranscript: "yes", steering: "yes", recovery: "yes", sandboxShell: "no" },
890
+ declares: {
891
+ isolation: ASSERTED,
892
+ ephemeral: ASSERTED,
893
+ // The job's mounts ARE the worker's now (the spec's mounts, handed to the driver), but the driver applies them,
894
+ // beside its own runtime and the supervisor's socket: no argv of the worker's is read back from a daemon.
895
+ mountSet: ASSERTED,
896
+ egress: ASSERTED,
897
+ jobToJobIsolation: ASSERTED,
898
+ imagePinning: ASSERTED,
899
+ exitCodes: ASSERTED,
900
+ abortable: ASSERTED,
901
+ // A read-only bind (measured: EROFS on a write) and the policy listing `/job` read_only (measured: a write
902
+ // denied), both applied by the vendor. No pi-landlock here: the bind is the control `local`'s `:ro` is, and the
903
+ // policy a second one.
904
+ readOnlyJobInputs: ASSERTED,
905
+ nonRoot: ASSERTED,
906
+ secretsCustody: ASSERTED,
907
+ credentialTransit: ENFORCED,
908
+ // The operator's folder, bind-mounted read-write as /workspace and edited in place (measured: rw binds write
909
+ // through, host writes are visible inside live), so a local or cron folder job is admitted. Asserted, not
910
+ // `local`'s enforced: the driver applies the bind.
911
+ localFolders: ASSERTED,
912
+ // Still no pause verb: live checkpoints stay off (`DES-RUN-CHECKPOINT-PAIRED`); an exit snapshot and a salvage
913
+ // of the host workspace need none.
914
+ pausable: ABSENT,
915
+ },
916
+ asserts: {
917
+ isolation: "OpenShell's compute driver (the agent container it creates with every capability dropped, no new privileges, networking off and a pids limit, under Landlock and seccomp)",
918
+ ephemeral: "the OpenShell gateway, which removes a sandbox's containers when the worker deletes it",
919
+ mountSet: "OpenShell's compute driver, which applies the binds the worker passes in --driver-config-json and mounts its own runtime and the supervisor's socket beside them",
920
+ egress: "OpenShell's supervisor, which decides every connection by the sandbox's policy",
921
+ jobToJobIsolation: "OpenShell's compute driver, which gives each sandbox's agent no network beside its own supervisor",
922
+ imagePinning: "the OpenShell gateway's image pull policy on its compute driver",
923
+ exitCodes: "OpenShell's exec relay, which returns the command's own code and 128 plus the signal",
924
+ abortable: "the OpenShell gateway, whose sandbox delete ends the running exec",
925
+ readOnlyJobInputs: "OpenShell's compute driver, which binds /job read-only, and its supervisor, which applies the policy that lists it read_only",
926
+ nonRoot: "OpenShell, which refuses a root workload, with the job user's uid as the policy's run_as_user",
927
+ secretsCustody: "the OpenShell gateway and supervisor, which hold the job's environment while it runs",
928
+ localFolders: "OpenShell's compute driver, which binds the operator's folder read-write as /workspace",
929
+ },
930
+ observedBy: {
931
+ credentialTransit: OPENSHELL_GATEWAY_LOCAL,
932
+ },
933
+ },
742
934
  };
743
935
 
744
936
  /**
@@ -747,6 +939,67 @@ const BACKENDS_TABLE = {
747
939
  */
748
940
  export const PODMAN_BACKEND = "podman";
749
941
 
942
+ /** The OpenShell venue's name (issue #683), for `PODMAN_BACKEND`'s reason. */
943
+ export const OPENSHELL_BACKEND = "openshell";
944
+ /** Its bind mode's (issue #686 part 3): a venue of its own, on the same gateway, settings and CLI. */
945
+ export const OPENSHELL_BIND_BACKEND = "openshell-bind";
946
+ /**
947
+ * Review round 2 of #714 (CONST-RETRY-INFRA-ONLY): the named result of an `openshell-bind` run whose sandbox could not
948
+ * be confirmed gone after its exec, where a retry would be wrong: a run that COMPLETED (its work is done and must not be
949
+ * thrown away as a retry) or a FOLDER job (a second agent must not start on a folder the first sandbox may still be
950
+ * writing). Determinate, so a policy outcome, never retried; its spend is recorded. Here, in the leaf, so the processor
951
+ * and the adapter's `OPENSHELL_REFUSALS` share one sentence.
952
+ */
953
+ export const OPENSHELL_BIND_LINGERING = Object.freeze({
954
+ reason: "openshell-bind-sandbox-lingering",
955
+ comment: "Stopped: this job ran in an OpenShell sandbox with its files bound from this machine, and the sandbox could not be confirmed gone after the run, so something in it may still be changing those files. The run's result is not used and it is not run again; what it already changed stays as it is. The operator must check the sandbox on the gateway.",
956
+ });
957
+
958
+ /** Both OpenShell venues, the copy mode first: what "is any OpenShell venue blessed" asks of a list. */
959
+ export const OPENSHELL_BACKENDS = Object.freeze([OPENSHELL_BACKEND, OPENSHELL_BIND_BACKEND]);
960
+
961
+ /**
962
+ * Who holds a venue's credentials (issue #683, `DES-CONTAINER-BACKEND-REGISTRY`): `container` (the job's own container,
963
+ * where the agent can read them) or `layer` (the venue's own egress layer, placeholders in the agent,
964
+ * `CONST-VENUE-EGRESS-LAYER`). A closed choice, not a word on the three-word scale, so `PI_BACKEND_FLOOR` never compares
965
+ * it; an entry that omits it is read as `container`.
966
+ */
967
+ export const CREDENTIAL_CUSTODY = Object.freeze(["container", "layer"]);
968
+
969
+ /**
970
+ * The run-time features a venue serves (issue #712, `DES-CONTAINER-BACKEND-REGISTRY`), each entry's `serves` in words:
971
+ * `liveTranscript` (the live transcript and the progress it drives), `steering` (`run.steer` messages), `recovery`
972
+ * (`run.recover`) and `sandboxShell` (`pi-dispatch sandbox <id>`). NOT properties on the three-word scale: they say what a
973
+ * venue CAN DO for an operator, not what it guarantees, so `PI_BACKEND_FLOOR` never compares them. Derived facts held to
974
+ * their sources by `worker/test/backends-serves.test.mjs` (the bundle's `binds` and `transcript`, `SANDBOX_LAUNCHERS`, the
975
+ * copy-mode recovery refusal), so a surface that words them (the console, the panel) reads this table instead of
976
+ * restating which venue does what.
977
+ */
978
+ export const SERVES = Object.freeze(["liveTranscript", "steering", "recovery", "sandboxShell"]);
979
+
980
+ /**
981
+ * The words a `serves` entry takes. `yes`: the venue carries the feature itself. `no`: it never does (and `run.recover`
982
+ * naming such a venue is refused). `image`: the venue SKIPS the feature by name (a `liveSkipped` or `steerSkipped`
983
+ * reason on the record) unless the job image declares the capability that carries it, and an exit key: a copying
984
+ * venue's stderr transcript (`transcriptStderr`) and its stdin steering (`steerStdin`, which also needs
985
+ * `transcriptStderr` for the acks). `yes` is about the VENUE only: a feature it serves can still be refused before
986
+ * spend by what the image lacks on any venue (`job-image-steer-unsupported`, `job-image-recover-unsupported`, a
987
+ * missing `exitAuth`), as it always was.
988
+ */
989
+ export const SERVES_WORDS = Object.freeze(["yes", "no", "image"]);
990
+
991
+ /** A venue's `serves` block, or null for a name this table does not hold. `Object.hasOwn`, so `"toString"` is not a venue. */
992
+ export function servesOf(name) {
993
+ return typeof name === "string" && Object.hasOwn(BACKENDS, name) ? BACKENDS[name].serves : null;
994
+ }
995
+
996
+ /**
997
+ * Venues in the table that `PI_BACKENDS` refuses by name (issue #683): built and tested, and not yet admitted, each with
998
+ * the sentence that says why. Removing a name here is the whole of blessing it. EMPTY since issue #684 blessed
999
+ * `openshell`; kept, so the next venue that lands before its gate has the same one-line switch.
1000
+ */
1001
+ export const NOT_YET_BLESSABLE = Object.freeze({});
1002
+
750
1003
  /**
751
1004
  * FROZEN, deeply. `makeLocalBackend` hands `BACKENDS[name].declares` out on the bundle it returns, so
752
1005
  * without this a consumer holding a backend could assign one word and silently rewrite what every later
@@ -760,7 +1013,7 @@ export const BACKENDS = Object.freeze(
760
1013
  // exists to prevent (#278 found `asserts` had been left mutable).
761
1014
  Object.entries(BACKENDS_TABLE).map(([name, entry]) => [
762
1015
  name,
763
- Object.freeze({ ...entry, declares: Object.freeze({ ...entry.declares }), asserts: Object.freeze({ ...(entry.asserts ?? {}) }), observedBy: Object.freeze({ ...(entry.observedBy ?? {}) }) }),
1016
+ Object.freeze({ ...entry, declares: Object.freeze({ ...entry.declares }), serves: Object.freeze({ ...entry.serves }), asserts: Object.freeze({ ...(entry.asserts ?? {}) }), observedBy: Object.freeze({ ...(entry.observedBy ?? {}) }) }),
764
1017
  ]),
765
1018
  ),
766
1019
  );
@@ -871,6 +1124,9 @@ export function parseBackendList(raw, { known = BACKEND_NAMES } = {}) {
871
1124
  if (!known.includes(name)) {
872
1125
  throw new Error(`PI_BACKENDS names an unknown backend ${JSON.stringify(name)} (known: ${known.join(", ")})`);
873
1126
  }
1127
+ // Issue #683: in the table, refused by name. Only the production table: a test passing its own `known` list
1128
+ // reaches an entry the way the conformance script does, without blessing it.
1129
+ if (known === BACKEND_NAMES && Object.hasOwn(NOT_YET_BLESSABLE, name)) throw new Error(`PI_BACKENDS names ${JSON.stringify(name)}: ${NOT_YET_BLESSABLE[name]}`);
874
1130
  }
875
1131
  // Deduplicated, order preserved: the first entry is what a deployment means by "the default one", and it is
876
1132
  // where a trigger that names no venue is dispatched.
@@ -902,7 +1158,44 @@ export function venuesOf(env) {
902
1158
  } catch {
903
1159
  blessed = [DEFAULT_BACKEND];
904
1160
  }
905
- return { localUsed: blessed.includes(DEFAULT_BACKEND), podmanUsed: blessed.includes(PODMAN_BACKEND), podmanDefault: blessed[0] === PODMAN_BACKEND };
1161
+ // `openshellUsed` (issue #683, blessable since #684): the doctor's openshell section runs on it, for either mode (issue
1162
+ // #686 part 3), whose gateway, CLI and settings are one; the two mode flags say which mode's own lines to add.
1163
+ const openshellCopyUsed = blessed.includes(OPENSHELL_BACKEND);
1164
+ const openshellBindUsed = blessed.includes(OPENSHELL_BIND_BACKEND);
1165
+ return { localUsed: blessed.includes(DEFAULT_BACKEND), podmanUsed: blessed.includes(PODMAN_BACKEND), podmanDefault: blessed[0] === PODMAN_BACKEND, openshellUsed: openshellCopyUsed || openshellBindUsed, openshellCopyUsed, openshellBindUsed };
1166
+ }
1167
+
1168
+ /** The two engines an OpenShell gateway's compute driver may be in v1 (`config.mjs` `openshellFrom` refuses any other). */
1169
+ export const OPENSHELL_ENGINES = Object.freeze(["docker", "podman"]);
1170
+
1171
+ /**
1172
+ * What setting up the openshell venues needs on this host (issue #707), from the venues `venuesOf` read and the two keys
1173
+ * the worker boots with (`PI_OPENSHELL_ENGINE`, `PI_OPENSHELL_GATEWAY`, read as `openshellFrom` reads them). ONE rule, so
1174
+ * `up`, `service install`, `init`'s next steps and doctor cannot disagree about it:
1175
+ *
1176
+ * - `engine`: the gateway's compute driver, docker unless the key says podman. It is the image store the gateway runs
1177
+ * sandboxes from, so the job image must be in it;
1178
+ * - `imageEngine`: that engine, or null where another blessed venue's own step already puts the image in the SAME
1179
+ * store (`local` in docker's, `podman` in this account's rootless Podman store), so `up` pulls it once;
1180
+ * - `valkeyEngine`: the engine Valkey runs on for this deployment, only where neither `local` nor `podman` is blessed
1181
+ * (each of those already decides where the queue runs, and a second Valkey on the same port would only fail to bind);
1182
+ * - `gateway`: the registered gateway's name the worker addresses, or null for the CLI's active one;
1183
+ * - `engineError`: a sentence when the key names an engine v1 does not admit (then both engines above are null).
1184
+ *
1185
+ * The squid egress proxy is NOT part of it, on purpose: the gateway's per-sandbox network policy is the openshell venue's
1186
+ * egress layer (`CONST-VENUE-EGRESS-LAYER`), and a proxy started for it would be one no job of this venue attaches to.
1187
+ * Not blessed: `{ used: false }` with every other field null, so a value left in `.env` changes nothing.
1188
+ */
1189
+ export function openshellSetupOf(venues, env = {}) {
1190
+ if (!venues?.openshellUsed) return { used: false, engine: null, gateway: null, imageEngine: null, valkeyEngine: null, engineError: null };
1191
+ const raw = env?.PI_OPENSHELL_ENGINE;
1192
+ const engine = raw === undefined || raw === null || raw === "" ? "docker" : String(raw);
1193
+ const gateway = typeof env?.PI_OPENSHELL_GATEWAY === "string" && env.PI_OPENSHELL_GATEWAY !== "" ? env.PI_OPENSHELL_GATEWAY : null;
1194
+ if (!OPENSHELL_ENGINES.includes(engine)) {
1195
+ return { used: true, engine, gateway, imageEngine: null, valkeyEngine: null, engineError: `PI_OPENSHELL_ENGINE must be docker or podman, the compute drivers the openshell venue admits (got ${JSON.stringify(engine)})` };
1196
+ }
1197
+ const covered = (engine === "docker" && venues.localUsed) || (engine === "podman" && venues.podmanUsed);
1198
+ return { used: true, engine, gateway, imageEngine: covered ? null : engine, valkeyEngine: venues.localUsed || venues.podmanUsed ? null : engine, engineError: null };
906
1199
  }
907
1200
 
908
1201
  /**
@@ -942,6 +1235,87 @@ export function parseBackendFloor(raw) {
942
1235
  return floor;
943
1236
  }
944
1237
 
1238
+ /**
1239
+ * Whether this host's OpenShell gateway is on this host, as a word for the host registry (issue #712): `local`, `remote`,
1240
+ * or `unknown` (not read: the gateway did not answer at boot). A WORD and never the gateway's address, which is operator
1241
+ * topology the registry's content rule keeps out. Closed, so a reader can refuse anything else.
1242
+ */
1243
+ export const HOST_GATEWAY_WORDS = Object.freeze(["local", "remote", "unknown"]);
1244
+
1245
+ /**
1246
+ * A venue name as a host row may carry one: lowercase-led, lowercase letters, digits and `-`, at most 32 characters.
1247
+ * Exported for the runs filter (issue #712), so a venue a newer worker blesses, which the Venues view lists, is also one
1248
+ * the filter takes. Every name in BACKENDS has this shape (a test holds it).
1249
+ */
1250
+ export const HOST_VENUE_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
1251
+ /** A property name as a host row's floor may carry one: lowercase-led letters and digits, bounded. */
1252
+ const HOST_PROPERTY_NAME_RE = /^[a-z][A-Za-z0-9]{0,47}$/;
1253
+
1254
+ /**
1255
+ * The registry row's `backends` field (issue #712, `INT-HOST-REGISTRY-CONTRACT`): what venues this host blesses, its
1256
+ * floor, and its OpenShell setup, for the console's and the panel's Venues view. One compact string of names and fixed
1257
+ * words only, so the row keeps its content rule (names, integers, digests):
1258
+ *
1259
+ * blessed=<venue>[,<venue>...];floor=<property>:<word>[,...];os=<engine>:<gateway word>
1260
+ *
1261
+ * `blessed` is `PI_BACKENDS` as the worker parsed it, in its order, so the FIRST is the default (the parse's own rule,
1262
+ * not a second field that could disagree with it). `floor` is `PI_BACKEND_FLOOR` as `parseBackendFloor` read it, its
1263
+ * property names and words, and is left out when there is none. `os` only while an OpenShell venue is blessed: the
1264
+ * engine (`OPENSHELL_ENGINES`) and whether the gateway is on this host (`HOST_GATEWAY_WORDS`); the modes are the blessed
1265
+ * OpenShell venues themselves (`openshell` copies, `openshell-bind` binds), so they are not written twice.
1266
+ *
1267
+ * NOT JSON, which `jobs` uses: `jobs` is a list of records, and this is three flat lists a reader splits on two
1268
+ * characters, none of which a venue name, a property name or a word can hold. Nothing reads it to decide anything, so
1269
+ * deleting `host:*` still changes nothing: it is what the host says about itself, displayed.
1270
+ */
1271
+ export function hostBackendsField({ backends = [], backendFloor = {}, openshellEngine = null, gatewayWord = "unknown" } = {}) {
1272
+ const blessed = (backends ?? []).filter((n) => typeof n === "string" && HOST_VENUE_NAME_RE.test(n));
1273
+ const parts = [`blessed=${blessed.join(",")}`];
1274
+ const floor = Object.entries(backendFloor ?? {}).filter(([p, w]) => HOST_PROPERTY_NAME_RE.test(p) && isDeclaration(w));
1275
+ if (floor.length > 0) parts.push(`floor=${floor.map(([p, w]) => `${p}:${w}`).join(",")}`);
1276
+ if (OPENSHELL_BACKENDS.some((n) => blessed.includes(n))) {
1277
+ const engine = OPENSHELL_ENGINES.includes(openshellEngine) ? openshellEngine : "docker";
1278
+ parts.push(`os=${engine}:${HOST_GATEWAY_WORDS.includes(gatewayWord) ? gatewayWord : "unknown"}`);
1279
+ }
1280
+ return parts.join(";");
1281
+ }
1282
+
1283
+ /**
1284
+ * A peer's `backends` field read back: `{ blessed, default, floor, openshell }`, or null for a row without the field (a
1285
+ * worker from before it) or one that does not read. PEER-WRITTEN, so every element is re-checked as `parseCaps` does:
1286
+ * a venue name of the right shape is kept even when this build's table does not hold it (a newer worker's venue, which
1287
+ * a reader names rather than hides), a floor entry needs a property-shaped name and one of the three words, and
1288
+ * `openshell` is `{ engine, gateway, modes }` with each value from its closed list (`unknown` otherwise). Unknown keys
1289
+ * are skipped, so a later field does not make an older reader drop the row.
1290
+ */
1291
+ export function parseHostBackends(raw) {
1292
+ if (typeof raw !== "string" || raw === "" || raw.length > 1024) return null;
1293
+ const out = { blessed: [], default: null, floor: {}, openshell: null };
1294
+ let sawBlessed = false;
1295
+ for (const part of raw.split(";")) {
1296
+ const at = part.indexOf("=");
1297
+ if (at <= 0) continue;
1298
+ const key = part.slice(0, at);
1299
+ const items = part.slice(at + 1).split(",").filter((s) => s !== "");
1300
+ if (key === "blessed") {
1301
+ sawBlessed = true;
1302
+ out.blessed = [...new Set(items.filter((n) => HOST_VENUE_NAME_RE.test(n)))];
1303
+ } else if (key === "floor") {
1304
+ for (const item of items) {
1305
+ const [property, word, extra] = item.split(":");
1306
+ if (extra === undefined && HOST_PROPERTY_NAME_RE.test(property ?? "") && isDeclaration(word) && !Object.hasOwn(out.floor, property)) out.floor[property] = word;
1307
+ }
1308
+ } else if (key === "os") {
1309
+ const [engine, gateway] = (items[0] ?? "").split(":");
1310
+ out.openshell = { engine: OPENSHELL_ENGINES.includes(engine) ? engine : "unknown", gateway: HOST_GATEWAY_WORDS.includes(gateway) ? gateway : "unknown", modes: [] };
1311
+ }
1312
+ }
1313
+ if (!sawBlessed) return null;
1314
+ out.default = out.blessed[0] ?? null;
1315
+ if (out.openshell) out.openshell.modes = [...(out.blessed.includes(OPENSHELL_BACKEND) ? ["copy"] : []), ...(out.blessed.includes(OPENSHELL_BIND_BACKEND) ? ["bind"] : [])];
1316
+ return out;
1317
+ }
1318
+
945
1319
  /**
946
1320
  * Which floored properties this deployment is NOT getting because their switch is off, as
947
1321
  * `[{ property, want, armedBy }]`.
@@ -1044,6 +1418,21 @@ export function backendRefusals({ backends = [], backendFloor = {}, egress = fal
1044
1418
  );
1045
1419
  }
1046
1420
 
1421
+ // Issue #684 (the operator's decision): a venue whose own layer holds the credentials (`credentialCustody: layer`)
1422
+ // cannot run with PI_EGRESS=0. Its policy IS the egress control and the provider's placeholders resolve only through
1423
+ // it, so "egress off" has no meaning there: honouring it would mean a policy admitting everything, which hands the
1424
+ // real credential to any host the agent names, and ignoring it would run the job under a control the operator
1425
+ // switched off. Refused at boot, by name, rather than either guess. Every layer venue, not `openshell` by name.
1426
+ if (egress !== true) {
1427
+ const layered = backends.filter((name) => (BACKENDS[name]?.credentialCustody ?? "container") === "layer");
1428
+ if (layered.length > 0) {
1429
+ out.push(
1430
+ `PI_EGRESS=0 cannot run ${layered.join(", ")}: ${layered.length === 1 ? "that venue's" : "those venues'"} own policy layer is the egress control and holds the job's credentials (CONST-VENUE-EGRESS-LAYER), so it has no "egress off". ` +
1431
+ "Arm PI_EGRESS, or remove that backend from PI_BACKENDS.",
1432
+ );
1433
+ }
1434
+ }
1435
+
1047
1436
  return out;
1048
1437
  }
1049
1438