@edgehero/pi-dispatch 4.3.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- 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
|
+
}
|