@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,249 @@
1
+ /**
2
+ * State this build still meets in the shape, or at the place, an older release left it (issue #761): one finding each,
3
+ * which doctor prints as `state.older` and `up`'s summary repeats for the Valkey container.
4
+ *
5
+ * THE RULE for every later release: one that moves or reshapes a store and keeps the old shape readable registers a
6
+ * detector here, gated so that it retires itself (it fires only while the old shape is really there, and says nothing
7
+ * once it is gone), and adds a row to docs/upgrading.md. A detector fires on an EXACT older reference, never on
8
+ * something an operator chose (an image they picked, a file they edited, a path they set): a warning that cannot go
9
+ * away, or that argues with a deliberate choice, is one nobody reads.
10
+ *
11
+ * No data store has an older shape yet. Every versioned file and record this build reads, it also still writes:
12
+ * versions are stamped by need (DES-STORE-VERSION-WHEN-NEEDED), so a version 1 file or record is what this build writes
13
+ * too while nothing in it needs more, and job data's version 2 and the scoped limits' 3 are stamped only where a field
14
+ * needs them. The older LOCATIONS of issues #290 and #464 (the run history and the settings overlay under the OS temp
15
+ * dir, retained workspaces under the old shared jobs root) keep their own lines in doctor's `state` and `dirs` sections.
16
+ *
17
+ * What is here: the Valkey image older releases shipped, the floating `valkey/valkey:8`, which this release pins as
18
+ * `VALKEY_IMAGE` (valkey-auth.mjs says why). An upgrade rewrites none of the three places that keep it:
19
+ * - a compose file in the deployment folder, which /dispatch setup copies create-only into `deploy/` (an earlier setup
20
+ * copied it to the folder's top);
21
+ * - the installed Quadlet unit, which only `service install --force` rewrites (`up` leaves a changed one as it is);
22
+ * - the `pi-dispatch-valkey` container an older `up` started, which keeps its image until it is recreated.
23
+ *
24
+ * Pure but for two seams, and it never throws: `readFile(path)` gives a file's text (a throw, a missing file, is no
25
+ * finding), and `valkeyContainer()` gives `valkeyContainerOwner`'s verdict on the `pi-dispatch-valkey` container
26
+ * (valkey-auth.mjs; null, a throw or no seam is no finding), which `upValkeyContainerImage` judges with the folder's
27
+ * `handedOver`, and which also decides whether the compose file's fix recreates compose's valkey. A detector that cannot
28
+ * read must not say something false.
29
+ */
30
+ import { join } from "node:path";
31
+ import { COMPOSE_FILE, COMPOSE_VALKEY_OVERRIDE, VALKEY_IMAGE, VALKEY_VOLUME, composeArgs, composeProjectName } from "./valkey-auth.mjs";
32
+ import { QUADLET_FILES, quadletDir } from "./podman-stack.mjs";
33
+
34
+ /**
35
+ * Every Valkey image reference an older release shipped, as each place spelled it: compose and `up`'s `docker run` the
36
+ * short name, the Quadlet unit the fully qualified one. Matched EXACTLY, so `valkey/valkey:8.1`, `:8-alpine` and
37
+ * `:8@sha256:...` (each an operator's or this release's choice) never are. When `VALKEY_IMAGE` moves on, the value it
38
+ * leaves is added here, in both spellings; a test holds `VALKEY_IMAGE` out of this list.
39
+ */
40
+ export const OLDER_VALKEY_IMAGES = Object.freeze(["valkey/valkey:8", "docker.io/valkey/valkey:8"]);
41
+
42
+ /** The container `up` starts Valkey as (`valkeyDockerRunArgs`), the one `valkeyContainer` gives the verdict on. */
43
+ export const VALKEY_CONTAINER = "pi-dispatch-valkey";
44
+
45
+ /**
46
+ * The image of the Valkey container `owner` describes (`valkeyContainerOwner`'s verdict, valkey-auth.mjs), when `up`
47
+ * runs that container itself, else null. THE one rule `up`'s summary and doctor's `state.older` both judge the container
48
+ * by, so neither names a container the other leaves alone:
49
+ * - it is provably this deployment's (`ours`: up's label naming this folder, compose's working dir here, or,
50
+ * unlabelled, this deployment's VALKEY_URL port on 127.0.0.1). Another deployment's container, or one nothing ties
51
+ * to this folder, is never stopped or removed from here, and the fix below does both;
52
+ * - and the folder is not handed to compose (`handedOver` false: no `COMPOSE_VALKEY_OVERRIDE`). There compose runs the
53
+ * Valkey and recreates it from the compose file, which its own detector judges, and `pi-dispatch up` starts compose's
54
+ * valkey, never a container of its own.
55
+ * Anything else, an unknown or absent verdict and a hand-over fact that is not a boolean included, is null: a fix that
56
+ * could act on the wrong container is worse than no finding.
57
+ */
58
+ export function upValkeyContainerImage(owner, { handedOver } = {}) {
59
+ if (!upRunsValkey(owner, { handedOver })) return null;
60
+ return typeof owner.image === "string" ? owner.image : null;
61
+ }
62
+
63
+ /**
64
+ * Whether this deployment's `up` runs its Valkey as that container, whatever its image: the first half of
65
+ * `upValkeyContainerImage`'s rule. Then compose does not run this deployment's Valkey, so the compose file's fix must
66
+ * not recreate compose's valkey, which would start a second, empty one beside the queue.
67
+ */
68
+ function upRunsValkey(owner, { handedOver } = {}) {
69
+ return owner?.ours === true && handedOver === false;
70
+ }
71
+
72
+ /** The legacy place an earlier /dispatch setup copied the compose file to, relative to the deployment folder. */
73
+ export const LEGACY_COMPOSE_FILE = "docker-compose.yml";
74
+
75
+ // Said by every fix below, as `podman-stack.mjs` says it for a Valkey restart: the cost, and how to avoid it.
76
+ const INTERRUPTS = "A job running right now is interrupted: pause first if one is (pi-dispatch pause, wait for active jobs, then pi-dispatch resume)";
77
+ const PINS = `this release pins ${VALKEY_IMAGE}`;
78
+
79
+ /** `ref` when it is exactly one of `OLDER_VALKEY_IMAGES` (surrounding whitespace aside), else null. */
80
+ export function olderValkeyImage(ref) {
81
+ const s = typeof ref === "string" ? ref.trim() : "";
82
+ return OLDER_VALKEY_IMAGES.includes(s) ? s : null;
83
+ }
84
+
85
+ /**
86
+ * Every `image:` of the compose file's `valkey` service (the top-level `services.valkey` block), quotes and a trailing
87
+ * comment aside, in file order, or none. That service's only: another service on `valkey/valkey:8` (a cache an operator
88
+ * added beside the queue, say) is the operator's choice, and a finding about it is one no upgrade step clears. A line
89
+ * scan by indentation rather than a YAML parse, since the only question is which image the queue's Valkey names:
90
+ * `services:` at the top, `valkey:` one level under it, and `image:` one level under that, where a nested map's
91
+ * `image:`, a comment and a blank line are not it. A spelling the scan does not follow (a flow mapping, an anchor, a
92
+ * quoted key) is no finding, never a false one.
93
+ *
94
+ * A merge left unresolved in the service (git's conflict markers at column 0, which would otherwise end the block as a
95
+ * top-level key does) is read through: the marker lines count as blank and EVERY `image:` at the service's own level
96
+ * counts, so a conflict between `:8` and `:8.1` is a finding, whichever side holds the older line. Under git's diff3 and
97
+ * zdiff3 styles the merge base sits between a `|||||||` marker and the `=======`: it is neither side, so it counts as
98
+ * blank too, and an older line in the base alone is no finding. The markers are read inside the service only; elsewhere
99
+ * they still end the scan, as any top-level line does.
100
+ */
101
+ function composeValkeyImages(text) {
102
+ const lines = String(text).split(/\r?\n/);
103
+ const depth = (line) => /^[ \t]*/.exec(line)[0].length;
104
+ const said = (line) => !/^\s*(?:#.*)?$/.test(line);
105
+ // git's markers: seven of one character at column 0, every one but `=======` followed by a name (a side, or the base).
106
+ const conflictMarker = (line) => /^(?:(?:<{7}|>{7}|\|{7})(?:[ \t].*)?|={7}[ \t]*)$/.test(line);
107
+ const baseMarker = (line) => /^\|{7}(?:[ \t].*)?$/.test(line);
108
+ const sidesMarker = (line) => /^={7}[ \t]*$/.test(line);
109
+ let i = lines.findIndex((line) => /^services:\s*(?:#.*)?$/.test(line));
110
+ if (i < 0) return [];
111
+ // The services, at the depth of the first one, until the next top-level key.
112
+ let services = null;
113
+ for (i += 1; i < lines.length; i++) {
114
+ if (!said(lines[i])) continue;
115
+ const d = depth(lines[i]);
116
+ if (d === 0) return [];
117
+ services ??= d;
118
+ if (d === services && /^\s*valkey:\s*(?:#.*)?$/.test(lines[i])) break;
119
+ }
120
+ // The valkey service's own keys, at the depth of its first one, until the next service.
121
+ const images = [];
122
+ let keys = null;
123
+ // Inside a conflict's merge base, from its `|||||||` to the `=======`.
124
+ let inBase = false;
125
+ for (i += 1; i < lines.length; i++) {
126
+ if (baseMarker(lines[i])) inBase = true;
127
+ else if (sidesMarker(lines[i])) inBase = false;
128
+ if (inBase || !said(lines[i]) || conflictMarker(lines[i])) continue;
129
+ const d = depth(lines[i]);
130
+ if (d <= services) break;
131
+ keys ??= d;
132
+ const m = d === keys ? /^\s*image:\s*(["']?)([^"'\s#]+)\1\s*(?:#.*)?$/.exec(lines[i]) : null;
133
+ if (m) images.push(m[2]);
134
+ }
135
+ return images;
136
+ }
137
+
138
+ /** The value of each `Image=` line of a unit file (systemd: a `#` or `;` line is a comment, the value is trimmed). */
139
+ function quadletImages(text) {
140
+ const found = [];
141
+ for (const line of String(text).split(/\r?\n/)) {
142
+ const m = /^\s*Image\s*=\s*(.*?)\s*$/.exec(line);
143
+ if (m) found.push(m[1]);
144
+ }
145
+ return found;
146
+ }
147
+
148
+ /** A file's text through the seam, or null when it cannot be read (absent above all). */
149
+ async function textOf(readFile, path) {
150
+ try {
151
+ const text = await readFile(path);
152
+ return text === null || text === undefined ? null : String(text);
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /** The verdict `valkeyContainer()` gives, synchronously or not, or null for no seam or one that throws. */
159
+ async function verdictOf(valkeyContainer) {
160
+ if (typeof valkeyContainer !== "function") return null;
161
+ try {
162
+ return await valkeyContainer();
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The finding for the `pi-dispatch-valkey` container, or null: `upValkeyContainerImage` of the verdict
170
+ * `valkeyContainer()` gives and `handedOver`, held against the older images. Exported apart for `up`, which hands it
171
+ * the verdict of the inspect it already made and repeats this in its summary. `valkeyContainer` may answer
172
+ * synchronously or not.
173
+ */
174
+ export async function olderValkeyContainerFinding({ valkeyContainer, handedOver } = {}) {
175
+ return containerFinding(await verdictOf(valkeyContainer), handedOver);
176
+ }
177
+
178
+ function containerFinding(owner, handedOver) {
179
+ const found = olderValkeyImage(upValkeyContainerImage(owner, { handedOver }));
180
+ if (!found) return null;
181
+ return {
182
+ store: "valkey-container",
183
+ label: `the ${VALKEY_CONTAINER} container runs ${found}, the floating tag an older \`pi-dispatch up\` started it with, and keeps it until it is recreated; ${PINS}`,
184
+ // stop, then rm, never `rm -f`: SIGTERM is what makes Valkey fsync its AOF and save a final snapshot before it exits
185
+ // (measured on 8.0.2 and 8.1.11), where `rm -f` kills it and can lose the last second of writes to the queue.
186
+ fix: `\`docker stop ${VALKEY_CONTAINER} && docker rm ${VALKEY_CONTAINER} && pi-dispatch up\` in the deployment folder starts ${VALKEY_IMAGE} on the same ${VALKEY_VOLUME} volume (stopped first, so Valkey writes its AOF out; the queue is kept). ${INTERRUPTS}`,
187
+ };
188
+ }
189
+
190
+ /** The first of a compose file's valkey images (`composeValkeyImages`) that is an older one, or null. */
191
+ const olderComposeImage = (text) => composeValkeyImages(text).map(olderValkeyImage).find(Boolean) ?? null;
192
+
193
+ /**
194
+ * Every finding for this deployment, in a fixed order: the folder's compose file, the earlier setup's copy at the
195
+ * folder's top, the installed Quadlet unit, the container. `cwd` is the deployment folder, `home` this account's home
196
+ * (where the Quadlet unit is installed); either missing skips its detectors. `handedOver` is whether the folder holds
197
+ * `COMPOSE_VALKEY_OVERRIDE`, as `up` reads it. The container's verdict is asked once, and decides the compose fix too:
198
+ * where this deployment's `up` runs the Valkey (`upRunsValkey`), the compose file only needs its line changed, since
199
+ * recreating compose's valkey would start a second, empty Valkey beside the queue. Where compose runs it, or the verdict
200
+ * cannot tell (no seam, a throw, a container that is not provably ours), the fix keeps the recreate.
201
+ */
202
+ export async function olderStateFindings({ cwd, home, readFile, valkeyContainer, handedOver } = {}) {
203
+ const findings = [];
204
+ try {
205
+ const owner = await verdictOf(valkeyContainer);
206
+ if (typeof readFile === "function" && typeof cwd === "string" && cwd !== "") {
207
+ const path = join(cwd, COMPOSE_FILE);
208
+ const found = olderComposeImage((await textOf(readFile, path)) ?? "");
209
+ if (found) {
210
+ // The command /dispatch setup runs for this folder: its project, and the hand-over override where it wrote one.
211
+ const project = composeProjectName(cwd);
212
+ const override = (await textOf(readFile, join(cwd, COMPOSE_VALKEY_OVERRIDE))) !== null;
213
+ const line = `change that line to \`image: ${VALKEY_IMAGE}\` (the installed package's ${COMPOSE_FILE} has it)`;
214
+ findings.push({
215
+ store: "valkey-compose",
216
+ label: `${path} names ${found} for Valkey, the floating tag an older pi-dispatch shipped, which moves this host to a newer Valkey minor on its next pull; ${PINS}`,
217
+ fix: upRunsValkey(owner, { handedOver })
218
+ ? `${line} and nothing more: this deployment's Valkey is the ${VALKEY_CONTAINER} container \`pi-dispatch up\` runs, not compose's, so recreating compose's valkey from this file would start a second, empty Valkey beside the queue`
219
+ : `${line}, then recreate Valkey from ${cwd} with \`docker ${composeArgs({ project, override }).join(" ")} up -d valkey\` (leave out \`-p ${project}\` if you run this file without it), which stops it first, so it writes its AOF out, and keeps its volume and the queue in it. ${INTERRUPTS}`,
220
+ });
221
+ }
222
+ const legacy = join(cwd, LEGACY_COMPOSE_FILE);
223
+ const old = olderComposeImage((await textOf(readFile, legacy)) ?? "");
224
+ if (old) {
225
+ findings.push({
226
+ store: "valkey-compose",
227
+ label: `${legacy} names ${old} for Valkey, the floating tag an older pi-dispatch shipped; ${PINS}`,
228
+ fix: `that file is an earlier /dispatch setup's copy, which setup no longer runs (${COMPOSE_FILE} is the one, and this copy's ../ paths reach the folder above ${cwd}): move any edits of yours there and remove it, or, if you still run it, change that line to \`image: ${VALKEY_IMAGE}\``,
229
+ });
230
+ }
231
+ }
232
+ if (typeof readFile === "function" && typeof home === "string" && home !== "") {
233
+ const path = join(quadletDir(home), QUADLET_FILES.valkey.file);
234
+ const found = quadletImages((await textOf(readFile, path)) ?? "").map(olderValkeyImage).find(Boolean);
235
+ if (found) {
236
+ findings.push({
237
+ store: "valkey-quadlet",
238
+ label: `${path} runs Valkey as ${found}, the floating tag an older pi-dispatch installed; this release pins docker.io/${VALKEY_IMAGE}`,
239
+ fix: `\`pi-dispatch service install --force\` in the deployment folder rewrites the unit and restarts ${QUADLET_FILES.valkey.unit} on the same ${VALKEY_VOLUME} volume (stopped first, so Valkey writes its AOF out; the queue is kept); \`pi-dispatch up\` leaves a changed unit file as it is. ${INTERRUPTS}`,
240
+ });
241
+ }
242
+ }
243
+ const container = containerFinding(owner, handedOver);
244
+ if (container) findings.push(container);
245
+ } catch {
246
+ // Nothing above throws by design (every read goes through a guarded seam); a finding already made still stands.
247
+ }
248
+ return findings;
249
+ }
@@ -11,11 +11,12 @@
11
11
  * is the right event regardless).
12
12
  * - **No verdict, no fault count, no profile table.** The hook changes NOTHING about the job: it runs
13
13
  * after the outcome is decided, fire and forget, and a hook fault must never flip a result
14
- * (CONST-RETRY-INFRA-ONLY). Its exit code is logged and unread -- OQ-027's residual, inherited.
14
+ * (CONST-RETRY-INFRA-ONLY). Its exit code is logged and published for display only (#775).
15
15
  * - **The reason is shape-guarded HERE, the single normalizing door.** `InfraRetry.reason` defaults to
16
16
  * the whole message when no token was given, and messages carry things like a library's own words --
17
- * so anything that does not look like a fixed token (`/^[a-z0-9-]{1,40}$/`) flattens to "infra"
18
- * before it can become an argv element. Id-only argv is the contract
17
+ * so anything that is not a record reason token (`RECORD_REASON_RE`, the run record's own rule, from
18
+ * its leaf: so a reason the record keeps reaches the hook as itself, never as "infra") flattens to
19
+ * "infra" before it can become an argv element. Id-only argv is the contract
19
20
  * (`INT-ON-FAILURE-HOOK-CONTRACT`), and one guard at the spawn is worth ten at the call sites.
20
21
  *
21
22
  * What transfers unchanged: the executable resolved at CALL time (an operator who fixes the path
@@ -26,28 +27,31 @@
26
27
 
27
28
  import { realpathSync, statSync } from "node:fs";
28
29
  import { spawn } from "node:child_process";
30
+ import { RECORD_REASON_RE } from "./record-reason.mjs";
29
31
 
30
32
  /** SIGTERM, then SIGKILL after this. secrets.mjs's grace, for its reason. */
31
33
  const KILL_GRACE_MS = 2000;
32
34
 
33
- /** A reason that may ride argv: a fixed lowercase token, never a message. */
34
- const REASON_SHAPE = /^[a-z0-9-]{1,40}$/;
35
-
36
35
  /**
37
36
  * Build the hook runner. Returns `fire({ jobId, outcome, reason })` -> void: spawns the operator's
38
37
  * command as `cmd <jobId> <outcome> <reason> <host>`, logs one `on_failure` line with the pinned key
39
38
  * set { jobId, code, detail }, and never throws, rejects, or delays anything.
40
39
  */
41
- export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, realExecutablePath = defaultRealExecutablePath, hostEnv = process.env, host = "", log = () => {} }) {
42
- const note = (jobId, code, detail) => {
40
+ export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, realExecutablePath = defaultRealExecutablePath, hostEnv = process.env, host = "", log = () => {}, publish = () => {} }) {
41
+ const note = (jobId, code, detail, outcome, reason) => {
43
42
  try {
44
43
  log("on_failure", { jobId, code, detail });
45
44
  } catch {
46
45
  // an injected sink that throws must not break the fire-and-forget contract
47
46
  }
47
+ // The log's existing keys and the hook's return value stay unchanged. Independent guards mean a broken log
48
+ // sink does not silence the display, and a display fault cannot become a job retry or delay another hook.
49
+ try { void Promise.resolve(publish({ jobId, outcome, reason, detail, code })).catch(() => {}); } catch {}
48
50
  };
49
51
 
50
52
  return function fire({ jobId, outcome, reason }) {
53
+ const safeReason = typeof reason === "string" && RECORD_REASON_RE.test(reason) ? reason : "infra";
54
+ const finishNote = (code, detail) => note(typeof jobId === "string" ? jobId : null, code, detail, outcome, safeReason);
51
55
  // Resolved at CALL time, not construction (wait-check's rule): realpath + the executable bit,
52
56
  // answering the same question spawn will.
53
57
  let path;
@@ -56,14 +60,14 @@ export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, re
56
60
  } catch {
57
61
  path = null;
58
62
  }
59
- if (!path) return note(typeof jobId === "string" ? jobId : null, null, "unresolvable");
63
+ if (!path) return finishNote(null, "unresolvable");
60
64
 
61
- const safeReason = typeof reason === "string" && REASON_SHAPE.test(reason) ? reason : "infra";
65
+ // A reason rides argv only as a record reason token (the record's own rule), never as a message.
62
66
  // Argv must be non-empty id-only strings, and none may start with a dash -- this is argv where a
63
67
  // dash parses as a flag, and the option parser is the operator's. `host` may honestly be "".
64
68
  const argv = [jobId, outcome, safeReason, host];
65
69
  if (argv.slice(0, 3).some((a) => typeof a !== "string" || a === "") || argv.some((a) => typeof a === "string" && a.startsWith("-"))) {
66
- return note(typeof jobId === "string" ? jobId : null, null, "argv-unusable");
70
+ return finishNote(null, "argv-unusable");
67
71
  }
68
72
 
69
73
  let child;
@@ -74,7 +78,7 @@ export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, re
74
78
  shell: false,
75
79
  });
76
80
  } catch {
77
- return note(jobId, null, "spawn");
81
+ return finishNote(null, "spawn");
78
82
  }
79
83
 
80
84
  let done = false;
@@ -84,7 +88,7 @@ export function makeOnFailure({ command, timeoutMs = 10_000, spawnFn = spawn, re
84
88
  if (done) return;
85
89
  done = true;
86
90
  clearTimeout(timer);
87
- note(jobId, code, detail);
91
+ finishNote(code, detail);
88
92
  };
89
93
  // The kill ladder: SIGTERM, and SIGKILL for a child that ignored it. The escalation timer is
90
94
  // deliberately not cleared by finish -- it still has to land on a stubborn child -- and both
@@ -1,5 +1,35 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
2
 
3
+ /**
4
+ * The opener's argv for `url` on `platform`. On win32 it is `rundll32 url.dll,FileProtocolHandler <url>`, the shell's
5
+ * own URL handler, and NOT `cmd /c start "" <url>` (what it was until issue #708): `cmd` re-parses its command line,
6
+ * so an unquoted `&` ends the command there (`start` opened the URL up to it and `cmd` ran the rest as a second
7
+ * command), and node quotes an argument only when it holds a space or a quote, which a URL never does. A login link
8
+ * (`login/code#<code>&next=/runs`) and a GitHub install URL both carry one. rundll32 is no shell: it hands the rest of
9
+ * its command line to the handler as it is, `&` and `#` included. Quoting the URL inside `start` was rejected because
10
+ * it relies on node's quoting rules and `cmd`'s together, and `^`-escaping because `cmd /c` and `start` each strip one
11
+ * level.
12
+ */
13
+ export function openerArgv(url, platform = process.platform) {
14
+ if (platform === "darwin") return ["open", [url]];
15
+ if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
16
+ return ["xdg-open", [url]];
17
+ }
18
+
19
+ /**
20
+ * Can a browser opened here be in front of the person at this terminal (issue #708)? Not over SSH (any of
21
+ * `SSH_CONNECTION`, `SSH_CLIENT` or `SSH_TTY` set: the browser would open on the remote host, or nowhere), and on
22
+ * Linux and the BSDs only with a display (`DISPLAY` or `WAYLAND_DISPLAY`: without one `xdg-open` falls back to a text
23
+ * browser or fails). macOS and Windows always have their desktop's opener. A false answer means the caller prints the
24
+ * URL and spawns nothing; the printed URL is the contract either way.
25
+ */
26
+ export function isLocalBrowserSession({ env = process.env, platform = process.platform } = {}) {
27
+ const set = (k) => typeof env?.[k] === "string" && env[k] !== "";
28
+ if (set("SSH_CONNECTION") || set("SSH_CLIENT") || set("SSH_TTY")) return false;
29
+ if (platform === "darwin" || platform === "win32") return true;
30
+ return set("DISPLAY") || set("WAYLAND_DISPLAY");
31
+ }
32
+
3
33
  /**
4
34
  * Best-effort platform browser opener. ALWAYS paired with the URL printed to the terminal -- a
5
35
  * headless or SSH'd operator has no opener that works, and the printed URL pasted into any browser
@@ -12,8 +42,7 @@ import { spawn as nodeSpawn } from "node:child_process";
12
42
  * without launching anything.
13
43
  */
14
44
  export function openBrowser(url, { spawn = nodeSpawn, platform = process.platform } = {}) {
15
- const [cmd, args] =
16
- platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
45
+ const [cmd, args] = openerArgv(url, platform);
17
46
  try {
18
47
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
19
48
  child.on("error", () => {});
@@ -0,0 +1,135 @@
1
+ /** An explicit import enrolls trusted Fleet mappings. A link never changes configuration. */
2
+ import * as nodeFs from "node:fs";
3
+ import { execFile as nodeExecFile, spawn as nodeSpawn } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { createInterface } from "node:readline";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, resolve, win32 } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { randomBytes } from "node:crypto";
10
+ import { parseSandboxOpenUrl } from "./opener-protocol.mjs";
11
+ import { readSandboxHosts, sandboxCommand } from "./sandbox-hosts.mjs";
12
+ import { spawnTerminal, terminalLaunch, windowsPowerShellEnv } from "./opener-terminal.mjs";
13
+ import { trustedChain } from "./service-env.mjs";
14
+
15
+ // env-internal XDG_CONFIG_HOME, HOME, USERPROFILE: the native handler belongs to the current account and uses its config directory.
16
+ export function openerDirectory(env = process.env) { return join(env.XDG_CONFIG_HOME || join(env.HOME || env.USERPROFILE || homedir(), ".config"), "pi-dispatch", "opener"); }
17
+ const apple = (value) => `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
18
+ // Desktop-entry values unescape first, then Exec unquotes. Both layers need their own backslash.
19
+ const desktop = (value) => `"${String(value).replaceAll("%", "%%").replace(/[\\"`$]/g, "\\$&").replaceAll("\\", "\\\\")}"`;
20
+
21
+ export function openerRegistration({ platform, home, directory, nodePath, cliPath, dataHome = join(home, ".local", "share") }) {
22
+ if (platform === "darwin") {
23
+ const app = join(home, "Applications", "pi-dispatch Opener.app");
24
+ const source = `on open location theURL\n do shell script (quoted form of ${apple(nodePath)} & " " & quoted form of ${apple(cliPath)} & " opener handle " & quoted form of theURL)\nend open location\n`;
25
+ const plist = `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>CFBundleExecutable</key><string>applet</string><key>CFBundleIdentifier</key><string>com.pi-dispatch.opener</string><key>CFBundleName</key><string>pi-dispatch Opener</string><key>CFBundlePackageType</key><string>APPL</string><key>LSUIElement</key><true/><key>CFBundleURLTypes</key><array><dict><key>CFBundleTypeRole</key><string>Editor</string><key>CFBundleURLName</key><string>pi-dispatch sandbox</string><key>CFBundleURLSchemes</key><array><string>pi-dispatch-open</string></array></dict></array></dict></plist>\n`;
26
+ // osacompile signs its bundle. Our URL metadata changes its signed resources, so sign again after the
27
+ // plist write; registering a modified signature can compile cleanly and still refuse at native launch.
28
+ return { commands: [["/usr/bin/osacompile", ["-o", app, "-e", source]], ["/usr/bin/codesign", ["--force", "--sign", "-", app]], ["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", app]]], between: { path: join(app, "Contents", "Info.plist"), content: plist }, directory: dirname(app) };
29
+ }
30
+ if (platform === "linux") {
31
+ if (nodePath.includes("=")) throw new Error("the desktop Exec format cannot name a Node executable whose path contains '='; use Copy or install Node at another path");
32
+ if (!dataHome.startsWith("/") || /[\x00-\x1f\x7f]/.test(dataHome)) throw new Error("XDG_DATA_HOME must be an absolute desktop data directory without control bytes");
33
+ const path = join(dataHome, "applications", "pi-dispatch-opener.desktop");
34
+ const content = `[Desktop Entry]\nType=Application\nName=pi-dispatch sandbox opener\nNoDisplay=true\nTerminal=false\nExec=${desktop(nodePath)} ${desktop(cliPath)} opener handle %u\nMimeType=x-scheme-handler/pi-dispatch-open;\n`;
35
+ return { files: [{ path, content }], commands: [["xdg-mime", ["default", "pi-dispatch-opener.desktop", "x-scheme-handler/pi-dispatch-open"]]], directory: dirname(path) };
36
+ }
37
+ if (platform === "win32") {
38
+ // HKCU registration requires no elevation. reg.exe takes values as argv, never through cmd.exe.
39
+ const key = "HKCU\\Software\\Classes\\pi-dispatch-open";
40
+ // ShellExecute expands percent placeholders in this registry template, even inside quoted paths.
41
+ // Escape only the fixed paths; the final %1 remains the single URI substitution.
42
+ const quoted = (value) => `"${String(value).replaceAll("%", "%%").replaceAll('"', '\\"')}"`;
43
+ return { commands: [["reg.exe", ["add", key, "/ve", "/d", "URL:pi-dispatch sandbox opener", "/f"]], ["reg.exe", ["add", key, "/v", "URL Protocol", "/d", "", "/f"]], ["reg.exe", ["add", `${key}\\shell\\open\\command`, "/ve", "/d", `${quoted(nodePath)} ${quoted(cliPath)} opener handle "%1"`, "/f"]]] };
44
+ }
45
+ throw new Error("this operating system has no supported opener installer; copy the sandbox command instead");
46
+ }
47
+
48
+ /** A blank line is the entire approval vocabulary. A pasted command, y, Escape or EOF never runs anything. */
49
+ export async function promptSandboxCommand(command, { input = process.stdin, output = process.stdout, spawn = nodeSpawn, isTty = Boolean(input.isTTY && output.isTTY) } = {}) {
50
+ if (!isTty) return { refused: true, message: "the opener needs a real terminal; copy the command into your terminal instead" };
51
+ output.write(`\n${command.display}\n\nPress Enter to run this command, or type anything to cancel.\n`);
52
+ const accepted = await new Promise((resolveAnswer) => {
53
+ const lines = createInterface({ input, output, terminal: false });
54
+ let answered = false;
55
+ lines.once("line", (line) => { answered = true; lines.close(); resolveAnswer(line === ""); });
56
+ lines.once("close", () => { if (!answered) resolveAnswer(false); });
57
+ });
58
+ if (!accepted) return { cancelled: true, code: 0 };
59
+ const code = await new Promise((resolveCode, reject) => {
60
+ const child = spawn(command.argv[0], command.argv.slice(1), { stdio: "inherit" });
61
+ child.once("error", reject);
62
+ child.once("exit", (code) => resolveCode(code ?? 1));
63
+ });
64
+ return { code };
65
+ }
66
+
67
+ export async function runOpener(argv = [], { env = process.env, deps = {} } = {}) {
68
+ // env-internal HOME, USERPROFILE: the current account's home, for current-user native protocol registration.
69
+ const { fs = nodeFs, platform = process.platform, home = env.HOME || env.USERPROFILE || homedir(), nodePath = process.execPath, cliPath = fileURLToPath(new URL("./cli.mjs", import.meta.url)), out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s), execFile = promisify(nodeExecFile), terminal = null, showTerminal = spawnTerminal, prompt = promptSandboxCommand, uid = process.geteuid?.() } = deps;
70
+ const directory = openerDirectory(env);
71
+ const configPath = join(directory, "hosts.json");
72
+ try {
73
+ if (argv[0] === "install") {
74
+ if (argv.length !== 3 || argv[1] !== "--config") throw new Error("usage: pi-dispatch opener install --config <approved Fleet export.json>");
75
+ const source = readSandboxHosts(resolve(argv[2]), { fs, uid, allowMissing: false });
76
+ if (source.refusal) throw new Error(source.refusal);
77
+ if (!Object.keys(source.config.hosts).length) throw new Error("the approved Fleet export has no sandbox hosts");
78
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
79
+ const configStat = fs.lstatSync(directory);
80
+ if (!configStat.isDirectory() || configStat.isSymbolicLink() || (Number.isInteger(uid) && (configStat.uid !== uid || trustedChain(fs.realpathSync(directory), { fs, uid })))) throw new Error("the opener configuration folder must belong to this account, have a trusted parent and cannot be a link");
81
+ fs.chmodSync(directory, 0o700);
82
+ if (platform === "win32") {
83
+ // chmod does not express a Windows DACL. Protect the imported mapping using this account's SID,
84
+ // independently of a directory's inherited grants; the URL never supplies the principal or path.
85
+ const identity = await execFile("whoami.exe", ["/user", "/fo", "csv", "/nh"], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
86
+ const sid = /S-1-\d+(?:-\d+)+/.exec(identity.stdout ?? "")?.[0];
87
+ if (!sid) throw new Error("the Windows account SID could not be resolved; the opener configuration was not imported");
88
+ // /grant:r replaces only this SID's grants. Reset first so a pre-existing explicit Everyone ACE is
89
+ // not retained beside it; removing inheritance then leaves only the grant below on this directory.
90
+ await execFile("icacls.exe", [directory, "/reset"], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
91
+ await execFile("icacls.exe", [directory, "/inheritance:r", "/grant:r", `*${sid}:(OI)(CI)F`], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
92
+ }
93
+ const temporary = join(directory, `import-${randomBytes(12).toString("hex")}.json`);
94
+ try {
95
+ fs.writeFileSync(temporary, JSON.stringify(source.config, null, 2) + "\n", { mode: 0o600, flag: "wx" });
96
+ fs.renameSync(temporary, configPath);
97
+ } finally { fs.rmSync(temporary, { force: true }); }
98
+ // env-internal XDG_DATA_HOME: xdg-mime and xdg-open look up desktop handlers in this same OS data directory.
99
+ const plan = openerRegistration({ platform, home, directory, nodePath, cliPath, dataHome: env.XDG_DATA_HOME || join(home, ".local", "share") });
100
+ if (plan.directory) {
101
+ fs.mkdirSync(plan.directory, { recursive: true, mode: 0o700 });
102
+ if (Number.isInteger(uid) && trustedChain(fs.realpathSync(plan.directory), { fs, uid })) throw new Error("the native handler directory is in a folder another account can change");
103
+ }
104
+ for (const file of plan.files ?? []) fs.writeFileSync(file.path, file.content, { mode: 0o600 });
105
+ for (let i = 0; i < plan.commands.length; i++) {
106
+ const [bin, args] = plan.commands[i];
107
+ // The MIME registration and the desktop entry must use the same XDG directories.
108
+ await execFile(bin, args, { env, timeout: 30_000, maxBuffer: 1024 * 1024 });
109
+ if (i === 0 && plan.between) fs.writeFileSync(plan.between.path, plan.between.content, { mode: 0o600 });
110
+ }
111
+ if (platform === "win32") {
112
+ // Windows requires SHCNE_ASSOCCHANGED for registered protocols too; writing HKCU alone can leave
113
+ // the Shell's old association cached until reboot. No URL or configurable text enters this script.
114
+ const notification = `Add-Type -Namespace PiDispatch -Name ShellChange -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll")] public static extern void SHChangeNotify(int eventId, uint flags, System.IntPtr item1, System.IntPtr item2);'; [PiDispatch.ShellChange]::SHChangeNotify(0x08000000, 0x1000, [System.IntPtr]::Zero, [System.IntPtr]::Zero)`;
115
+ // env-internal SystemRoot: Windows' own PowerShell binary for its Shell association notification.
116
+ await execFile(win32.join(env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoProfile", "-NonInteractive", "-Command", notification], { env: windowsPowerShellEnv(env), timeout: 30_000, maxBuffer: 64 * 1024 });
117
+ }
118
+ out("installed pi-dispatch-open for this account; commands wait for Enter in the terminal\n");
119
+ return 0;
120
+ }
121
+ if (!["handle", "prompt"].includes(argv[0]) || argv.length !== 2) throw new Error("usage: pi-dispatch opener install --config <file> (or handle <pi-dispatch-open://link>)");
122
+ const fields = parseSandboxOpenUrl(argv[1]);
123
+ const stored = readSandboxHosts(configPath, { fs, uid, allowMissing: false });
124
+ if (stored.refusal) throw new Error("the local opener has no usable approved configuration; run pi-dispatch opener install --config <Fleet export.json>");
125
+ const command = sandboxCommand(stored.config, fields);
126
+ if (argv[0] === "prompt") {
127
+ const result = await prompt(command);
128
+ if (result.refused) throw new Error(result.message);
129
+ return result.code ?? 0;
130
+ }
131
+ const plan = terminalLaunch({ platform, nodePath, cliPath, url: argv[1], directory: join(directory, "prompts"), env, fs, ...(terminal ? { terminal } : {}) });
132
+ await showTerminal(plan);
133
+ return 0;
134
+ } catch (error) { err(`opener: ${error.message}\n`); return 1; }
135
+ }
@@ -0,0 +1,27 @@
1
+ /** Secret-free terminal handoff. No URL field is ever interpreted as a command or configuration. */
2
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
3
+
4
+ export const SANDBOX_OPEN_VERBS = Object.freeze(["open", "attach"]);
5
+ export const SANDBOX_OPEN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
6
+ export function sandboxOpenFields({ verb, id, host } = {}) {
7
+ if (!SANDBOX_OPEN_VERBS.includes(verb)) throw new Error("the opener verb must be open or attach");
8
+ if (typeof id !== "string" || !SANDBOX_OPEN_ID_RE.test(id)) throw new Error("the opener id is not a run or sandbox id");
9
+ if (typeof host !== "string" || !WORKER_NAME_RE.test(host) || /\.(?:json|log)$/i.test(host)) throw new Error("the opener host is not a worker name");
10
+ return { verb, id, host };
11
+ }
12
+ export function sandboxOpenUrl(fields) {
13
+ const { verb, id, host } = sandboxOpenFields(fields);
14
+ return `pi-dispatch-open://${verb}/${encodeURIComponent(id)}?host=${encodeURIComponent(host)}`;
15
+ }
16
+ export const buildSandboxOpenUrl = sandboxOpenUrl;
17
+ export function parseSandboxOpenUrl(value) {
18
+ if (typeof value !== "string" || value.length > 1024) throw new Error("the opener link is invalid");
19
+ // Match the wire grammar before URL normalisation can hide a path traversal or a second decoding.
20
+ const match = /^pi-dispatch-open:\/\/(open|attach)\/([^/?#]+)\?host=([^&#]+)$/.exec(value);
21
+ if (!match) throw new Error("the opener link must name one open or attach verb, id and host");
22
+ let fields;
23
+ try { fields = sandboxOpenFields({ verb: match[1], id: decodeURIComponent(match[2]), host: decodeURIComponent(match[3]) }); }
24
+ catch (error) { throw new Error(error instanceof URIError ? "the opener link has invalid encoding" : error.message); }
25
+ if (sandboxOpenUrl(fields) !== value) throw new Error("the opener link is not canonical");
26
+ return fields;
27
+ }