@edgehero/pi-dispatch 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +199 -30
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,130 @@
1
+ /**
2
+ * doctor's project secret bundle lines (issue #704, REQ-TRIGGER-SECRETS, DES-PROJECT-SECRET-BUNDLES).
3
+ *
4
+ * Its own module, the doctor-openshell.mjs precedent: doctor.mjs is a ten-thousand-line file, and these lines read
5
+ * three files and (with `--secrets`) spawn resolvers, so a seam per input keeps them testable without a host.
6
+ *
7
+ * WHAT IS CHECKED, and why each is a failure rather than a warning:
8
+ * - a bundle naming a profile this host does not declare (or declares twice, or declares in the panel outside
9
+ * PI_SECRET_RESOLVER_ROOTS): every job of that project refuses pre-spend on this host, deliberately;
10
+ * - a bundle naming a project projects.json does not hold: the binding can never apply, and the operator reads it
11
+ * as granting an environment. That is the silent no-op this project refuses;
12
+ * - bundle profiles declared with no triggers file to read: no job on this host can get a bundle, while the host
13
+ * reads as serving them.
14
+ * Two bundles naming one project is refused by the triggers file's own validator, so it surfaces as that file's
15
+ * parse failure, which doctor already prints, rather than as a second line here.
16
+ *
17
+ * And a WARNING per bundle whose project holds a folder: a local job's /workspace IS the operator's folder, so an
18
+ * agent that writes a credential to `.env` there writes the whole bundle's into a real repository. The run.secrets
19
+ * disclosure, raised to a warning because a bundle is a whole environment, not one field.
20
+ *
21
+ * `--secrets` (`secrets: true`) resolves each bundle ON THIS HOST and prints its variable NAMES and their count, never
22
+ * a value. It is opt-in because it spawns every bundle resolver, which reads the manager (and counts against its
23
+ * rate limit) once per bundle.
24
+ */
25
+
26
+ import { FORGE_KINDS } from "./forges.mjs";
27
+ import { mergeSecretProfiles, parseSecretBundleProfiles, servableSecretProfiles } from "./secret-profiles.mjs";
28
+
29
+ /** Whether a projects.json member is a FOLDER (a local job's scope), not a forge-qualified repository. */
30
+ function isFolderMember(member) {
31
+ return typeof member === "string" && !FORGE_KINDS.some((kind) => member.startsWith(`${kind}:`));
32
+ }
33
+
34
+ /** `PI_SECRET_BUNDLES`, never throwing: `{ bundles }` or `{ error }` (the worker refuses to boot on the latter). */
35
+ export function parseSecretBundleProfilesSafe(raw) {
36
+ try {
37
+ return { bundles: parseSecretBundleProfiles(raw), error: null };
38
+ } catch (err) {
39
+ return { bundles: {}, error: err?.message ?? "unparseable" };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The checks. Inputs, every one already read by the caller:
45
+ * - `bundles`: the triggers file's validated `secretBundles` (`{}` when none);
46
+ * - `triggersPresent`: whether the worker has a triggers file to read them from;
47
+ * - `projects` / `projectsError`: projects.json as the worker loads it, or its load failure;
48
+ * - `envRaw`: `PI_SECRET_BUNDLES` as set; `overlayBundles`: the overlay's `secretBundles`; `roots`: PI_SECRET_RESOLVER_ROOTS;
49
+ * - `realExecutablePath`: the resolver's own probe (secrets.mjs `realExecutable`), or null when pi did not load;
50
+ * - `resolveBundle`: `(binding) => resolve result`, only with `--secrets`.
51
+ */
52
+ export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
53
+ const checks = [];
54
+ const { bundles: envBundles, error } = parseSecretBundleProfilesSafe(envRaw);
55
+ if (error) {
56
+ checks.push({ ok: false, label: "PI_SECRET_BUNDLES does not parse", fix: `${error} -- the worker refuses to boot until this is fixed` });
57
+ return checks;
58
+ }
59
+ const ids = Object.keys(bundles);
60
+ const declaredNames = [...new Set([...Object.keys(envBundles), ...Object.keys(overlayBundles)])].sort();
61
+ if (ids.length === 0 && declaredNames.length === 0) return checks;
62
+
63
+ // The overlay half counts only where the worker would use it: an executable inside the roots (servableSecretProfiles
64
+ // asks the resolver's own question). Without the probe (pi did not load) an overlay profile counts as written.
65
+ const overlayPaths = Object.fromEntries(Object.entries(overlayBundles).map(([name, entry]) => [name, entry?.path]));
66
+ const usableOverlay = realExecutablePath === null ? overlayPaths : servableSecretProfiles({ envProfiles: {}, overlayProfiles: overlayPaths, roots, realExecutablePath }).profiles;
67
+ const ambiguous = mergeSecretProfiles(envBundles, overlayBundles).ambiguous !== undefined;
68
+ const ambiguousNames = Object.keys(overlayBundles).filter((name) => Object.hasOwn(envBundles, name));
69
+
70
+ if (declaredNames.length > 0) {
71
+ checks.push({ ok: true, label: `Secret bundle profiles declared: ${declaredNames.map((n) => `${n} (${(Object.hasOwn(envBundles, n) ? envBundles[n] : overlayBundles[n])?.format ?? "?"})`).join(", ")}` });
72
+ // The rate figure, stated once: what a manager's rate limit counts is job starts.
73
+ checks.push({ ok: true, label: "A project with a secret bundle costs one resolver call per job start (never one per variable), so a secrets manager's rate limit counts job starts: size it for your busiest hour of jobs across every bundled project" });
74
+ if (!triggersPresent) {
75
+ checks.push({ ok: false, label: "secret bundle profiles are declared, but the worker has no triggers file to read secretBundles from", fix: "set PI_TRIGGERS_FILE (or put triggers.json in the worker's folder) on this host -- the worker reads which project holds a bundle from its own copy of that file, so without it no job on this host gets one" });
76
+ }
77
+ }
78
+ if (ambiguous) {
79
+ checks.push({ ok: false, label: `${ambiguousNames.length} secret bundle profile name(s) are declared in both PI_SECRET_BUNDLES and the settings overlay: ${ambiguousNames.join(", ")}`, fix: "remove each name from one of the two (neither source wins a collision) -- until then every bundled project's jobs refuse as secret-bundle-profile-ambiguous" });
80
+ }
81
+
82
+ const projectIds = new Set((projects ?? []).map((p) => p?.id));
83
+ for (const id of ids) {
84
+ const b = bundles[id];
85
+ const fromEnv = Object.hasOwn(envBundles, b.profile);
86
+ const fromOverlay = Object.hasOwn(overlayBundles, b.profile);
87
+ const format = fromEnv ? envBundles[b.profile].format : fromOverlay ? overlayBundles[b.profile]?.format : null;
88
+ checks.push({ ok: true, label: `secret bundle ${id}: profile ${b.profile}${format ? ` (${format})` : ""}, project ${b.project}${b.forkReviews === true ? ", fork reviews included" : ""}` });
89
+ if (!fromEnv && !fromOverlay) {
90
+ checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, which this host does not declare`, fix: "declare it in PI_SECRET_BUNDLES as name:/absolute/path:format (format dotenv or json), on EVERY host that takes this project's jobs -- until then they refuse pre-spend as secret-bundle-profile-unknown" });
91
+ } else if (!fromEnv && !Object.hasOwn(usableOverlay, b.profile)) {
92
+ checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, declared in the settings overlay but not usable`, fix: roots.length === 0 ? "PI_SECRET_RESOLVER_ROOTS is unset, so the worker uses no overlay-declared resolver: set it, or declare the profile in PI_SECRET_BUNDLES" : "the resolver must be an executable file whose real path sits inside PI_SECRET_RESOLVER_ROOTS" });
93
+ }
94
+ if (projectsError !== null) {
95
+ checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, and projects.json does not load`, fix: "fix projects.json (its own line above says why) -- a bundle applies to a project's jobs only once the worker knows the project" });
96
+ } else if (!projectIds.has(b.project)) {
97
+ checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, which projects.json does not hold`, fix: "add the project to PI_PROJECTS_FILE, or correct the bundle's project -- until then the bundle reaches no job, while the file reads as granting it" });
98
+ } else {
99
+ const project = projects.find((p) => p?.id === b.project);
100
+ const folders = (project?.members ?? []).filter(isFolderMember);
101
+ if (folders.length > 0) {
102
+ checks.push({ ok: true, warn: true, label: `secret bundle ${id}'s project ${b.project} holds ${folders.length} folder(s), whose local jobs run IN the operator's own folder with the whole bundle in their environment`, fix: "a value the agent writes to .env, .netrc or .git-credentials there lands in your real repository (and in a retained sandbox), and nothing scans for that: keep those folders out of anything you push, or set run.secretBundle: false on their triggers" });
103
+ }
104
+ }
105
+ }
106
+
107
+ if (typeof resolveBundle === "function") {
108
+ for (const id of ids) {
109
+ const b = bundles[id];
110
+ let read;
111
+ try {
112
+ read = await resolveBundle({ id, ...b });
113
+ } catch {
114
+ read = { unreachable: true, failure: "spawn" };
115
+ }
116
+ if (read?.ok === true) {
117
+ const names = Object.keys(read.vars).sort();
118
+ checks.push({ ok: true, label: `secret bundle ${id} resolved on this host: ${names.length} variable(s): ${names.join(", ")}` });
119
+ } else if (read?.reserved !== undefined) {
120
+ checks.push({ ok: false, label: `secret bundle ${id} carries ${read.reserved}, a name this deployment reserves`, fix: "rename it in the secrets manager -- every job of the project refuses pre-spend as secret-name-reserved until you do" });
121
+ } else if (read?.profileUnknown !== undefined || read?.ambiguous !== undefined) {
122
+ // Already its own line above.
123
+ } else {
124
+ const what = read?.unreachable ? "could not reach its manager (exit 1, a timeout or a spawn fault: retried at job time)" : `was refused (${read?.failure ?? "unknown"}${read?.name ? `, variable ${read.name}` : ""}${read?.line ? `, line ${read.line}` : ""})`;
125
+ checks.push({ ok: false, label: `secret bundle ${id}'s resolver ${what}`, fix: "run the profile's resolver by hand with the bundle's ref as its argument -- doctor prints no byte of what it printed" });
126
+ }
127
+ }
128
+ }
129
+ return checks;
130
+ }
@@ -0,0 +1,109 @@
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ProxyAgent, fetch as proxyFetch } from "undici";
4
+ import { parseTriggersDocument } from "./triggers.mjs";
5
+ import { parseAllowlist, allowlistEntryFor } from "./egress-allowlist.mjs";
6
+ import { parseModelsJson } from "./models-json.mjs";
7
+ import { endpointsForModel, loadModelEndpoints } from "./model-endpoints.mjs";
8
+ import { lifecycleModels, roleProposalChecks } from "./model-proposals.mjs";
9
+ import { getPricedModel } from "./pricing.mjs";
10
+ import { splitModelEntry } from "./model-ref.mjs";
11
+
12
+ // The default catalog omits retired models, which would make a role's warning vanish on retirement.
13
+ // Match the provider SDK's bracket-array query encoding (internal/utils/query.ts).
14
+ const APIS = Object.freeze({ anthropic: "https://api.anthropic.com/v1/models?limit=1000&lifecycle%5B%5D=active&lifecycle%5B%5D=deprecated&lifecycle%5B%5D=retired", openai: "https://api.openai.com/v1/models" });
15
+ const KEYS = Object.freeze({ anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY" });
16
+ const safeRead = (path) => {
17
+ const stat = lstatSync(path);
18
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("not a bounded regular file");
19
+ return readFileSync(path, "utf8");
20
+ };
21
+
22
+ export function parseProposalConfig(text) {
23
+ const value = JSON.parse(text);
24
+ if (!value || value.version !== 1 || Object.keys(value).some((key) => !["version", "lifecycleProviders", "proxyUrl", "noticeDays", "replacements", "routes"].includes(key))) throw new Error("invalid proposal configuration");
25
+ const providers = value.lifecycleProviders ?? [];
26
+ if (!Array.isArray(providers) || providers.length > 2 || new Set(providers).size !== providers.length || providers.some((p) => !Object.hasOwn(APIS, p))) throw new Error("unsupported lifecycle provider");
27
+ if (value.noticeDays !== undefined && (!Number.isInteger(value.noticeDays) || value.noticeDays < 1 || value.noticeDays > 365)) throw new Error("invalid notice window");
28
+ if (providers.length) {
29
+ const proxy = new URL(value.proxyUrl);
30
+ if (!["http:", "https:"].includes(proxy.protocol) || proxy.username || proxy.password || proxy.pathname !== "/" || proxy.search || proxy.hash) throw new Error("invalid lifecycle egress proxy");
31
+ }
32
+ const ref = (v) => splitModelEntry(v) !== null;
33
+ if (value.replacements !== undefined && (!value.replacements || typeof value.replacements !== "object" || Array.isArray(value.replacements) || Object.entries(value.replacements).some(([a, b]) => !ref(a) || !ref(b)))) throw new Error("invalid replacements");
34
+ if (value.routes !== undefined && (!Array.isArray(value.routes) || value.routes.length > 100 || value.routes.some((r) => !r || Object.keys(r).some((k) => !["model", "alternative"].includes(k)) || !ref(r.model) || !ref(r.alternative)))) throw new Error("invalid declared equivalent routes");
35
+ return { ...value, lifecycleProviders: providers };
36
+ }
37
+
38
+ /** Every request traverses an operator-declared proxy; no direct fallback and no redirects. */
39
+ export async function fetchLifecycle(provider, { key, proxyUrl, fetch: request, timeoutMs = 5000 }) {
40
+ if (!Object.hasOwn(APIS, provider)) throw new Error("unsupported lifecycle provider");
41
+ let dispatcher;
42
+ if (!request) {
43
+ dispatcher = new ProxyAgent(proxyUrl);
44
+ request = proxyFetch;
45
+ }
46
+ try {
47
+ const response = await request(APIS[provider], { dispatcher, redirect: "error", signal: AbortSignal.timeout(timeoutMs), headers: provider === "anthropic" ? { "x-api-key": key, "anthropic-version": "2023-06-01" } : { authorization: `Bearer ${key}` } });
48
+ if (!response.ok) throw new Error("lifecycle API unavailable");
49
+ const parts = [];
50
+ let length = 0;
51
+ for await (const part of response.body) {
52
+ length += part.length;
53
+ if (length > 1024 * 1024) throw new Error("lifecycle response too large");
54
+ parts.push(part);
55
+ }
56
+ const value = JSON.parse(Buffer.concat(parts).toString("utf8"));
57
+ if (value.has_more === true) throw new Error("lifecycle catalog is incomplete");
58
+ return lifecycleModels(provider, value);
59
+ } finally { await dispatcher?.close(); }
60
+ }
61
+
62
+ export function cheaperRouteChecks(roles, routes, models, endpoints, builtin = getPricedModel) {
63
+ const active = new Set(Object.values(roles).flatMap((scope) => Object.values(scope).map((v) => typeof v === "string" ? v : v.model)));
64
+ const lookup = (ref, declaredOnly) => {
65
+ const slash = ref.indexOf("/"), provider = ref.slice(0, slash), modelId = ref.slice(slash + 1);
66
+ const own = models.providers?.[provider]?.models?.find((m) => m.id === modelId);
67
+ if (declaredOnly && (!own || endpointsForModel({ models, provider, modelId, endpoints }).length === 0)) return null;
68
+ return own?.cost ?? builtin(provider, modelId)?.cost;
69
+ };
70
+ const checks = [];
71
+ for (const route of routes) {
72
+ if (!active.has(route.model)) continue;
73
+ const a = lookup(route.model, false), b = lookup(route.alternative, true);
74
+ const fields = ["input", "output", "cacheRead", "cacheWrite"];
75
+ if (a && b && !a.tiers && !b.tiers && fields.every((f) => Number.isFinite(a[f]) && a[f] >= 0 && Number.isFinite(b[f]) && b[f] >= 0 && b[f] <= a[f]) && fields.some((f) => b[f] < a[f])) checks.push({ proposal: { kind: "cheaper-route", model: route.model, candidate: route.alternative }, ok: true, label: `Model proposal: ${route.alternative} has lower recorded rates than ${route.model} through a declared endpoint; equivalence is operator-declared, review with a capped canary` });
76
+ }
77
+ return checks;
78
+ }
79
+
80
+ export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints } = {}) {
81
+ let config;
82
+ try { config = parseProposalConfig(read(join(cwd, "model-proposals.json"))); }
83
+ catch (error) {
84
+ if (error?.code === "ENOENT") return [];
85
+ return [{ proposal: { kind: "unavailable" }, ok: false, warn: true, label: "Model proposals: model-proposals.json is unreadable or invalid", fix: "use version 1 with explicit lifecycleProviders, proxyUrl and optional noticeDays, replacements or equivalent routes" }];
86
+ }
87
+ const checks = [];
88
+ let roles;
89
+ try { roles = parseTriggersDocument(read(env.PI_TRIGGERS_FILE ?? join(cwd, "triggers.json"))).modelRoles ?? {}; }
90
+ catch { return [{ proposal: { kind: "unavailable" }, ok: false, warn: true, label: "Model proposals: current role map unavailable", fix: "fix triggers.json before evaluating proposals" }]; }
91
+ const catalogs = {};
92
+ for (const provider of config.lifecycleProviders) {
93
+ try {
94
+ const host = new URL(APIS[provider]).hostname;
95
+ const allowed = parseAllowlist(read(join(cwd, "egress-allowlist.conf")));
96
+ if (!allowlistEntryFor(allowed, host) || !credentials[KEYS[provider]]) throw new Error("lifecycle egress or credential unavailable");
97
+ catalogs[provider] = await fetch(provider, { key: credentials[KEYS[provider]], proxyUrl: config.proxyUrl });
98
+ checks.push({ proposal: { kind: "catalog-checked", provider }, ok: true, label: `Model proposals: ${provider} lifecycle catalog checked through declared proxy` });
99
+ } catch { checks.push({ proposal: { kind: "unavailable", provider }, ok: false, warn: true, label: `Model proposals: ${provider} lifecycle catalog unavailable`, fix: "check the opt-in proxy, egress allowlist and API credential; no model was changed" }); }
100
+ }
101
+ checks.push(...roleProposalChecks({ roles, catalogs, replacements: config.replacements, now: now(), noticeDays: config.noticeDays }));
102
+ if (config.routes?.length) try {
103
+ const parsed = parseModelsJson(read(join(env.PI_GLOBAL_PI_DIR, "models.json")));
104
+ if (parsed.error) throw new Error("overlay unavailable");
105
+ const endpoints = suppliedEndpoints ?? loadModelEndpoints({ modelEndpointsFile: env.PI_MODEL_ENDPOINTS_FILE }, { cwd });
106
+ checks.push(...cheaperRouteChecks(roles, config.routes, parsed.value, endpoints));
107
+ } catch { checks.push({ proposal: { kind: "unavailable" }, ok: false, warn: true, label: "Model proposals: declared route prices or endpoints unavailable", fix: "check the overlay models.json and declared endpoints; no route was changed" }); }
108
+ return checks;
109
+ }
@@ -0,0 +1,238 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { DAEMON_FACTS_ARGS, parseDaemonFacts } from "./daemon-facts.mjs";
7
+ import { decideJobUser, JOB_USER_FIX, resolveImageUser } from "./job-user.mjs";
8
+ import { bindProbeArgs, GATEWAY_TOML_WHERE, gatewayTomlFor, judgeBindAnswer, judgeGatewayStatus, judgePtraceScope, OPENSHELL_BREW_INSTALL, OPENSHELL_TESTED_VERSION, PTRACE_SCOPE_PATH, UPSTREAM_BIND_WARNING } from "./openshell-cli.mjs";
9
+
10
+ /**
11
+ * `pi-dispatch doctor --openshell` (issue #683): whether this host can run the `openshell` venue, asked before
12
+ * blessing it in PI_BACKENDS (and run by plain `doctor` once it is, issue #684), so an operator can set the gateway up
13
+ * and read what the venue meets. Every line is a free read; nothing creates a sandbox (the conformance run,
14
+ * `.github/scripts/openshell-conformance.mjs`, is the live half).
15
+ *
16
+ * The lines, each with what fixes it:
17
+ * - the CLI and the gateway are the tested release (`OPENSHELL_TESTED_VERSION`). EXACT, not a floor: OpenShell is
18
+ * 0.1.x and marks the interfaces this adapter drives experimental, so a newer release is untested until the
19
+ * conformance run says otherwise (`OQ-047`);
20
+ * - the gateway is connected and on this host (`CONST-VENUE-EGRESS-LAYER`'s third condition, the same judgement the
21
+ * per-job check makes);
22
+ * - its compute driver is the engine this deployment names, Docker or Podman, the only two v1 admits;
23
+ * - no gateway-wide policy is in force;
24
+ * - that engine is Docker 28 or later, or Podman 5 or later (measured: Docker Desktop 27.4 refused every create);
25
+ * - this host's kernel offers Landlock, on Linux, where the gateway on this host shares it; elsewhere it cannot be
26
+ * read here, and Docker Desktop's LinuxKit kernel has none (measured), which is said, never passed;
27
+ * - this host's Yama `ptrace_scope` is readable and 1 or more (issue #730), the setting the per-job check refuses
28
+ * without, since the job's key holders run readable on this venue;
29
+ * - the job image declares `sandboxLayout`, read on that engine, the store the gateway runs images from (copy mode);
30
+ * - the job image declares `observableRunner` (issue #730, both modes), without which no connection of a job is made.
31
+ *
32
+ * BIND MODE (issue #686 part 3, `openshell-bind` blessed): its engine is Docker (the Podman driver is refused for it), the
33
+ * gateway's three bind settings are on (one probe create that can only be refused, the adapter's own
34
+ * `bindProbeArgs`, since no read reveals them; the fix is the gateway.toml to write), and upstream's warning, quoted.
35
+ * The probe is the one line that asks a create; a gateway that admits binds refuses it on its reserved target at
36
+ * provisioning and leaves it in phase `Error` (measured), so it is deleted here on every answer.
37
+ */
38
+
39
+ /** The labels the adapter's probe carries (backend-openshell.mjs re-exports none of its run path to doctor). */
40
+ const OWNER_LABEL = "pi.dispatch.owner";
41
+ const OWNER = "pi-dispatch";
42
+ const MODE_LABEL = "pi.dispatch.openshell-mode";
43
+
44
+ /** The settings this check reads, the same two the worker's boot reads (`config.mjs` `openshellFrom`, issue #684). */
45
+ export const OPENSHELL_ENGINE_ENV = "PI_OPENSHELL_ENGINE";
46
+ export const OPENSHELL_GATEWAY_ENV = "PI_OPENSHELL_GATEWAY";
47
+
48
+ /** The engine floors (issue #683): OpenShell's own support matrix, and Docker 28 measured as a real one. */
49
+ export const ENGINE_FLOORS = Object.freeze({ docker: [28, 0], podman: [5, 0] });
50
+
51
+ /**
52
+ * The probe's empty source directory (review round 2 of #714): under the deployment's jobs dir, the tree the engine binds
53
+ * job dirs from, as the adapter makes it (`bind-probe-*`, which the `job-*` orphan sweep leaves alone). A jobs dir that
54
+ * does not exist yet (doctor before the first boot) is not created here: the worker's tmp dir stands in, since the probe
55
+ * is refused on its reserved target whatever the source.
56
+ */
57
+ function probeSourceIn(jobsDir) {
58
+ if (typeof jobsDir === "string" && jobsDir !== "") {
59
+ try {
60
+ return mkdtempSync(join(jobsDir, "bind-probe-"));
61
+ } catch {}
62
+ }
63
+ return mkdtempSync(join(tmpdir(), "pi-dispatch-bind-probe-"));
64
+ }
65
+
66
+ /** `{ code, stdout, stderr }`, `code: null` when it could not run or overran. Never throws. */
67
+ export function runQuiet(bin, args, { timeoutMs = 20_000, execFileFn = execFile } = {}) {
68
+ return new Promise((resolve) => {
69
+ try {
70
+ execFileFn(bin, args, { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
71
+ resolve({ code: err ? (typeof err.code === "number" ? err.code : null) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
72
+ });
73
+ } catch {
74
+ resolve({ code: null, stdout: "", stderr: "" });
75
+ }
76
+ });
77
+ }
78
+
79
+ const json = (text) => {
80
+ try {
81
+ return JSON.parse(text);
82
+ } catch {
83
+ return null;
84
+ }
85
+ };
86
+
87
+ /** `[major, minor]` of the first `x.y` in `text`, or null. */
88
+ function versionOf(text) {
89
+ const m = /(\d+)\.(\d+)/.exec(String(text ?? ""));
90
+ return m ? [Number(m[1]), Number(m[2])] : null;
91
+ }
92
+ const atLeast = (have, want) => have !== null && (have[0] > want[0] || (have[0] === want[0] && have[1] >= want[1]));
93
+
94
+ /**
95
+ * The checks, as doctor's `{ ok, warn?, label, fix? }`. `run` is the seam every spawn goes through; `env` is the
96
+ * service environment doctor resolved; `platform` and `readLsm` are the Landlock read's, `readPtraceScope` Yama's.
97
+ */
98
+ export async function openshellChecks(env = {}, { run = runQuiet, platform = process.platform, readLsm = () => readFileSync("/sys/kernel/security/lsm", "utf8"), readPtraceScope = () => readFileSync(PTRACE_SCOPE_PATH, "utf8"), image = env.PI_JOB_IMAGE || "pi-job:latest", modes = { copy: true, bind: false }, ids = { euid: typeof process.geteuid === "function" ? process.geteuid() : null, egid: typeof process.getegid === "function" ? process.getegid() : null }, jobsDir = null, mkdtemp = () => probeSourceIn(jobsDir), rmdir = (dir) => rmSync(dir, { recursive: true, force: true }) } = {}) {
99
+ const checks = [];
100
+ const engine = env.PI_OPENSHELL_ENGINE === undefined || env.PI_OPENSHELL_ENGINE === "" ? "docker" : env.PI_OPENSHELL_ENGINE;
101
+ const gateway = env.PI_OPENSHELL_GATEWAY || null;
102
+ if (engine !== "docker" && engine !== "podman") {
103
+ checks.push({ ok: false, label: `openshell: ${OPENSHELL_ENGINE_ENV} names the gateway's engine`, fix: `set ${OPENSHELL_ENGINE_ENV} to docker or podman, the compute drivers v1 admits (it is ${JSON.stringify(engine)})` });
104
+ return checks;
105
+ }
106
+ if (gateway !== null && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/.test(gateway)) {
107
+ checks.push({ ok: false, label: `openshell: ${OPENSHELL_GATEWAY_ENV} names a registered gateway`, fix: `set ${OPENSHELL_GATEWAY_ENV} to a name \`openshell gateway list\` shows, or unset it for the CLI's active gateway` });
108
+ return checks;
109
+ }
110
+ const os = (args, opts) => run("openshell", [...(gateway ? ["--gateway", gateway] : []), ...args], ...(opts ? [opts] : []));
111
+
112
+ const cli = await os(["--version"]);
113
+ const cliVersion = /openshell (\S+)/.exec(cli.stdout)?.[1] ?? null;
114
+ if (cli.code !== 0 || cliVersion === null) {
115
+ checks.push({ ok: false, label: "openshell: the CLI is installed", fix: `install OpenShell ${OPENSHELL_TESTED_VERSION} (https://github.com/NVIDIA/OpenShell/releases/tag/v${OPENSHELL_TESTED_VERSION}), the release this venue was measured on` });
116
+ return checks;
117
+ }
118
+ checks.push(cliVersion === OPENSHELL_TESTED_VERSION ? { ok: true, label: `openshell: CLI ${cliVersion}, the tested release` } : { ok: false, label: `openshell: CLI ${cliVersion} is the tested release (${OPENSHELL_TESTED_VERSION})`, fix: `install OpenShell ${OPENSHELL_TESTED_VERSION}: the venue drives interfaces OpenShell marks experimental, so another release is untested until the conformance run passes on it` });
119
+
120
+ const status = judgeGatewayStatus(json((await os(["status", "-o", "json"])).stdout));
121
+ if (!status.connected) {
122
+ checks.push({ ok: false, label: "openshell: the gateway answers", fix: "start the gateway (`systemctl --user start openshell-gateway`, or on macOS `brew services start openshell`, which only OpenShell's release formula defines: `" + OPENSHELL_BREW_INSTALL + "`) and register it on loopback: `openshell gateway add https://127.0.0.1:17670 --local`" });
123
+ return checks;
124
+ }
125
+ checks.push(status.local ? { ok: true, label: "openshell: the gateway is on this host (loopback or a unix socket)" } : { ok: false, label: "openshell: the gateway is on this host", fix: "the venue holds the job's credentials in its gateway, so it runs only on one this deployment runs itself (CONST-VENUE-EGRESS-LAYER): register the local gateway on loopback" });
126
+ checks.push(status.version === OPENSHELL_TESTED_VERSION ? { ok: true, label: `openshell: gateway ${status.version}, the tested release` } : { ok: false, label: `openshell: gateway ${status.version ?? "of unknown version"} is the tested release (${OPENSHELL_TESTED_VERSION})`, fix: `run the gateway of OpenShell ${OPENSHELL_TESTED_VERSION}` });
127
+
128
+ const info = json((await os(["gateway", "info", "-o", "json"])).stdout);
129
+ const drivers = Array.isArray(info?.compute_drivers) ? info.compute_drivers.map((d) => d?.name).filter((n) => typeof n === "string") : null;
130
+ if (drivers === null) checks.push({ ok: true, warn: true, label: "openshell: the gateway's compute driver could not be read (gateway info answers an admin only)", fix: `make sure the gateway's compute_driver is ${engine}` });
131
+ else checks.push(drivers.length === 1 && drivers[0] === engine ? { ok: true, label: `openshell: the gateway's compute driver is ${engine}` } : { ok: false, label: `openshell: the gateway's compute driver is ${engine}`, fix: `the gateway runs ${drivers.join(", ") || "no driver"}; set its compute_driver to ${engine} (OPENSHELL_COMPUTE_DRIVER=${engine}), or ${OPENSHELL_ENGINE_ENV} to the one it runs, if that is docker or podman` });
132
+
133
+ const global = await os(["policy", "get", "--global", "-o", "json"]);
134
+ const none = global.code !== 0 && /no global policy revision found/.test(global.stderr.replace(/[│\s]+/g, " "));
135
+ checks.push(global.code === 0 ? { ok: false, label: "openshell: no gateway-wide policy is in force", fix: "a global policy replaces every sandbox's own, so the per-job policy the worker generates would not be the one in force: `openshell policy delete --global`" } : none ? { ok: true, label: "openshell: no gateway-wide policy is in force" } : { ok: false, label: "openshell: whether a gateway-wide policy is in force could be read", fix: "`openshell policy get --global` failed for another reason than none being set; run it and read the error" });
136
+
137
+ if (modes.bind === true) checks.push(...(await bindChecks({ os, run, engine, image, mkdtemp, rmdir, platform, ids })));
138
+
139
+ const engineRead = await run(engine, engine === "docker" ? ["version", "--format", "{{.Server.Version}}"] : ["version", "--format", "{{.Version}}"]);
140
+ const engineVersion = engineRead.code === 0 ? versionOf(engineRead.stdout) : null;
141
+ const floor = ENGINE_FLOORS[engine];
142
+ checks.push(atLeast(engineVersion, floor) ? { ok: true, label: `openshell: ${engine} ${engineVersion.join(".")} (${floor[0]} or later)` } : { ok: false, label: `openshell: ${engine} is ${floor[0]} or later`, fix: engineVersion === null ? `${engine} did not answer \`${engine} version\`` : `${engine} ${engineVersion.join(".")} is older than OpenShell supports (measured: Docker Desktop 27.4 refused every sandbox create); upgrade it` });
143
+
144
+ if (platform === "linux") {
145
+ let lsm = null;
146
+ try {
147
+ lsm = String(readLsm());
148
+ } catch {
149
+ lsm = null;
150
+ }
151
+ if (lsm === null) checks.push({ ok: true, warn: true, label: "openshell: Landlock could not be read (/sys/kernel/security/lsm)", fix: "OpenShell needs Landlock ABI 3 (Linux 6.2 or later); a kernel without it fails every create" });
152
+ else checks.push(lsm.split(",").map((s) => s.trim()).includes("landlock") ? { ok: true, label: "openshell: this kernel offers Landlock" } : { ok: false, label: "openshell: this kernel offers Landlock", fix: "boot a kernel with Landlock in its LSM list (Linux 6.2 or later, `lsm=...,landlock`); the venue confines every sandbox and its job inputs with it" });
153
+ } else {
154
+ checks.push({ ok: true, warn: true, label: "openshell: Landlock cannot be read from this host", fix: "the gateway's engine runs in a VM whose kernel must offer Landlock ABI 3; Docker Desktop's LinuxKit kernel offers none (measured), so every create fails there" });
155
+ }
156
+ // Issue #730: the same judgement the per-job check makes, so doctor says before a job does.
157
+ const yama = judgePtraceScope(() => readPtraceScope());
158
+ const yamaFix = yama.reason === "too-strict"
159
+ ? `this host's kernel.yama.ptrace_scope is ${yama.scope}, which also stops the OpenShell sandbox from observing a job well enough to let it reach the network, so every job here would start and then fail; set it to exactly 1 (\`sysctl -w kernel.yama.ptrace_scope=1\`, and in /etc/sysctl.d to keep it)`
160
+ : yama.scope === 0
161
+ ? `this host's kernel.yama.ptrace_scope is 0, so a job's tools could read the memory of the processes holding the key that signs its result; set it to 1 (\`sysctl -w kernel.yama.ptrace_scope=1\`, and in /etc/sysctl.d to keep it), or every job here is refused`
162
+ : `${PTRACE_SCOPE_PATH} cannot be read here (a kernel without Yama, or not Linux), so whether a job's tools could read its key holders' memory is not known, and every job here is refused; run the gateway on a Linux host whose kernel has Yama at exactly 1`;
163
+ checks.push(yama.ok ? { ok: true, label: `openshell: Yama ptrace_scope is 1 (a tool cannot read its ancestor, the broker can)` } : { ok: false, label: "openshell: Yama ptrace_scope is exactly 1", fix: yamaFix });
164
+
165
+ const labels = await run(engine, ["image", "inspect", "--format", '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}', image]);
166
+ const caps = labels.code === 0 ? labels.stdout.trim().split(",") : null;
167
+ // Issue #730: both modes. Said only when the image was read; the store line below says when it was not.
168
+ if (caps !== null) checks.push(caps.includes("observableRunner") ? { ok: true, label: `openshell: the job image (${image}) declares observableRunner` } : { ok: false, label: `openshell: the job image (${image}) declares observableRunner`, fix: "use a job image built after issue #730 (ghcr.io/edgehero/pi-job:latest, or rebuild yours FROM it): it starts the runner from a node the sandbox can observe, without which no job here reaches its model" });
169
+ // Bind mode alone reads the `mounts` layout, so `sandboxLayout` is copy mode's line only.
170
+ if (modes.copy !== true) {
171
+ checks.push(caps === null ? { ok: false, label: `openshell: the job image (${image}) is in ${engine}'s store`, fix: `build or pull ${image} into ${engine}, the store the gateway runs images from` } : { ok: true, label: `openshell: the job image (${image}) is in ${engine}'s store` });
172
+ return checks;
173
+ }
174
+ checks.push(caps === null ? { ok: false, label: `openshell: the job image (${image}) is in ${engine}'s store`, fix: `build or pull ${image} into ${engine}, the store the gateway runs images from` } : caps.includes("sandboxLayout") ? { ok: true, label: `openshell: the job image (${image}) declares sandboxLayout` } : { ok: false, label: `openshell: the job image (${image}) declares sandboxLayout`, fix: "use a job image built after issue #683 (ghcr.io/edgehero/pi-job:latest, or rebuild yours FROM it): its runner finds the job under /sandbox and carries pi-landlock" });
175
+ return checks;
176
+ }
177
+
178
+ /**
179
+ * Bind mode's lines (issue #686 part 3). The settings line asks the adapter's own probe (`bindProbeArgs`) and judges the
180
+ * answer with the adapter's own reader (`judgeBindAnswer`), so doctor and the per-job gate cannot disagree; its fix is
181
+ * the gateway.toml to write for this engine. Upstream's warning is a ⚠ on every run, by design: it is true of every host
182
+ * that runs bind mode, and what holds instead is named in its fix.
183
+ */
184
+ async function bindChecks({ os, run, engine, image, mkdtemp, rmdir, platform, ids }) {
185
+ const checks = [];
186
+ if (engine !== "docker") {
187
+ checks.push({ ok: false, label: "openshell-bind: the gateway's engine is docker", fix: "bind mode was measured on OpenShell's Docker driver only, and the worker refuses it on Podman (a rootless Podman maps the sandbox's uid away from the worker's); set PI_OPENSHELL_ENGINE=docker, or run the copy venue (openshell)" });
188
+ return checks;
189
+ }
190
+ const toml = gatewayTomlFor(engine);
191
+ const fixFor = (setting) => `the gateway refuses binds (${setting}); write this into ${GATEWAY_TOML_WHERE}, then restart the gateway:\n${toml}`;
192
+ const idRead = await run(engine, ["image", "inspect", "--format", "{{.Id}}", image]);
193
+ const id = idRead.code === 0 ? idRead.stdout.trim() : "";
194
+ if (!/^sha256:[0-9a-f]{64}$/.test(id)) {
195
+ checks.push({ ok: true, warn: true, label: "openshell-bind: whether the gateway admits binds could not be asked (the job image is not in the engine's store)", fix: `build or pull ${image} into ${engine}, then run doctor again; the gateway needs, in ${GATEWAY_TOML_WHERE}:\n${toml}` });
196
+ } else {
197
+ let source = null;
198
+ let answer;
199
+ try {
200
+ source = mkdtemp();
201
+ // In the job namespace and owner-labelled, so a probe that was made after all is the reaper's if this delete fails.
202
+ const name = `pi-job-s${randomBytes(6).toString("hex").slice(0, 11)}`;
203
+ // 60 s, the adapter's own bound: an admitting gateway answers only after provisioning.
204
+ const probe = await os(bindProbeArgs({ name, image: id, source, engine, ownerLabel: OWNER_LABEL, owner: OWNER, modeLabel: MODE_LABEL }), { timeoutMs: 60_000 });
205
+ // Deleted on every answer: an admitting gateway refuses the probe at provisioning and leaves it in phase
206
+ // `Error` (measured); a settings refusal makes nothing, so this delete is a no-op there.
207
+ await os(["sandbox", "delete", name]);
208
+ answer = probe.code === 0 ? { admitted: true } : judgeBindAnswer(`${probe.stderr} ${probe.stdout}`);
209
+ } catch {
210
+ answer = {};
211
+ } finally {
212
+ if (source !== null) {
213
+ try {
214
+ rmdir(source);
215
+ } catch {}
216
+ }
217
+ }
218
+ if (answer.admitted) checks.push({ ok: true, label: "openshell-bind: the gateway admits bind mounts (allow_driver_config, enable_bind_mounts, resource admission off)" });
219
+ else if (answer.setting) checks.push({ ok: false, label: "openshell-bind: the gateway admits bind mounts", fix: fixFor(answer.setting) });
220
+ else checks.push({ ok: true, warn: true, label: "openshell-bind: whether the gateway admits bind mounts could not be read", fix: `the probe create answered in words the worker does not read; the gateway needs, in ${GATEWAY_TOML_WHERE}:\n${toml}` });
221
+ }
222
+ // Review round 1 (#714), M2: who a job runs as, by the adapter's rule (`decideOpenshellBindUser`: `local`'s
223
+ // `decideJobUser` over the engine's `docker info`, then `resolveImageUser` with the image's capabilities).
224
+ {
225
+ const desktop = platform === "darwin" || platform === "win32";
226
+ const info = desktop ? null : await run(engine, [...DAEMON_FACTS_ARGS]);
227
+ const parsed = info && info.code === 0 ? parseDaemonFacts(info.stdout) : null;
228
+ const daemon = parsed?.facts ? { answered: true, facts: parsed.facts } : { answered: false, reason: "no-daemon-facts", transient: true };
229
+ const capsRead = await run(engine, ["image", "inspect", "--format", '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}', image]);
230
+ const capabilities = capsRead.code === 0 ? capsRead.stdout.trim().split(",") : [];
231
+ const chosen = resolveImageUser(decideJobUser({ platform, euid: ids.euid, egid: ids.egid, endpoint: { local: true }, daemon }), { capabilities, euid: ids.euid, egid: ids.egid });
232
+ if (chosen.refused) checks.push({ ok: false, label: "openshell-bind: a uid the job may run as exists", fix: `${JOB_USER_FIX[chosen.cause] ?? "the job user could not be decided"} (the openshell-bind venue binds the job's directories, which the sandbox reads and writes as its uid)` });
233
+ else if (chosen.unavailable) checks.push({ ok: true, warn: true, label: `openshell-bind: who a job runs as could not be read (\`${engine} info\` did not answer)`, fix: `make \`${engine} info\` answer for this account; the venue decides the job's uid from it, before each job` });
234
+ else checks.push({ ok: true, label: chosen.user ? `openshell-bind: jobs run as uid:gid ${chosen.user} (the policy's run_as_user) with HOME=/home/pi` : "openshell-bind: jobs run as the image's own user (uid 1001)" });
235
+ }
236
+ checks.push({ ok: true, warn: true, label: `openshell-bind: upstream warns: "${UPSTREAM_BIND_WARNING}"`, fix: "on this venue what holds is the worker's own read-only binds (/job, /inbox, the overlay) and the policy entries that list every bind target; /workspace, /session and /outbox are the job's to write, as on local, and a folder job edits the operator's folder in place" });
237
+ return checks;
238
+ }