@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
@@ -0,0 +1,1540 @@
1
+ import { SANDBOX_NAME, sandboxNameOf } from "./container-names.mjs";
2
+ import { spawn } from "node:child_process";
3
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { isJobNamespace, jobContainerName, JOB_NAME_PREFIX, reaperExec } from "./backend-local.mjs";
7
+ import { FORGES } from "./forges.mjs";
8
+ import { NON_SECRET_ENV_KEYS } from "./redact-secrets.mjs";
9
+ import { BACKENDS, OPENSHELL_BACKEND, OPENSHELL_BIND_BACKEND } from "./backends.mjs";
10
+ import { containerSpec, SHIPPED_IMAGE_UID, SIZE_LABEL_CPU, SIZE_LABEL_MEM, transfersFromSpec } from "./container-spec.mjs";
11
+ import { BUNDLE_ENV_FILE_CAPABILITY, makeImagePreflight, normalizeImageId, resolveJobImage } from "./image-preflight.mjs";
12
+ import { LAYOUTS } from "./job-layout.mjs";
13
+ import { DAEMON_FACTS_MAX_BUFFER, DAEMON_FACTS_TIMEOUT_MS } from "./daemon-facts.mjs";
14
+ import { JOB_USER_FACTS_MAX_AGE_MS, decideJobUser, makeDaemonFactsReader, relabelsPrivateMounts, resolveImageUser } from "./job-user.mjs";
15
+ import { networkPolicyFor } from "./openshell-policy.mjs";
16
+ import { bindProbeArgs, driverConfigFor, judgeBindAnswer, judgeGatewayStatus, judgePtraceScope, OPENSHELL_TESTED_VERSION, PTRACE_SCOPE_PATH } from "./openshell-cli.mjs";
17
+ // Re-exported for the venue's tests and readers, which know them as this module's (issue #730).
18
+ export { judgePtraceScope, PTRACE_SCOPE_PATH };
19
+ import { DEFAULT_JOB_SIZE } from "./job-size.mjs";
20
+ import { makeRunContainer } from "./run-container.mjs";
21
+ import { OPENSHELL_REFUSALS } from "./comment-tables.mjs";
22
+
23
+ /**
24
+ * The `openshell` venue (issue #683, `DES-OPENSHELL-VENUE`): one NVIDIA OpenShell sandbox per job, on a gateway this
25
+ * deployment runs on its own host, driven through the real `openshell` CLI with `-o json` (`DES-WORKER-ON-HOST`'s
26
+ * reasoning: the CLI owns its config and its endpoint, so the worker shells out rather than reimplementing either).
27
+ *
28
+ * THE SHAPE, and why it reuses `makeRunContainer` rather than copying it. Everything that makes a run trustworthy is
29
+ * already there and venue-blind: the per-attempt exit key on stdin, the signed exit line read off the child's stdout,
30
+ * the abort flag, the result's shape. What differs is what surrounds the child, so the venue hands three steps to it
31
+ * (`stageRun`, `collectRun`, `teardownRun`) and an argv builder whose child is `openshell sandbox exec`:
32
+ * 1. create the sandbox, sized and labelled, under a filesystem policy this worker generates, and READ IT BACK;
33
+ * 2. upload the job's inputs under the image's WORKDIR (`/sandbox`, the `sandbox` layout, job-layout.mjs);
34
+ * 3. exec the image's entrypoint through `/opt/pi-dispatch/pi-landlock`, which confines the runner's writes to the
35
+ * paths named here, AFTER the upload: the only way an uploaded `/job` is read-only on this venue (measured, see
36
+ * `DES-OPENSHELL-VENUE`'s #683 correction);
37
+ * 4. download the writable outputs (workspace, session) back into the host paths the processor reads;
38
+ * 5. delete the sandbox, on every path.
39
+ *
40
+ * Measured on OpenShell 0.1.4, Docker driver, ubuntu-24.04 (Linux 6.17), 2026-10-19 (the issue #683 lab).
41
+ *
42
+ * BLESSABLE SINCE ISSUE #684: the sandbox's network policy is generated per job (openshell-policy.mjs) and the job's
43
+ * credentials live in a per-job provider, placeholders in the agent (`CONST-VENUE-EGRESS-LAYER`); only names the
44
+ * redactor treats as not secret reach the exec's `--env`. A project secret bundle's PLAIN variables (issue #704 part 3),
45
+ * which the operator hands the agent as values, reach it in a file in the job directory (`BUNDLE_ENV_FILE`), never the
46
+ * exec, whose env the gateway logs (`bundleProviderPlan` below says which variable goes where).
47
+ *
48
+ * THE LIVE TRANSCRIPT (issue #686): the session file is in the sandbox until the download, so no host tail can read it.
49
+ * The runner mirrors its lines over the exec's STDERR as records signed with the exit key (transcript-mirror.mjs), and
50
+ * makeRunContainer, told `transcript: "stdio"`, verifies them and feeds the same publisher (stderr-transcript.mjs), so
51
+ * the live transcript, run progress and the status comment are served here.
52
+ *
53
+ * STEERING (issue #686, part 2): there is no `/inbox` mount a later file could reach, so a steer-armed job on an image
54
+ * declaring `steerStdin` takes its messages as MAC'd frames on the exec's stdin, the key frame first
55
+ * (INT-STEER-INBOX-CONTRACT's second transport): makeRunContainer, handed the relay opener and told this venue copies
56
+ * (`binds: false`), writes them, and the acks come back over the stderr transcript above. An image without `steerStdin`
57
+ * runs unsteered, `steerSkipped: "venue-copies"`. Recovery is not served here (`serves.recovery: "no"`): `run.recover` is
58
+ * refused in the triggers file when the trigger names this venue (issue #712), and before spend when it lands here as
59
+ * the deployment default.
60
+ *
61
+ * BIND MODE (issue #686 part 3, the `openshell-bind` venue): the same factory with `mode: "bind"`, rather than a second
62
+ * adapter, because everything that makes the venue trustworthy is shared and must stay one copy: the image read by ID,
63
+ * the per-job network policy and its read-back, the credential provider and its placeholders, the gateway checks, the
64
+ * stop, the teardown, the reaper. What differs is exactly the transport of the job's files, and with it four answers:
65
+ * - no upload and no download: the container spec's mounts are handed to the compute driver as `--driver-config-json`
66
+ * binds (read-only unless the spec mounts them writable), at the `mounts` layout's own paths, which OpenShell admits
67
+ * (none is `/sandbox` or under its own trees);
68
+ * - the policy lists every bind target, read-only ones under `read_only` and writable ones under `read_write`
69
+ * (measured: the read-back is unchanged), and runs as the job user (`decideOpenshellBindUser`), since a bind is
70
+ * written as the sandbox's uid;
71
+ * - the runner reads the `mounts` layout, so `/inbox` file steering, the host tail of the session file and folder jobs
72
+ * work as on `local` (`binds: true`, `transcript: "file"`), and `run.recover` is served by the host workspace's
73
+ * salvage and exit snapshots (no pause verb: live checkpoints stay off);
74
+ * - no pi-landlock: `/job` is a read-only bind (measured: EROFS) and a `read_only` policy path (measured: denied), the
75
+ * kernel control `local`'s `:ro` is, so the helper would add nothing for it and would tie the venue to the
76
+ * `sandboxLayout` capability for no guarantee.
77
+ * Bind mounts need the gateway's own settings (`OPENSHELL_BIND_SETTINGS`), which no read reveals, so the preflight asks a
78
+ * create that can only be refused (`bindProbeArgs`) and caches the answer per gateway (`bindGate` below).
79
+ */
80
+
81
+
82
+ /** The label every sandbox this worker makes carries, which `list` selects on: a name alone is a substring world. */
83
+ export const OPENSHELL_OWNER_LABEL = "pi.dispatch.owner";
84
+ export const OPENSHELL_OWNER = "pi-dispatch";
85
+ /**
86
+ * The label a BIND-mode sandbox carries (issue #686 part 3), value `bind`, so each mode's listing, its gone-check and the
87
+ * host budget's leftovers count only its own sandboxes. A copy-mode sandbox carries none, so its create is unchanged and
88
+ * every sandbox made before bind mode existed reads as copy mode's. The boot sweep reaps both modes' (one owner label):
89
+ * it runs before this process has started any job, and either mode's leftover from a crash is the same orphan.
90
+ */
91
+ export const OPENSHELL_MODE_LABEL = "pi.dispatch.openshell-mode";
92
+ /** The two modes, `copy` the default (`DES-OPENSHELL-VENUE`). */
93
+ export const OPENSHELL_MODES = Object.freeze(["copy", "bind"]);
94
+ /** The marker beside a bind-mode job dir (`<jobDir>.openshell-bind`): a sandbox may write it (review round 2 of #714). */
95
+ export const OPENSHELL_BIND_MARKER = ".openshell-bind";
96
+ /** A bind-mode sandbox's job directory, by its name (`job-...`), so a salvage can end that sandbox first (#714). */
97
+ export const OPENSHELL_JOB_DIR_LABEL = "pi.dispatch.job-dir";
98
+
99
+ /** The entrypoint and the confinement it is started through, both baked into the job image (image/Dockerfile). */
100
+ export const OPENSHELL_ENTRYPOINT = "/entrypoint.sh";
101
+ export const PI_LANDLOCK = "/opt/pi-dispatch/pi-landlock";
102
+
103
+ // Re-exported: the adapter is where a reader looks for them, and doctor imports the leaf alone.
104
+ export { judgeGatewayStatus, OPENSHELL_TESTED_VERSION };
105
+
106
+ const SANDBOX = LAYOUTS.sandbox;
107
+
108
+ export { SANDBOX_NAME, sandboxNameOf } from "./container-names.mjs";
109
+
110
+ /**
111
+ * The paths the runner's process tree may WRITE (pi-landlock's `--rw`): the job's workspace and session, the scratch
112
+ * and home every tool writes, `/dev/null`, and `/proc`, where the in-image supervisor raises the runner tree's
113
+ * `oom_score_adj` (measured: refused without it, so the supervisor would lose the OOM race it exists to win). Never
114
+ * `/sandbox/job` or the overlay: that is the point.
115
+ */
116
+ export const OPENSHELL_WRITABLE = Object.freeze([SANDBOX.workspace, SANDBOX.session, "/tmp", "/home/pi", "/dev/null", "/proc"]);
117
+
118
+ /**
119
+ * The sandbox's filesystem policy, generated per job and read back before the exec (`CONST-VENUE-EGRESS-LAYER`'s second
120
+ * condition). A policy with a `filesystem_policy` grants nothing it does not list (measured: `/opt` and `/sys/fs/cgroup`
121
+ * were unreadable under the default one), so the image's own trees are listed read-only, the cgroup files the
122
+ * supervisor reads at a kill among them, and the WORKDIR is writable through `include_workdir`. `hard_requirement`, so a
123
+ * kernel that cannot apply it fails the create instead of running the job without it. Its `network_policies` are the
124
+ * job's own (`networkPolicyFor`, issue #684), spread in beside it by the stage; none is deny-all. `run_as_user` is the image's own uid, so OpenShell's refusal of root and this
125
+ * worker's non-root uid are the same fact.
126
+ */
127
+ export function sandboxPolicy({ uid = SHIPPED_IMAGE_UID, gid = SHIPPED_IMAGE_UID, binds = [] } = {}) {
128
+ // Issue #686 part 3: a bind-mode sandbox lists every bind target, each where its mount puts it (a policy path the
129
+ // filesystem section does not list is unreadable, measured for `/opt`), after the base lists so copy mode's body is
130
+ // byte-identical. `/opt/pi-global` is already beneath `/opt`; listed anyway, so the list says every bind.
131
+ const ro = binds.filter((b) => b.readOnly === true).map((b) => b.target);
132
+ const rw = binds.filter((b) => b.readOnly !== true).map((b) => b.target);
133
+ return {
134
+ version: 1,
135
+ filesystem_policy: {
136
+ include_workdir: true,
137
+ read_only: ["/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", "/opt", "/app", "/ms-playwright", "/sys/fs/cgroup", "/dev/urandom", OPENSHELL_ENTRYPOINT, ...ro],
138
+ read_write: ["/tmp", "/dev/null", "/home/pi", "/proc", ...rw],
139
+ },
140
+ landlock: { compatibility: "hard_requirement" },
141
+ process: { run_as_user: String(uid), run_as_group: String(gid) },
142
+ };
143
+ }
144
+
145
+ /** The image label naming the binaries that may need the network (image/Dockerfile, issue #683). */
146
+ export const NET_BINARIES_LABEL = "dev.pi-dispatch.net-binaries";
147
+
148
+ /**
149
+ * The policy OpenShell reads back for one the worker wrote (issue #684, measured on OpenShell 0.1.4): with at least one
150
+ * network rule it adds its baseline paths that exist and the policy does not list, and of those this policy lists all
151
+ * but `/var/log` (read-only). Nothing else is added to the base. Provider rules arrive as their own `_provider_*` layers,
152
+ * which issue #684 part 2 accounts for.
153
+ */
154
+ export const BASELINE_ADDED_READ_ONLY = Object.freeze(["/var/log"]);
155
+ export function expectedPolicy(policy) {
156
+ const rules = policy?.network_policies;
157
+ if (!rules || Object.keys(rules).length === 0) return policy;
158
+ const fsPolicy = policy.filesystem_policy;
159
+ const listed = new Set([...(fsPolicy.read_only ?? []), ...(fsPolicy.read_write ?? [])]);
160
+ const added = BASELINE_ADDED_READ_ONLY.filter((p) => !listed.has(p));
161
+ return { ...policy, filesystem_policy: { ...fsPolicy, read_only: [...fsPolicy.read_only, ...added] } };
162
+ }
163
+ /**
164
+ * The read-backs that count as the policy written: with the baseline addition, or without it, since OpenShell adds a
165
+ * baseline path only where the image HAS it (an operator-built image without `/var/log` reads back as written).
166
+ * Nothing else: any other difference never starts the job.
167
+ */
168
+ export function expectedPolicies(policy, { providers = [] } = {}) {
169
+ const withBaseline = expectedPolicy(policy);
170
+ const bodies = withBaseline === policy ? [policy] : [withBaseline, policy];
171
+ if (providers.length === 0) return bodies;
172
+ // Issue #684: an attached provider adds one layer of its own to the full read-back (measured: a rule named
173
+ // `_provider_<name, its dashes as underscores>` holding only its name, for an endpointless profile). Issue #704 part
174
+ // 3: one layer per attached provider, EXACTLY the providers this job attached and nothing else. Read at the pin
175
+ // (openshell-policy/src/compose.rs `provider_rule_name`: lowercased, every character but [a-z0-9_] an underscore;
176
+ // `compose_effective_policy` adds each under its own key, a numeric suffix only on a clash, which names of the shape
177
+ // `pi-job-s<11 hex>` and `pi-job-s<11 hex>-b<n>` cannot have; openshell-server/src/grpc/policy.rs
178
+ // `provider_policy_context_from_records` pushes a layer for an endpointless profile with an empty rule, which is the
179
+ // measured `{ name }`). `--full` is kept rather than `--base`: comparing the base alone would no longer see a layer
180
+ // this job did not attach, which is the one difference a read-back exists to catch.
181
+ const layers = Object.fromEntries(providers.map((p) => {
182
+ const key = `_provider_${p.replace(/-/g, "_")}`;
183
+ return [key, { name: key }];
184
+ }));
185
+ return bodies.map((b) => ({ ...b, network_policies: { ...b.network_policies, ...layers } }));
186
+ }
187
+
188
+ /** How often, and how far apart, a provider's delete is asked again while its sandbox's cleanup finishes. */
189
+ const PROVIDER_DELETE_TRIES = 10;
190
+ const PROVIDER_DELETE_WAIT_MS = 2_000;
191
+
192
+ /**
193
+ * Rewrite a staged job's `mcp.json` for the `sandbox` layout (issue #684): every stdio server's `command` and `args`
194
+ * that name the overlay at `/opt/pi-global/` are moved to `/sandbox/pi-global/`, where this venue uploads it. Nothing
195
+ * else changes, and a job without the file is left alone. A document this cannot parse is left as written: the runner
196
+ * refuses it before any spend (`mcp-config-invalid`), which is the honest answer, not a guess here.
197
+ */
198
+ export function rewriteMcpForSandbox(path, fs) {
199
+ let text;
200
+ try {
201
+ text = fs.readFileSync(path, "utf8");
202
+ } catch {
203
+ return false;
204
+ }
205
+ let doc;
206
+ try {
207
+ doc = JSON.parse(text);
208
+ } catch {
209
+ return false;
210
+ }
211
+ const from = `${LAYOUTS.mounts.global}/`;
212
+ const to = `${LAYOUTS.sandbox.global}/`;
213
+ // A path at the start of a value, or after `=` (`--root=/opt/pi-global/x`, `NODE_PATH=/opt/pi-global/lib`).
214
+ const move = (v) => (typeof v === "string" ? v.replace(/(^|=)\/opt\/pi-global\//g, (_m, lead) => `${lead}${to}`) : v);
215
+ if (from !== "/opt/pi-global/") throw new Error("rewriteMcpForSandbox: the mounts overlay moved; update the pattern");
216
+ let changed = false;
217
+ for (const server of Object.values(doc?.servers ?? {})) {
218
+ if (!server || typeof server !== "object") continue;
219
+ const before = JSON.stringify(server);
220
+ server.command = move(server.command);
221
+ if (Array.isArray(server.args)) server.args = server.args.map(move);
222
+ if (server.env && typeof server.env === "object") for (const [k, v] of Object.entries(server.env)) server.env[k] = move(v);
223
+ if (JSON.stringify(server) !== before) changed = true;
224
+ }
225
+ if (changed) {
226
+ // The staged copy is 0444 as the preparer wrote it, so it is replaced rather than opened for writing.
227
+ fs.rmSync(path, { force: true });
228
+ fs.writeFileSync(path, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o444 });
229
+ }
230
+ return changed;
231
+ }
232
+
233
+ /**
234
+ * The names of a job's env that carry a credential (issue #684): every key the redactor treats as secret
235
+ * (`NON_SECRET_ENV_KEYS`, redact-secrets.mjs, is the one list of what is NOT), with a value. On this venue none of them
236
+ * reaches the sandbox as a value: each goes into the job's provider, and the agent sees OpenShell's placeholder.
237
+ */
238
+ export function secretNamesOf(env = {}) {
239
+ return Object.entries(env ?? {})
240
+ .filter(([key, value]) => !NON_SECRET_ENV_KEYS.has(key) && typeof value === "string" && value !== "" && PROVIDER_ENV_NAME.test(key))
241
+ .map(([key]) => key);
242
+ }
243
+
244
+ /** What a provider credential's env var may be called: a shell name, 128 at most (the CLI reads `$NAME`). */
245
+ const PROVIDER_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
246
+
247
+ /**
248
+ * The secret names a provider cannot carry (issue #684, review): a `run.secrets` key or a `PI_FORWARD_ENV` name that is
249
+ * no shell name, or longer than 128. Never a value on the exec's argv instead: such a name reaches the sandbox not at
250
+ * all, and the stage logs it by name (`openshell_secret_unbindable`), so the job runs without it and says which.
251
+ */
252
+ export function unbindableSecretNamesOf(env = {}) {
253
+ return Object.entries(env ?? {})
254
+ .filter(([key, value]) => !NON_SECRET_ENV_KEYS.has(key) && typeof value === "string" && value !== "" && !PROVIDER_ENV_NAME.test(key))
255
+ .map(([key]) => key);
256
+ }
257
+
258
+ /**
259
+ * The per-job endpointless provider profile (issue #684, measured on OpenShell 0.1.4): one credential per secret name,
260
+ * no endpoints and no binaries of its own, so the credentials resolve only where the job's own policy binds them
261
+ * (`credential_binding.provider`). Named by the sandbox, so the reaper finds a crashed job's.
262
+ */
263
+ export function providerProfileFor(name, secretNames, { auth = {} } = {}) {
264
+ return {
265
+ id: name,
266
+ display_name: name,
267
+ description: "pi-dispatch per-job credentials",
268
+ category: "other",
269
+ credentials: secretNames.map((env, i) => ({ name: `c${i}`, env_vars: [env], required: true, ...bundleAuthFields(auth[env]) })),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * A bundle endpoint's `auth` (triggers.mjs `BUNDLE_ENDPOINT_AUTH`) in the profile's own words (issue #704 part 3), read
275
+ * at the pin. On OpenShell 0.1.4 these fields DOCUMENT the scheme and change nothing a static credential does: the
276
+ * supervisor swaps a placeholder wherever a request carries it (a header value whole, after a `Bearer `-style prefix,
277
+ * inside Basic's decoded `user:pass`, in the path or query; openshell-core/src/secrets.rs `rewrite_header_value`), and
278
+ * no supervisor code reads `auth_style` or `header_name` for a static credential (they drive only token grants,
279
+ * openshell-supervisor-network/src/l7/token_grant_injection.rs). The gateway validates them at import
280
+ * (openshell-providers/src/profiles.rs, the `auth_style` match: empty or `basic` free, `bearer` and `header` need a
281
+ * `header_name`) and `provider profile get` prints them ("Authentication: bearer"), so the operator's declaration is
282
+ * visible in the gateway's own view of the credential. Rejected: leaving them out because they do nothing; the profile
283
+ * is the one place an operator reading the gateway sees what the reviewed file meant. The binding, which DOES narrow,
284
+ * is per endpoint (`credential_binding`), below.
285
+ */
286
+ export function bundleAuthFields(auth) {
287
+ if (auth === "bearer") return { auth_style: "bearer", header_name: "authorization" };
288
+ if (auth === "basic") return { auth_style: "basic" };
289
+ const header = /^header:([A-Za-z0-9-]{1,64})$/.exec(String(auth ?? ""));
290
+ return header ? { auth_style: "header", header_name: header[1].toLowerCase() } : {};
291
+ }
292
+
293
+ /**
294
+ * A bundle provider's name (issue #704 part 3): the sandbox's name and `-b<n>`, one per bundle host, so the reaper's
295
+ * namespace match (`isJobProviderName`) finds a crashed job's, and every attempt of one job uses the same names (an
296
+ * earlier attempt's leftover is removed first, as the job provider's is). A valid profile id at the pin (lowercase
297
+ * kebab-case, openshell-providers/src/profiles.rs `is_valid_profile_id`), 22 to 23 characters, inside the 64 a provider
298
+ * type may have (openshell-server/src/grpc/mod.rs `MAX_PROVIDER_TYPE_LEN`).
299
+ */
300
+ export const BUNDLE_PROVIDER_NAME = /^pi-job-s[0-9a-f]{11}-b[0-9]{1,2}$/;
301
+ export const bundleProviderNameOf = (sandbox, index) => `${sandbox}-b${index}`;
302
+ /** Whether a provider is one this worker makes per job: the job's own (named as its sandbox) or a bundle host's. */
303
+ export const isJobProviderName = (name) => typeof name === "string" && (SANDBOX_NAME.test(name) || BUNDLE_PROVIDER_NAME.test(name));
304
+
305
+ /**
306
+ * What OpenShell 0.1.4 can carry, read at the pin, and what a bundle is judged against before the sandbox exists
307
+ * (issue #704 part 3), so a value the gateway would refuse is a determinate refusal before spend rather than a create
308
+ * that fails as infrastructure and is retried into the same answer: a provider credential value is at most 8 KiB, a
309
+ * provider holds at most 32, a sandbox at most 32 providers (openshell-server/src/grpc/mod.rs `MAX_MAP_VALUE_LEN`,
310
+ * `MAX_PROVIDER_CREDENTIALS_ENTRIES`, `MAX_PROVIDERS`; validation.rs `validate_provider_mutable_fields`, sandbox.rs's
311
+ * attach check). A PLAIN variable meets none of these (review round 1, M1): it travels in `BUNDLE_ENV_FILE`, which
312
+ * copy mode uploads with the job directory as tar over the sandbox's SSH, extracted by the sandbox's own user
313
+ * (openshell-cli/src/ssh.rs `sandbox_sync_up_files`, no size cap at the pin) and bind mode binds, so its only bounds
314
+ * are the bundle's own (64 variables, 256 KiB, secret-bundles.mjs), judged before the read is accepted. What stays on
315
+ * the exec is the closed set of non-secret names (`NON_SECRET_ENV_KEYS`), which no bundle can grow.
316
+ */
317
+ export const OPENSHELL_LIMITS = Object.freeze({ credentialBytes: 8192, credentialsPerProvider: 32, providersPerSandbox: 32 });
318
+
319
+ /**
320
+ * The job-directory file a bundle's plain variables reach the runner by on this venue (issue #704 part 3, review round
321
+ * 1, H1): one JSON object, written 0600 by the stage beside the job's other inputs (so `/sandbox/job/bundle-env.json` in
322
+ * copy mode, the read-only `/job/bundle-env.json` bind in bind mode), read by an image declaring `bundleEnvFile` on
323
+ * PI_BUNDLE_ENV=1 (image/runner/src/bundle-env.mjs), and removed at teardown. JSON carries a newline as `\n`, so a PEM
324
+ * arrives intact. The agent can read it: these are values the operator hands the agent on every venue.
325
+ */
326
+ export const BUNDLE_ENV_FILE = "bundle-env.json";
327
+
328
+ /**
329
+ * The variable names a bundle variable may not take on this venue (issue #704 part 3), because the sandbox already
330
+ * sets them and two writers of one name means one is silently lost: the `OPENSHELL_` prefix, which the CLI and the
331
+ * gateway refuse on an exec (openshell-cli/src/commands/common.rs `parse_env_pairs`; openshell-server
332
+ * validation.rs `validate_env_entries_inner`) and the sandbox reserves (openshell-sandbox/src/process.rs
333
+ * `RESERVED_ENV_PREFIX`); the CA variables the sandbox writes for its TLS-terminating proxy
334
+ * (openshell-sandbox/src/child_env.rs `tls_env_vars`) and the proxy variables it strips (process.rs `PROXY_ENV_VARS`);
335
+ * the session names the sandbox sets for every exec, `USER`, `SHELL` and `TERM` (openshell-sandbox/src/process.rs, the
336
+ * `HOME`/`USER`/`SHELL`/`TERM` loop; boundary_exec.rs, the `env_clear` builder), with `LOGNAME`, which 0.1.4 does not
337
+ * set but a login shell does, beside them; and the names this adapter's own exec sets (`HOME`, the git helper's
338
+ * `GIT_CONFIG_*`, `gitCredentialEnv`). The `PI_` prefix is refused here too (final review round): it is the worker's and
339
+ * the runner's own namespace, which the runner refuses at start, so a bundle name in it would otherwise pass this gate
340
+ * and fail after spend. Refused
341
+ * by NAME before spend, never dropped. Most are reserved on every venue already (HOME, the proxy names); listed whole
342
+ * so this venue's answer does not lean on another module's list staying what it is. The runner refuses any name already
343
+ * set in its environment besides (bundle-env.mjs), the backstop for a name this list misses.
344
+ */
345
+ export const OPENSHELL_RESERVED_ENV_PREFIX = "OPENSHELL_";
346
+ export const OPENSHELL_SET_ENV_NAMES = Object.freeze(["HOME", "USER", "SHELL", "TERM", "LOGNAME", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "NODE_EXTRA_CA_CERTS", "DENO_CERT", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "all_proxy", "http_proxy", "https_proxy", "no_proxy", "grpc_proxy", "NODE_USE_ENV_PROXY"]);
347
+ const isSandboxSetName = (name) => name.startsWith(OPENSHELL_RESERVED_ENV_PREFIX) || name.startsWith("PI_") || OPENSHELL_SET_ENV_NAMES.includes(name);
348
+
349
+ /**
350
+ * The hosts a job's own credentials are sent to, which a bundle endpoint may not claim (issue #704 part 3, review round
351
+ * 1, H2), since at a bundle host only that host's bundle variables resolve (`judgeBundle`):
352
+ * - every forge's API, upload and git hosts this deployment can serve (GitHub's three, GitLab's and Azure's public
353
+ * ones, and each configured instance URL's host), which every forge job's git, CLI and API use;
354
+ * - `extra`, the processor's: the composed `baseUrl` host of every model the job may call (the overlay's models.json
355
+ * over pi's catalog, output-cap.mjs `outputCapView`), every declared model endpoint's host whether keyed or not (the
356
+ * simple rule: a keyless one costs the operator nothing to keep off a bundle), and every HTTP MCP server's host the
357
+ * job names (its headers may carry a run.secrets value).
358
+ * Before review round 1 the model's hosts were left out on the reasoning that the catalog host could not be known; it
359
+ * can (`outputCapView` composes it, as the output cap and the zero-rated check already do), and leaving it out let a
360
+ * bundle endpoint on `api.anthropic.com` take the model key's binding, so the run failed `provider-auth-refused` after
361
+ * spend.
362
+ */
363
+ export const FORGE_CREDENTIAL_HOSTS = Object.freeze(["github.com", "api.github.com", "uploads.github.com", "gitlab.com", "dev.azure.com"]);
364
+ export function credentialHostsOf({ forgeHosts = {}, extra = [] } = {}) {
365
+ const hosts = new Set(FORGE_CREDENTIAL_HOSTS);
366
+ for (const url of Object.values(forgeHosts ?? {})) {
367
+ try {
368
+ if (typeof url === "string" && url !== "") hosts.add(new URL(url).hostname.toLowerCase());
369
+ } catch {}
370
+ }
371
+ for (const host of Array.isArray(extra) ? extra : []) if (typeof host === "string" && host !== "") hosts.add(host.toLowerCase());
372
+ return hosts;
373
+ }
374
+
375
+ /**
376
+ * Judge a job's bundle against this venue (issue #704 part 3, `DES-OPENSHELL-VENUE`), pure: `{ ok, placeholders, plain }`
377
+ * or `{ refused: reason, name?, host? }`. `network` is the job's generated `{ network_policies }`; `vars` the bundle
378
+ * variables the job receives (NOT the ones its own run.secrets shadowed), null before the bundle is read, when only
379
+ * the hosts can be judged; `shadowed` the names run.secrets took; `capabilities` the job image's.
380
+ *
381
+ * WHY A HOST IS CLAIMED WHOLE. At the pin a policy endpoint names ONE provider (sandbox.proto `NetworkCredentialBinding`
382
+ * has a `provider` and nothing else), a provider binds EVERY one of its keys to every endpoint naming it
383
+ * (openshell-server/src/grpc/policy.rs `policy_static_credential_endpoint_bindings`, then provider.rs's
384
+ * `resolve_provider_environment_from_records_with_policy_bindings_and_credentials`), two attached providers may not
385
+ * expose one env key (provider.rs `validate_provider_environment_key_ownership`: "is provided by both provider"), and
386
+ * two endpoints that can match one request may not name different providers (openshell-policy/src/ambiguity.rs, the
387
+ * `credential_binding.provider` conflict). So a variable bound to ONE host needs a provider of its own whose binding is
388
+ * on that host's endpoints, and those endpoints then carry no binding to the job's own provider: at a bundle host only
389
+ * that host's bundle variables resolve. A placeholder of any other credential sent there is refused by the supervisor,
390
+ * 403 `credential_endpoint_mismatch`, never forwarded (openshell-supervisor-network/src/l7/relay.rs
391
+ * `reject_credential_resolution`): loud, never a leak. Hence the refusals:
392
+ * - `openshell-bundle-endpoint-not-allowed`: the host is not EXACTLY an endpoint host of the generated policy (a
393
+ * reviewed file may not widen egress), or a wildcard endpoint of the policy covers it (`.example.com` in the
394
+ * allowlist admits `*.example.com`, which would match the host's requests too and is bound to the job's provider:
395
+ * the ambiguity OpenShell refuses at create);
396
+ * - `openshell-bundle-endpoint-shared`: the host is one the job's own credentials are sent to (`credentialHostsOf`),
397
+ * which the claim would take from them;
398
+ * - `openshell-bundle-endpoint-unknown`: an `endpoints` key names no variable the bundle delivered (a typo, or one
399
+ * removed in the manager) and no run.secrets value shadowed. The named variable would otherwise be missing and a
400
+ * misspelt one would go in PLAIN, visible to the agent, while the reviewed file reads as holding it as a placeholder;
401
+ * - `openshell-bundle-name-taken`: a bundle variable is named as one the sandbox sets itself (`OPENSHELL_SET_ENV_NAMES`);
402
+ * - `openshell-bundle-unpassable`: what the gateway would refuse (`OPENSHELL_LIMITS`): a placeholder value over 8 KiB,
403
+ * more than 32 placeholders for one host, more bundle hosts than a sandbox's providers leave room for, or a name no
404
+ * provider can carry;
405
+ * - `openshell-bundle-image-too-old`: plain variables, and a job image that does not declare `bundleEnvFile`, whose
406
+ * runner would ignore `BUNDLE_ENV_FILE` and run the job without them.
407
+ * A shadowed endpoint name (review round 1, M2) is still a placeholder at its host: the run.secrets value, which is the
408
+ * one that wins, goes into that host's provider (`bundleProviderPlan`), so it counts among the placeholders here.
409
+ */
410
+ export function judgeBundle({ endpoints = {}, vars = null, shadowed = [], network = { network_policies: {} }, credentialHosts = new Set(), capabilities = [] } = {}) {
411
+ const policyHosts = Object.values(network?.network_policies ?? {}).flatMap((rule) => (rule?.endpoints ?? []).map((e) => String(e?.host ?? "").toLowerCase()));
412
+ const exact = new Set(policyHosts.filter((h) => !h.startsWith("*.")));
413
+ const wildcards = policyHosts.filter((h) => h.startsWith("*.")).map((h) => h.slice(1));
414
+ const names = Object.keys(endpoints ?? {}).sort();
415
+ for (const name of names) {
416
+ const host = String(endpoints[name]?.host ?? "").toLowerCase();
417
+ // Conservative on the wildcard twice over (review round 1, L3): any `*.base` the host ends under, though a single
418
+ // star crosses no dot at the pin, and whatever its PORT, though OpenShell's ambiguity check compares ports too
419
+ // (an allowlist wildcard is 443, a model endpoint may be another): both err toward refusing, never toward a create
420
+ // the gateway refuses after the read.
421
+ if (!exact.has(host) || wildcards.some((w) => host.endsWith(w))) return { refused: "openshell-bundle-endpoint-not-allowed", name, host };
422
+ if (credentialHosts.has(host)) return { refused: "openshell-bundle-endpoint-shared", name, host };
423
+ }
424
+ const hosts = new Set(names.map((n) => String(endpoints[n].host).toLowerCase()));
425
+ // One provider per bundle host, beside the job's own.
426
+ if (hosts.size > OPENSHELL_LIMITS.providersPerSandbox - 1) return { refused: "openshell-bundle-unpassable", name: null, host: null };
427
+ if (vars === null) return { ok: true };
428
+ const delivered = new Set(Object.keys(vars));
429
+ const unknown = names.find((n) => !delivered.has(n) && !shadowed.includes(n));
430
+ if (unknown !== undefined) return { refused: "openshell-bundle-endpoint-unknown", name: unknown };
431
+ const placeholders = names.filter((n) => delivered.has(n) || shadowed.includes(n));
432
+ const plain = [...delivered].filter((n) => !Object.hasOwn(endpoints ?? {}, n)).sort();
433
+ const taken = [...placeholders, ...plain].find(isSandboxSetName);
434
+ if (taken !== undefined) return { refused: "openshell-bundle-name-taken", name: taken };
435
+ for (const n of placeholders) {
436
+ // A shadowed name's value is the trigger's own, which #684's path already carried at any size; judged as before.
437
+ if (!PROVIDER_ENV_NAME.test(n) || (delivered.has(n) && Buffer.byteLength(vars[n]) > OPENSHELL_LIMITS.credentialBytes)) return { refused: "openshell-bundle-unpassable", name: n };
438
+ }
439
+ for (const host of hosts) {
440
+ if (placeholders.filter((n) => String(endpoints[n].host).toLowerCase() === host).length > OPENSHELL_LIMITS.credentialsPerProvider) return { refused: "openshell-bundle-unpassable", name: null, host };
441
+ }
442
+ if (plain.length > 0 && !(Array.isArray(capabilities) && capabilities.includes(BUNDLE_ENV_FILE_CAPABILITY))) return { refused: "openshell-bundle-image-too-old", name: null };
443
+ return { ok: true, placeholders: placeholders.length, plain: plain.length, placeholderNames: placeholders.filter((n) => delivered.has(n)).sort() };
444
+ }
445
+
446
+ /**
447
+ * Which provider holds which of a job's secrets, and which bundle variables go in plain (issue #704 part 3), pure:
448
+ * `{ job: { name, keys }, bundles: [{ name, host, keys, auth }], plain }` or `{ collision: name }`.
449
+ * - the job's own provider (named as its sandbox, #684's, unchanged): every secret of the env (`secretNamesOf`) that is
450
+ * not a bundle variable, so run.secrets, the forge token, the model key and the forwarded secrets keep their path;
451
+ * - one provider per bundle HOST (`bundleProviderNameOf`, sorted by host so every attempt names them alike): the
452
+ * variables the bundle's `endpoints` declare for that host, each with its `auth` documented in the profile. The
453
+ * processor includes a declared name a run.secrets value shadowed (review round 1, M2), so that value, the one that
454
+ * wins, resolves at the host the reviewed file binds the name to, and not in the job's provider bound elsewhere;
455
+ * - `plain`: every other bundle variable, which reaches the runner in `BUNDLE_ENV_FILE`, its value visible to the agent
456
+ * as on every other venue.
457
+ * `bundleKeys` is the processor's `{ endpoints, plain }`, null for a job without a bundle, which plans exactly #684's
458
+ * one provider. An env key in two places is a `collision`, named, never a guess at which wins: OpenShell refuses two
459
+ * providers exposing one key, and a plain variable beside a placeholder of the same name would shadow one of them. The
460
+ * processor's split and the reserved names make it unreachable; checked anyway.
461
+ */
462
+ export function bundleProviderPlan({ name, env = {}, bundleKeys = null } = {}) {
463
+ const endpoints = bundleKeys?.endpoints ?? {};
464
+ const plainNames = Array.isArray(bundleKeys?.plain) ? bundleKeys.plain : [];
465
+ const has = (k) => typeof env?.[k] === "string" && env[k] !== "";
466
+ const bundleNames = new Set([...Object.keys(endpoints), ...plainNames]);
467
+ const jobKeys = secretNamesOf(env).filter((k) => !bundleNames.has(k));
468
+ const endpointNames = Object.keys(endpoints).filter(has).sort();
469
+ const hostOf = (k) => String(endpoints[k].host).toLowerCase();
470
+ const hosts = [...new Set(endpointNames.map(hostOf))].sort();
471
+ const bundles = hosts.map((host, i) => {
472
+ const keys = endpointNames.filter((k) => hostOf(k) === host);
473
+ return { name: bundleProviderNameOf(name, i), host, keys, auth: Object.fromEntries(keys.map((k) => [k, endpoints[k].auth])) };
474
+ });
475
+ const plain = [...new Set(plainNames)].filter(has).sort();
476
+ const seen = new Set();
477
+ for (const k of [...jobKeys, ...bundles.flatMap((b) => b.keys), ...plain]) {
478
+ if (seen.has(k)) return { collision: k };
479
+ seen.add(k);
480
+ }
481
+ return { job: { name, keys: jobKeys }, bundles, plain };
482
+ }
483
+
484
+ /**
485
+ * The git credential helper a forge job's exec gets on this venue (issue #684), as `GIT_CONFIG_COUNT`, `_KEY_0` and
486
+ * `_VALUE_0`: it prints the forge token's PLACEHOLDER as the password, and OpenShell's supervisor rewrites it into the
487
+ * real token in the Basic header (measured), at any host the policy admits, as every credential of the provider is bound
488
+ * to every endpoint (squid's reach, not narrower). git sends a token only through a helper, and
489
+ * the clone's remote is tokenless (prepare-github.mjs), so without it a push would never authenticate. Null for a job
490
+ * whose forge token is not in its env.
491
+ */
492
+ export function gitCredentialEnv(env = {}) {
493
+ // The forge is the one whose token variable the env carries: the worker writes a minted token into its own forge's
494
+ // names only (env-allowlist.mjs), so at most one forge matches.
495
+ const kind = Object.keys(FORGES).find((k) => (FORGES[k].tokenVars ?? []).some((v) => typeof env[v] === "string" && env[v] !== ""));
496
+ if (!kind) return null;
497
+ const tokenVar = FORGES[kind].tokenVars.find((v) => typeof env[v] === "string" && env[v] !== "");
498
+ const user = kind === "github" ? "x-access-token" : kind === "gitlab" ? "oauth2" : "pi-dispatch";
499
+ return {
500
+ GIT_CONFIG_COUNT: "1",
501
+ GIT_CONFIG_KEY_0: "credential.helper",
502
+ GIT_CONFIG_VALUE_0: `!f() { test "$1" = get || exit 0; echo username=${user}; echo "password=$${tokenVar}"; }; f`,
503
+ };
504
+ }
505
+
506
+ /**
507
+ * The targets OpenShell refuses for a bind (measured on 0.1.4: `/sandbox` "is reserved for the OpenShell workspace", and
508
+ * nothing may overlap its own trees). A `mounts`-layout path never is one; checked anyway, so a layout change is a
509
+ * refusal at the stage, never a create the gateway refuses with words this adapter does not read.
510
+ */
511
+ export const BIND_RESERVED_TARGETS = Object.freeze(["/sandbox", "/opt/openshell", "/etc/openshell", "/run/openshell"]);
512
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
513
+
514
+ /**
515
+ * A bind-mode job's binds (issue #686 part 3), from the container spec's mounts: each host path at its `mounts`-layout
516
+ * target, read-only exactly where the spec mounts it read-only (`/job`, `/inbox`, the overlay), writable where it does
517
+ * (`/workspace`, `/session`, `/outbox`). The same list `local` hands docker, so a job sees the same tree on both. THROWS
518
+ * on a target OpenShell reserves (`BIND_RESERVED_TARGETS`) or a relative one.
519
+ */
520
+ export function bindMountsOf(spec) {
521
+ return (spec?.mounts ?? []).map((m) => {
522
+ const target = String(m.container ?? "");
523
+ if (!target.startsWith("/") || target === "/" || BIND_RESERVED_TARGETS.some((r) => overlaps(target, r))) throw new Error(`openshell: ${JSON.stringify(target)} is not a target OpenShell admits for a bind`);
524
+ // Review round 1 (#714): the spec's private relabel (`:Z`) is the driver's `selinux_label: "private"` (upstream's
525
+ // schema: "shared" for :z, "private" for :Z), never dropped. Only the worker's own per-job directories carry it.
526
+ return { source: m.host, target, readOnly: m.readOnly === true, ...(m.relabel === "private" ? { selinuxLabel: "private" } : {}) };
527
+ });
528
+ }
529
+
530
+ /**
531
+ * Who a bind-mode job runs as (issue #686 part 3), by `local`'s job-user rule (issue #341, `decideJobUser` then
532
+ * `resolveImageUser`) and for its reason: a bind is read and written as the sandbox's uid (measured: files owned by the
533
+ * image's 1001), so on a host whose worker is another uid the sandbox could neither read the worker's 0700 job dir nor
534
+ * leave files the worker can remove. OpenShell's policy sets the uid (`process.run_as_user`/`run_as_group`), so the job
535
+ * runs as the worker's own `<euid>:<egid>` where that is not 1001, which the image must declare it can (`anyUid`).
536
+ *
537
+ * REVIEW ROUND 1 (#714): decided from the gateway's ENGINE as well as the ids, `daemon` being its `docker info` read
538
+ * (`makeDaemonFactsReader`): a rootless or userns-remapped Docker behind the gateway maps every uid away from the
539
+ * worker's, so no uid could read the job dir, and `local`'s rows refuse that before spend (`rootless`, `userns-remap`,
540
+ * `desktop-linux-userns`, `job-user-unmappable`) rather than after it. The engine is on this host (the gateway is
541
+ * observed local, and the bind sources are this host's paths), so its endpoint counts as local. darwin and win32 run
542
+ * the image's own user (`desktop-platform`, a VM's file sharing maps ownership; UNMEASURED for OpenShell); a root worker
543
+ * and a primary group of 0 are refused; an unanswered read is `unavailable`, retried. Returns `resolveImageUser`'s answer.
544
+ * Measured: the Docker driver keeps uids across a bind (no user namespace), and `run_as_user` 2000 ran as uid 2000,
545
+ * wrote a bind as 2000, and found the image's /home/pi 0777.
546
+ */
547
+ export function decideOpenshellBindUser({ platform = process.platform, euid, egid, capabilities = [], daemon = null } = {}) {
548
+ const decision = decideJobUser({ platform, euid, egid, endpoint: { local: true }, daemon: daemon ?? { answered: false, reason: "no-daemon-facts", transient: true } });
549
+ // `socket: null`, deliberately (review round 2): `local`'s socket rows judge the DOCKER CLI's own unix socket, which
550
+ // the worker never talks to on this venue (the gateway does), and `docker-group` refuses a job user whose primary
551
+ // group is that socket's because `--user` would hand the container the docker group. Here the gid reaches only the
552
+ // policy's `run_as_group`, inside a sandbox OpenShell builds without the socket, so that refusal has nothing to guard.
553
+ // A rootless engine is still caught, by `docker info`'s own `name=rootless`, which the socket row only backs up.
554
+ const chosen = resolveImageUser(decision, { capabilities, euid, egid });
555
+ // The relabel rule is `local`'s too: true only for a Podman daemon reporting SELinux, which bind mode (Docker only)
556
+ // never reads, so this stays false there; kept so the two cannot disagree.
557
+ return chosen.user !== undefined && relabelsPrivateMounts(daemon?.answered ? daemon.facts : null, { local: true }, platform) ? { ...chosen, relabel: true } : chosen;
558
+ }
559
+
560
+ /** The policy's uid and gid for a job user (`"<uid>:<gid>"`, or null for the image's own). */
561
+ function policyIds(user) {
562
+ const m = /^([1-9]\d{0,9}):([1-9]\d{0,9})$/.exec(String(user ?? ""));
563
+ return m ? { uid: Number(m[1]), gid: Number(m[2]) } : { uid: SHIPPED_IMAGE_UID, gid: SHIPPED_IMAGE_UID };
564
+ }
565
+
566
+ /** The sandbox-layout path a `mounts`-layout container path is copied to, or null for one the layout does not name. */
567
+ export function sandboxPathOf(container) {
568
+ const key = Object.keys(LAYOUTS.mounts).find((k) => LAYOUTS.mounts[k] === container);
569
+ return key === undefined ? null : SANDBOX[key];
570
+ }
571
+
572
+ /** The CLI's global flags: the gateway, when the deployment names one, on every call so no step reaches another. */
573
+ function gatewayArgs(gateway) {
574
+ return typeof gateway === "string" && gateway !== "" ? ["--gateway", gateway] : [];
575
+ }
576
+
577
+ /**
578
+ * The exec argv (excluding the leading `openshell`): the job's env as `--env K=V`, the runner started in its workspace
579
+ * through pi-landlock. `--timeout 0` because the worker owns the job's timeout (a gateway timeout exits 124, which no
580
+ * classification here would read), `--no-tty` and `--no-login-shell` so the child is the command and nothing a login
581
+ * profile adds. HOME is the image's home whoever the env says: OpenShell runs the image's own uid, and a tool's cache
582
+ * must land there.
583
+ */
584
+ export function openshellExecArgs({ name, env = {}, gateway = null, mode = "copy" }) {
585
+ // Issue #684: no credential value on this argv, ever. ONLY the names the redactor treats as not secret
586
+ // (`NON_SECRET_ENV_KEYS`) travel here, an allowlist rather than "every name but the provider's": a secret the provider
587
+ // could not carry (a name no shell can spell) would otherwise fall through to this argv as a value (review H1). Every
588
+ // other name is the provider's, its placeholder in the sandbox's env already; the git helper names the forge token's
589
+ // placeholder, never its value.
590
+ //
591
+ // Issue #704 part 3: a project secret bundle's PLAIN variables never travel here either (review round 1, H1). The
592
+ // gateway puts an exec's env pairs at the head of the remote command and logs its first 120 characters at INFO
593
+ // (openshell-server/src/grpc/sandbox.rs `build_remote_exec_command`, `stream_exec_over_relay` and the interactive
594
+ // path), so a value here would be on the host's argv AND in the gateway's journal. They go by file instead
595
+ // (`BUNDLE_ENV_FILE` in the job directory, read by the runner on PI_BUNDLE_ENV=1, which rides here as a non-secret
596
+ // name). Rejected: `--env` as built first (the log), a provider (the agent would hold useless placeholders for a
597
+ // value it must use itself), `sandbox create --env` (the same flag, kept in the gateway's sandbox spec besides).
598
+ const envArgs = [];
599
+ const carried = Object.fromEntries(Object.entries(env ?? {}).filter(([key]) => NON_SECRET_ENV_KEYS.has(key)));
600
+ for (const [key, value] of Object.entries({ ...carried, ...(gitCredentialEnv(env) ?? {}), HOME: "/home/pi" })) {
601
+ if (value === undefined || value === null) continue;
602
+ envArgs.push("--env", `${key}=${value}`);
603
+ }
604
+ const head = [...gatewayArgs(gateway), "sandbox", "exec", "--name", name, "--no-login-shell", "--no-tty", "--timeout", "0"];
605
+ // Issue #686 part 3: bind mode runs the entrypoint itself, in `/workspace`: `/job` is a read-only bind already.
606
+ if (mode === "bind") return [...head, "--workdir", LAYOUTS.mounts.workspace, ...envArgs, "--", OPENSHELL_ENTRYPOINT];
607
+ const rw = OPENSHELL_WRITABLE.flatMap((path) => ["--rw", path]);
608
+ return [...head, "--workdir", SANDBOX.workspace, ...envArgs, "--", PI_LANDLOCK, ...rw, "--", OPENSHELL_ENTRYPOINT];
609
+ }
610
+
611
+ /** `--cpu` in the CLI's spelling (whole or fractional CPUs) and `--memory` in MiB, from the job's size. */
612
+ function sizeArgs(size) {
613
+ const cpus = size.cpuCenti % 100 === 0 ? String(size.cpuCenti / 100) : (size.cpuCenti / 100).toFixed(2).replace(/0$/, "");
614
+ return ["--cpu", cpus, "--memory", `${size.memMiB}Mi`];
615
+ }
616
+
617
+ /** JSON with every object's keys sorted, so a read-back that only reorders keys is not a difference. */
618
+ function canonical(value) {
619
+ if (Array.isArray(value)) return value.map(canonical);
620
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
621
+ return value;
622
+ }
623
+
624
+ /** Parse a CLI answer as JSON, or throw a plain Error naming the step: the caller decides the class. */
625
+ function parseJson(stdout, step) {
626
+ try {
627
+ return JSON.parse(String(stdout ?? ""));
628
+ } catch {
629
+ throw new Error(`openshell ${step}: the answer was not JSON`);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Phases a sandbox holds nothing in (the host budget's "gone"): it ran and ended, or never could. `Deleting` is NOT
635
+ * here: its containers may still be running down, so the budget keeps its hold until the gateway stops listing it.
636
+ */
637
+ export const SANDBOX_GONE_PHASES = Object.freeze(new Set(["Stopped", "Completed", "Error"]));
638
+ /** Phases a sandbox has not started its workload in: deleted, then gone, as `created` is on docker. */
639
+ export const SANDBOX_UNSTARTED_PHASES = Object.freeze(new Set(["Provisioning", "Starting", "Unspecified"]));
640
+
641
+ /** Whether a listed sandbox is a bind-mode one (issue #686 part 3): its mode label, and nothing else, says so. */
642
+ const isBindSandbox = (s) => s?.labels?.[OPENSHELL_MODE_LABEL] === "bind";
643
+ /** Whether a listed sandbox belongs to `mode`'s bundle: a copy-mode one carries no mode label. */
644
+ const ofMode = (mode) => (s) => (mode === "bind") === isBindSandbox(s);
645
+
646
+ /** Every sandbox the gateway lists with this worker's owner label, page by page. THROWS when it does not answer. */
647
+ async function listOwned(run, stepOpts) {
648
+ const out = [];
649
+ let token = "";
650
+ for (let page = 0; page < 1000; page++) {
651
+ const args = ["sandbox", "list", "--selector", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "--page-size", "100", ...(token ? ["--page-token", token] : []), "-o", "json"];
652
+ const doc = parseJson((await (stepOpts ? run(args, stepOpts) : run(args))).stdout, "sandbox list");
653
+ if (!doc || !Array.isArray(doc.sandboxes)) throw new Error("openshell sandbox list: no sandboxes array");
654
+ out.push(...doc.sandboxes.filter((s) => s && typeof s.name === "string"));
655
+ token = typeof doc.next_page_token === "string" ? doc.next_page_token : "";
656
+ if (token === "") return out;
657
+ }
658
+ throw new Error("openshell sandbox list: more than 1000 pages");
659
+ }
660
+
661
+ /** A label value as a positive integer, or null (the host budget's own rule for a size label). */
662
+ const intLabel = (v) => (/^[1-9][0-9]{0,8}$/.test(String(v ?? "")) ? Number(v) : null);
663
+
664
+
665
+ /**
666
+ * A provider's delete, asked again for a bounded while: OpenShell refuses it while a sandbox still has it attached, and
667
+ * the sandbox's delete is asynchronous (measured). Its profile goes after it; a profile still named by a provider is
668
+ * refused, and the reaper's next sweep finds both again.
669
+ */
670
+ function providerDeleter(run, { log, delay }) {
671
+ return async (provider) => {
672
+ for (let i = 0; i < PROVIDER_DELETE_TRIES; i++) {
673
+ try {
674
+ await run(["provider", "delete", provider]);
675
+ break;
676
+ } catch {
677
+ if (i === PROVIDER_DELETE_TRIES - 1) log("openshell_provider_delete_failed", { provider });
678
+ else await delay(PROVIDER_DELETE_WAIT_MS);
679
+ }
680
+ }
681
+ try {
682
+ await run(["provider", "profile", "delete", provider]);
683
+ } catch {}
684
+ };
685
+ }
686
+
687
+ /** The sweep: every owned sandbox in the job namespace, then every owned provider (and its profile) named like one. */
688
+ function reapWith(run, deleteProvider, log) {
689
+ return async () => {
690
+ try {
691
+ const owned = (await listOwned(run)).filter((s) => isJobNamespace(s.name));
692
+ for (const s of owned) await run(["sandbox", "delete", s.name]);
693
+ if (owned.length > 0) log("reaped_sandboxes", { count: owned.length });
694
+ // Issue #684: a crashed job's provider and profile, by the owner label and the job namespace. Issue #704 part 3:
695
+ // its bundle hosts' providers too (`<sandbox>-b<n>`, `isJobProviderName`), after the sandboxes above, since a
696
+ // provider still attached is refused deletion.
697
+ const providers = parseJson((await run(["provider", "list", "--label-selector", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "-o", "json"])).stdout, "provider list");
698
+ for (const p of (providers?.providers ?? []).filter((x) => isJobProviderName(x?.name))) await deleteProvider(p.name);
699
+ // Review round 1, L4: a profile whose provider never existed (an import, then a crash or a refused create) is
700
+ // not reached through the providers, so the profiles are swept too, by the same names: a profile carries no
701
+ // label, and only a name of this worker's job namespace (`isJobProviderName`) is ever deleted. `-o json` is every
702
+ // page of the catalog as one array of profiles (openshell-cli/src/commands/provider.rs
703
+ // `provider_list_profiles_text`, openshell-providers/src/profiles.rs `profiles_to_json`). A listing that fails
704
+ // leaves the answer as it is: the sandboxes are what the tri-state is about, and the next sweep asks again.
705
+ try {
706
+ const profiles = parseJson((await run(["provider", "profile", "list", "-o", "json"])).stdout, "provider profile list");
707
+ for (const prof of (Array.isArray(profiles) ? profiles : []).filter((x) => isJobProviderName(x?.id))) await run(["provider", "profile", "delete", prof.id]).catch(() => {});
708
+ } catch {
709
+ log("openshell_profiles_unswept", {});
710
+ }
711
+ return { reaped: true };
712
+ } catch {
713
+ return { reaped: false };
714
+ }
715
+ };
716
+ }
717
+
718
+ /**
719
+ * The venue's reaper on its own (issue #684), for the boot sweep, which runs before the bundle can be built (it needs the
720
+ * log sink and the package resolver); the bundle is then handed this same function as `reap`, podman's pattern.
721
+ */
722
+ export function makeOpenshellReaper({ gateway = null, exec = reaperExec(), log = () => {}, delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
723
+ const run = (args, stepOpts) => exec("openshell", [...gatewayArgs(gateway), ...args], stepOpts);
724
+ return reapWith(run, providerDeleter(run, { log, delay }), log);
725
+ }
726
+
727
+ /**
728
+ * The boot's version judgement (issue #684, the half #683 left to the blessing): the CLI and, when it answers, the
729
+ * gateway must be `OPENSHELL_TESTED_VERSION`, since the venue drives interfaces OpenShell marks experimental and another
730
+ * release is untested until the conformance run passes on it. `{ refusal, transient }` or `{ gatewayDown }` or `{}`:
731
+ *
732
+ * - a CLI that does not answer is transient (exit 1, restarted): an install mid-way or a PATH the unit has not
733
+ * picked up yet, which a restart can meet fixed;
734
+ * - a CLI or a gateway at another release is the deployment's (a configError, exit 2, not restarted into it);
735
+ * - a gateway that does not answer refuses nothing here: every job's preflight reads it again and retries, so a
736
+ * gateway started after the worker is not a boot loop. Said as `gatewayDown` for the boot's log.
737
+ */
738
+ export async function openshellBootVersions({ gateway = null, exec = reaperExec() } = {}) {
739
+ const run = (args) => exec("openshell", [...gatewayArgs(gateway), ...args]);
740
+ let cli;
741
+ try {
742
+ cli = /openshell (\S+)/.exec(String((await run(["--version"])).stdout ?? ""))?.[1] ?? null;
743
+ } catch {
744
+ cli = null;
745
+ }
746
+ if (cli === null) return { refusal: "PI_BACKENDS blesses openshell, and the openshell CLI did not answer `openshell --version`: install OpenShell " + OPENSHELL_TESTED_VERSION + " on this host's PATH (pi-dispatch doctor --openshell)", transient: true };
747
+ if (cli !== OPENSHELL_TESTED_VERSION) return { refusal: `PI_BACKENDS blesses openshell, and the openshell CLI is ${cli}, not ${OPENSHELL_TESTED_VERSION}, the release this venue was measured on: install ${OPENSHELL_TESTED_VERSION}, or remove openshell from PI_BACKENDS`, transient: false };
748
+ let status;
749
+ try {
750
+ status = judgeGatewayStatus(JSON.parse(String((await run(["status", "-o", "json"])).stdout ?? "")));
751
+ } catch {
752
+ return { gatewayDown: true };
753
+ }
754
+ if (!status.connected) return { gatewayDown: true };
755
+ if (status.version !== OPENSHELL_TESTED_VERSION) return { refusal: `PI_BACKENDS blesses openshell, and its gateway runs ${status.version ?? "an unknown release"}, not ${OPENSHELL_TESTED_VERSION}, the release this venue was measured on: run the gateway of ${OPENSHELL_TESTED_VERSION}`, transient: false };
756
+ // Issue #712: whether that gateway is on this host, for the registry row's `backends` field (a word, never the
757
+ // address). Not a refusal here: every job's preflight refuses a gateway that is not local, by name.
758
+ return { gatewayLocal: status.local };
759
+ }
760
+
761
+ /**
762
+ * The pi providers that READ their credential's value inside the job (issue #684 review, read at the pinned pi-ai), where
763
+ * this venue's placeholder cannot stand in for it: Bedrock signs each request with the secret (SigV4), Vertex reads a
764
+ * credentials FILE, Codex decodes its key as a JWT for the account id, and Copilot exchanges its token for another.
765
+ * Refused before spend by name. Anthropic is judged by the VALUE, as pi judges it (`isOAuthToken`, a substring test for
766
+ * `sk-ant-oat`): an OAuth token picks other headers. A provider not listed here sends its key as it is, in a header the
767
+ * supervisor rewrites; one that does otherwise fails with its own provider error, never silently (`OQ-047`).
768
+ */
769
+ export const VALUE_READING_PROVIDERS = Object.freeze(["amazon-bedrock", "google-vertex", "openai-codex", "github-copilot"]);
770
+
771
+ /** Whether a job's provider credential, as the worker's environment holds it, is one pi reads by value. */
772
+ // env-internal ANTHROPIC_OAUTH_TOKEN: pi's own variable, read from the worker's environment only to refuse a job whose
773
+ // credential the venue's placeholder cannot stand in for; documented with the provider keys pi reads.
774
+ export function credentialReadsValue(provider, hostEnv = {}) {
775
+ if (VALUE_READING_PROVIDERS.includes(provider)) return true;
776
+ if (provider !== "anthropic") return false;
777
+ if (typeof hostEnv?.ANTHROPIC_OAUTH_TOKEN === "string" && hostEnv.ANTHROPIC_OAUTH_TOKEN !== "") return true;
778
+ return typeof hostEnv?.ANTHROPIC_API_KEY === "string" && hostEnv.ANTHROPIC_API_KEY.includes("sk-ant-oat");
779
+ }
780
+
781
+ // The fixed comments of this venue's own refusals (OPENSHELL_REFUSALS) live in comment-tables.mjs since issue #710, so the
782
+ // reason catalog reads them without this adapter; re-exported here under the name they always had.
783
+ export { OPENSHELL_REFUSALS };
784
+
785
+ /**
786
+ * How long a bind-settings answer is kept (issue #686 part 3), per bundle and so per gateway: an admitting one ten
787
+ * minutes, a refusing one a minute, so an operator who fixes gateway.toml and restarts the gateway is read again soon
788
+ * without a probe per job. A create refused with a settings sentence after a probe admitted (the probe's reading of the
789
+ * settings and the create's disagreeing) is held the full ten minutes, so the probe cannot re-admit a job into the same
790
+ * refusal: at most one attempt is retried as infrastructure, every later one refused before spend. The probe's order of
791
+ * checks is measured, so that disagreement means a gateway reconfigured within the admitting answer's ten minutes.
792
+ */
793
+ export const BIND_GATE_ADMITTED_MS = 10 * 60_000;
794
+ export const BIND_GATE_REFUSED_MS = 60_000;
795
+ /**
796
+ * Review round 1 (#714): how long a bind-mode attempt waits for its sandbox to be GONE before its run returns. A bind is
797
+ * the host's own tree, so a process the agent left running in the sandbox (`setsid ... &`) keeps writing the workspace,
798
+ * the folder, the session and the outbox after the exec ends, and a sandbox's delete is asynchronous (measured in #684).
799
+ * So the sandbox is deleted and the listing polled until it no longer names it; past this bound the result is marked
800
+ * `lingering` (`openshell_bind_sandbox_lingering` in the log), its exit code UNCHANGED (review round 2), and the processor
801
+ * decides: no exit checkpoint; the named result `openshell-bind-sandbox-lingering` for a completed run or a folder job;
802
+ * infrastructure as before for any other forge exit; exit 2 and a stop as they are.
803
+ */
804
+ export const BIND_SETTLE_MS = 60_000;
805
+ const BIND_SETTLE_POLL_MS = 1_000;
806
+
807
+ const OPENSHELL_BACKEND_OPTIONS = Object.freeze(["mode", "euid", "egid", "now", "jobsDir", "readDaemonFacts", "image", "gateway", "engine", "readAllowlist", "readEndpoints", "hostEnv", "openJobLog", "openLiveTranscript", "openInboxRelay", "onOutput", "globalPiDir", "allowGlobalExtensions", "packagePaths", "forwardEnv", "authFromPi", "forgeHosts", "log", "spawnFn", "exec", "fs", "readLsm", "readPtraceScope", "platform", "delay", "reap", "makeRunContainer"]);
808
+
809
+ /**
810
+ * Build the `openshell` bundle. `engine` is the gateway's compute driver, `docker` or `podman` (the only two in v1),
811
+ * whose CLI the image preflight reads the job image's labels through: the gateway pulls from that engine's store, so a
812
+ * label read anywhere else describes another image (`OQ-012`). `gateway` names a registered gateway, or none for the
813
+ * CLI's active one.
814
+ */
815
+ export function makeOpenshellBackend(opts = {}) {
816
+ // Issue #686 part 3: `copy` (the `openshell` venue, the default) or `bind` (`openshell-bind`).
817
+ const mode = opts?.mode ?? "copy";
818
+ if (!OPENSHELL_MODES.includes(mode)) throw new Error(`backend "${OPENSHELL_BACKEND}": mode must be "copy" or "bind", got ${JSON.stringify(mode)}`);
819
+ const binding = mode === "bind";
820
+ const venue = binding ? OPENSHELL_BIND_BACKEND : OPENSHELL_BACKEND;
821
+ for (const key of Object.keys(opts ?? {})) {
822
+ if (!OPENSHELL_BACKEND_OPTIONS.includes(key)) throw new Error(`backend "${venue}": unknown option ${JSON.stringify(key)} (takes ${OPENSHELL_BACKEND_OPTIONS.join(", ")})`);
823
+ }
824
+ const {
825
+ image,
826
+ gateway = null,
827
+ engine = "docker",
828
+ // Issue #684: the deployment's egress-allowlist.conf entries and model-endpoints.json endpoints, read per job as
829
+ // the MCP preflight reads them (start.mjs), so the venue's policy is built from the sources the squid venues
830
+ // read. Each a function; an unreadable source throws there, which a job reads as infrastructure.
831
+ readAllowlist = () => [],
832
+ readEndpoints = () => [],
833
+ hostEnv = process.env,
834
+ openJobLog,
835
+ // Issue #686: the live transcript publisher's opener (start.mjs's, shared with every venue), fed from the exec's
836
+ // stderr records. Absent, no job here publishes a transcript or progress, as before.
837
+ openLiveTranscript = null,
838
+ // Issue #686: the steer inbox relay's opener (start.mjs's, shared with every venue), whose relay writes frames on
839
+ // the exec's stdin here. Absent, a steer job here runs unsteered (`steerSkipped: "no-transcript"`).
840
+ openInboxRelay = null,
841
+ onOutput,
842
+ globalPiDir = null,
843
+ allowGlobalExtensions = true,
844
+ packagePaths = [],
845
+ forwardEnv = [],
846
+ authFromPi = false,
847
+ forgeHosts = {},
848
+ log = () => {},
849
+ spawnFn = spawn,
850
+ exec = reaperExec(),
851
+ fs = { cpSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync },
852
+ // Issue #686 part 3: the worker's own ids, which a bind-mode job runs as (`decideOpenshellBindUser`), and the clock
853
+ // the bind gate's cache reads.
854
+ euid = typeof process.geteuid === "function" ? process.geteuid() : null,
855
+ egid = typeof process.getegid === "function" ? process.getegid() : null,
856
+ now = () => Date.now(),
857
+ // Review round 1 (#714): where the bind probe's empty source directory is made, the jobs dir (a path the gateway's
858
+ // engine sees as the job dirs it binds), and the engine's `docker info` read the job user is decided from.
859
+ jobsDir = null,
860
+ readDaemonFacts = null,
861
+ readLsm = () => readFileSync("/sys/kernel/security/lsm", "utf8"),
862
+ // Issue #730: this host's Yama setting, which is the sandbox's, since the gateway must run on this host.
863
+ readPtraceScope = () => readFileSync(PTRACE_SCOPE_PATH, "utf8"),
864
+ delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
865
+ platform = process.platform,
866
+ // The boot-built reaper (`makeOpenshellReaper`), so the sweep and the registry hold the same function; none builds
867
+ // this bundle's own.
868
+ reap: reapFn = null,
869
+ makeRunContainer: makeRunContainerFn = makeRunContainer,
870
+ } = opts ?? {};
871
+ if (engine !== "docker" && engine !== "podman") throw new Error(`backend "${venue}": engine must be "docker" or "podman" (the compute drivers v1 admits), got ${JSON.stringify(engine)}`);
872
+ if (gateway !== null && (typeof gateway !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/.test(gateway))) throw new Error(`backend "${venue}": gateway must be a registered gateway's name`);
873
+ const run = (args, stepOpts) => exec("openshell", [...gatewayArgs(gateway), ...args], stepOpts);
874
+
875
+ // Per attempt, keyed by THAT attempt's own build options (the one object all three hooks are handed), so two
876
+ // attempts of one job that overlap in this process (a stop that did not take, then a retry) never read each other's
877
+ // record. `live` holds the sandbox names an attempt is between its create and its delete, so the host budget's
878
+ // gone-check never deletes a live attempt's sandbox as an orphan; `stopping` holds the names a stop was sent for
879
+ // before the sandbox existed, so a stage still running notices and stops.
880
+ const attempts = new WeakMap();
881
+ const live = new Map();
882
+ const stopping = new Set();
883
+
884
+ // Issue #684: the job's network policy, generated from the deployment's sources and the image's network binaries.
885
+ // `{ network_policies }`, or `{ refused, entry? }` when a source cannot be translated without guessing.
886
+ const networkFor = (job, endpoints, binaries) =>
887
+ networkPolicyFor({ allowlist: readAllowlist(), endpoints: endpoints ?? readEndpoints(), kind: job?.kind ?? null, repo: job?.repo ?? null, binaries, onSkipped: (what) => log("openshell_policy_skipped", what) });
888
+
889
+ // Issue #686 part 3: the bind settings' verdict for this bundle's gateway, `{ until, setting | null, pinned }`, and the
890
+ // probe in flight, so jobs picked up together share one create.
891
+ let bindVerdict = null;
892
+ let bindProbing = null;
893
+ const holdBindRefusal = (setting, at) => {
894
+ bindVerdict = { until: now() + BIND_GATE_ADMITTED_MS, setting, pinned: true };
895
+ log("openshell_bind_mounts_disabled", { setting, at });
896
+ };
897
+ // `{ admitted: true }`, `{ setting }`, or `{ unknown: reason }` when the probe's answer is none of the known ones.
898
+ // THE PROBE LEAVES A SANDBOX when the settings admit binds (measured, 0.1.4: the reserved-target refusal comes at
899
+ // provisioning, "sandbox entered error phase while provisioning: ContainerCreateFailed: mount target '/sandbox' is
900
+ // reserved ...", and the sandbox stays listed in phase `Error`), so it is deleted on EVERY answer. A settings refusal
901
+ // creates nothing (measured, 50 to 90 ms), and its delete is a no-op. Should the worker die between the two, the
902
+ // sandbox is the boot reaper's: it carries the owner label, and its name (`sandboxNameOf`, `pi-job-s` and 11 hex
903
+ // digits) is in the job namespace `isJobNamespace` sweeps.
904
+ const probeBind = async (imageId) => {
905
+ const name = sandboxNameOf(`pi-job-bind-probe-${process.pid}-${now()}`);
906
+ let source = null;
907
+ try {
908
+ // Under the jobs dir, the tree the engine binds job dirs from (not `job-*`, so the orphan sweep leaves it alone).
909
+ source = fs.mkdtempSync(join(jobsDir ?? tmpdir(), "bind-probe-"));
910
+ await exec("openshell", bindProbeArgs({ name, image: imageId, source, engine, ownerLabel: OPENSHELL_OWNER_LABEL, owner: OPENSHELL_OWNER, modeLabel: OPENSHELL_MODE_LABEL, gateway }), { timeoutMs: 60_000 });
911
+ // Created and not refused at all: the gateway took the binds, so its settings admit them.
912
+ log("openshell_bind_probe_created", { container: name });
913
+ log("openshell_bind_probed", { answer: "admitted" });
914
+ return { admitted: true };
915
+ } catch (err) {
916
+ const judged = judgeBindAnswer(`${err?.stderr ?? ""} ${err?.stdout ?? ""}`);
917
+ // Said once per probe, by answer: the lane reads it to tell a probe that ran from a gate that skipped one.
918
+ log("openshell_bind_probed", { answer: judged.setting ?? (judged.admitted ? "admitted" : "unread") });
919
+ if (judged.setting || judged.admitted) return judged;
920
+ return { unknown: "probe-unread" };
921
+ } finally {
922
+ await run(["sandbox", "delete", name]).catch(() => {});
923
+ if (source !== null) {
924
+ try {
925
+ fs.rmSync(source, { recursive: true, force: true });
926
+ } catch {}
927
+ }
928
+ }
929
+ };
930
+ // The gate itself: `{ refused: setting }`, `{ ok: true }`, or `{ unavailable: reason }` (retried as infrastructure).
931
+ const bindGate = async (job) => {
932
+ if (bindVerdict !== null && now() < bindVerdict.until) return bindVerdict.setting ? { refused: bindVerdict.setting } : { ok: true };
933
+ // By ID, as the create is: a NAME the engine lacks would be pulled by the gateway (measured). An image the engine does
934
+ // not hold is the image preflight's refusal, next; the gate decides nothing for this job then.
935
+ let imageId = null;
936
+ try {
937
+ imageId = normalizeImageId(String((await exec(engine, ["image", "inspect", "--format", "{{.Id}}", resolveJobImage(job ?? {}, image)])).stdout ?? "").trim());
938
+ } catch {
939
+ imageId = null;
940
+ }
941
+ if (!/^sha256:[0-9a-f]{64}$/.test(imageId ?? "")) return { ok: true, unprobed: true };
942
+ bindProbing ??= probeBind(imageId).finally(() => {
943
+ bindProbing = null;
944
+ });
945
+ const answer = await bindProbing;
946
+ if (answer.unknown) return { unavailable: answer.unknown };
947
+ // A refusal seen at a create since this probe began is not overturned by it.
948
+ if (bindVerdict?.pinned && now() < bindVerdict.until) return { refused: bindVerdict.setting };
949
+ bindVerdict = answer.setting ? { until: now() + BIND_GATE_REFUSED_MS, setting: answer.setting, pinned: false } : { until: now() + BIND_GATE_ADMITTED_MS, setting: null, pinned: false };
950
+ if (answer.setting) log("openshell_bind_mounts_disabled", { setting: answer.setting, at: "probe" });
951
+ return answer.setting ? { refused: answer.setting } : { ok: true };
952
+ };
953
+
954
+ // Review round 1 (#714), M1: delete a bind-mode sandbox and wait until the listing no longer names it, so nothing in it
955
+ // can still write the host tree. `true` once gone, `false` past `BIND_SETTLE_MS` or when the listing cannot be read.
956
+ // Review round 2: every listing is bounded by what is left of the deadline (a gateway that does not answer cannot hold
957
+ // the slot past it), and an aborted run (a timeout, a shutdown) is asked once and not waited for: its stop already sent
958
+ // the delete, and a shutdown must not sit out the bound.
959
+ const settleSandboxes = async (names, { signal = null } = {}) => {
960
+ for (const name of names) await run(["sandbox", "delete", name]).catch(() => {});
961
+ const deadline = now() + BIND_SETTLE_MS;
962
+ for (;;) {
963
+ let listed;
964
+ try {
965
+ listed = new Set((await listOwned(run, { timeoutMs: Math.max(1, deadline - now()) })).map((s) => s.name));
966
+ } catch {
967
+ listed = null;
968
+ }
969
+ if (listed !== null && names.every((n) => !listed.has(n))) return true;
970
+ if (now() >= deadline || signal?.aborted === true) return false;
971
+ await delay(BIND_SETTLE_POLL_MS);
972
+ }
973
+ };
974
+ // The attempt's settle, once: the run-container hook below, else the teardown. `settleTried` keeps the teardown from
975
+ // waiting the bound a second time after a settle that timed out (review round 2).
976
+ const settleAttempt = async (built, { signal = null } = {}) => {
977
+ const at = attempts.get(built);
978
+ const name = sandboxNameOf(built.name);
979
+ if (at) at.settleTried = true;
980
+ const gone = await settleSandboxes([name], { signal });
981
+ if (at) at.settled = gone;
982
+ if (!gone) log("openshell_bind_sandbox_lingering", { container: name });
983
+ return gone;
984
+ };
985
+
986
+ const stageRun = async ({ opts: built, signal = null, job = null, modelEndpoints = null }) => {
987
+ const { jobDir, size = DEFAULT_JOB_SIZE } = built;
988
+ const name = sandboxNameOf(built.name);
989
+ // `providers` and `profiles`: what this attempt created and imported (issue #704 part 3: the job's own and one per
990
+ // bundle host), so the teardown deletes exactly those; `profileFiles`, the files the imports read.
991
+ const at = { name, jobDir, policyFile: `${jobDir}.openshell-policy.json`, uploadDir: `${jobDir}.openshell-up`, downloadDir: `${jobDir}.openshell-down`, transfers: [], providers: [], profiles: [], profileFiles: [] };
992
+ attempts.set(built, at);
993
+ at.signal = signal;
994
+ live.set(name, (live.get(name) ?? 0) + 1);
995
+ // Review round 2: a marker BESIDE the job dir saying a bind sandbox may write it, so a salvage of a crashed attempt
996
+ // settles that sandbox first, and only then (recovery-queue.mjs, `BESIDE_JOB_DIR`). Removed at teardown once the
997
+ // sandbox is confirmed gone; left (with the job dir) when it is not, or when the worker dies.
998
+ if (binding) {
999
+ at.markerFile = `${jobDir}${OPENSHELL_BIND_MARKER}`;
1000
+ fs.writeFileSync(at.markerFile, "", { mode: 0o600 });
1001
+ }
1002
+ // Asked after every step that waits: a stop is the job's answer, and run-container ends the run as stopped.
1003
+ const stopped = () => signal?.aborted === true || stopping.has(name);
1004
+ // Bind mode passes the relabel decision on (review round 1: SELinux `private` on the worker's own directories).
1005
+ const spec = containerSpec({ image: built.image, name: built.name, jobDir, workspace: built.workspace, outboxDir: built.outboxDir, sessionDir: built.sessionDir, steerInboxDir: binding ? built.steerInboxDir : undefined, globalPiDir: built.globalPiDir, ...(binding ? { relabel: built.relabel === true, workspaceOwned: built.workspaceOwned === true } : {}) });
1006
+ // Issue #686 part 3: bind mode hands the spec's mounts to the driver and copies nothing.
1007
+ const binds = binding ? bindMountsOf(spec) : [];
1008
+ if (!binding) {
1009
+ at.transfers = transfersFromSpec(spec, { binds: false }).map((t) => ({ ...t, sandbox: sandboxPathOf(t.container) }));
1010
+ const unmapped = at.transfers.find((t) => t.sandbox === null);
1011
+ if (unmapped) throw new Error(`openshell: the sandbox layout has no place for ${unmapped.container}`);
1012
+ }
1013
+
1014
+ // The image BY ID, read now from the gateway's own engine: the gateway's driver pulls a NAME it does not hold
1015
+ // (`if_not_present`, measured: a missing tag was fetched from Docker Hub), and no registry serves a local image's
1016
+ // ID, so `--from <id>` is this venue's `--pull=never`. It also pins the run to the image the preflight just read,
1017
+ // not to whatever the tag points at when the gateway gets to it.
1018
+ // The network binaries ride the same read (issue #684): the policy's rules name them, and the image declares them.
1019
+ const inspected = await exec(engine, ["image", "inspect", "--format", `{{.Id}}|{{index .Config.Labels "${NET_BINARIES_LABEL}"}}`, built.image]);
1020
+ if (stopped()) return;
1021
+ const [rawId, rawBinaries = ""] = String(inspected.stdout ?? "").trim().split("|");
1022
+ const imageId = normalizeImageId(rawId);
1023
+ if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error(`openshell: ${engine} gave no image ID for the job image`);
1024
+ const binaries = rawBinaries === "" || rawBinaries === "<no value>" ? [] : rawBinaries.split(",");
1025
+
1026
+ // The checks a job is refused for before spend ran in `observationPreflight` (the same function, the same
1027
+ // sources). The image's label cannot refuse here: the runner's node is always first, and a bad entry is skipped
1028
+ // and logged, so only a source edited between the preflight and now can, and that retries into the preflight's
1029
+ // own refusal.
1030
+ const network = networkFor(job, modelEndpoints?.endpoints ?? null, binaries);
1031
+ if (network.refused) {
1032
+ log("openshell_policy_untranslatable", { container: name, refused: network.refused });
1033
+ throw new Error(`openshell: the job's network policy could not be built (${network.refused})`);
1034
+ }
1035
+ // No rule at all (no allowlist, no endpoint) leaves the key out: the body is then exactly #683's, deny-all, which
1036
+ // OpenShell reads back as written.
1037
+ // Issue #686 part 3: a bind-mode policy lists the binds and runs as the job user (copy mode's is the image's uid).
1038
+ const base = binding ? sandboxPolicy({ ...policyIds(built.user), binds }) : sandboxPolicy();
1039
+ const policy = Object.keys(network.network_policies).length > 0 ? { ...base, ...network } : base;
1040
+ // BESIDE the job directory, never inside what is uploaded: the policy is the box, not an input the agent reads.
1041
+ // Issue #684: the job's credentials, into a per-job provider (CONST-VENUE-EGRESS-LAYER, OQ-028 closed here): an
1042
+ // endpointless profile naming every secret of the env, a provider of it whose values travel in the CLI's own
1043
+ // ENVIRONMENT (`--credential NAME` reads `$NAME`, measured), never its argv, and every endpoint of the policy
1044
+ // bound to it, so a placeholder resolves only in a request to a host the policy admits. With no rule the job
1045
+ // reaches nothing, so no provider is made and the secrets go nowhere.
1046
+ //
1047
+ // Issue #704 part 3: a project secret bundle splits that (`bundleProviderPlan`). The job's own provider keeps every
1048
+ // secret but the bundle's; each bundle HOST gets a provider of its own holding the variables the bundle binds to
1049
+ // it, and only that host's endpoints are bound to it (one provider per endpoint at the pin, `judgeBundle` says
1050
+ // why); every other bundle variable goes in plain, by file (`BUNDLE_ENV_FILE`). The bundle was judged before
1051
+ // spend (`bundleGate`), so a plan this cannot build here is a defect, refused as infrastructure, never run around.
1052
+ const plan = bundleProviderPlan({ name, env: built.env ?? {}, bundleKeys: built.bundleKeys ?? null });
1053
+ if (plan.collision) {
1054
+ log("openshell_bundle_collision", { container: name, name: plan.collision });
1055
+ throw new Error(`openshell: ${plan.collision} would be exposed twice in the sandbox`);
1056
+ }
1057
+ const secrets = plan.job.keys;
1058
+ // Review round 1, L5: a bundle's variables are judged by name before spend (`judgeBundle`), and a plain one is never
1059
+ // a provider key, so none is said here as a secret this venue could not carry.
1060
+ const bundleNames = new Set([...Object.keys(built.bundleKeys?.endpoints ?? {}), ...(built.bundleKeys?.plain ?? [])]);
1061
+ const unbindable = unbindableSecretNamesOf(built.env ?? {}).filter((k) => !bundleNames.has(k));
1062
+ // Review round 1, H1: the plain variables, by file in the job directory, never on the exec. Written before the
1063
+ // upload (copy mode carries the job directory) or the create (bind mode binds it), 0600, removed at teardown.
1064
+ if (plan.plain.length > 0) {
1065
+ at.bundleEnvFile = join(jobDir, BUNDLE_ENV_FILE);
1066
+ fs.writeFileSync(at.bundleEnvFile, `${JSON.stringify(Object.fromEntries(plan.plain.map((k) => [k, built.env[k]])))}\n`, { mode: 0o600 });
1067
+ }
1068
+ if (unbindable.length > 0) log("openshell_secret_unbindable", { container: name, names: unbindable });
1069
+ const rules = policy.network_policies ?? {};
1070
+ const hasRules = Object.keys(rules).length > 0;
1071
+ const endpointHosts = new Set(Object.values(rules).flatMap((rule) => rule.endpoints.map((e) => String(e.host).toLowerCase())));
1072
+ const unbound = plan.bundles.find((b) => !endpointHosts.has(b.host));
1073
+ if (unbound) {
1074
+ log("openshell_bundle_host_unbound", { container: name, host: unbound.host });
1075
+ throw new Error("openshell: a bundle host is not an endpoint of the job's policy");
1076
+ }
1077
+ // Named by the sandbox, so every attempt of one job uses the same names: an earlier attempt's leftover (a delete
1078
+ // refused while its sandbox lingered, a crash between import and create) is removed first, once, or this create
1079
+ // would collide with it until the next boot's reap (review M2).
1080
+ const makeProvider = async ({ name: provider, keys, auth = {} }) => {
1081
+ await run(["provider", "delete", provider]).catch(() => {});
1082
+ await run(["provider", "profile", "delete", provider]).catch(() => {});
1083
+ // Final review round: the file is deleted as soon as the import has answered, either way, since nothing reads it
1084
+ // after the import. A name per provider (`.openshell-profile-<name>.json`) is not one `BESIDE_JOB_DIR`'s fixed
1085
+ // suffixes match, so a worker that died between the write and the import would leave it for good; deleting it at
1086
+ // once makes that window the import's alone, and the teardown's removal stays the backstop. Names only.
1087
+ const file = `${jobDir}.openshell-profile-${provider}.json`;
1088
+ at.profileFiles.push(file);
1089
+ fs.writeFileSync(file, `${JSON.stringify(providerProfileFor(provider, keys, { auth }))}\n`, { mode: 0o600 });
1090
+ try {
1091
+ await run(["provider", "profile", "import", "-f", file]);
1092
+ } finally {
1093
+ try {
1094
+ fs.rmSync(file, { force: true });
1095
+ } catch {}
1096
+ }
1097
+ at.profiles.push(provider);
1098
+ // The CLI child's environment is the worker's own, as on every other step (so it reaches the same gateway with
1099
+ // the same settings), plus the values; never its argv.
1100
+ const valueEnv = { ...process.env, ...Object.fromEntries(keys.map((k) => [k, built.env[k]])) };
1101
+ await run(["provider", "create", "--name", provider, "--type", provider, ...keys.flatMap((k) => ["--credential", k]), "--label", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`], { env: valueEnv });
1102
+ at.providers.push(provider);
1103
+ };
1104
+ const jobProvider = secrets.length > 0 && hasRules ? name : null;
1105
+ if (jobProvider !== null) await makeProvider({ name, keys: secrets });
1106
+ else if (secrets.length > 0) log("openshell_secrets_unbound", { container: name, n: secrets.length });
1107
+ for (const b of plan.bundles) await makeProvider(b);
1108
+ // The bindings: a bundle host's endpoints to its own provider, every other endpoint to the job's, as before.
1109
+ const byHost = new Map(plan.bundles.map((b) => [b.host, b.name]));
1110
+ for (const rule of Object.values(rules)) {
1111
+ for (const endpoint of rule.endpoints) {
1112
+ const provider = byHost.get(String(endpoint.host).toLowerCase()) ?? jobProvider;
1113
+ if (provider !== null) endpoint.credential_binding = { provider };
1114
+ }
1115
+ }
1116
+ if (plan.bundles.length > 0 || plan.plain.length > 0) log("openshell_bundle_bound", { container: name, providers: plan.bundles.length, placeholders: plan.bundles.reduce((n, b) => n + b.keys.length, 0), plain: plan.plain.length });
1117
+ // Issue #712: what this attempt's policy admitted and bound, for the record's `openshell` (collectRun carries it on
1118
+ // the result, run-history.mjs `recordedOpenshell` rebuilds it). The hosts are the policy's own endpoints, which come
1119
+ // from the operator's allowlist, model endpoints and the fixed forge host, never from the job; the repository the
1120
+ // push rule names is a PATH of that rule and is not taken. Counts only for the credentials: never a value, never
1121
+ // a key name. Set here, after every provider exists, so a record never claims a provider the create did not get.
1122
+ at.facts = { mode, ruleHosts: [...endpointHosts], providers: at.providers.length, placeholders: (jobProvider !== null ? secrets.length : 0) + plan.bundles.reduce((n, b) => n + b.keys.length, 0) };
1123
+ if (stopped()) return;
1124
+ fs.writeFileSync(at.policyFile, `${JSON.stringify(policy)}\n`, { mode: 0o600 });
1125
+ // Issue #686 part 3: the mode label and the binds, bind mode only, so copy mode's create is unchanged.
1126
+ // The job dir's name as a label too (review round 1, M1), so a salvage of a lost attempt's tree can find and end the
1127
+ // sandbox that bound it before reading it (`settleJobDir`).
1128
+ const bindArgs = binding ? ["--label", `${OPENSHELL_MODE_LABEL}=bind`, "--label", `${OPENSHELL_JOB_DIR_LABEL}=${basename(jobDir)}`, "--driver-config-json", driverConfigFor(engine, binds)] : [];
1129
+ let createdOut;
1130
+ try {
1131
+ createdOut = await run(["sandbox", "create", "--detach", "--name", name, "--from", imageId, "--label", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "--label", `${SIZE_LABEL_MEM}=${size.memMiB}`, "--label", `${SIZE_LABEL_CPU}=${size.cpuCenti}`, ...bindArgs, ...sizeArgs(size), "--policy", at.policyFile, ...at.providers.flatMap((p) => ["--provider", p]), "--no-auto-providers", "-o", "json"], { timeoutMs: 360_000 });
1132
+ } catch (err) {
1133
+ // The backstop for a probe that admitted: the gateway refused the binds at the create, with one of its settings
1134
+ // sentences. This attempt is infrastructure (nothing ran, refunded), and the verdict is held so the next attempt's
1135
+ // preflight refuses it before spend instead of retrying into the same refusal.
1136
+ if (binding) {
1137
+ const judged = judgeBindAnswer(`${err?.stderr ?? ""} ${err?.stdout ?? ""}`);
1138
+ if (judged.setting) holdBindRefusal(judged.setting, "create");
1139
+ }
1140
+ throw err;
1141
+ }
1142
+ const created = parseJson(createdOut.stdout, "sandbox create");
1143
+ if (created?.phase !== "Ready") throw new Error(`openshell sandbox create: phase ${JSON.stringify(created?.phase ?? null)}, not Ready`);
1144
+ if (stopped()) return;
1145
+ // READ BACK (`CONST-VENUE-EGRESS-LAYER`'s second condition): the policy in force is the one written, from this
1146
+ // sandbox, or the job does not run. With a network rule OpenShell adds its baseline paths (measured: `/var/log`
1147
+ // read-only, the one baseline path this policy does not list), so the read-back is held to the written body
1148
+ // plus exactly that (`expectedPolicy`), never to "close enough".
1149
+ const effective = parseJson((await run(["policy", "get", name, "--full", "-o", "json"])).stdout, "policy get");
1150
+ const readBack = JSON.stringify(canonical(effective?.policy));
1151
+ if (effective?.policy_source !== "sandbox" || !expectedPolicies(policy, { providers: at.providers }).some((p) => JSON.stringify(canonical(p)) === readBack)) {
1152
+ log("openshell_policy_mismatch", { container: name, source: typeof effective?.policy_source === "string" ? effective.policy_source : null });
1153
+ throw new Error("openshell: the sandbox's policy read back differs from the one the worker wrote");
1154
+ }
1155
+ // Bind mode: the driver mounted the job's directories; there is nothing to upload.
1156
+ if (binding) return;
1157
+ // The inputs, each to its place under /sandbox. A directory keeps its basename on upload and merges into the
1158
+ // destination (measured), so one whose basename is not its place's, or one with another mount nested inside (the
1159
+ // job directory holds the workspace and the session), is staged under the right name first, the nested ones left
1160
+ // out. `--no-git-ignore`: a bind shows the agent the whole tree, so a copy must not drop what .gitignore names.
1161
+ for (const t of at.transfers) {
1162
+ const place = basename(t.sandbox);
1163
+ let source = t.host;
1164
+ if (basename(t.host) !== place || t.contains.length > 0) {
1165
+ const nested = at.transfers.filter((o) => t.contains.includes(o.container)).map((o) => o.host);
1166
+ source = join(at.uploadDir, place);
1167
+ fs.rmSync(source, { recursive: true, force: true });
1168
+ fs.mkdirSync(at.uploadDir, { recursive: true, mode: 0o700 });
1169
+ fs.cpSync(t.host, source, { recursive: true, verbatimSymlinks: true, filter: (src) => !nested.some((n) => src === n || src.startsWith(`${n}/`)) });
1170
+ // Issue #684: the job's MCP document names its stdio commands by the `mounts` layout's paths; on this venue
1171
+ // the overlay sits at /sandbox/pi-global, so the staged copy is rewritten (the host's file is untouched).
1172
+ if (t.container === LAYOUTS.mounts.job) rewriteMcpForSandbox(join(source, "mcp.json"), fs);
1173
+ }
1174
+ await run(["sandbox", "upload", name, source, dirname(t.sandbox), "--no-git-ignore"], { timeoutMs: 600_000 });
1175
+ if (stopped()) return;
1176
+ }
1177
+ };
1178
+
1179
+ // Issue #712: every path out of collectRun carries the attempt's policy facts (`at.facts`, set by the stage once its
1180
+ // providers exist), so an aborted or a failed-download run's record still says what its sandbox was given.
1181
+ const withFacts = (result, at) => (at?.facts && result !== null && typeof result === "object" ? { ...result, openshell: at.facts } : result);
1182
+ const collectRun = async (result, { opts: built }) => withFacts(await collectOutputs(result, { opts: built }), attempts.get(built));
1183
+ const collectOutputs = async (result, { opts: built }) => {
1184
+ const at = attempts.get(built);
1185
+ // Bind mode: the writable outputs are the host's own directories, and the sandbox was already ended before the
1186
+ // transcript's final read (`settleRun`, review rounds 1 and 2). A sandbox not confirmed gone rides the result as
1187
+ // `lingering`, its exit code UNCHANGED (CONST-RETRY-INFRA-ONLY: a timeout never rewrites a determinate exit into a
1188
+ // retry); the processor decides from it.
1189
+ if (binding) return result;
1190
+ // A stopped run's sandbox is already being deleted, and its outputs are not read: the abort is the answer.
1191
+ if (result?.aborted === true) return result;
1192
+ if (!at) throw new Error("openshell: no staged attempt to bring the outputs of back");
1193
+ // Each writable output into a fresh directory first, then swapped for the host's, so the host tree is exactly the
1194
+ // sandbox's: a merge into the old one would keep every file the agent deleted.
1195
+ //
1196
+ // A download that FAILS after the job ran never throws: the run's own result (its verified tokens and usage) is
1197
+ // returned, so the processor records what the provider was paid. What changes is the classification: the
1198
+ // workspace and the session the processor would read are the stale upload, so a run that did not end on a
1199
+ // determinate refusal is reported as the runner's infrastructure exit, 1, retried with its reservation kept (the
1200
+ // safe direction); exit 2, a determinate refusal, stays exit 2 and is not paid for twice.
1201
+ for (const t of at.transfers.filter((x) => x.direction === "in-out")) {
1202
+ const fresh = join(at.downloadDir, basename(t.sandbox));
1203
+ try {
1204
+ fs.rmSync(fresh, { recursive: true, force: true });
1205
+ fs.mkdirSync(fresh, { recursive: true, mode: 0o700 });
1206
+ await run(["sandbox", "download", at.name, t.sandbox, fresh], { timeoutMs: 600_000 });
1207
+ fs.rmSync(t.host, { recursive: true, force: true });
1208
+ fs.renameSync(fresh, t.host);
1209
+ } catch {
1210
+ log("openshell_download_failed", { container: at.name, path: t.sandbox, code: result?.code ?? null });
1211
+ return result?.code === 2 ? result : { ...result, code: 1, exitReason: null };
1212
+ }
1213
+ }
1214
+ return result;
1215
+ };
1216
+
1217
+ const deleteProvider = providerDeleter(run, { log, delay });
1218
+ // The bundle hosts' providers of one sandbox, by the owner label and their names, each once (the gone-check's).
1219
+ const deleteBundleProvidersOf = async (sandbox) => {
1220
+ const listed = parseJson((await run(["provider", "list", "--label-selector", `${OPENSHELL_OWNER_LABEL}=${OPENSHELL_OWNER}`, "-o", "json"])).stdout, "provider list");
1221
+ for (const p of (listed?.providers ?? []).filter((x) => BUNDLE_PROVIDER_NAME.test(x?.name ?? "") && x.name.startsWith(`${sandbox}-b`))) {
1222
+ await run(["provider", "delete", p.name]).catch(() => {});
1223
+ await run(["provider", "profile", "delete", p.name]).catch(() => {});
1224
+ }
1225
+ };
1226
+
1227
+ const teardownRun = async ({ opts: built }) => {
1228
+ const name = sandboxNameOf(built.name);
1229
+ const at = attempts.get(built);
1230
+ attempts.delete(built);
1231
+ // Deleted even when the stage never recorded the attempt: a create that answered nothing may still have made one.
1232
+ // Bind mode waits for it to be gone on every path collectRun did not (a throw, a spawn error), for M1's reason.
1233
+ if (binding) {
1234
+ if (at?.settleTried !== true) await settleAttempt(built, { signal: at?.signal ?? null });
1235
+ if (at?.markerFile && at.settled === true) {
1236
+ try {
1237
+ fs.rmSync(at.markerFile, { force: true });
1238
+ } catch {}
1239
+ }
1240
+ } else {
1241
+ try {
1242
+ await run(["sandbox", "delete", name]);
1243
+ } catch {
1244
+ log("openshell_delete_failed", { container: name });
1245
+ }
1246
+ }
1247
+ // Issue #684: the provider and its profile, after the sandbox. A provider still attached is refused (measured:
1248
+ // "is attached to sandbox(es)"), and a sandbox delete is accepted before its cleanup ends, so the provider's
1249
+ // delete is asked again for a bounded while; what is left, the reaper deletes by the owner label.
1250
+ // Issue #704 part 3: every provider this attempt made (the job's and each bundle host's), then any profile imported
1251
+ // for a provider whose create never answered.
1252
+ for (const provider of at?.providers ?? []) await deleteProvider(provider);
1253
+ for (const profile of (at?.profiles ?? []).filter((p) => !at.providers.includes(p))) await run(["provider", "profile", "delete", profile]).catch(() => {});
1254
+ for (const path of at ? [at.policyFile, ...at.profileFiles, at.uploadDir, at.downloadDir, ...(at.bundleEnvFile ? [at.bundleEnvFile] : [])] : []) {
1255
+ try {
1256
+ fs.rmSync(path, { recursive: true, force: true });
1257
+ } catch {}
1258
+ }
1259
+ if (at) {
1260
+ const left = (live.get(name) ?? 1) - 1;
1261
+ if (left > 0) live.set(name, left);
1262
+ else live.delete(name);
1263
+ }
1264
+ stopping.delete(name);
1265
+ };
1266
+
1267
+ const runContainer = makeRunContainerFn({
1268
+ image,
1269
+ hostEnv,
1270
+ ...(openJobLog ? { openJobLog } : {}),
1271
+ ...(onOutput ? { onOutput } : {}),
1272
+ globalPiDir,
1273
+ allowGlobalExtensions,
1274
+ packagePaths,
1275
+ forwardEnv,
1276
+ authFromPi,
1277
+ forgeHosts,
1278
+ // No egress variables: the layer's policy is the egress control, and the runner must not dial a proxy that does
1279
+ // not exist (image/runner/src/env-proxy.mjs installs one only on NODE_USE_ENV_PROXY=1).
1280
+ egress: false,
1281
+ // Never-started is decided by the step that failed (`stageRun` throws `container-never-started`), not by a code:
1282
+ // OpenShell's CLI failures exit 1, the runner's own infrastructure code (measured).
1283
+ neverStartedExits: [],
1284
+ bin: "openshell",
1285
+ // The sandbox's own name; PI_JOB_ID in the env stays the container name, which the exit line and its MAC carry.
1286
+ buildArgs: (built) => openshellExecArgs({ name: sandboxNameOf(built.name), env: built.env, gateway, mode }),
1287
+ // Issue #704 part 3: a bundle's plain variables reach the runner by file (`BUNDLE_ENV_FILE`), never the exec.
1288
+ bundleEnvFile: true,
1289
+ // Issue #730: the sandbox's connect broker must read the caller's memory, so the key holders start readable.
1290
+ observableRunner: true,
1291
+ memoryLimitOf: (_args, built) => (built.size?.memMiB ?? DEFAULT_JOB_SIZE.memMiB) * 1024 * 1024,
1292
+ // Copy mode: no recovery staging, since the session file is in the sandbox until the download, so a tail on the host
1293
+ // reads nothing. The live transcript comes over the exec's stderr instead, and steering over its stdin (issue #686).
1294
+ // Bind mode (part 3): the `mounts` layout and the host file, tailed as on `local`, the recovery staging included
1295
+ // (makeRunContainer's own defaults), and steering through the `/inbox` bind.
1296
+ ...(binding ? { layout: "mounts", transcript: "file", settleRun: ({ opts: built }) => settleAttempt(built, { signal: attempts.get(built)?.signal ?? null }) } : { layout: "sandbox", stageLive: null, transcript: "stdio" }),
1297
+ ...(typeof openLiveTranscript === "function" ? { openLiveTranscript } : {}),
1298
+ ...(typeof openInboxRelay === "function" ? { openInboxRelay } : {}),
1299
+ stageRun,
1300
+ collectRun,
1301
+ teardownRun,
1302
+ log,
1303
+ spawnFn,
1304
+ });
1305
+
1306
+ // The per-job, pre-spend venue checks (`CONST-VENUE-EGRESS-LAYER`'s third and fourth conditions): free reads, each
1307
+ // refusing by name. A gateway that does not answer is infrastructure (retried); one that answers wrong is policy.
1308
+ const observationPreflight = async (job) => {
1309
+ // Issue #684: `run.recover` needs a checkpoint of the running session and its workspace, and copy mode keeps both
1310
+ // inside the sandbox until the exit's download. #686 brought the transcript back over stdio, but not the workspace,
1311
+ // and there is no pause verb for a live checkpoint, so a lost attempt leaves nothing on the host to salvage or
1312
+ // restore (bind mode, whose workspace IS on the host, serves it). So it is refused by name, before
1313
+ // anything is read, never run unarmed (`serves.recovery: "no"`); since issue #712 a trigger that names this venue
1314
+ // with `run.recover` is refused in the triggers file too (`recoverVenueProblem`), and this check stays for one that
1315
+ // names no venue and lands here as the deployment default.
1316
+ // A `kind: local` job runs unarmed on every venue (`recoverSkipped: "local"`), so it is not refused here either.
1317
+ // Issue #686 part 3: bind mode serves it (the host workspace is salvaged and snapshotted at exit, as on `local`).
1318
+ if (!binding && job?.recover === true && job?.kind !== "local") return { venueRefused: { reason: "openshell-recover-unsupported", comment: OPENSHELL_REFUSALS["openshell-recover-unsupported"] } };
1319
+ if (credentialReadsValue(job?.provider, hostEnv)) {
1320
+ log("openshell_credential_reads_value", { provider: typeof job?.provider === "string" ? job.provider : null });
1321
+ return { venueRefused: { reason: "openshell-credential-reads-value", comment: OPENSHELL_REFUSALS["openshell-credential-reads-value"] } };
1322
+ }
1323
+ // Issue #730: a free local read, before any CLI call, so the answer does not wait on the gateway. Yama must be
1324
+ // EXACTLY 1: 0 (or unreadable) leaves the readable key holders' memory open to a tool, and 2 or 3 stops the
1325
+ // broker too, so the job would reserve and then fail.
1326
+ const yama = judgePtraceScope(() => readPtraceScope());
1327
+ if (!yama.ok) {
1328
+ const reason = yama.reason === "too-strict" ? "openshell-ptrace-scope-too-strict" : "openshell-ptrace-scope-unprotected";
1329
+ log("openshell_ptrace_scope_refused", { reason: yama.reason, ...(yama.scope === null ? {} : { scope: yama.scope }) });
1330
+ return { venueRefused: { reason, comment: OPENSHELL_REFUSALS[reason] } };
1331
+ }
1332
+ let status;
1333
+ try {
1334
+ status = judgeGatewayStatus(parseJson((await run(["status", "-o", "json"])).stdout, "status"));
1335
+ } catch {
1336
+ // No `message`: the processor reads one as a host FILE that could not be read. Its venue-neutral sentence is the
1337
+ // right one, and the reason is logged here, without the CLI's own words.
1338
+ log("openshell_unavailable", { reason: "cli-no-answer" });
1339
+ return { unavailable: true };
1340
+ }
1341
+ if (!status.connected) {
1342
+ log("openshell_unavailable", { reason: "gateway-not-connected" });
1343
+ return { unavailable: true };
1344
+ }
1345
+ // Per job as well as at boot (review M4): a gateway down at boot and started later at another release is read here.
1346
+ if (status.local && status.version !== OPENSHELL_TESTED_VERSION) {
1347
+ log("openshell_gateway_untested", { version: status.version });
1348
+ return { venueRefused: { reason: "openshell-gateway-untested", comment: OPENSHELL_REFUSALS["openshell-gateway-untested"] }, observations: { openshellGatewayLocal: true } };
1349
+ }
1350
+ if (!status.local) return { venueRefused: { reason: "openshell-gateway-not-local", comment: OPENSHELL_REFUSALS["openshell-gateway-not-local"] }, observations: { openshellGatewayLocal: false } };
1351
+ // A gateway-wide policy replaces every sandbox's. With none set the CLI exits 1 with this sentence (measured, the
1352
+ // one non-JSON answer this adapter reads); any other failure is not knowing, which is infrastructure.
1353
+ try {
1354
+ await run(["policy", "get", "--global", "-o", "json"]);
1355
+ return { venueRefused: { reason: "openshell-global-policy", comment: OPENSHELL_REFUSALS["openshell-global-policy"] }, observations: { openshellGatewayLocal: true } };
1356
+ } catch (err) {
1357
+ // The CLI wraps its error box to the terminal's width (measured: the sentence broke across two lines behind a
1358
+ // `│`), so the text is unwrapped before it is read.
1359
+ const said = String(err?.stderr ?? "").replace(/[│\s]+/g, " ");
1360
+ if (!/no global policy revision found/.test(said)) {
1361
+ log("openshell_unavailable", { reason: "global-policy-unread" });
1362
+ return { unavailable: true };
1363
+ }
1364
+ }
1365
+ // The gateway is on this host, so this host's kernel is the sandbox's on Linux. Elsewhere (Docker Desktop's VM)
1366
+ // it cannot be read from here: said, and left to the create, which fails closed (`hard_requirement`).
1367
+ if (platform === "linux") {
1368
+ let lsm = null;
1369
+ try {
1370
+ lsm = String(readLsm());
1371
+ } catch {
1372
+ lsm = null;
1373
+ }
1374
+ if (lsm === null) log("openshell_landlock_unknown", { reason: "lsm-unreadable" });
1375
+ else if (!lsm.split(",").map((s) => s.trim()).includes("landlock")) return { venueRefused: { reason: "openshell-landlock-missing", comment: OPENSHELL_REFUSALS["openshell-landlock-missing"] }, observations: { openshellGatewayLocal: true } };
1376
+ } else {
1377
+ log("openshell_landlock_unknown", { reason: "not-linux" });
1378
+ }
1379
+ // Issue #684: a source this venue cannot translate (a top-level wildcard, a loopback endpoint, a repository path
1380
+ // a push rule cannot name) refuses the job here, before spend, with the entry in the log only. Checked with the
1381
+ // runner's path standing in for the image's binaries, which only the stage's inspect reads.
1382
+ let network;
1383
+ try {
1384
+ network = networkFor(job, null, []);
1385
+ } catch {
1386
+ log("openshell_unavailable", { reason: "egress-sources-unread" });
1387
+ return { unavailable: true };
1388
+ }
1389
+ if (network.refused) {
1390
+ log("openshell_policy_untranslatable", { refused: network.refused, ...(network.entry ? { entry: network.entry } : {}) });
1391
+ return { venueRefused: { reason: "openshell-egress-untranslatable", comment: OPENSHELL_REFUSALS["openshell-egress-untranslatable"] }, observations: { openshellGatewayLocal: true } };
1392
+ }
1393
+ // Issue #686 part 3: bind mode's gateway settings, last (the one check that asks a create, which never runs a job).
1394
+ if (binding) {
1395
+ const gate = await bindGate(job);
1396
+ if (gate.refused) return { venueRefused: { reason: "openshell-bind-mounts-disabled", comment: OPENSHELL_REFUSALS["openshell-bind-mounts-disabled"] }, observations: { openshellGatewayLocal: true } };
1397
+ if (gate.unavailable) {
1398
+ log("openshell_unavailable", { reason: `bind-${gate.unavailable}` });
1399
+ return { unavailable: true };
1400
+ }
1401
+ }
1402
+ return { observations: { openshellGatewayLocal: true } };
1403
+ };
1404
+
1405
+ // Issue #704 part 3: a project secret bundle judged against this venue before the sandbox exists and before spend
1406
+ // (`judgeBundle`), from the same sources the policy is generated from. The processor asks it twice, inside its bundle
1407
+ // read: with the hosts alone before the resolver runs (a file that names a host this job cannot bind costs no read of
1408
+ // the operator's manager), then with the variables. `{ ok, placeholders?, plain? }`, `{ venueRefused }`, or
1409
+ // `{ unavailable }` when a source cannot be read (retried, as the observation preflight's own read is).
1410
+ const bundleGate = async (job, { endpoints = {}, vars = null, shadowed = [], credentialHosts = [], capabilities = [] } = {}) => {
1411
+ let network;
1412
+ try {
1413
+ network = networkFor(job, readEndpoints(), []);
1414
+ } catch {
1415
+ log("openshell_unavailable", { reason: "egress-sources-unread" });
1416
+ return { unavailable: true };
1417
+ }
1418
+ if (network.refused) return { venueRefused: { reason: "openshell-egress-untranslatable", comment: OPENSHELL_REFUSALS["openshell-egress-untranslatable"] } };
1419
+ const judged = judgeBundle({ endpoints, vars, shadowed, network, credentialHosts: credentialHostsOf({ forgeHosts, extra: credentialHosts }), capabilities });
1420
+ if (judged.refused) {
1421
+ // Names and the host only: operator config and the manager's names, never a value.
1422
+ log("refused_openshell_bundle", { reason: judged.refused, name: judged.name ?? null, host: judged.host ?? null });
1423
+ return { venueRefused: { reason: judged.refused, comment: OPENSHELL_REFUSALS[judged.refused] } };
1424
+ }
1425
+ return judged;
1426
+ };
1427
+
1428
+ // Issue #686 part 3: who a bind-mode job runs as, after the image probe (its `anyUid`). Copy mode carries none, so the
1429
+ // registry's default (the image's own user) stands, exactly as before.
1430
+ // Review round 1 (#714), M2: the engine's `docker info`, read through this bundle's exec, cached like `local`'s (an
1431
+ // answered read for `JOB_USER_FACTS_MAX_AGE_MS`; an unanswered one never), so a rootless or remapped Docker behind
1432
+ // the gateway refuses before spend.
1433
+ const engineFacts =
1434
+ readDaemonFacts ??
1435
+ makeDaemonFactsReader({
1436
+ run: async (args) => {
1437
+ try {
1438
+ return { code: 0, stdout: String((await exec(engine, args, { timeoutMs: DAEMON_FACTS_TIMEOUT_MS, maxBuffer: DAEMON_FACTS_MAX_BUFFER })).stdout ?? "") };
1439
+ } catch (error) {
1440
+ return { code: typeof error?.code === "number" ? error.code : null, stdout: "", error };
1441
+ }
1442
+ },
1443
+ });
1444
+ let factsCache = null;
1445
+ const cachedFacts = async () => {
1446
+ if (factsCache !== null && now() - factsCache.at < JOB_USER_FACTS_MAX_AGE_MS) return factsCache.daemon;
1447
+ const daemon = await engineFacts();
1448
+ if (daemon?.answered === true) factsCache = { at: now(), daemon };
1449
+ return daemon;
1450
+ };
1451
+ let jobUserSaid = null;
1452
+ const jobUserPreflight = async (_job, { capabilities = [] } = {}) => {
1453
+ const daemon = platform === "darwin" || platform === "win32" ? null : await cachedFacts();
1454
+ const chosen = decideOpenshellBindUser({ platform, euid, egid, capabilities, daemon });
1455
+ const said = JSON.stringify(chosen);
1456
+ if (said !== jobUserSaid) {
1457
+ jobUserSaid = said;
1458
+ log("job_user", { backend: OPENSHELL_BIND_BACKEND, user: chosen.user ?? null, refused: chosen.refused ?? null, cause: chosen.cause ?? null, reason: chosen.reason ?? null });
1459
+ }
1460
+ return chosen;
1461
+ };
1462
+
1463
+ // Review round 1 (#714), M1: before a salvage reads a lost attempt's job directory, the sandbox that bound it (found
1464
+ // by its job-dir label) is deleted and confirmed gone. `true` when none is left, `false` when one lingers or the
1465
+ // listing cannot be read, and the salvage then reads nothing.
1466
+ const settleJobDir = async (jobDirName) => {
1467
+ try {
1468
+ const named = (await listOwned(run)).filter((s) => isBindSandbox(s) && s.labels?.[OPENSHELL_JOB_DIR_LABEL] === jobDirName).map((s) => s.name);
1469
+ if (named.length === 0) return true;
1470
+ const gone = await settleSandboxes(named);
1471
+ if (!gone) log("openshell_bind_sandbox_lingering", { jobDir: jobDirName, code: null });
1472
+ return gone;
1473
+ } catch {
1474
+ return false;
1475
+ }
1476
+ };
1477
+
1478
+ return {
1479
+ name: venue,
1480
+ declares: BACKENDS[venue].declares,
1481
+ neverStartedExits: [],
1482
+ containerName: jobContainerName,
1483
+ namePrefix: JOB_NAME_PREFIX,
1484
+ // Copy mode copies, so the registry arms no file steer channel there (stdin frames instead) and the conformance
1485
+ // harness holds `readOnlyJobInputs` to `asserted`; bind mode binds, so `/inbox` steering is armed as on `local`.
1486
+ binds: binding,
1487
+ runContainer,
1488
+ // The gateway's own engine, and in copy mode `copies`, which arms the `sandboxLayout` capability row on every job.
1489
+ // Both modes are brokered (issue #730), which arms the `observableRunner` row on every job.
1490
+ imagePreflight: makeImagePreflight({ image, bin: engine, copies: !binding, brokered: true, spawnFn }),
1491
+ ...(binding ? { jobUserPreflight, settleJobDir } : {}),
1492
+ // The layer's policy is the egress control; there is no proxy to look for.
1493
+ egressPreflight: async () => ({}),
1494
+ // Marked first, so a stage still creating or uploading under this name stops at its next step (the delete below
1495
+ // finds nothing to delete before the create has answered).
1496
+ stopContainer: async (name) => {
1497
+ stopping.add(sandboxNameOf(name));
1498
+ return run(["sandbox", "delete", sandboxNameOf(name)]);
1499
+ },
1500
+ reap: reapFn ?? reapWith(run, deleteProvider, log),
1501
+ // This mode's sandboxes only (issue #686 part 3), so a host blessing both modes counts each leftover once.
1502
+ listJobContainers: async () =>
1503
+ (await listOwned(run))
1504
+ .filter((s) => isJobNamespace(s.name) && ofMode(mode)(s) && !SANDBOX_GONE_PHASES.has(s.phase))
1505
+ .map((s) => ({ name: s.name, memMiB: intLabel(s.labels?.[SIZE_LABEL_MEM]), cpuCenti: intLabel(s.labels?.[SIZE_LABEL_CPU]) })),
1506
+ containerGone: async (name) => {
1507
+ if (typeof name !== "string" || !isJobNamespace(name)) return null;
1508
+ const sandbox = sandboxNameOf(name);
1509
+ // A sandbox an attempt in this process is between its create and its delete is not gone, whatever name the
1510
+ // question came under (the host budget seeds a crash's leftover by its sandbox name, a live job by its container
1511
+ // name; both map here). Never deleted as an orphan.
1512
+ if (live.has(sandbox)) return false;
1513
+ try {
1514
+ const s = (await listOwned(run)).find((x) => x.name === sandbox);
1515
+ if (!s || SANDBOX_GONE_PHASES.has(s.phase)) {
1516
+ // Issue #684 (review M3): the gone sandbox's provider goes too, so a teardown that could not delete it does
1517
+ // not leave the job's credentials in the gateway until the next boot's reap. Once, not awaited: the
1518
+ // budget's question is about the sandbox, and the boot reap is the backstop.
1519
+ // Issue #704 part 3: and its bundle hosts' providers, found by the owner label and named `<sandbox>-b<n>`.
1520
+ run(["provider", "delete", sandbox])
1521
+ .then(() => run(["provider", "profile", "delete", sandbox]))
1522
+ .catch(() => {})
1523
+ .then(() => deleteBundleProvidersOf(sandbox))
1524
+ .catch(() => {});
1525
+ return true;
1526
+ }
1527
+ if (!SANDBOX_UNSTARTED_PHASES.has(s.phase)) return false;
1528
+ // Issue #686 part 3: the other mode's sandbox is the other bundle's to delete; its live attempts are in its own
1529
+ // `live`, which this one cannot see. Not gone, so the hold stays.
1530
+ if (!ofMode(mode)(s)) return false;
1531
+ await run(["sandbox", "delete", sandbox]);
1532
+ return true;
1533
+ } catch {
1534
+ return null;
1535
+ }
1536
+ },
1537
+ observationPreflight,
1538
+ bundleGate,
1539
+ };
1540
+ }