@edgehero/pi-dispatch 4.3.1 → 5.1.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 (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -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 +118 -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 +136 -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 +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { realpathSync } from "node:fs";
17
17
  import { execDockerBounded } from "./backend-local.mjs";
18
+ import { PODMAN_RESTART_HOLD_EXPIRED } from "./comment-tables.mjs";
18
19
  import { DAEMON_APPLIES_BOUNDS, DOCKER_ENDPOINT_LOCAL, PODMAN_CONF_WIDENS_JOB, PODMAN_ROOTFUL_WIDENING_KEYS, RUNTIME_ADDS_NO_MOUNTS } from "./backends.mjs";
19
20
 
20
21
  /**
@@ -1123,7 +1124,8 @@ export function rootfulConfResidual(unread) {
1123
1124
  */
1124
1125
  export const PODMAN_RESTART_HOLD_RECHECK_MS = 60_000;
1125
1126
  export const PODMAN_RESTART_HOLD_MAX_MS = 3_600_000;
1126
- export const PODMAN_RESTART_HOLD_EXPIRED = "podman-service-restart-hold-expired";
1127
+ // The expiry's reason token lives in comment-tables.mjs beside its comment (issue #710); re-exported under its old name.
1128
+ export { PODMAN_RESTART_HOLD_EXPIRED };
1127
1129
 
1128
1130
  /** Whether a rootful finding holds jobs back only until something heals by itself (a retry), not a configuration fix. */
1129
1131
  export function rootfulConfRetries(found) {
@@ -1,20 +1,32 @@
1
1
  import * as nodeFs from "node:fs";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { dirname } from "node:path";
3
4
  import { ensureUnderAccountRoot } from "./config.mjs";
5
+ import { expectRefusal } from "./file-expect.mjs";
4
6
  import { findDuplicateKey } from "./json-duplicates.mjs";
7
+ import { modelIdProblem, providerIdProblem } from "./model-ref.mjs";
5
8
  import { checkDollarInvariant, parseUsdMicros } from "./money.mjs";
9
+ import { BUNDLE_FORMATS } from "./secret-profiles.mjs";
10
+ import { neededVersion, underDeclared, valueNeededVersion, valueUnderDeclared, versionProblem } from "./store-version.mjs";
6
11
 
7
12
  /**
8
13
  * Runtime-settings overlay: the shared, durable truth between the admin extension and the worker
9
- * (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json` with fourteen
10
- * optional keys -- `model`, `provider` (non-empty strings), `maxTurns`, `dailyCap`, `weeklyCap`,
11
- * `monthlyCap`, `maxTokens`, `dailyTokenCap` (int >= 1), `concurrency` (int 1-10), `softHoldPct`
14
+ * (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json` of optional keys:
15
+ * the fourteen settings in `KNOWN_KEYS` -- `model`, `provider` (non-empty strings), `maxTurns`, `dailyCap`,
16
+ * `weeklyCap`, `monthlyCap`, `maxTokens`, `dailyTokenCap` (int >= 1), `concurrency` (int 1-10), `softHoldPct`
12
17
  * (int 1-99), and the dollar keys `maxCostUsd`, `dailyCostUsd`, `weeklyCostUsd`, `monthlyCostUsd`
13
- * (a decimal string or number, `parseUsdMicros`) -- read by the worker at each job start and written
18
+ * (a decimal string or number, `parseUsdMicros`) -- the two operator-only resolver tables in
19
+ * `OPERATOR_ONLY_KEYS` (`secretProfiles`, `secretBundles`), and an optional `version` that describes the file
20
+ * rather than setting anything (issue #761, `SETTINGS_VERSION`). Read by the worker at each job start and written
14
21
  * atomically by the admin extension (tmp + rename). `maxTokens`/`dailyTokenCap` are the optional token
15
22
  * controls (issue #25); the dollar keys are issue #501's (a window needs `maxCostUsd`, checked on the merged
16
23
  * values by `resolveSettings`).
17
24
  *
25
+ * A key this build does not know is dropped from the overlay it hands out and logged, and a writer given the
26
+ * `carry` its read returned writes that key back exactly as it found it, in its place (issue #761): the file is shared
27
+ * by every console, panel and worker of a deployment, and a writer from an older release must not delete a key a newer
28
+ * one added just because it cannot read it.
29
+ *
18
30
  * `readOverlay` NEVER throws: a bad settings file returns a discriminated `{ invalid }` rather than an
19
31
  * exception, so the processor RETURNS a policy refusal (`settings-overlay-invalid`) instead of letting
20
32
  * its catch classify the file as retryable infra and pay to retry a job that can never succeed
@@ -45,8 +57,43 @@ export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyC
45
57
  *
46
58
  * A profile is declared through the operator-typed `/dispatch` command instead, which pi reaches only from
47
59
  * its own user-input path, and every declared path is bounded by PI_SECRET_RESOLVER_ROOTS in the worker.
60
+ *
61
+ * `secretBundles` (issue #704) is the same capability for a bundle resolver, `{ [name]: { path, format } }`, and is
62
+ * operator-only for the same reason: its path is an exec, bounded by the same roots.
63
+ */
64
+ export const OPERATOR_ONLY_KEYS = ["secretProfiles", "secretBundles"];
65
+
66
+ /**
67
+ * The highest overlay version this build reads (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`). A file without
68
+ * `version` is version 1, and a writer writes `version` only when the content needs more than 1, so every overlay of
69
+ * today's keys stays byte-identical.
70
+ *
71
+ * `version` is deliberately NOT in `KNOWN_KEYS`: it describes the file, never a setting, so no tool, command or dialog
72
+ * can set it, and it never reaches the overlay a reader hands out. The writer computes it from what it writes.
73
+ */
74
+ export const SETTINGS_VERSION = 1;
75
+
76
+ /**
77
+ * Each overlay key whose ABSENCE an older build would misread (one that narrows: a cap, a refusal, a list a job is
78
+ * held to), with the version that introduced it. A key that is harmless when absent declares nothing and is simply
79
+ * carried by older writers. Empty until a key needs one; raising `SETTINGS_VERSION` and adding the row is one commit.
48
80
  */
49
- export const OPERATOR_ONLY_KEYS = ["secretProfiles"];
81
+ export const SETTINGS_KEY_VERSIONS = Object.freeze({});
82
+
83
+ /**
84
+ * Each EXISTING key whose range a later release widens, as `key -> (value) => version`: the version a value needs.
85
+ * A key-name table cannot say this, and an older build meeting the wider value refuses the whole overlay (every job
86
+ * refused `settings-overlay-invalid`) with no stamp to say an upgrade is the fix. So a rule here makes the writer stamp
87
+ * the version for exactly the values that need it. The same holds for a new field inside a closed nested shape
88
+ * (a `secretBundles` entry refuses an unknown field). Empty until a value needs one.
89
+ */
90
+ export const SETTINGS_VALUE_VERSIONS = Object.freeze({});
91
+
92
+ /** The lowest version a VALIDATED overlay needs, from its keys and the value rules: 1 while nothing needs more. */
93
+ export function settingsVersionFor(overlay, { table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
94
+ if (overlay === null || typeof overlay !== "object") return 1;
95
+ return Math.max(neededVersion(Object.keys(overlay), table), valueNeededVersion(overlay, valueTable));
96
+ }
50
97
 
51
98
  // The dollar keys' near-miss spellings (issue #501), already normalised (`overlayKeyShape`), each mapped to the key it
52
99
  // meant: the four keys, their env names (`PI_MAX_COST_USD` pasted into settings.json is the likeliest slip of all),
@@ -81,10 +128,6 @@ function overlayKeyShape(key) {
81
128
  return out.replace(/[_\-. ]/g, "");
82
129
  }
83
130
 
84
- function isNonEmptyString(value) {
85
- return typeof value === "string" && value.trim() !== "";
86
- }
87
-
88
131
  function isIntAtLeast(value, min) {
89
132
  return Number.isInteger(value) && value >= min;
90
133
  }
@@ -105,24 +148,63 @@ function isIntInRange(value, min, max) {
105
148
  export { settingsFilePath } from "./config.mjs";
106
149
 
107
150
  /**
108
- * Validate a parsed overlay against the key contract, returning the sanitized overlay (known keys only)
109
- * or `{ invalid }`. Shared by `readOverlay` (post-parse) and `writeOverlay` (pre-write) so a single
110
- * definition governs both directions. Unknown keys are dropped and logged, leaving the overlay valid;
111
- * any invalid known key fails the WHOLE object.
151
+ * Validate a parsed overlay FILE against the key contract: `{ overlay, unknownKeys }` (the sanitized overlay of known
152
+ * keys only, and the names this build does not know, in file order), `{ invalid }`, or `{ invalid, newer: true }` for
153
+ * a file written by a newer pi-dispatch. Shared by `readOverlayDocument` (post-parse) and `writeOverlay`'s re-read of
154
+ * its own output, so a single definition governs both directions. Unknown keys are dropped from `overlay` and logged,
155
+ * leaving it valid; any invalid known key fails the WHOLE object.
156
+ *
157
+ * The VERSION IS JUDGED FIRST, before any key (issue #761). A newer file may hold a value this build's rules refuse (a
158
+ * range a later release widened), and judged key by key it would read as broken when the truth is "upgrade". A broken
159
+ * `version` (not an integer of 1 or more) is an ordinary `{ invalid }`. Then, after the keys, a known key or value
160
+ * that needs a higher version than the file declares is refused by name: such a file was written by hand or by a
161
+ * writer that dropped the stamp, and an older build would read it without the key.
112
162
  */
113
- function validateOverlay(candidate, log) {
163
+ export function validateOverlay(parsed, log = () => {}, { supported = SETTINGS_VERSION, table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
164
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
165
+ return { invalid: "root must be a JSON object" };
166
+ }
167
+ const declared = Object.hasOwn(parsed, "version") ? parsed.version : undefined;
168
+ const problem = versionProblem(declared, { supported, what: "settings file" });
169
+ if (problem) return problem.newer ? { invalid: problem.message, newer: true } : { invalid: problem.message };
170
+ const result = validateKeys(parsed, log);
171
+ if (result.invalid) return result;
172
+ const short = underDeclared(Object.keys(result.overlay), table, declared);
173
+ if (short) return { invalid: `${short.field} needs settings version ${short.needs}, and the file declares ${declared ?? 1}` };
174
+ const shortValue = valueUnderDeclared(result.overlay, valueTable, declared);
175
+ if (shortValue) return { invalid: `${shortValue.field} holds a value that needs settings version ${shortValue.needs}, and the file declares ${declared ?? 1}` };
176
+ return result;
177
+ }
178
+
179
+ /**
180
+ * The per-key half of `validateOverlay`, and all of what `writeOverlay` asks of a CANDIDATE: `{ overlay, unknownKeys }`
181
+ * or `{ invalid }`. `version` is skipped here, never copied: the file's is judged by `validateOverlay`, and a
182
+ * candidate's is ignored because the writer computes the version from what it writes.
183
+ */
184
+ function validateKeys(candidate, log) {
114
185
  if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
115
186
  return { invalid: "root must be a JSON object" };
116
187
  }
117
188
  const overlay = {};
189
+ const unknownKeys = [];
118
190
  for (const key of Object.keys(candidate)) {
119
191
  const value = candidate[key];
120
192
  switch (key) {
193
+ case "version":
194
+ break;
121
195
  case "model":
122
- case "provider":
123
- if (!isNonEmptyString(value)) return { invalid: `${key} must be a non-empty string` };
196
+ case "provider": {
197
+ // Issue #673: the one model-id and provider-id shape a trigger's run.model and run.provider, PI_MODEL and
198
+ // PI_PROVIDER are held to (model-ref.mjs), so a run on the overlay's model is one the console's runs
199
+ // filter can find. Here, not at one admin surface, because every write (dispatch_set, /dispatch set,
200
+ // the panel dialog, the console's setting-set) and every read goes through this function: a bad value
201
+ // is refused at write, and one already on disk makes the overlay invalid, the loud path jobs and doctor
202
+ // already take. The value is never echoed. Kept in its original case: only the check lowercases.
203
+ const problem = key === "model" ? modelIdProblem(value) : providerIdProblem(value);
204
+ if (problem !== null) return { invalid: `${key} ${problem}` };
124
205
  overlay[key] = value;
125
206
  break;
207
+ }
126
208
  case "maxTurns":
127
209
  case "dailyCap":
128
210
  case "weeklyCap":
@@ -165,6 +247,37 @@ function validateOverlay(candidate, log) {
165
247
  overlay[key] = { ...value };
166
248
  break;
167
249
  }
250
+ case "secretBundles": {
251
+ // Issue #704. `{ [name]: { path: "/abs/path", format: "dotenv" | "json" } }`, the panel-authored half of
252
+ // PI_SECRET_BUNDLES. `secretProfiles`' rules (shape here, the path bound in the worker, an invalid value
253
+ // fails the whole overlay), plus two of its own: the format is REQUIRED and never guessed (secret-profiles.mjs
254
+ // `parseSecretBundleProfiles` says why), and an unknown field refuses rather than drops, because a misspelt
255
+ // `fromat` would otherwise read as declared while the entry has no format at all.
256
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
257
+ return { invalid: "secretBundles must be an object mapping bundle profile names to { path, format }" };
258
+ }
259
+ const bundles = {};
260
+ for (const name of Object.keys(value)) {
261
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
262
+ return { invalid: "secretBundles names may use letters, digits, dot, dash and underscore only" };
263
+ }
264
+ const entry = value[name];
265
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
266
+ return { invalid: `secretBundles.${name} must be an object { path, format }` };
267
+ }
268
+ const extra = Object.keys(entry).find((k) => k !== "path" && k !== "format");
269
+ if (extra !== undefined) return { invalid: `secretBundles.${name} has an unknown field (only path and format are read)` };
270
+ if (typeof entry.path !== "string" || entry.path.trim() === "") {
271
+ return { invalid: `secretBundles.${name}.path must be a non-empty absolute path` };
272
+ }
273
+ if (!BUNDLE_FORMATS.includes(entry.format)) {
274
+ return { invalid: `secretBundles.${name}.format must be ${BUNDLE_FORMATS.join(" or ")}` };
275
+ }
276
+ Object.defineProperty(bundles, name, { value: { path: entry.path, format: entry.format }, enumerable: true, writable: true, configurable: true });
277
+ }
278
+ overlay[key] = bundles;
279
+ break;
280
+ }
168
281
  case "maxCostUsd":
169
282
  case "dailyCostUsd":
170
283
  case "weeklyCostUsd":
@@ -196,29 +309,49 @@ function validateOverlay(candidate, log) {
196
309
  const meant = Object.hasOwn(DOLLAR_NEAR_MISSES, overlayKeyShape(key)) ? DOLLAR_NEAR_MISSES[overlayKeyShape(key)] : undefined;
197
310
  if (meant !== undefined) return { invalid: `${key} is not a settings key -- did you mean ${meant}? A dollar cap the worker drops would not apply while the file reads as set` };
198
311
  log("settings_overlay_unknown_key", { key });
312
+ // Issue #761: the ONE place that decides a key is unknown, so the list a writer carries cannot drift from
313
+ // this switch. A near miss never gets here (refused above), so it is never carried either.
314
+ unknownKeys.push(key);
199
315
  }
200
316
  }
201
317
  }
202
- return { overlay };
318
+ return { overlay, unknownKeys };
203
319
  }
204
320
 
205
321
  /**
206
- * Read the overlay at `path`. Returns `{ overlay }` (known keys only) on success, or `{ invalid }` with
207
- * a key-only reason on failure. NEVER throws.
322
+ * Read the overlay at `path` for a writer (issue #761): `{ overlay, carry: { doc, unknownKeys }, text }` on success,
323
+ * `{ invalid }` with a key-only reason, or `{ invalid, newer: true }` for a file written by a newer pi-dispatch.
324
+ * NEVER throws. `carry` is what `writeOverlay` needs to write back every key this build does not know (the parsed
325
+ * document and the unknown names), and `text` is the file as read, for a writer's `expect` snapshot.
326
+ *
327
+ * A missing file (ENOENT) is the normal empty overlay, `{ overlay: {}, carry: null, text: null }` (`null` is
328
+ * `fileSnapshot`'s encoding of a missing file). Any OTHER read error (EACCES, EISDIR, ...) is `{ invalid }`: a
329
+ * present-but-unreadable file must fail closed, not silently become an empty overlay that restores env's higher
330
+ * `dailyCap`. Unparseable JSON, a duplicate key, a non-object root, a broken `version` or an invalid known key is
331
+ * `{ invalid }`.
208
332
  *
209
- * A missing file (ENOENT) is the normal empty overlay `{ overlay: {} }`. Any OTHER read error
210
- * (EACCES, EISDIR, ...) is `{ invalid }`: a present-but-unreadable file must fail closed, not silently
211
- * become an empty overlay that restores env's higher `dailyCap`. Unparseable JSON, a non-object root,
212
- * or an invalid known key is `{ invalid }`.
333
+ * `supported`, `table` and `valueTable` default to this build's and are injectable so a test can run the version
334
+ * machinery while the shipped tables are empty.
213
335
  */
214
- export function readOverlay(path, { fs = nodeFs, log = () => {} } = {}) {
336
+ export function readOverlayDocument(path, { fs = nodeFs, log = () => {}, supported = SETTINGS_VERSION, table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
215
337
  let text;
216
338
  try {
217
339
  text = fs.readFileSync(path, "utf8");
218
340
  } catch (err) {
219
- if (err?.code === "ENOENT") return { overlay: {} }; // missing file is a normal empty overlay
341
+ if (err?.code === "ENOENT") return { overlay: {}, carry: null, text: null }; // missing file is a normal empty overlay
220
342
  return { invalid: `settings file unreadable (${err?.code ?? "read-error"})` };
221
343
  }
344
+ const read = judgeOverlayText(text, log, { supported, table, valueTable });
345
+ if (read.invalid) return read;
346
+ return { overlay: read.overlay, carry: { doc: read.doc, unknownKeys: read.unknownKeys }, text };
347
+ }
348
+
349
+ /**
350
+ * The settings file's TEXT judged as a reader judges it: `{ overlay, unknownKeys, doc }`, `{ invalid }` or
351
+ * `{ invalid, newer: true }`. Shared by the read and by `writeOverlay`'s re-read of its own output, so the writer can
352
+ * never produce a file the reader refuses.
353
+ */
354
+ function judgeOverlayText(text, log, versions) {
222
355
  let parsed;
223
356
  try {
224
357
  parsed = JSON.parse(text);
@@ -230,7 +363,24 @@ export function readOverlay(path, { fs = nodeFs, log = () => {} } = {}) {
230
363
  // is named, never a value. `writeOverlay` serialises an object, so a file it wrote never has one.
231
364
  const duplicate = findDuplicateKey(text);
232
365
  if (duplicate) return { invalid: `settings file has a duplicate key ${JSON.stringify(duplicate.key)}: JSON keeps the last value, so the file that reads and the file that runs differ` };
233
- return validateOverlay(parsed, log);
366
+ const result = validateOverlay(parsed, log, versions);
367
+ if (result.invalid) return result;
368
+ return { ...result, doc: parsed };
369
+ }
370
+
371
+ /**
372
+ * Read the overlay at `path`. Returns `{ overlay }` (known keys only) on success, or `{ invalid }` with
373
+ * a key-only reason on failure, plus `newer: true` when the file was written by a newer pi-dispatch (issue #761: the
374
+ * worker defers rather than refuses then). NEVER throws. `readOverlayDocument`'s rules, without the carry.
375
+ *
376
+ * A key this build does not know NEVER appears in `overlay`, and that is load-bearing beyond tidiness: the admin's
377
+ * `withoutOperatorOnly` keeps operator-only values out of model-visible views with a BLOCK list, so a future
378
+ * operator-only key, unknown to this build, is kept out only because it never enters the overlay.
379
+ */
380
+ export function readOverlay(path, { fs = nodeFs, log = () => {} } = {}) {
381
+ const read = readOverlayDocument(path, { fs, log });
382
+ if (read.invalid) return read.newer === true ? { invalid: read.invalid, newer: true } : { invalid: read.invalid };
383
+ return { overlay: read.overlay };
234
384
  }
235
385
 
236
386
  /**
@@ -274,28 +424,97 @@ export function effectiveSettings(config, overlay) {
274
424
  * `secretProfiles` (REQ-TRIGGER-SECRETS) rides ALONGSIDE the effective keys rather than inside them:
275
425
  * `effectiveSettings` resolves `overlay > env` over a fixed literal whose tests pin its key set, and this key
276
426
  * deliberately has no such precedence (a name declared in both sources is refused per delivery, not silently
277
- * won by either).
427
+ * won by either). `secretBundles` (issue #704) rides beside it for the same reason, under the same rule.
428
+ *
429
+ * A file written by a newer pi-dispatch keeps its `newer: true` (issue #761), so the processor can defer the job for an
430
+ * upgrade instead of refusing it; every other invalid read is exactly `{ invalid }`, as it always was.
278
431
  */
279
432
  export function resolveSettings(config, read) {
280
- if (read.invalid) return { invalid: read.invalid };
433
+ if (read.invalid) return read.newer === true ? { invalid: read.invalid, newer: true } : { invalid: read.invalid };
281
434
  const settings = effectiveSettings(config, read.overlay);
282
435
  const broken = checkDollarInvariant(settings);
283
436
  if (broken) return broken;
284
- return { ...settings, secretProfiles: read.overlay?.secretProfiles ?? {} };
437
+ return { ...settings, secretProfiles: read.overlay?.secretProfiles ?? {}, secretBundles: read.overlay?.secretBundles ?? {} };
438
+ }
439
+
440
+ /**
441
+ * The file text for a validated overlay (issue #761): 2-space JSON and a trailing newline.
442
+ *
443
+ * Without `carry` it is exactly `JSON.stringify(validated, null, 2)` plus the newline, the bytes this writer always
444
+ * wrote. With it, the text follows the FILE's key order: walking `carry.doc`, a key this build does not know is written
445
+ * with its value as read, a known key is written from `validated` only if it is still there (so an unset removes it),
446
+ * and `version` is skipped; then the keys `validated` sets that the file did not have are appended in the candidate's
447
+ * order. So a changed key keeps its line, a file of known keys is rewritten byte for byte, and a key from a later
448
+ * release survives a write by an older console. `version` leads, and only when the content needs more than 1: the
449
+ * writer owns it, so a hand-written `"version": 1` is dropped and a stamp whose key was unset goes with it.
450
+ *
451
+ * Built with `Object.defineProperty`, `secretBundles`' precedent above: an assignment to a carried key named
452
+ * `__proto__` would set the result's PROTOTYPE and drop the key from the text, while a defined property stays data.
453
+ * Known residual: JavaScript lists integer-like keys (`"1"`) first in any object, so such a carried key moves to the top.
454
+ */
455
+ function overlayText(validated, carry, versions) {
456
+ const out = {};
457
+ const put = (key, value) => Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
458
+ const version = settingsVersionFor(validated, versions);
459
+ if (version > 1) put("version", version);
460
+ const doc = carry?.doc;
461
+ if (doc !== null && typeof doc === "object" && !Array.isArray(doc)) {
462
+ const unknown = new Set(Array.isArray(carry.unknownKeys) ? carry.unknownKeys : []);
463
+ for (const key of Object.keys(doc)) {
464
+ if (key === "version") continue;
465
+ if (unknown.has(key)) put(key, doc[key]);
466
+ else if (Object.hasOwn(validated, key)) put(key, validated[key]);
467
+ }
468
+ }
469
+ for (const key of Object.keys(validated)) {
470
+ if (!Object.hasOwn(out, key)) put(key, validated[key]);
471
+ }
472
+ return `${JSON.stringify(out, null, 2)}\n`;
285
473
  }
286
474
 
287
475
  /**
288
476
  * Write `candidate` to `path` atomically. Validates with the same rules as `readOverlay` (an invalid
289
477
  * candidate returns `{ invalid }` and touches no file; an empty `{}` candidate is valid), serialises
290
- * the validated overlay with 2-space indent and a trailing newline, writes a same-directory
291
- * `<path>.tmp`, then renames it over `path`. NEVER throws.
478
+ * the validated overlay with 2-space indent and a trailing newline, writes a same-directory tmp file of
479
+ * its own, then renames it over `path`. NEVER throws.
480
+ *
481
+ * The tmp name is `<path>.<pid>.<random>.tmp`, created exclusively (`wx`), as `writePauseWindows` and the admin's
482
+ * `replaceFile` do (issue #642). It was a fixed `<path>.tmp`, harmless while one process wrote the overlay; the
483
+ * console and the pi extension are two, and a shared name lets one rename the other's bytes past its own re-check.
484
+ * A write that fails after the tmp exists removes it, so a unique name never becomes litter. A file already at the tmp
485
+ * name (another writer's, or one planted there) refuses the write rather than being overwritten, and is left alone:
486
+ * the exclusive create is what makes the name this writer's. `random` is injectable so a test can aim at that name.
487
+ *
488
+ * `expect` (issue #642): the `{ path, text }` snapshots the change was built from (the admin's `writeInputs`, taken
489
+ * before an operator's confirm). Right before the rename each file is re-read, and a difference refuses with nothing
490
+ * written (`file-expect.mjs`). Without it the write is what it always was.
292
491
  *
293
492
  * The rename is retried once on EPERM to ride out a Windows AV/indexer briefly locking the destination;
294
493
  * a second failure surfaces as `{ invalid }`, never a throw. Returns `{ ok: true }` on success.
494
+ *
495
+ * `carry` (issue #761): `readOverlayDocument`'s `carry` for the file the candidate was built from. With it, the text
496
+ * keeps the FILE's key order and every key this build does not know, exactly as read (`overlayText`); without it, the
497
+ * output is what it always was. Only what came from the file is carried: an unknown key on the candidate itself
498
+ * (anything a `mutate` added) is still dropped and logged, so no caller can write an arbitrary key through here. The
499
+ * output is then judged by the reader's own rules before the tmp file exists, so a write never leaves a file the
500
+ * worker would refuse. `table`, `valueTable` and `supported` are the version rules, injectable for tests.
295
501
  */
296
- export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {} } = {}) {
297
- const result = validateOverlay(candidate, log);
502
+ export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {}, expect = null, carry = null, random = randomBytes, supported = SETTINGS_VERSION, table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
503
+ const result = validateKeys(candidate, log);
298
504
  if (result.invalid) return { invalid: result.invalid };
505
+ // A carry is only ever a READABLE file's (`readOverlayDocument` refuses the rest), and this keeps it so for any
506
+ // caller: the text below drops the file's `version` and writes its own, so a newer file's carry would otherwise come
507
+ // out stamped as a version this build vouches for, under keys it cannot read.
508
+ const carried = carry?.doc;
509
+ if (carried !== null && typeof carried === "object" && Object.hasOwn(carried, "version")) {
510
+ const problem = versionProblem(carried.version, { supported, what: "settings file" });
511
+ if (problem) return problem.newer ? { invalid: problem.message, newer: true } : { invalid: problem.message };
512
+ }
513
+ const data = overlayText(result.overlay, carry, { table, valueTable });
514
+ // The reader's own judgement of the bytes about to be written (parse, duplicate keys, version, every key), silently:
515
+ // the candidate's unknown keys were logged above, and the carried ones were logged when the file was read.
516
+ const reread = judgeOverlayText(data, () => {}, { supported, table, valueTable });
517
+ if (reread.invalid) return { invalid: reread.invalid };
299
518
 
300
519
  try {
301
520
  // Issue #464: on an account with no home the overlay's default lives in the per-account temp root, which is made
@@ -306,21 +525,37 @@ export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {} } =
306
525
  return { invalid: err?.piDispatchConfig ? `settings dir refused: ${err.message}` : `settings dir unwritable (${err?.code ?? "mkdir-error"})` };
307
526
  }
308
527
 
309
- const tmp = `${path}.tmp`;
310
- const data = `${JSON.stringify(result.overlay, null, 2)}\n`;
528
+ const tmp = `${path}.${process.pid}.${random(6).toString("hex")}.tmp`;
529
+ const drop = () => {
530
+ try {
531
+ fs.unlinkSync?.(tmp);
532
+ } catch {
533
+ // nothing to drop
534
+ }
535
+ };
311
536
  try {
312
- fs.writeFileSync(tmp, data);
537
+ fs.writeFileSync(tmp, data, { flag: "wx" });
313
538
  } catch (err) {
314
539
  return { invalid: `settings write failed (${err?.code ?? "write-error"})` };
315
540
  }
316
541
 
542
+ const changed = expectRefusal(fs, expect);
543
+ if (changed) {
544
+ drop();
545
+ return { invalid: changed };
546
+ }
547
+
317
548
  try {
318
549
  fs.renameSync(tmp, path);
319
550
  } catch (err) {
320
- if (err?.code !== "EPERM") return { invalid: `settings rename failed (${err?.code ?? "rename-error"})` };
551
+ if (err?.code !== "EPERM") {
552
+ drop();
553
+ return { invalid: `settings rename failed (${err?.code ?? "rename-error"})` };
554
+ }
321
555
  try {
322
556
  fs.renameSync(tmp, path); // single retry: Windows AV/indexer lock is transient
323
557
  } catch (retryErr) {
558
+ drop();
324
559
  return { invalid: `settings rename failed (${retryErr?.code ?? "rename-error"})` };
325
560
  }
326
561
  }
@@ -2,8 +2,11 @@ import { spawn } from "node:child_process";
2
2
  import { parseArgs } from "node:util";
3
3
  import { loadConfig } from "./config.mjs";
4
4
  import { basename } from "node:path";
5
- import { SANDBOX_OPEN_GRACE_MS, launchSandbox, listRunningSandboxes, openSandbox, parsePublish, sandboxContainerName, sandboxLauncher, sandboxVenueOf, sandboxVenueRefusal, sandboxVenues } from "./sandbox.mjs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { SANDBOX_OPEN_GRACE_MS, decideSandboxJobUser, launchSandbox, listRunningSandboxes, openSandbox, parsePublish, resolveSandbox, sandboxContainerName, sandboxLauncher, sandboxVenueOf, sandboxVenueRefusal, sandboxVenues } from "./sandbox.mjs";
6
7
  import { listSandboxes, pinSandbox, sandboxDeadline } from "./sandbox-store.mjs";
8
+ import { ensureSandboxMonitor, inspectSandboxSession, makeSandboxMonitor } from "./sandbox-session-watch.mjs";
9
+ import { SANDBOX_SESSION_ID_RE } from "./sandbox-sessions.mjs";
7
10
 
8
11
  /**
9
12
  * `pi-dispatch sandbox` -- re-open a finished run's sandbox as an interactive shell
@@ -43,13 +46,50 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
43
46
  list: { type: "boolean", default: false },
44
47
  publish: { type: "string", multiple: true }, // repeatable; always bound to 127.0.0.1
45
48
  pin: { type: "boolean", default: false },
49
+ attach: { type: "boolean", default: false },
50
+ new: { type: "string" },
51
+ ref: { type: "string" },
52
+ as: { type: "string" },
53
+ deployment: { type: "string" },
54
+ "expected-host": { type: "string" },
55
+ "opened-by": { type: "string" },
46
56
  },
47
57
  }));
48
58
  } catch (error) {
49
59
  return fail(err, error.message);
50
60
  }
51
61
 
52
- const config = loadConfig(env);
62
+ if (values.deployment) {
63
+ const { sandboxDeploymentEnv } = await import("./sandbox-deployment.mjs");
64
+ const loaded = await (deps.deploymentEnv ?? sandboxDeploymentEnv)({ dir: values.deployment, env, expectedHost: values["expected-host"] });
65
+ if (loaded.refused) return fail(err, loaded.message);
66
+ // Readers and runtime helpers use their process's environment. Re-exec in the clean environment so none
67
+ // can accidentally consult the SSH shell's DOCKER_HOST, egress or rootless store (#771).
68
+ const next = [];
69
+ for (let i = 0; i < argv.length; i++) {
70
+ if (argv[i] === "--deployment" || argv[i] === "--expected-host") { i++; continue; }
71
+ if (argv[i].startsWith("--deployment=") || argv[i].startsWith("--expected-host=")) continue;
72
+ next.push(argv[i]);
73
+ }
74
+ return (deps.runDeployment ?? runDeploymentSandbox)(next, loaded.env, values.deployment);
75
+ }
76
+ if (values["expected-host"]) return fail(err, "--expected-host requires --deployment");
77
+ let config;
78
+ try { config = loadConfig(env); } catch (error) { return fail(err, error.message); }
79
+ if (values.new !== undefined) {
80
+ if (positionals.length || values.list || values.attach || values.publish?.length || values.ref === undefined || values.as === undefined || !/^(0|[1-9][0-9]*)$/.test(values.as)) return fail(err, "--new takes --ref <branch|pr:N|sha> and --as <trigger index>, with no run id, --list, --attach or --publish");
81
+ try {
82
+ const { createSandboxPreparer } = await import("./sandbox-prepare.mjs");
83
+ const prepare = deps.prepare ?? createSandboxPreparer({ config, env });
84
+ const result = await prepare({ repo: values.new, ref: values.ref, triggerIndex: Number(values.as), host: config.workerName, pin: values.pin });
85
+ if (result.refused) return fail(err, result.message);
86
+ out(`prepared ${result.id}, kept until ${new Date(result.until).toISOString()}; no container started\n`);
87
+ out(`open it with: pi-dispatch sandbox ${result.id}\n`);
88
+ return 0;
89
+ } catch { return fail(err, "sandbox preparation failed; check the repository, forge access and deployment settings, then try again"); }
90
+ }
91
+ if (values.ref !== undefined || values.as !== undefined) return fail(err, "--ref and --as require --new");
92
+ if (positionals.length > 1 || (values.list && (positionals.length || values.attach || values.pin || values.publish?.length))) return fail(err, "choose one sandbox id, or --list");
53
93
  // A docker that cannot be reached costs a column, never the command: `listRunningSandboxes` throws so
54
94
  // the REAPER can tell "none" from "could not ask", and these callers only draw a marker. Asked only
55
95
  // where it is used, and never before the arguments are known good -- a typo should not shell out.
@@ -75,6 +115,7 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
75
115
 
76
116
  const jobId = positionals[0];
77
117
  if (!jobId) return fail(err, "a job id is required — `pi-dispatch sandbox --list` shows what is still re-openable");
118
+ if (!SANDBOX_SESSION_ID_RE.test(jobId)) return fail(err, "the sandbox id must contain only letters, digits, dot, underscore, colon and hyphen (at most 200 characters)");
78
119
 
79
120
  // Refused BEFORE anything else that could half-succeed. `-t` against a pipe fails inside docker with
80
121
  // "the input device is not a TTY", which names neither the cause nor the fix; a sandbox is an operator
@@ -82,6 +123,24 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
82
123
  if (!isTty) {
83
124
  return fail(err, "`pi-dispatch sandbox` needs a terminal — it opens an interactive shell, so it cannot run from a pipe, a script without a TTY, or CI");
84
125
  }
126
+ const monitorSession = deps.monitorSession ?? makeSandboxMonitor({ env, config, by: values["opened-by"], now });
127
+ if (values.attach) {
128
+ if (values.publish?.length || values.pin) return fail(err, "--attach does not take --publish or --pin; pin the kept workspace separately");
129
+ const resolved = resolveSandbox({ jobId, sandboxDir: config.sandboxDir, retentionHours: config.sandboxRetentionHours, blessed: config.backends, pin: true, now });
130
+ if (resolved.refused) return fail(err, resolved.message);
131
+ const { bin } = sandboxLauncher(resolved.venue);
132
+ const state = await (deps.inspectSession ?? inspectSandboxSession)({ bin, name: resolved.name });
133
+ if (state.unknown) return fail(err, "the runtime could not report this sandbox; check its host and try again");
134
+ if (!state.present || !state.running) return fail(err, "this sandbox is no longer running; open its kept workspace again");
135
+ if (!/^[a-f0-9]{32}$/.test(state.nonce ?? "")) return fail(err, `this shell predates session tracking; return with \`${bin} attach ${resolved.name}\`, or exit it and open it again to register it`);
136
+ const user = await (resolveJobUser ?? decideSandboxJobUser)({ manifest: resolved.manifest, venue: resolved.venue, backendFloor: config.backendFloor });
137
+ if (user?.refused) return fail(err, user.message);
138
+ try { await (deps.ensureMonitor ?? ensureSandboxMonitor)({ resolved, runtime: bin, state, config, env, admittedRuntime: user?.runtime ?? null, by: values["opened-by"], now }); }
139
+ catch { return fail(err, "sandbox tracking could not be recovered; check Valkey before attaching"); }
140
+ out(`attaching to ${resolved.name} (${bin}); exit the shell to end it\n`);
141
+ const attached = await (deps.attach ?? attachSandbox)({ bin, containerId: state.containerId });
142
+ return attached.code ?? 1;
143
+ }
85
144
 
86
145
  let publish;
87
146
  try {
@@ -121,6 +180,7 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
121
180
  // Issue #429: which venues open here, and what a podman sandbox's observations must show, as the worker reads them.
122
181
  blessed: config.backends,
123
182
  backendFloor: config.backendFloor,
183
+ monitorSession,
124
184
  now,
125
185
  // Pin BEFORE the shell, not after: the operator asked to keep this one, and a session that ends in a crashed
126
186
  // terminal or a closed laptop lid must not be the reason the pin never landed. And since issue #446 before
@@ -149,6 +209,22 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
149
209
  return result.code ?? 0;
150
210
  }
151
211
 
212
+ function runDeploymentSandbox(argv, env, dir) {
213
+ return new Promise((resolve) => {
214
+ const child = spawn(process.execPath, [fileURLToPath(new URL("./cli.mjs", import.meta.url)), "sandbox", ...argv], { env, ...(dir ? { cwd: dir } : {}), stdio: "inherit" });
215
+ child.once("error", () => resolve(1));
216
+ child.once("exit", (code) => resolve(code ?? 1));
217
+ });
218
+ }
219
+
220
+ export function attachSandbox({ bin, containerId, spawnFn = spawn }) {
221
+ return new Promise((resolve) => {
222
+ const child = spawnFn(bin, ["attach", containerId], { stdio: "inherit" });
223
+ child.once("error", () => resolve({ code: 1 }));
224
+ child.once("exit", (code) => resolve({ code: code ?? 1 }));
225
+ });
226
+ }
227
+
152
228
  /**
153
229
  * What is still re-openable, newest first, plus what is running right now.
154
230
  *
@@ -0,0 +1,51 @@
1
+ /** An explicit deployment handoff is stronger than a generic deployment pointer: it loads sandbox authority only. */
2
+ import * as nodeFs from "node:fs";
3
+ import { isAbsolute, join, win32 } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { deploymentServiceEnv } from "./service-env.mjs";
6
+ import { workerName } from "./config.mjs";
7
+
8
+ export const SANDBOX_DEPLOYMENT_KEYS = Object.freeze([
9
+ "PI_WORKER_NAME", "PI_SANDBOX_DIR", "PI_JOBS_DIR", "PI_SANDBOX_RETENTION_HOURS", "PI_SANDBOX_PIN_DAYS", "PI_SANDBOX_IDLE_MINUTES",
10
+ "PI_BACKENDS", "PI_BACKEND_FLOOR", "PI_EGRESS", "PI_EGRESS_PROXY", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH",
11
+ "VALKEY_URL", "VALKEY_PASSWORD", "PI_VALKEY_SHARED", "PATH", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "CONTAINERS_STORAGE_CONF", "CONTAINERS_CONF", "CONTAINERS_CONF_OVERRIDE", "CONTAINERS_REGISTRIES_CONF", "CONTAINER_HOST", "CONTAINER_CONNECTION",
12
+ ]);
13
+ const OS_KEYS = Object.freeze(["HOME", "USERPROFILE", "SystemRoot", "WINDIR", "TEMP", "TMP", "TMPDIR", "TERM", "LANG", "LC_ALL", "PATH"]);
14
+ export function sandboxDeploymentEnv({ dir, env = process.env, expectedHost = null, fs = nodeFs, platform = process.platform, uid = process.geteuid?.(), home = homedir(), read = deploymentServiceEnv } = {}) {
15
+ const refuse = (message) => ({ refused: "sandbox-deployment", message });
16
+ if (typeof dir !== "string" || !isAbsolute(dir) || /[\x00-\x1f\x7f]/.test(dir)) return refuse("--deployment must name an absolute deployment directory");
17
+ // One descriptor-backed read. Ambient values are compared afterwards, never used as defaults for authority.
18
+ const resolved = read({ dir, env: {}, keys: SANDBOX_DEPLOYMENT_KEYS, platform, fs, uid });
19
+ if (resolved.unreadable || resolved.untrusted || resolved.hazard) return refuse("the deployment .env cannot be trusted or read as its service reads it; run pi-dispatch doctor");
20
+ if (!resolved.path || (resolved.fromFile && Object.keys(resolved.fromFile).length === 0 && resolved.env && !fs.existsSync(resolved.path))) return refuse("the deployment .env is missing; point --deployment at the installed deployment");
21
+ if (resolved.unread?.length || resolved.hazardSkipped?.length) return refuse("a sandbox setting cannot be read the way the deployment service reads it; run pi-dispatch doctor");
22
+ const clean = { ...resolved.env };
23
+ for (const key of SANDBOX_DEPLOYMENT_KEYS) {
24
+ if (typeof env[key] !== "string") continue;
25
+ // SSH's PATH routinely lacks the service's Docker/Podman directory. A reviewed deployment PATH wins;
26
+ // when absent, PATH alone keeps the ordinary OS fallback (it grants no sandbox policy or runtime endpoint).
27
+ if (key === "PATH") continue;
28
+ if (typeof clean[key] === "string") {
29
+ if (clean[key] !== env[key]) return refuse(`the shell disagrees with the deployment for ${key}; unset that variable before opening`);
30
+ continue;
31
+ }
32
+ // PAM supplies the account's standard runtime directory on an SSH login, as a user service does. Admit it
33
+ // only after identity/ownership validation; a non-default rootless store remains deployment authority.
34
+ if (key === "XDG_RUNTIME_DIR" && platform === "linux" && Number.isInteger(uid) && env[key] === `/run/user/${uid}`) {
35
+ try { const stat = fs.lstatSync(env[key]); if (stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === uid && !(stat.mode & 0o077)) { clean[key] = env[key]; continue; } } catch {}
36
+ }
37
+ if (key === "XDG_DATA_HOME" && env[key] === join(home, ".local", "share")) { clean[key] = env[key]; continue; }
38
+ if (key === "XDG_CONFIG_HOME" && env[key] === join(home, ".config")) { clean[key] = env[key]; continue; }
39
+ return refuse(`the shell sets sandbox authority absent from the deployment: ${key}; unset that variable before opening`);
40
+ }
41
+ const result = {};
42
+ for (const key of OS_KEYS) if (typeof env[key] === "string") result[key] = env[key];
43
+ // env-internal HOME: compare the launching account's home to the OS identity before opening a rootless store.
44
+ if (platform !== "win32" && env.HOME && env.HOME !== home) return refuse("HOME is not this account's home; open the sandbox as the worker's account");
45
+ for (const key of SANDBOX_DEPLOYMENT_KEYS) if (typeof clean[key] === "string") result[key] = clean[key];
46
+ if (typeof result.PATH === "string" && (/[\x00-\x1f\x7f]/.test(result.PATH) || result.PATH.split(platform === "win32" ? ";" : ":").some((part) => !part || !(platform === "win32" ? win32.isAbsolute(part) : isAbsolute(part))))) return refuse("the sandbox runtime PATH must contain only absolute tool directories; correct PATH in the deployment .env");
47
+ try {
48
+ if (expectedHost !== null && workerName(result) !== expectedHost) return refuse("the deployment worker name does not match the opener host; check Fleet and the local opener configuration");
49
+ } catch { return refuse("the deployment worker name is invalid; run pi-dispatch doctor"); }
50
+ return { env: result };
51
+ }