@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,378 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
3
+
4
+ /** Private control facts only. Neither a console session nor terminal bytes belong in this protocol. */
5
+ export const GATEWAY_ID_RE = /^[A-Za-z0-9_-]{43}$/;
6
+ export const GATEWAY_HASH_RE = /^[a-f0-9]{64}$/;
7
+ export const GATEWAY_PENDING_MS = 300_000;
8
+ export const GATEWAY_HANDOFF_MS = 60_000;
9
+ export const GATEWAY_GRANT_MS = 60_000;
10
+ export const GATEWAY_AUTH_MS = 5_000;
11
+ export const GATEWAY_WINDOW_MS = 15_000;
12
+ export const GATEWAY_MAX_INTENTS = 256;
13
+ export const GATEWAY_MAX_WATCHERS = 3;
14
+ export const GATEWAY_RUN_OUTCOMES = Object.freeze(["completed", "refused", "failed", "awaiting-input", "awaiting-approval", "cancelled"]);
15
+ export const GATEWAY_STATES = Object.freeze(["pending", "bound", "authorized", "connected", "ended", "revoked"]);
16
+ export const GATEWAY_END_REASONS = Object.freeze(["disconnected", "idle", "session-ended", "authorization-unavailable", "sandbox-ended", "runtime-unavailable", "window-lost", "phone", "protocol-error", "backpressure", "audit-unavailable", "gateway-stopped", "watch-disabled", "watch-removed", "driver-ended"]);
17
+ export const gatewayRandom = () => randomBytes(32).toString("base64url");
18
+ export const gatewayHash = (value) => createHash("sha256").update(String(value)).digest("hex");
19
+ export const gatewaySessionKey = (sessionId) => gatewayHash(`sandbox-workbench-session\0${sessionId}`);
20
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
21
+ const only = (v, fields) => object(v) && Object.keys(v).every((k) => fields.includes(k));
22
+ const instant = (v) => Number.isSafeInteger(v) && v >= 0;
23
+ const validId = (v) => typeof v === "string" && GATEWAY_ID_RE.test(v) && Buffer.from(v, "base64url").toString("base64url") === v;
24
+
25
+ export function gatewayOrigin(value) {
26
+ if (typeof value !== "string" || value.length > 2048) return null;
27
+ try {
28
+ const u = new URL(value);
29
+ if (u.hostname.endsWith(".") || u.hostname.endsWith(".docker.internal") || u.hostname.endsWith(".containers.internal")) return null;
30
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname) || u.hostname.endsWith(".localhost");
31
+ return u.origin === value && !u.username && !u.password && !u.search && !u.hash && (u.protocol === "https:" || u.protocol === "http:" && loopback) ? value : null;
32
+ } catch { return null; }
33
+ }
34
+ export function distinctGatewayOrigins(gateway, console) {
35
+ return Boolean(gatewayOrigin(gateway) && gatewayOrigin(console) && new URL(gateway).hostname !== new URL(console).hostname);
36
+ }
37
+ export function cleanGatewayTarget(value) {
38
+ if (!object(value) || !WORKER_NAME_RE.test(value.host ?? "") || /\.(?:json|log)$/i.test(value.host) || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value.id ?? "") || !/^[a-f0-9]{32}$/.test(value.nonce ?? "") || !GATEWAY_HASH_RE.test(value.containerId ?? "") || !["local", "podman"].includes(value.venue)) return null;
39
+ return { host: value.host, id: value.id, nonce: value.nonce, containerId: value.containerId, venue: value.venue };
40
+ }
41
+ function validActor(value) {
42
+ return only(value, ["name", "role", "session"]) && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(value.name ?? "") && value.role === "operator" && /^[a-f0-9]{12}$/.test(value.session ?? "");
43
+ }
44
+ export function gatewayTargetKey(target) {
45
+ return cleanGatewayTarget(target) ? gatewayHash(JSON.stringify([target.host, target.venue, target.containerId, target.nonce])) : null;
46
+ }
47
+ export function validGatewayRunFacts(v) {
48
+ if (v === null) return true;
49
+ return only(v, ["id", "outcome", "project", "target", "flow", "endedAt"]) && Object.keys(v).length === 6 &&
50
+ /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(v.id ?? "") && GATEWAY_RUN_OUTCOMES.includes(v.outcome) &&
51
+ (v.project === null || /^[a-z0-9][a-z0-9-]{0,31}$/.test(v.project ?? "")) &&
52
+ (v.target === null || typeof v.target === "string" && /^[A-Za-z0-9._:/#! -]{1,300}$/.test(v.target)) &&
53
+ (v.flow === null || /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/.test(v.flow ?? "")) &&
54
+ (v.endedAt === null || typeof v.endedAt === "string" && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v.endedAt) && Number.isFinite(Date.parse(v.endedAt)) && new Date(Date.parse(v.endedAt)).toISOString() === v.endedAt);
55
+ }
56
+ const driverRef = v => only(v, ["intentId", "connectionId", "epoch"]) && Object.keys(v).length === 3 && [v.intentId, v.connectionId, v.epoch].every(validId);
57
+ const integer = v => Number.isSafeInteger(v) && v >= 0;
58
+ const storedTarget = v => only(v, ["host", "id", "nonce", "containerId", "venue"]) && Boolean(cleanGatewayTarget(v));
59
+ function validRoom(r) {
60
+ if (!only(r, ["v", "target", "driverIntentId", "driverConnectionId", "driverActor", "sessionKey", "gatewayOrigin", "consoleOrigin", "enabled", "epoch", "geometry", "rosterRevision", "seenRosterRevision", "watchers"]) || r.v !== 2 || !storedTarget(r.target) || !validId(r.driverIntentId) || !validId(r.driverConnectionId) || !validActor(r.driverActor) || !GATEWAY_HASH_RE.test(r.sessionKey ?? "") || !distinctGatewayOrigins(r.gatewayOrigin, r.consoleOrigin) || typeof r.enabled !== "boolean" || (r.enabled ? !validId(r.epoch) : r.epoch !== null) || !only(r.geometry, ["cols", "rows", "revision"]) || !Number.isInteger(r.geometry.cols) || r.geometry.cols < 2 || r.geometry.cols > 500 || !Number.isInteger(r.geometry.rows) || r.geometry.rows < 1 || r.geometry.rows > 200 || !integer(r.geometry.revision) || !integer(r.rosterRevision) || !integer(r.seenRosterRevision) || r.seenRosterRevision > r.rosterRevision || !object(r.watchers) || Object.keys(r.watchers).length > GATEWAY_MAX_WATCHERS) return false;
61
+ return Object.entries(r.watchers).every(([id, w]) => validId(id) && only(w, ["watcherKey", "actor", "connectionId", "registeredRevision", "expiresAt", "state"]) && w.watcherKey === gatewayHash(id) && validActor(w.actor) && (w.connectionId === null || validId(w.connectionId)) && integer(w.registeredRevision) && w.registeredRevision <= r.rosterRevision && instant(w.expiresAt) && ["pending", "watching"].includes(w.state));
62
+ }
63
+ function validIntent(m) {
64
+ return only(m, ["v", "intentId", "owner", "sessionKey", "kind", "runFacts", "driver", "driverReady", "receiptHash", "removed", "target", "gatewayOrigin", "consoleOrigin", "theme", "handoffHash", "browserHash", "createdAt", "handoffUntil", "pendingUntil", "grantUntil", "state", "actor", "connectionId", "connectedAt", "endedAt", "reason"]) && m.v === 2 && validId(m.intentId) && validId(m.owner) && GATEWAY_HASH_RE.test(m.sessionKey ?? "") && ["drive", "watch"].includes(m.kind) && validGatewayRunFacts(m.runFacts) && (m.runFacts === null || m.runFacts.id === m.target?.id) && (m.kind === "watch" ? driverRef(m.driver) : m.driver === undefined) && (m.driverReady === undefined || m.kind === "drive" && m.driverReady === true) && storedTarget(m.target) && distinctGatewayOrigins(m.gatewayOrigin, m.consoleOrigin) && [null, "light", "dark"].includes(m.theme) && GATEWAY_STATES.includes(m.state) && ["createdAt", "handoffUntil", "pendingUntil"].every((k) => instant(m[k])) && ["handoffHash", "browserHash"].every((k) => m[k] === undefined || GATEWAY_HASH_RE.test(m[k])) && (m.actor === undefined || validActor(m.actor)) && (m.connectionId === undefined || validId(m.connectionId)) && ["grantUntil", "connectedAt", "endedAt"].every((k) => m[k] === undefined || instant(m[k])) && (m.reason === undefined || GATEWAY_END_REASONS.includes(m.reason));
65
+ }
66
+
67
+ // Redis owns expiry and comparisons. Renewals only extend an EXISTING key: a command delayed beyond
68
+ // its five-second authority can never recreate it after the browser has already detached.
69
+ const PRELUDE = `
70
+ local function denied(reason) return cjson.encode({refused=reason}) end
71
+ local t=redis.call('TIME'); local now=tonumber(t[1])*1000+math.floor(tonumber(t[2])/1000)
72
+ local prefix=ARGV[9]
73
+ local function decoded(key)
74
+ local raw=redis.call('GET',key); if not raw then return nil end
75
+ local ok,v=pcall(cjson.decode,raw); if not ok then error('invalid control value') end; return v
76
+ end
77
+ local function roomKey(target) return prefix..'room:'..target.host..':'..target.containerId end
78
+ local function windowKey(target) return prefix..'window:'..target.host..':'..target.containerId end
79
+ local function watchWindow(m) return prefix..'watch-window:'..m.target.host..':'..m.target.containerId..':'..m.sessionKey..':'..m.driver.connectionId..':'..m.driver.epoch end
80
+ local function roomFor(target) local r=decoded(roomKey(target)); if r and r.v~=2 then error('invalid room version') end; return r end
81
+ local function sameTarget(a,b)
82
+ return a.host==b.host and a.id==b.id and a.containerId==b.containerId and a.nonce==b.nonce and a.venue==b.venue
83
+ end
84
+ local function driverRemaining(r)
85
+ if not r then return 0 end
86
+ local d=decoded(prefix..'intent:'..r.driverIntentId)
87
+ if not d or d.v~=2 or d.kind~='drive' or d.state~='connected' or not d.driverReady or d.connectionId~=r.driverConnectionId or not sameTarget(d.target,r.target) or d.gatewayOrigin~=r.gatewayOrigin or d.consoleOrigin~=r.consoleOrigin or d.sessionKey~=r.sessionKey or redis.call('GET',prefix..'auth:'..r.driverIntentId)~=d.owner or redis.call('GET',windowKey(r.target))~=r.driverConnectionId then return 0 end
88
+ -- The console can keep renewing a driver whose process has died, so its AUTH alone cannot bound a watcher.
89
+ -- The room is what that process renews, and it lapses with it.
90
+ local roomTtl=redis.call('PTTL',roomKey(r.target)); if roomTtl==-1 then roomTtl=${GATEWAY_AUTH_MS} end
91
+ return math.min(${GATEWAY_AUTH_MS},redis.call('PTTL',prefix..'auth:'..r.driverIntentId),roomTtl)
92
+ end
93
+ local function watchRoom(m)
94
+ local r=roomFor(m.target); local remaining=driverRemaining(r)
95
+ if remaining<=0 or not sameTarget(r.target,m.target) or r.gatewayOrigin~=m.gatewayOrigin or r.consoleOrigin~=m.consoleOrigin or r.driverIntentId~=m.driver.intentId or r.driverConnectionId~=m.driver.connectionId then return nil,'driver-ended' end
96
+ if not r.enabled or r.epoch~=m.driver.epoch then return nil,'watch-disabled' end
97
+ if m.sessionKey==r.sessionKey then return nil,'self-watch' end
98
+ return r,remaining
99
+ end
100
+ local function prune(r)
101
+ local changed=false
102
+ for id,w in pairs(r.watchers) do if w.expiresAt<=now then r.watchers[id]=nil; changed=true end end
103
+ if changed then r.rosterRevision=r.rosterRevision+1 end
104
+ end
105
+ local function saveRoom(r) redis.call('SET',roomKey(r.target),cjson.encode(r),'KEEPTTL') end
106
+ `;
107
+ const READ = `${PRELUDE}
108
+ local raw=redis.call('GET',KEYS[1]); if not raw then return denied('intent-missing') end
109
+ local ok,m=pcall(cjson.decode,raw); if not ok or m.v~=2 then return denied('intent-invalid') end
110
+ `;
111
+ const answer = `return cjson.encode({ok=true,intent=m})`;
112
+ export const GATEWAY_SCRIPTS = Object.freeze({
113
+ time: `${PRELUDE}return now`,
114
+ create: `${PRELUDE}
115
+ redis.call('ZREMRANGEBYSCORE',KEYS[2],'-inf',now)
116
+ if redis.call('ZCARD',KEYS[2])>=tonumber(ARGV[5]) then return denied('intent-limit') end
117
+ if redis.call('EXISTS',KEYS[1])==1 then return denied('intent-exists') end
118
+ local m=cjson.decode(ARGV[1]); m.createdAt=now; m.handoffUntil=now+tonumber(ARGV[2]); m.pendingUntil=now+tonumber(ARGV[3])
119
+ redis.call('SET',KEYS[1],cjson.encode(m),'PX',ARGV[3]); redis.call('ZADD',KEYS[2],m.pendingUntil,m.intentId); redis.call('PEXPIRE',KEYS[2],ARGV[3])
120
+ ${answer}`,
121
+ bind: `${READ}
122
+ if m.state~='pending' or now>=m.handoffUntil or now>=m.pendingUntil then return denied('handoff-expired') end
123
+ if m.handoffHash~=ARGV[1] or m.gatewayOrigin~=ARGV[3] or m.target.host~=ARGV[4] then return denied('handoff-invalid') end
124
+ m.handoffHash=nil; m.browserHash=ARGV[2]; m.receiptHash=ARGV[5]; m.state='bound'; redis.call('SET',KEYS[1],cjson.encode(m),'KEEPTTL')
125
+ ${answer}`,
126
+ authorize: `${READ}
127
+ if m.owner~=ARGV[1] or m.state~='bound' or now>=m.pendingUntil then return denied('intent-unavailable') end
128
+ local observed=tonumber(ARGV[6]); local deadline=observed+tonumber(ARGV[4]); if observed>now or now>=deadline then return denied('authorization-expired') end
129
+ local r=nil; local remaining=0
130
+ if m.kind=='watch' then
131
+ local reason; r,reason=watchRoom(m); if not r then return denied(reason) end
132
+ remaining=driverRemaining(r); prune(r); local count=0; for _,_ in pairs(r.watchers) do count=count+1 end
133
+ if count>=${GATEWAY_MAX_WATCHERS} then saveRoom(r); return denied('watch-limit') end
134
+ end
135
+ m.actor=cjson.decode(ARGV[2]); m.state='authorized'; m.grantUntil=now+tonumber(ARGV[3]); redis.call('SET',KEYS[1],cjson.encode(m),'PX',ARGV[5]); redis.call('SET',KEYS[2],m.owner,'PXAT',deadline); redis.call('ZADD',KEYS[3],now+tonumber(ARGV[5]),m.intentId); redis.call('PEXPIRE',KEYS[3],ARGV[5])
136
+ if r then
137
+ r.rosterRevision=r.rosterRevision+1
138
+ r.watchers[m.intentId]={watcherKey=ARGV[7],actor=m.actor,connectionId=cjson.null,registeredRevision=r.rosterRevision,expiresAt=math.min(deadline,now+remaining),state='pending'}
139
+ saveRoom(r)
140
+ end
141
+ ${answer}`,
142
+ renewAuth: `${READ}
143
+ if m.owner~=ARGV[1] or (m.state~='authorized' and m.state~='connected') or (m.state=='authorized' and now>=m.grantUntil) or redis.call('GET',KEYS[2])~=ARGV[1] then return denied('authorization-expired') end
144
+ local observed=tonumber(ARGV[4]); local deadline=observed+tonumber(ARGV[2]); if observed>now or now>=deadline then return denied('authorization-expired') end
145
+ -- A gateway that died leaves its intents 'connected'. Its lease lapses, and with it the reason to keep renewing: otherwise
146
+ -- the console holds a sign-in's slots and a ghost watcher's slot for an attachment nobody has.
147
+ if m.state=='connected' then
148
+ local lock=windowKey(m.target); if m.kind=='watch' then lock=watchWindow(m) end
149
+ if redis.call('GET',lock)~=m.connectionId then return denied('window-lost') end
150
+ end
151
+ if m.kind=='watch' then
152
+ local r,remaining=watchRoom(m); if not r then return denied(remaining) end
153
+ local w=r.watchers[m.intentId]; if not w or w.expiresAt<=now then return denied('watch-removed') end
154
+ w.expiresAt=math.min(deadline,now+remaining); saveRoom(r)
155
+ end
156
+ redis.call('PEXPIREAT',KEYS[2],deadline); redis.call('PEXPIRE',KEYS[1],ARGV[3]); redis.call('ZADD',KEYS[3],now+tonumber(ARGV[3]),m.intentId); redis.call('PEXPIRE',KEYS[3],ARGV[3])
157
+ ${answer}`,
158
+ revoke: `${READ}
159
+ if m.owner~=ARGV[1] then return denied('intent-unavailable') end
160
+ m.state='revoked'; m.reason=ARGV[2]; m.endedAt=now; redis.call('DEL',KEYS[2]); redis.call('SET',KEYS[1],cjson.encode(m),'PX',ARGV[3]); redis.call('ZADD',KEYS[3],now+tonumber(ARGV[3]),m.intentId)
161
+ ${answer}`,
162
+ redeem: `${READ}
163
+ if m.state~='authorized' or now>=m.grantUntil or m.browserHash~=ARGV[1] or redis.call('GET',KEYS[2])~=m.owner then return denied('grant-unavailable') end
164
+ local lock=KEYS[3]; local r=nil
165
+ if m.kind=='watch' then
166
+ local reason; r,reason=watchRoom(m); if not r then return denied(reason) end
167
+ local w=r.watchers[m.intentId]; if not w or w.expiresAt<=now then return denied('watch-removed') end
168
+ lock=watchWindow(m)
169
+ end
170
+ -- A losing request cannot restore its ticket when another browser already owns this incarnation.
171
+ m.state='ended'; m.reason='window-lost'; m.endedAt=now
172
+ if redis.call('SET',lock,ARGV[2],'NX','PX',ARGV[3])==false then
173
+ local winner=redis.call('GET',lock)
174
+ redis.call('SET',KEYS[1],cjson.encode(m),'KEEPTTL'); redis.call('DEL',KEYS[2])
175
+ if r then r.watchers[m.intentId]=nil; r.rosterRevision=r.rosterRevision+1; saveRoom(r) end
176
+ return cjson.encode({refused='window-busy',winnerConnectionId=winner})
177
+ end
178
+ m.state='connected'; m.reason=nil; m.endedAt=nil; m.connectionId=ARGV[2]; m.connectedAt=now; redis.call('SET',KEYS[1],cjson.encode(m),'KEEPTTL')
179
+ if r then r.watchers[m.intentId].connectionId=ARGV[2]; saveRoom(r) end
180
+ return cjson.encode({ok=true,intent=m,authRemainingMs=redis.call('PTTL',KEYS[2])})`,
181
+ renewAttachment: `${READ}
182
+ if m.state~='connected' or m.connectionId~=ARGV[1] or m.browserHash~=ARGV[2] then return denied('authorization-expired') end
183
+ local remaining=math.min(${GATEWAY_AUTH_MS},redis.call('PTTL',KEYS[2])); local r=nil; local lock=KEYS[3]; local seen=true
184
+ if m.kind=='watch' then
185
+ local reason; r,reason=watchRoom(m); if not r then return denied(reason) end
186
+ local w=r.watchers[m.intentId]
187
+ -- A lapsed slot and a removed one look alike once pruned, and only one of them was the driver's doing. Telling a watcher
188
+ -- whose authority lapsed that the driver removed them would be false, so a removal is recorded on the intent itself.
189
+ if not w and m.removed then return denied('watch-removed') end
190
+ if not w or w.expiresAt<=now or w.connectionId~=ARGV[1] then return denied('authorization-expired') end
191
+ remaining=math.min(remaining,driverRemaining(r)); lock=watchWindow(m); seen=r.seenRosterRevision>=w.registeredRevision
192
+ elseif m.driverReady then
193
+ if redis.call('GET',KEYS[2])~=m.owner then return denied('authorization-expired') end
194
+ r=roomFor(m.target); if driverRemaining(r)<=0 or r.driverConnectionId~=ARGV[1] then return denied('driver-ended') end
195
+ prune(r); saveRoom(r); redis.call('PEXPIRE',roomKey(m.target),remaining)
196
+ end
197
+ if redis.call('GET',KEYS[2])~=m.owner then return denied('authorization-expired') end
198
+ if redis.call('GET',lock)~=ARGV[1] then return denied('window-lost') end
199
+ redis.call('PEXPIRE',lock,ARGV[3])
200
+ return cjson.encode({ok=true,intent=m,authRemainingMs=remaining,room=r,rosterSeen=seen})`,
201
+ release: `${READ}
202
+ if m.connectionId~=ARGV[1] then return denied('window-lost') end
203
+ local r=roomFor(m.target); local lock=KEYS[3]
204
+ if m.kind=='watch' then
205
+ lock=watchWindow(m)
206
+ if r and r.driverConnectionId==m.driver.connectionId and r.epoch==m.driver.epoch and r.watchers[m.intentId] then r.watchers[m.intentId]=nil; r.rosterRevision=r.rosterRevision+1; saveRoom(r) end
207
+ elseif r and r.driverConnectionId==ARGV[1] then
208
+ for id,_ in pairs(r.watchers) do redis.call('DEL',prefix..'auth:'..id) end
209
+ redis.call('DEL',roomKey(m.target))
210
+ end
211
+ if redis.call('GET',lock)==ARGV[1] then redis.call('DEL',lock) end
212
+ redis.call('DEL',KEYS[2]); if m.state~='revoked' then m.state='ended'; m.reason=ARGV[2]; m.endedAt=now end
213
+ redis.call('SET',KEYS[1],cjson.encode(m),'PX',ARGV[3]); redis.call('ZADD',KEYS[4],now+tonumber(ARGV[3]),m.intentId)
214
+ ${answer}`,
215
+ room: `${PRELUDE}
216
+ local target=cjson.decode(ARGV[1]); local r=roomFor(target); if not r or not sameTarget(r.target,target) then return denied('room-missing') end
217
+ local remaining=driverRemaining(r); if remaining<=0 then return denied('driver-ended') end
218
+ return cjson.encode({ok=true,room=r,authRemainingMs=remaining})`,
219
+ startDriver: `${READ}
220
+ if m.kind~='drive' or m.state~='connected' or m.connectionId~=ARGV[1] or m.browserHash~=ARGV[2] or redis.call('GET',KEYS[2])~=m.owner or redis.call('GET',KEYS[3])~=ARGV[1] or m.driverReady then return denied('driver-ended') end
221
+ local remaining=math.min(${GATEWAY_AUTH_MS},redis.call('PTTL',KEYS[2])); if remaining<=0 then return denied('authorization-expired') end
222
+ local r={v=2,target=m.target,driverIntentId=m.intentId,driverConnectionId=m.connectionId,driverActor=m.actor,sessionKey=m.sessionKey,gatewayOrigin=m.gatewayOrigin,consoleOrigin=m.consoleOrigin,enabled=false,epoch=cjson.null,geometry={cols=tonumber(ARGV[3]),rows=tonumber(ARGV[4]),revision=1},rosterRevision=0,seenRosterRevision=0,watchers={}}
223
+ m.driverReady=true; redis.call('SET',KEYS[1],cjson.encode(m),'KEEPTTL'); redis.call('SET',roomKey(m.target),cjson.encode(r),'PX',remaining)
224
+ return cjson.encode({ok=true,intent=m,room=r,authRemainingMs=remaining})`,
225
+ control: `${READ}
226
+ local r=roomFor(m.target)
227
+ if m.kind~='drive' then return denied('driver-ended') end
228
+ if m.state~='connected' or m.connectionId~=ARGV[1] or m.browserHash~=ARGV[2] or redis.call('GET',KEYS[2])~=m.owner then return denied('authorization-expired') end
229
+ if not r or r.driverIntentId~=m.intentId or r.driverConnectionId~=ARGV[1] or driverRemaining(r)<=0 then return denied('driver-ended') end
230
+ local action=ARGV[3]
231
+ if action=='policy' then
232
+ local enabled=ARGV[4]=='true'
233
+ if r.enabled~=enabled then
234
+ for id,_ in pairs(r.watchers) do redis.call('DEL',prefix..'auth:'..id) end
235
+ r.watchers={}; r.enabled=enabled; if enabled then r.epoch=ARGV[5] else r.epoch=cjson.null end; r.rosterRevision=r.rosterRevision+1
236
+ end
237
+ elseif action=='remove' then
238
+ for id,w in pairs(r.watchers) do if w.watcherKey==ARGV[4] then redis.call('DEL',prefix..'auth:'..id); local gone=decoded(prefix..'intent:'..id); if gone then gone.removed=true; redis.call('SET',prefix..'intent:'..id,cjson.encode(gone),'KEEPTTL') end; r.watchers[id]=nil; r.rosterRevision=r.rosterRevision+1; break end end
239
+ elseif action=='seen' then
240
+ local revision=tonumber(ARGV[4]); if revision>r.rosterRevision then return denied('roster-invalid') end
241
+ r.seenRosterRevision=math.max(r.seenRosterRevision,revision)
242
+ elseif action=='geometry' then
243
+ local cols=tonumber(ARGV[4]); local rows=tonumber(ARGV[5])
244
+ if r.geometry.cols~=cols or r.geometry.rows~=rows then r.geometry={cols=cols,rows=rows,revision=r.geometry.revision+1} end
245
+ elseif action=='watch-ready' then
246
+ return denied('control-invalid')
247
+ else return denied('control-invalid') end
248
+ saveRoom(r)
249
+ return cjson.encode({ok=true,intent=m,room=r,authRemainingMs=driverRemaining(r)})`,
250
+ watchReady: `${READ}
251
+ if m.kind~='watch' or m.state~='connected' or m.connectionId~=ARGV[1] or m.browserHash~=ARGV[2] then return denied('authorization-expired') end
252
+ local r,reason=watchRoom(m); if not r then return denied(reason) end
253
+ local w=r.watchers[m.intentId]; if not w or w.expiresAt<=now then return denied('watch-removed') end
254
+ if redis.call('GET',KEYS[2])~=m.owner or redis.call('GET',watchWindow(m))~=ARGV[1] then return denied('authorization-expired') end
255
+ if r.seenRosterRevision<w.registeredRevision then return denied('roster-unseen') end
256
+ if w.state~='watching' then w.state='watching'; r.rosterRevision=r.rosterRevision+1 end; saveRoom(r)
257
+ return cjson.encode({ok=true,intent=m,room=r,authRemainingMs=math.min(driverRemaining(r),redis.call('PTTL',KEYS[2]))})`,
258
+ });
259
+
260
+ export function makeGatewayStore({ redis, timeoutMs = 2_000, prefix = "sandbox-gateway:", pendingMs = GATEWAY_PENDING_MS, handoffMs = GATEWAY_HANDOFF_MS, grantMs = GATEWAY_GRANT_MS, authMs = GATEWAY_AUTH_MS, windowMs = GATEWAY_WINDOW_MS, maxIntents = GATEWAY_MAX_INTENTS } = {}) {
261
+ for (const [n, cap] of [[pendingMs, GATEWAY_PENDING_MS], [handoffMs, GATEWAY_HANDOFF_MS], [grantMs, GATEWAY_GRANT_MS], [authMs, GATEWAY_AUTH_MS], [windowMs, GATEWAY_WINDOW_MS], [maxIntents, GATEWAY_MAX_INTENTS]]) if (!Number.isInteger(n) || n < 1 || n > cap) throw new Error("gateway protocol bounds cannot exceed their contract");
262
+ if (typeof prefix !== "string" || !/^[A-Za-z0-9:_-]{1,100}$/.test(prefix)) throw new Error("invalid gateway key prefix");
263
+ const key = (id) => `${prefix}intent:${id}`;
264
+ const auth = (id) => `${prefix}auth:${id}`;
265
+ const index = `${prefix}intents`;
266
+ const windowKey = (target) => `${prefix}window:${target.host}:${target.containerId}`;
267
+ async function bounded(fn) {
268
+ let timer;
269
+ try { return await Promise.race([Promise.resolve().then(fn), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("gateway-control-timeout")), timeoutMs); })]); }
270
+ finally { clearTimeout(timer); }
271
+ }
272
+ async function evaluate(script, keys, args) {
273
+ try {
274
+ const wire = [...args]; while (wire.length < 8) wire.push(""); wire.push(prefix);
275
+ const raw = await bounded(() => redis.eval(script, keys.length, ...keys, ...wire));
276
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > 8192) return { refused: "control-invalid" };
277
+ const result = JSON.parse(raw);
278
+ if (only(result, ["ok", "intent", "room", "authRemainingMs", "rosterSeen"]) && result.ok === true && (result.intent !== undefined || result.room !== undefined) && (result.intent === undefined || validIntent(result.intent)) && (result.room === undefined || validRoom(result.room)) && (result.authRemainingMs === undefined || Number.isInteger(result.authRemainingMs) && result.authRemainingMs > 0 && result.authRemainingMs <= GATEWAY_AUTH_MS) && (result.rosterSeen === undefined || typeof result.rosterSeen === "boolean")) return result;
279
+ if (only(result, ["refused", "winnerConnectionId"]) && result.refused === "window-busy" && validId(result.winnerConnectionId)) return result;
280
+ return object(result) && Object.keys(result).length === 1 && typeof result.refused === "string" && /^[a-z-]{1,40}$/.test(result.refused) ? { refused: result.refused } : { refused: "control-invalid" };
281
+ } catch { return { refused: "control-unavailable" }; }
282
+ }
283
+ async function readIntent(intentId) {
284
+ if (!validId(intentId)) return null;
285
+ const raw = await bounded(() => redis.get(key(intentId)));
286
+ if (raw === null) return null;
287
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > 8192) throw new Error("gateway-control-invalid");
288
+ let m; try { m = JSON.parse(raw); } catch { throw new Error("gateway-control-invalid"); }
289
+ if (!validIntent(m) || m.intentId !== intentId) throw new Error("gateway-control-invalid");
290
+ return m;
291
+ }
292
+ const invalid = () => ({ refused: "intent-invalid" });
293
+ const clockOk = (at) => instant(at) && Number.isSafeInteger(at + authMs);
294
+ const dimensions = (cols, rows) => Number.isInteger(cols) && cols >= 2 && cols <= 500 && Number.isInteger(rows) && rows >= 1 && rows <= 200;
295
+ async function connectionKeys(intentId, connectionId, browserHash) {
296
+ if (!validId(intentId) || !validId(connectionId) || !GATEWAY_HASH_RE.test(browserHash ?? "")) return null;
297
+ const m = await readIntent(intentId);
298
+ return m ? [key(intentId), auth(intentId), windowKey(m.target)] : null;
299
+ }
300
+ async function control(input, action, args) {
301
+ let keys; try { keys = await connectionKeys(input.intentId, input.connectionId, input.browserHash); } catch { return { refused: "control-unavailable" }; }
302
+ return keys ? evaluate(GATEWAY_SCRIPTS.control, keys, [input.connectionId, input.browserHash, action, ...args]) : invalid();
303
+ }
304
+ return {
305
+ readIntent,
306
+ async readRoom(target) {
307
+ if (!cleanGatewayTarget(target)) throw new Error("gateway-control-invalid");
308
+ const result = await evaluate(GATEWAY_SCRIPTS.room, [], [JSON.stringify(cleanGatewayTarget(target))]);
309
+ if (result.ok) return result.room;
310
+ if (["room-missing", "driver-ended"].includes(result.refused)) return null;
311
+ throw new Error("gateway-control-unavailable");
312
+ },
313
+ async observeTime() {
314
+ try { const nowMs = await bounded(() => redis.eval(GATEWAY_SCRIPTS.time, 0)); return instant(nowMs) ? { ok: true, nowMs } : { refused: "control-invalid" }; }
315
+ catch { return { refused: "control-unavailable" }; }
316
+ },
317
+ async createIntent(input) {
318
+ if (!only(input, ["intentId", "owner", "handoffHash", "target", "gatewayOrigin", "consoleOrigin", "theme", "sessionKey", "kind", "runFacts", "driver"]) || !validId(input.intentId) || !validId(input.owner) || !GATEWAY_HASH_RE.test(input.sessionKey ?? "") || !["drive", "watch"].includes(input.kind) || !validGatewayRunFacts(input.runFacts) || (input.runFacts !== null && input.runFacts.id !== input.target?.id) || (input.kind === "watch" ? !driverRef(input.driver) : input.driver !== undefined) || !GATEWAY_HASH_RE.test(input.handoffHash ?? "") || !cleanGatewayTarget(input.target) || !distinctGatewayOrigins(input.gatewayOrigin, input.consoleOrigin) || ![undefined, null, "light", "dark"].includes(input.theme)) return invalid();
319
+ const m = { v: 2, ...input, target: cleanGatewayTarget(input.target), theme: input.theme ?? null, state: "pending" };
320
+ return evaluate(GATEWAY_SCRIPTS.create, [key(input.intentId), index], [JSON.stringify(m), handoffMs, pendingMs, input.intentId, maxIntents]);
321
+ },
322
+ async bindIntent({ intentId, handoff, browserHash, receiptHash, gatewayOrigin: origin, host } = {}) {
323
+ if (!validId(intentId) || !validId(handoff) || !GATEWAY_HASH_RE.test(browserHash ?? "") || !GATEWAY_HASH_RE.test(receiptHash ?? "") || !gatewayOrigin(origin) || !WORKER_NAME_RE.test(host ?? "")) return invalid();
324
+ return evaluate(GATEWAY_SCRIPTS.bind, [key(intentId)], [gatewayHash(handoff), browserHash, origin, host, receiptHash]);
325
+ },
326
+ async authorizeIntent({ intentId, owner, actor, observedAt } = {}) {
327
+ if (!validId(intentId) || !validId(owner) || !validActor(actor) || !clockOk(observedAt)) return invalid();
328
+ return evaluate(GATEWAY_SCRIPTS.authorize, [key(intentId), auth(intentId), index], [owner, JSON.stringify(actor), grantMs, authMs, pendingMs, observedAt, gatewayHash(intentId)]);
329
+ },
330
+ async renewAuthorization({ intentId, owner, observedAt } = {}) {
331
+ if (!validId(intentId) || !validId(owner) || !clockOk(observedAt)) return invalid();
332
+ return evaluate(GATEWAY_SCRIPTS.renewAuth, [key(intentId), auth(intentId), index], [owner, authMs, pendingMs, observedAt]);
333
+ },
334
+ async revoke({ intentId, owner, reason = "session-ended" } = {}) {
335
+ if (!validId(intentId) || !validId(owner) || !GATEWAY_END_REASONS.includes(reason)) return invalid();
336
+ return evaluate(GATEWAY_SCRIPTS.revoke, [key(intentId), auth(intentId), index], [owner, reason, grantMs]);
337
+ },
338
+ async redeem({ intentId, browserHash, connectionId } = {}) {
339
+ if (!validId(intentId) || !validId(connectionId) || !GATEWAY_HASH_RE.test(browserHash ?? "")) return invalid();
340
+ let m; try { m = await readIntent(intentId); } catch { return { refused: "control-unavailable" }; }
341
+ if (!m) return { refused: "intent-missing" };
342
+ return evaluate(GATEWAY_SCRIPTS.redeem, [key(intentId), auth(intentId), windowKey(m.target)], [browserHash, connectionId, windowMs]);
343
+ },
344
+ async renewAttachment({ intentId, connectionId, browserHash } = {}) {
345
+ if (!validId(intentId) || !validId(connectionId) || !GATEWAY_HASH_RE.test(browserHash ?? "")) return invalid();
346
+ let m; try { m = await readIntent(intentId); } catch { return { refused: "control-unavailable" }; }
347
+ if (!m) return { refused: "intent-missing" };
348
+ return evaluate(GATEWAY_SCRIPTS.renewAttachment, [key(intentId), auth(intentId), windowKey(m.target)], [connectionId, browserHash, windowMs]);
349
+ },
350
+ async release({ intentId, connectionId, reason = "disconnected" } = {}) {
351
+ if (!validId(intentId) || !validId(connectionId) || !GATEWAY_END_REASONS.includes(reason)) return invalid();
352
+ let m; try { m = await readIntent(intentId); } catch { return { refused: "control-unavailable" }; }
353
+ if (!m) return { refused: "intent-missing" };
354
+ return evaluate(GATEWAY_SCRIPTS.release, [key(intentId), auth(intentId), windowKey(m.target), index], [connectionId, reason, grantMs]);
355
+ },
356
+ async startDriver(input = {}) {
357
+ if (!dimensions(input.cols, input.rows)) return invalid();
358
+ let keys; try { keys = await connectionKeys(input.intentId, input.connectionId, input.browserHash); } catch { return { refused: "control-unavailable" }; }
359
+ return keys ? evaluate(GATEWAY_SCRIPTS.startDriver, keys, [input.connectionId, input.browserHash, input.cols, input.rows]) : invalid();
360
+ },
361
+ async setWatching(input = {}) {
362
+ return typeof input.enabled === "boolean" ? control(input, "policy", [String(input.enabled), gatewayRandom()]) : invalid();
363
+ },
364
+ async removeWatcher(input = {}) {
365
+ return GATEWAY_HASH_RE.test(input.watcherKey ?? "") ? control(input, "remove", [input.watcherKey]) : invalid();
366
+ },
367
+ async acknowledgeRoster(input = {}) {
368
+ return integer(input.revision) ? control(input, "seen", [input.revision]) : invalid();
369
+ },
370
+ async updateGeometry(input = {}) {
371
+ return dimensions(input.cols, input.rows) ? control(input, "geometry", [input.cols, input.rows]) : invalid();
372
+ },
373
+ async readyWatch(input = {}) {
374
+ let keys; try { keys = await connectionKeys(input.intentId, input.connectionId, input.browserHash); } catch { return { refused: "control-unavailable" }; }
375
+ return keys ? evaluate(GATEWAY_SCRIPTS.watchReady, keys, [input.connectionId, input.browserHash]) : invalid();
376
+ },
377
+ };
378
+ }
@@ -0,0 +1,100 @@
1
+ /** Fleet targets are an operator-edited file. A URL cannot enroll or alter a local trusted target. */
2
+ import * as nodeFs from "node:fs";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { dirname, isAbsolute, resolve, win32 } from "node:path";
5
+ import { sandboxOpenFields } from "./opener-protocol.mjs";
6
+ import { trustedChain } from "./service-env.mjs";
7
+
8
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
9
+ const text = (value) => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/.test(value);
10
+ const absolute = (value) => text(value) && (isAbsolute(value) || win32.isAbsolute(value));
11
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
12
+ /**
13
+ * The sandbox hosts file the console edits: PI_SANDBOX_HOSTS_FILE, or `sandbox-hosts.json`, resolved against the
14
+ * deployment folder `dir`, as `tokensFilePath` resolves the tokens file. It is the file's only default (issue #806): a
15
+ * reader that guessed another folder would find no file, which reads as no hosts, and nothing would say so.
16
+ */
17
+ export function sandboxHostsPath(env, dir) {
18
+ const configured = typeof env?.PI_SANDBOX_HOSTS_FILE === "string" ? env.PI_SANDBOX_HOSTS_FILE.trim() : "";
19
+ return resolve(dir, configured || "sandbox-hosts.json");
20
+ }
21
+ export function validateSandboxHosts(input) {
22
+ if (!object(input) || input.version !== 1 || !object(input.hosts) || Object.keys(input).some((key) => !["version", "hosts"].includes(key))) throw new Error("sandbox hosts must contain version 1 and a hosts object");
23
+ if (Object.keys(input.hosts).length > 64) throw new Error("sandbox hosts accepts at most 64 hosts");
24
+ const hosts = {};
25
+ for (const [host, raw] of Object.entries(input.hosts)) {
26
+ sandboxOpenFields({ verb: "open", id: "sb-check", host });
27
+ if (!object(raw) || Object.keys(raw).some((key) => !["target", "deployment", "nodePath", "cliPath", "shell"].includes(key))) throw new Error("a sandbox host accepts only target, deployment, nodePath, cliPath and shell");
28
+ if (raw.target !== null && (typeof raw.target !== "string" || !/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9][A-Za-z0-9_.-]{0,252}$/.test(raw.target))) throw new Error("the SSH target must be a host or user@host (use an SSH config alias for ports)");
29
+ for (const key of ["deployment", "nodePath", "cliPath"]) if (!absolute(raw[key])) throw new Error(`the sandbox host ${key} must be an absolute path without control bytes`);
30
+ const shell = raw.shell ?? (/^(?:[A-Za-z]:[\\/]|\\\\)/.test(raw.nodePath) ? "powershell" : "posix");
31
+ if (!["posix", "powershell"].includes(shell)) throw new Error("a sandbox host shell must be posix or powershell");
32
+ if (raw.target !== null && [raw.deployment, raw.nodePath, raw.cliPath].some((p) => shell === "posix" ? !p.startsWith("/") : !win32.isAbsolute(p))) throw new Error("the SSH sandbox paths must be absolute for the selected host shell");
33
+ Object.defineProperty(hosts, host, { value: { target: raw.target, deployment: raw.deployment, nodePath: raw.nodePath, cliPath: raw.cliPath, shell }, enumerable: true, writable: true, configurable: true });
34
+ }
35
+ return { version: 1, hosts };
36
+ }
37
+ export function readSandboxHosts(path, { fs = nodeFs, uid = process.geteuid?.(), allowMissing = true, platform = process.platform } = {}) {
38
+ let fd;
39
+ try {
40
+ if (fs.lstatSync(path).isSymbolicLink()) return { refusal: "the sandbox hosts file cannot be a symbolic link" };
41
+ fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
42
+ const stat = fs.fstatSync(fd);
43
+ if (!stat.isFile() || (Number.isInteger(uid) && stat.uid !== uid) || (platform !== "win32" && (stat.mode & 0o022))) return { refusal: "the sandbox hosts file must be a regular file owned by this account and writable only by it" };
44
+ if (Number.isInteger(uid) && trustedChain(fs.realpathSync(dirname(path)), { fs, uid })) return { refusal: "the sandbox hosts file is in a folder another account can change" };
45
+ const raw = fs.readFileSync(fd, "utf8");
46
+ if (Buffer.byteLength(raw) > 128 * 1024) return { refusal: "the sandbox hosts file is too large" };
47
+ return { config: validateSandboxHosts(JSON.parse(raw)), expect: digest(raw) };
48
+ } catch (error) {
49
+ if (allowMissing && error.code === "ENOENT") return { config: { version: 1, hosts: {} }, expect: null };
50
+ return { refusal: `the sandbox hosts file could not be used (${typeof error.code === "string" ? error.code : "invalid configuration"})` };
51
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
52
+ }
53
+ export function planSandboxHost(path, host, mapping, options = {}) {
54
+ const read = readSandboxHosts(path, options);
55
+ if (read.refusal) return read;
56
+ try {
57
+ sandboxOpenFields({ verb: "open", id: "sb-check", host });
58
+ const hosts = { ...read.config.hosts };
59
+ if (mapping === null) delete hosts[host]; else Object.defineProperty(hosts, host, { value: mapping, enumerable: true, configurable: true });
60
+ const config = validateSandboxHosts({ version: 1, hosts });
61
+ return { config, before: JSON.stringify(read.config, null, 2) + "\n", after: JSON.stringify(config, null, 2) + "\n", expect: read.expect };
62
+ } catch (error) { return { refusal: error.message }; }
63
+ }
64
+ export function writeSandboxHost(path, host, mapping, { expect, fs = nodeFs, uid = process.geteuid?.(), platform = process.platform } = {}) {
65
+ const plan = planSandboxHost(path, host, mapping, { fs, uid });
66
+ if (plan.refusal) return plan;
67
+ if (plan.expect !== expect) return { refusal: "the sandbox hosts file changed since the preview; preview again" };
68
+ let temporary;
69
+ try {
70
+ fs.mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
71
+ const parent = fs.lstatSync(dirname(path));
72
+ if (!parent.isDirectory() || parent.isSymbolicLink() || (Number.isInteger(uid) && (parent.uid !== uid || trustedChain(fs.realpathSync(dirname(path)), { fs, uid }))) || (platform !== "win32" && (parent.mode & 0o022))) return { refusal: "the sandbox hosts folder must be owned by this account and writable only by it" };
73
+ temporary = `${path}.${randomBytes(12).toString("hex")}.tmp`;
74
+ fs.writeFileSync(temporary, plan.after, { flag: "wx", mode: 0o600 });
75
+ const fresh = readSandboxHosts(path, { fs, uid });
76
+ if (fresh.refusal || fresh.expect !== expect) return { refusal: "the sandbox hosts file changed since the preview; preview again" };
77
+ fs.renameSync(temporary, path);
78
+ return { written: true, expect: digest(plan.after), config: plan.config };
79
+ } catch (error) { return { refusal: `the sandbox hosts file was not written (${error.code ?? "write failed"})` }; }
80
+ finally { if (temporary) { try { fs.rmSync(temporary, { force: true }); } catch {} } }
81
+ }
82
+ export function quoteSandboxArg(value) {
83
+ const word = String(value);
84
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
85
+ }
86
+ export function quotePowerShellArg(value) { return `'${String(value).replaceAll("'", "''")}'`; }
87
+ export function sandboxCommand(input, fields) {
88
+ const config = validateSandboxHosts(input);
89
+ const { verb, id, host } = sandboxOpenFields(fields);
90
+ const mapping = Object.hasOwn(config.hosts, host) ? config.hosts[host] : null;
91
+ if (!mapping) throw new Error("the opener host is unknown; import the approved Fleet host configuration first");
92
+ const command = [mapping.nodePath, mapping.cliPath, "sandbox", id, ...(verb === "attach" ? ["--attach"] : []), "--deployment", mapping.deployment, "--expected-host", host];
93
+ const remoteScript = mapping.shell === "powershell" ? `& ${command.map(quotePowerShellArg).join(" ")}; exit $LASTEXITCODE` : command.map(quoteSandboxArg).join(" ");
94
+ // EncodedCommand keeps one fixed script intact through either cmd.exe or PowerShell as the Windows SSH
95
+ // login shell. Its expanded text is shown beside the exact wire command before Enter, never hidden by encoding.
96
+ const remoteCommand = mapping.shell === "powershell" ? `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(remoteScript, "utf16le").toString("base64")}` : remoteScript;
97
+ const argv = mapping.target === null ? command : ["ssh", "-t", "--", mapping.target, remoteCommand];
98
+ const display = mapping.target === null && mapping.shell === "powershell" ? `& ${argv.map(quotePowerShellArg).join(" ")}` : argv.map(quoteSandboxArg).join(" ");
99
+ return { argv, display: mapping.target !== null && mapping.shell === "powershell" ? `${display}\nRemote PowerShell command: ${remoteScript}` : display, local: mapping.target === null, remoteScript: mapping.target !== null ? remoteScript : null };
100
+ }
@@ -0,0 +1,83 @@
1
+ /** Durable per-host kept-workspace snapshots. This operator-only display is never an admission authority (#771). */
2
+ import { parseSandboxes, sandboxManifestGeneration } from "./host-telemetry.mjs";
3
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
4
+ import { imageRefProblem } from "./image-ref.mjs";
5
+ import { BACKEND_NAMES } from "./backends.mjs";
6
+ import { jobSizeDefaults } from "./job-size.mjs";
7
+
8
+ export { sandboxManifestGeneration };
9
+ export const SANDBOX_INVENTORY_HOSTS = "sandbox:inventory:hosts";
10
+ export const sandboxInventoryKey = (host) => `sandbox:inventory:h:${host}`;
11
+ export const SANDBOX_INVENTORY_INTERVAL_MS = 60_000;
12
+ export const SANDBOX_INVENTORY_TTL_MS = 7 * 86_400_000;
13
+ export const SANDBOX_INVENTORY_HOSTS_MAX = 64;
14
+ const integer = (v) => Number.isSafeInteger(v) && v >= 0;
15
+
16
+ function bounded(promise, timeoutMs) {
17
+ return new Promise((resolve, reject) => {
18
+ const timer = setTimeout(() => reject(new Error("sandbox inventory did not answer")), timeoutMs);
19
+ Promise.resolve(promise).then((r) => { clearTimeout(timer); resolve(r); }, (e) => { clearTimeout(timer); reject(e); });
20
+ });
21
+ }
22
+
23
+ /** Fields which let another host preview precisely its image/venue/size/egress, with no credential or host path. */
24
+ export function sandboxInventorySettings(raw) {
25
+ if (!raw || typeof raw !== "object" || imageRefProblem(raw.image) || !BACKEND_NAMES.includes(raw.defaultBackend) || !Array.isArray(raw.backends) || raw.backends.some((v) => !BACKEND_NAMES.includes(v)) || typeof raw.egress !== "boolean" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(raw.egressProxy ?? "") || !integer(raw.sandboxRetentionHours) || !Number.isInteger(raw.sandboxPinDays) || raw.sandboxPinDays < 1) return null;
26
+ try { jobSizeDefaults({ PI_JOB_MEMORY: raw.jobMemory, PI_JOB_CPUS: raw.jobCpus }); } catch { return null; }
27
+ return { image: raw.image, defaultBackend: raw.defaultBackend, backends: [...raw.backends], egress: raw.egress, egressProxy: raw.egressProxy, sandboxRetentionHours: raw.sandboxRetentionHours, sandboxPinDays: raw.sandboxPinDays, ...(raw.jobMemory !== undefined ? { jobMemory: raw.jobMemory } : {}), ...(raw.jobCpus !== undefined ? { jobCpus: raw.jobCpus } : {}) };
28
+ }
29
+
30
+ export function makeSandboxInventory({ redis, host, read, settings = null, now = Date.now, log = () => {}, intervalMs = SANDBOX_INVENTORY_INTERVAL_MS, ttlMs = SANDBOX_INVENTORY_TTL_MS, timeoutMs = 2_000 } = {}) {
31
+ if (!WORKER_NAME_RE.test(host ?? "") || typeof read !== "function") throw new Error("sandbox inventory requires a checked host and reader");
32
+ let timer = null, closed = false, inFlight = null, failed = false;
33
+ const publish = async () => {
34
+ if (closed) return { closed: true };
35
+ if (inFlight) return inFlight;
36
+ inFlight = (async () => {
37
+ try {
38
+ let projection;
39
+ try { projection = await read(); } catch { projection = {}; }
40
+ const rows = parseSandboxes(projection?.sandboxes);
41
+ const more = /^\d+$/.test(String(projection?.sandboxesMore ?? "")) ? Number(projection.sandboxesMore) : null;
42
+ const retentionHours = /^\d+$/.test(String(projection?.sandboxRetentionHours ?? "")) ? Number(projection.sandboxRetentionHours) : null;
43
+ const record = { version: 1, host, at: now(), rows, more: integer(more) ? more : null, retentionHours: integer(retentionHours) ? retentionHours : null, settings: sandboxInventorySettings(typeof settings === "function" ? settings() : settings) };
44
+ await bounded(redis.set(sandboxInventoryKey(host), JSON.stringify(record), "PX", ttlMs), timeoutMs);
45
+ if (!closed) await bounded(redis.sadd(SANDBOX_INVENTORY_HOSTS, host), timeoutMs);
46
+ failed = false;
47
+ return { published: true };
48
+ } catch {
49
+ if (!closed && !failed) log("sandbox_inventory_unreachable", { reason: "publish-failed" });
50
+ failed = true;
51
+ return { unreachable: true };
52
+ }
53
+ })();
54
+ try { return await inFlight; } finally { inFlight = null; }
55
+ };
56
+ return { publish, start() { if (!timer && !closed) { void publish(); timer = setInterval(() => void publish(), intervalMs); timer.unref?.(); } return this; }, async close() { closed = true; if (timer) clearInterval(timer); timer = null; if (inFlight) await bounded(inFlight, timeoutMs).catch(() => {}); } };
57
+ }
58
+
59
+ export async function readSandboxInventories(redis, { now = Date.now, hosts = [], timeoutMs = 2_000, prune = false } = {}) {
60
+ let indexed;
61
+ try { indexed = await bounded(redis.smembers(SANDBOX_INVENTORY_HOSTS), timeoutMs); }
62
+ catch { return { unreachable: true }; }
63
+ const all = [...new Set([...(Array.isArray(indexed) ? indexed : []), ...hosts].filter((h) => typeof h === "string" && WORKER_NAME_RE.test(h)))].sort();
64
+ const names = all.slice(0, SANDBOX_INVENTORY_HOSTS_MAX);
65
+ if (!names.length) return { hosts: [], more: 0 };
66
+ let values;
67
+ try { values = await bounded(redis.mget(...names.map(sandboxInventoryKey)), timeoutMs); }
68
+ catch { return { unreachable: true }; }
69
+ const at = now();
70
+ const records = names.map((name, i) => {
71
+ let raw;
72
+ try { if (typeof values[i] === "string" && values[i].length <= 64 * 1024) raw = JSON.parse(values[i]); } catch {}
73
+ if (!raw || raw.version !== 1 || raw.host !== name || !integer(raw.at) || !(raw.more === null || integer(raw.more)) || !(raw.retentionHours === null || integer(raw.retentionHours))) return { name, unreadable: true, stale: true, missing: values[i] == null, rows: null, more: null, retentionHours: null, settings: null };
74
+ const rows = parseSandboxes(raw.rows);
75
+ const ageMs = Math.max(0, at - raw.at);
76
+ return { name, at: raw.at, ageMs, stale: ageMs > 2 * SANDBOX_INVENTORY_INTERVAL_MS || at < raw.at - 30_000, unreadable: rows === null, rows, more: raw.more, retentionHours: raw.retentionHours, settings: sandboxInventorySettings(raw.settings) };
77
+ });
78
+ if (prune) {
79
+ const missing = records.filter((r) => r.missing).map((r) => r.name);
80
+ if (missing.length) await bounded(redis.srem(SANDBOX_INVENTORY_HOSTS, ...missing), timeoutMs).catch(() => {});
81
+ }
82
+ return { hosts: records, more: all.length - names.length };
83
+ }