@edgehero/pi-dispatch 4.3.1 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +84 -3
- package/README.md +21 -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 +118 -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 +136 -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 +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -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 +1657 -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/envelope.mjs +5 -3
- 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 +4 -2
- 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 +12 -2
- 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 +36 -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 +402 -53
- 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,161 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
3
|
+
|
|
4
|
+
/** Operator-private observations, never authority over a job or a reusable container name (#771). */
|
|
5
|
+
export const SANDBOX_SESSION_TTL_MS = 45_000;
|
|
6
|
+
export const SANDBOX_SESSION_POLL_MS = 1_000;
|
|
7
|
+
export const SANDBOX_SESSION_MAX = 256;
|
|
8
|
+
export const SANDBOX_SESSION_INDEX = "sandbox:shells";
|
|
9
|
+
export const SANDBOX_NOTICE_INDEX = "sandbox:notices";
|
|
10
|
+
export const SANDBOX_NOTICE_TTL_MS = 86_400_000;
|
|
11
|
+
export const SANDBOX_SESSION_LABEL = "org.pi-dispatch.sandbox-session";
|
|
12
|
+
export const SANDBOX_SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
13
|
+
const nonceRe = /^[a-f0-9]{32}$/;
|
|
14
|
+
const containerRe = /^[a-f0-9]{64}$/;
|
|
15
|
+
const memberRe = /^[A-Za-z0-9._-]{1,64}:[a-f0-9]{64}$/;
|
|
16
|
+
const instant = (v) => Number.isSafeInteger(v) && v >= 0 && v <= 8_640_000_000_000_000;
|
|
17
|
+
const digest = (id) => createHash("sha256").update(id).digest("hex");
|
|
18
|
+
export const sandboxSessionMember = (host, id) => `${host}:${digest(id)}`;
|
|
19
|
+
export const sandboxSessionKey = (host, id) => `sandbox:shell:${sandboxSessionMember(host, id)}`;
|
|
20
|
+
export const sandboxNoticeKey = (host, id) => `sandbox:notice:${sandboxSessionMember(host, id)}`;
|
|
21
|
+
export const sandboxEndKey = (nonce) => `sandbox:end:${nonce}`;
|
|
22
|
+
export const sandboxEndAckKey = (nonce, requestId) => `sandbox:ended:${nonce}:${requestId}`;
|
|
23
|
+
|
|
24
|
+
export function parseSandboxSession(raw) {
|
|
25
|
+
let s;
|
|
26
|
+
try { s = typeof raw === "string" && raw.length <= 4096 ? JSON.parse(raw) : raw; } catch { return null; }
|
|
27
|
+
if (!s || ["host", "id", "nonce", "containerId", "venue", "by"].some((k) => typeof s[k] !== "string") || s.v !== 1 || !WORKER_NAME_RE.test(s.host) || !SANDBOX_SESSION_ID_RE.test(s.id) || !nonceRe.test(s.nonce) || !containerRe.test(s.containerId) || !["local", "podman"].includes(s.venue) || !/^[A-Za-z0-9._@-]{1,64}$/.test(s.by) || !instant(s.since) || !instant(s.at) || s.at < s.since) return null;
|
|
28
|
+
return { v: 1, host: s.host, id: s.id, nonce: s.nonce, containerId: s.containerId, venue: s.venue, by: s.by, since: s.since, at: s.at };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sandboxSessionAccount(name) {
|
|
32
|
+
return typeof name === "string" && /^[A-Za-z0-9._@-]{1,64}$/.test(name) ? name : "operator";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sandboxBounded(promise, ms = 2_000) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const timer = setTimeout(() => reject(new Error("sandbox control timed out")), ms);
|
|
38
|
+
Promise.resolve(promise).then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A delayed renew/delete must never replace or erase the next shell's lease.
|
|
43
|
+
const RENEW = `local raw=redis.call('GET',KEYS[1]); if raw then local ok,s=pcall(cjson.decode,raw); if not ok or s.nonce~=ARGV[1] or s.containerId~=ARGV[2] then return 0 end end; redis.call('SET',KEYS[1],ARGV[3],'PX',ARGV[4]); local t=redis.call('TIME'); local at=t[1]*1000+math.floor(t[2]/1000); redis.call('ZADD',KEYS[2],at+tonumber(ARGV[4]),ARGV[5]); redis.call('ZREMRANGEBYSCORE',KEYS[2],'-inf',at-tonumber(ARGV[4])); return 1`;
|
|
44
|
+
const RELEASE = `local raw=redis.call('GET',KEYS[1]); if not raw then return 0 end; local ok,s=pcall(cjson.decode,raw); if not ok or s.nonce~=ARGV[1] or s.containerId~=ARGV[2] then return 0 end; return redis.call('DEL',KEYS[1])`;
|
|
45
|
+
const REMOVE_REQUEST = `if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) end; return 0`;
|
|
46
|
+
|
|
47
|
+
export async function renewSandboxSession(redis, session, { now = Date.now, ttlMs = SANDBOX_SESSION_TTL_MS, timeoutMs = 2_000 } = {}) {
|
|
48
|
+
const value = parseSandboxSession({ ...session, v: 1, at: now() });
|
|
49
|
+
if (!value) throw new Error("invalid sandbox session");
|
|
50
|
+
const owned = await sandboxBounded(redis.eval(RENEW, 2, sandboxSessionKey(value.host, value.id), SANDBOX_SESSION_INDEX, value.nonce, value.containerId, JSON.stringify(value), String(ttlMs), sandboxSessionMember(value.host, value.id)), timeoutMs);
|
|
51
|
+
if (owned !== 1) return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function redisNow(redis, timeoutMs = 2_000) {
|
|
56
|
+
const value = await sandboxBounded(redis.time(), timeoutMs);
|
|
57
|
+
if (!Array.isArray(value) || value.length !== 2 || !/^\d+$/.test(value[0]) || !/^\d+$/.test(value[1])) throw new Error("the sandbox lease clock is unreadable");
|
|
58
|
+
const at = Number(value[0]) * 1000 + Math.floor(Number(value[1]) / 1000);
|
|
59
|
+
if (!instant(at)) throw new Error("the sandbox lease clock is invalid");
|
|
60
|
+
return at;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function releaseSandboxSession(redis, session, { timeoutMs = 2_000 } = {}) {
|
|
64
|
+
return sandboxBounded(redis.eval(RELEASE, 1, sandboxSessionKey(session.host, session.id), session.nonce, session.containerId), timeoutMs);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function publishSandboxNotice(redis, { host, id, reason }, { now = Date.now } = {}) {
|
|
68
|
+
if (!WORKER_NAME_RE.test(host ?? "") || !SANDBOX_SESSION_ID_RE.test(id ?? "") || !["network-kept", "network-cleanup-failed", "runtime-unreadable"].includes(reason)) throw new Error("invalid sandbox notice");
|
|
69
|
+
const at = now();
|
|
70
|
+
const serverAt = await redisNow(redis);
|
|
71
|
+
await sandboxBounded(redis.set(sandboxNoticeKey(host, id), JSON.stringify({ host, id, at, reason }), "PX", SANDBOX_NOTICE_TTL_MS));
|
|
72
|
+
await sandboxBounded(redis.zadd(SANDBOX_NOTICE_INDEX, serverAt + SANDBOX_NOTICE_TTL_MS, sandboxSessionMember(host, id)));
|
|
73
|
+
await sandboxBounded(redis.zremrangebyscore(SANDBOX_NOTICE_INDEX, "-inf", serverAt));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readNotices(redis, { now, timeoutMs, hosts }) {
|
|
77
|
+
const members = await sandboxBounded(redis.zrangebyscore(SANDBOX_NOTICE_INDEX, now(), "+inf", "LIMIT", 0, SANDBOX_SESSION_MAX + 1), timeoutMs);
|
|
78
|
+
if (!Array.isArray(members)) throw new Error("unreadable notices");
|
|
79
|
+
const wanted = members.slice(0, SANDBOX_SESSION_MAX).filter((m) => memberRe.test(m) && (!hosts.length || hosts.some((h) => m.startsWith(`${h}:`))));
|
|
80
|
+
const values = wanted.length ? await sandboxBounded(redis.mget(...wanted.map((m) => `sandbox:notice:${m}`)), timeoutMs) : [];
|
|
81
|
+
if (!Array.isArray(values)) throw new Error("unreadable sandbox notice values");
|
|
82
|
+
let unreadable = values.length !== wanted.length;
|
|
83
|
+
const notices = values.flatMap((raw, i) => {
|
|
84
|
+
try {
|
|
85
|
+
if (raw === null) return [];
|
|
86
|
+
if (typeof raw !== "string" || raw.length > 1024) { unreadable = true; return []; }
|
|
87
|
+
const n = JSON.parse(raw);
|
|
88
|
+
if (typeof n?.host !== "string" || !WORKER_NAME_RE.test(n.host) || typeof n.id !== "string" || !SANDBOX_SESSION_ID_RE.test(n.id) || !instant(n.at) || !["network-kept", "network-cleanup-failed", "runtime-unreadable"].includes(n.reason) || sandboxSessionMember(n.host, n.id) !== wanted[i]) { unreadable = true; return []; }
|
|
89
|
+
return [{ host: n.host, id: n.id, at: n.at, reason: n.reason }];
|
|
90
|
+
} catch { unreadable = true; return []; }
|
|
91
|
+
});
|
|
92
|
+
return { notices, partial: members.length > SANDBOX_SESSION_MAX || unreadable };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The console reads only these bounded keys; an unreachable read is never an invented empty list. */
|
|
96
|
+
export async function readSandboxSessions(redis, { hosts = [], now = Date.now, timeoutMs = 2_000 } = {}) {
|
|
97
|
+
try {
|
|
98
|
+
const serverAt = await redisNow(redis, timeoutMs);
|
|
99
|
+
const raw = await sandboxBounded(redis.zrangebyscore(SANDBOX_SESSION_INDEX, serverAt, "+inf", "LIMIT", 0, SANDBOX_SESSION_MAX + 1), timeoutMs);
|
|
100
|
+
if (!Array.isArray(raw)) throw new Error("unreadable index");
|
|
101
|
+
const members = [...new Set(raw.filter((m) => typeof m === "string" && memberRe.test(m) && (!hosts.length || hosts.some((h) => m.startsWith(`${h}:`)))))].sort();
|
|
102
|
+
const wanted = members.slice(0, SANDBOX_SESSION_MAX);
|
|
103
|
+
const values = wanted.length ? await sandboxBounded(redis.mget(...wanted.map((m) => `sandbox:shell:${m}`)), timeoutMs) : [];
|
|
104
|
+
if (!Array.isArray(values) || values.length !== wanted.length) throw new Error("unreadable leases");
|
|
105
|
+
const sessions = [];
|
|
106
|
+
let unreadable = 0;
|
|
107
|
+
for (const [i, value] of values.entries()) {
|
|
108
|
+
if (value === null) continue;
|
|
109
|
+
const s = parseSandboxSession(value);
|
|
110
|
+
if (!s || sandboxSessionMember(s.host, s.id) !== wanted[i]) { unreadable++; continue; }
|
|
111
|
+
// Key existence/TTL is the lease. Host and console clocks can disagree by more than its lifetime.
|
|
112
|
+
sessions.push(s);
|
|
113
|
+
}
|
|
114
|
+
let notices = [], noticesUnavailable = false;
|
|
115
|
+
try { const read = await readNotices(redis, { now: () => serverAt, timeoutMs, hosts }); notices = read.notices; noticesUnavailable = read.partial; } catch { noticesUnavailable = true; }
|
|
116
|
+
return { sessions: sessions.sort((a, b) => a.since - b.since), more: Math.max(0, raw.length - SANDBOX_SESSION_MAX, members.length - wanted.length), unreadable, notices, ...(noticesUnavailable ? { noticesUnavailable: true } : {}) };
|
|
117
|
+
} catch { return { sessions: [], unreachable: true }; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** End is an attended request for ONE immutable container, never a name that can be reused. */
|
|
121
|
+
export async function requestSandboxEnd({ redis, host, id, nonce, containerId, now = Date.now, ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = 2_000, random = () => randomBytes(16).toString("hex") }) {
|
|
122
|
+
if ([host, id, nonce, containerId].some((v) => typeof v !== "string") || !WORKER_NAME_RE.test(host) || !SANDBOX_SESSION_ID_RE.test(id) || !nonceRe.test(nonce) || !containerRe.test(containerId)) return { refused: "invalid-session", message: "The sandbox session is invalid; reload its current state." };
|
|
123
|
+
const current = parseSandboxSession(await sandboxBounded(redis.get(sandboxSessionKey(host, id)), timeoutMs));
|
|
124
|
+
if (!current || current.nonce !== nonce || current.containerId !== containerId) return { refused: "session-changed", message: "The sandbox session ended or changed; reload before asking to end it." };
|
|
125
|
+
const requestId = random();
|
|
126
|
+
if (!nonceRe.test(requestId)) throw new Error("invalid sandbox End request identity");
|
|
127
|
+
const value = JSON.stringify({ host, id, nonce, containerId, requestId });
|
|
128
|
+
await sandboxBounded(redis.set(sandboxEndKey(nonce), value, "PX", Math.max(ackTimeoutMs + 5_000, 15_000)), timeoutMs);
|
|
129
|
+
for (let elapsed = 0; ; elapsed += pollMs) {
|
|
130
|
+
const ack = await sandboxBounded(redis.get(sandboxEndAckKey(nonce, requestId)), timeoutMs);
|
|
131
|
+
if (ack === "ended") return { ended: true };
|
|
132
|
+
if (ack === "stop-failed") return { refused: "stop-failed", message: "The runtime did not confirm this sandbox stopped. Check its terminal and reload." };
|
|
133
|
+
if (elapsed + pollMs > ackTimeoutMs) break;
|
|
134
|
+
await sleep(pollMs);
|
|
135
|
+
}
|
|
136
|
+
await sandboxBounded(redis.eval(REMOVE_REQUEST, 1, sandboxEndKey(nonce), value), timeoutMs).catch(() => {});
|
|
137
|
+
return { timeout: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One monitor tick, injected so identity races, outages and stop failures are exercised without a daemon. */
|
|
141
|
+
export async function sandboxSessionTick({ redis, session, inspect, stop, now = Date.now }) {
|
|
142
|
+
const state = await inspect(session.containerId);
|
|
143
|
+
if (state.unknown) return { unknown: true };
|
|
144
|
+
if (!state.present) {
|
|
145
|
+
await releaseSandboxSession(redis, session);
|
|
146
|
+
return { gone: true };
|
|
147
|
+
}
|
|
148
|
+
if (state.containerId !== session.containerId || state.nonce !== session.nonce) return { refused: "container-changed" };
|
|
149
|
+
if (!await renewSandboxSession(redis, session, { now })) return { refused: "lease-changed" };
|
|
150
|
+
const raw = await sandboxBounded(redis.get(sandboxEndKey(session.nonce)));
|
|
151
|
+
let request;
|
|
152
|
+
try { request = JSON.parse(raw); } catch { return { live: true }; }
|
|
153
|
+
if (!request || !nonceRe.test(request.requestId ?? "") || ["host", "id", "nonce", "containerId"].some((k) => request[k] !== session[k])) return { live: true };
|
|
154
|
+
const stopped = await stop(session.containerId);
|
|
155
|
+
const after = await inspect(session.containerId);
|
|
156
|
+
const ended = stopped && !after.unknown && !after.present;
|
|
157
|
+
await sandboxBounded(redis.set(sandboxEndAckKey(session.nonce, request.requestId), ended ? "ended" : "stop-failed", "PX", 60_000));
|
|
158
|
+
await sandboxBounded(redis.eval(REMOVE_REQUEST, 1, sandboxEndKey(session.nonce), raw));
|
|
159
|
+
if (ended) { await releaseSandboxSession(redis, session); return { gone: true, ended: true }; }
|
|
160
|
+
return { live: true, stopFailed: true };
|
|
161
|
+
}
|
package/src/sandbox-store.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { isAbsolute, join, relative } from "node:path";
|
|
|
3
3
|
import { sanitizeJobId } from "./run-history.mjs";
|
|
4
4
|
import { scrubCredentials } from "./redact.mjs";
|
|
5
5
|
import { TRANSIENT_READ_ERRORS } from "./runtime-observations.mjs";
|
|
6
|
+
import { neededVersion } from "./store-version.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* sandbox-store.mjs -- the host side of a resurrectable sandbox (REQ-RESURRECTABLE-SANDBOX,
|
|
@@ -37,6 +38,53 @@ import { TRANSIENT_READ_ERRORS } from "./runtime-observations.mjs";
|
|
|
37
38
|
/** The manifest filename inside a retained directory. */
|
|
38
39
|
export const SANDBOX_MANIFEST = "manifest.json";
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The highest manifest version this build reads (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`): a top-level `version`,
|
|
43
|
+
* absent meaning 1, written only when the manifest uses a field that needs more, so every manifest of today's fields is
|
|
44
|
+
* the shape it always was. Defined here, beside the writers (`retainJobDir`, `pinSandbox`, the preparer), and read by
|
|
45
|
+
* `sandbox-policy.mjs`'s refusal, which already imports this module (the reverse would be a cycle).
|
|
46
|
+
*
|
|
47
|
+
* THE DEADLINE FIELDS NEVER CHANGE MEANING: `createdAt`, `retainUntil` and `keepUntil` keep theirs in every version, so
|
|
48
|
+
* an older worker's retention sweep (after a rollback, or on a host a newer CLI pinned) still deletes a newer run on
|
|
49
|
+
* time. That is why the sweep never consults the version, while opening and pinning refuse a newer manifest.
|
|
50
|
+
*/
|
|
51
|
+
export const SANDBOX_MANIFEST_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
/** Each manifest field whose absence an older build would misread, with the version that introduced it. Empty until one does. */
|
|
54
|
+
export const SANDBOX_MANIFEST_FIELD_VERSIONS = Object.freeze({});
|
|
55
|
+
|
|
56
|
+
/** The lowest version `manifest` needs from its fields (`version` and the reader-added `dir` aside): 1 while none needs more. */
|
|
57
|
+
export function sandboxManifestVersionFor(manifest, { table = SANDBOX_MANIFEST_FIELD_VERSIONS } = {}) {
|
|
58
|
+
if (manifest === null || typeof manifest !== "object") return 1;
|
|
59
|
+
return neededVersion(Object.keys(manifest).filter((key) => key !== "version" && key !== "dir"), table);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `manifest` with `version` set to what it needs: the higher of the version it carries (a manifest a newer build
|
|
64
|
+
* wrote, rewritten here by a pin) and the one its fields need, in its place when it had one and first when added, and
|
|
65
|
+
* no key at all while that is 1. So a manifest of today's fields is returned unchanged, byte for byte once serialized.
|
|
66
|
+
*/
|
|
67
|
+
export function withManifestVersion(manifest, { table = SANDBOX_MANIFEST_FIELD_VERSIONS } = {}) {
|
|
68
|
+
const declared = Number.isSafeInteger(manifest?.version) && manifest.version >= 1 ? manifest.version : 1;
|
|
69
|
+
const needed = Math.max(declared, sandboxManifestVersionFor(manifest, { table }));
|
|
70
|
+
const had = Object.hasOwn(manifest ?? {}, "version");
|
|
71
|
+
if (needed <= 1 && !had) return manifest;
|
|
72
|
+
const { version: _owned, ...rest } = manifest;
|
|
73
|
+
if (needed <= 1) return rest;
|
|
74
|
+
return had ? { ...manifest, version: needed } : { version: needed, ...rest };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether this build cannot read `manifest`'s version: `{ version }` when `version` is present and is not an integer
|
|
79
|
+
* from 1 to `supported` (a newer build's manifest, version N; or a value nothing here wrote, version null), else null.
|
|
80
|
+
*/
|
|
81
|
+
export function sandboxManifestNewer(manifest, { supported = SANDBOX_MANIFEST_VERSION } = {}) {
|
|
82
|
+
if (manifest === null || typeof manifest !== "object" || !Object.hasOwn(manifest, "version")) return null;
|
|
83
|
+
const declared = manifest.version;
|
|
84
|
+
if (Number.isSafeInteger(declared) && declared >= 1 && declared <= supported) return null;
|
|
85
|
+
return { version: Number.isSafeInteger(declared) && declared >= 1 ? declared : null };
|
|
86
|
+
}
|
|
87
|
+
|
|
40
88
|
const HOUR_MS = 3600000;
|
|
41
89
|
const DAY_MS = 86400000;
|
|
42
90
|
|
|
@@ -152,6 +200,9 @@ export function retainJobDir(prepared, { sandboxDir, retentionHours = null, fs =
|
|
|
152
200
|
// with a different, operator-extendable lifetime would silently extend that TTL, which is not a
|
|
153
201
|
// weakening of the session policy so much as an end-run around it.
|
|
154
202
|
fs.rmSync(join(jobDir, "session"), { recursive: true, force: true });
|
|
203
|
+
// Issue #796: a canary's candidate is third-party code that was on trial for that one job. Reopening the retained directory loads neither it nor the overlay, so it would only
|
|
204
|
+
// sit there looking as if it were part of the run's inputs.
|
|
205
|
+
fs.rmSync(join(jobDir, "canary"), { recursive: true, force: true });
|
|
155
206
|
|
|
156
207
|
fs.mkdirSync(sandboxDir, { recursive: true, mode: 0o700 });
|
|
157
208
|
// Issue #464 (gate round 1): this account's, asked again here and not only at boot. A sandbox dir absent at boot is
|
|
@@ -167,8 +218,10 @@ export function retainJobDir(prepared, { sandboxDir, retentionHours = null, fs =
|
|
|
167
218
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
168
219
|
fs.renameSync(jobDir, dest);
|
|
169
220
|
|
|
170
|
-
|
|
221
|
+
// Issue #761: `withManifestVersion` stamps a version only when a field needs one, so today's manifest is unchanged.
|
|
222
|
+
const manifest = withManifestVersion({
|
|
171
223
|
jobId: meta.jobId,
|
|
224
|
+
...(typeof prepared.sha === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(prepared.sha) ? { baseSha: prepared.sha } : {}),
|
|
172
225
|
kind: meta.kind ?? null,
|
|
173
226
|
image: meta.image ?? null,
|
|
174
227
|
// The venue the job resolved to (#277), which `resolveSandbox` refuses by when it is not this
|
|
@@ -196,7 +249,7 @@ export function retainJobDir(prepared, { sandboxDir, retentionHours = null, fs =
|
|
|
196
249
|
// reader's window alone, as every manifest from before the key does.
|
|
197
250
|
retainUntil: Number.isFinite(retentionHours) && retentionHours > 0 ? new Date(created + retentionHours * HOUR_MS).toISOString() : null,
|
|
198
251
|
keepUntil: null,
|
|
199
|
-
};
|
|
252
|
+
});
|
|
200
253
|
fs.writeFileSync(join(dest, SANDBOX_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
201
254
|
log("sandbox_retained", { jobId: meta.jobId, kind: manifest.kind });
|
|
202
255
|
return manifest;
|
|
@@ -267,12 +320,13 @@ export function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
|
|
|
267
320
|
* An entry with no readable manifest is skipped rather than surfaced: it cannot be resurrected, and the
|
|
268
321
|
* reaper removes it on the next sweep.
|
|
269
322
|
*/
|
|
270
|
-
export function listSandboxes({ sandboxDir, fs = defaultFs }) {
|
|
323
|
+
export function listSandboxes({ sandboxDir, fs = defaultFs, throwOnReadFailure = false }) {
|
|
271
324
|
if (!sandboxDir) return [];
|
|
272
325
|
let names;
|
|
273
326
|
try {
|
|
274
327
|
names = fs.readdirSync(sandboxDir);
|
|
275
|
-
} catch {
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (throwOnReadFailure) throw error;
|
|
276
330
|
return [];
|
|
277
331
|
}
|
|
278
332
|
const out = [];
|
|
@@ -297,9 +351,12 @@ export function listSandboxes({ sandboxDir, fs = defaultFs }) {
|
|
|
297
351
|
* is how a directory holding a full repository clone per run becomes unbounded, and the acceptance this
|
|
298
352
|
* feature was written against says retention stays swept.
|
|
299
353
|
*/
|
|
300
|
-
export function pinSandbox({ sandboxDir, jobId, pinDays, fs = defaultFs, now = () => Date.now(), euid = process.geteuid?.() }) {
|
|
354
|
+
export function pinSandbox({ sandboxDir, jobId, pinDays, fs = defaultFs, now = () => Date.now(), euid = process.geteuid?.(), supported = SANDBOX_MANIFEST_VERSION }) {
|
|
301
355
|
const manifest = readManifest({ sandboxDir, jobId, fs });
|
|
302
356
|
if (!manifest) return { pinned: false, reason: "absent" };
|
|
357
|
+
// Issue #761: refused BEFORE the rewrite. This build would write back a manifest it cannot read, under a stamp it
|
|
358
|
+
// cannot vouch for; the release that kept the run pins it. (The sweep still honours the deadline either way.)
|
|
359
|
+
if (sandboxManifestNewer(manifest, { supported })) return { pinned: false, reason: "manifest-newer" };
|
|
303
360
|
const keepUntil = new Date(now() + pinDays * DAY_MS).toISOString();
|
|
304
361
|
const { dir, ...body } = manifest;
|
|
305
362
|
// ATOMIC (issue #429 review): a temp file beside it, then a rename over it. `writeFileSync` on the manifest itself
|
|
@@ -319,7 +376,7 @@ export function pinSandbox({ sandboxDir, jobId, pinDays, fs = defaultFs, now = (
|
|
|
319
376
|
try {
|
|
320
377
|
const original = fs.lstatSync(target);
|
|
321
378
|
const mode = Number.isInteger(original?.mode) ? original.mode & 0o777 : 0o600;
|
|
322
|
-
fs.writeFileSync(tmp, `${JSON.stringify({ ...body, keepUntil }, null, 2)}\n`, { mode, flag: "wx" });
|
|
379
|
+
fs.writeFileSync(tmp, `${JSON.stringify(withManifestVersion({ ...body, keepUntil }), null, 2)}\n`, { mode, flag: "wx" });
|
|
323
380
|
created = true;
|
|
324
381
|
if (Number.isInteger(original?.uid) && Number.isInteger(original?.gid)) {
|
|
325
382
|
try {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** A retained workspace is human-only, including when an operator later points a local trigger at it (#771). */
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { dirname, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const LOCAL_FOLDER_IS_SANDBOX = "local-folder-is-sandbox";
|
|
6
|
+
const policy = () => ({ outcome: "policy", reason: LOCAL_FOLDER_IS_SANDBOX });
|
|
7
|
+
const within = (path, root) => path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
8
|
+
const absent = (error) => error?.code === "ENOENT" || error?.code === "ENOTDIR";
|
|
9
|
+
const identity = (stat) => stat && ((typeof stat.dev === "bigint" && typeof stat.ino === "bigint") || (Number.isSafeInteger(stat.dev) && Number.isSafeInteger(stat.ino))) ? `${stat.dev}:${stat.ino}` : null;
|
|
10
|
+
|
|
11
|
+
/** Policy is returned; an unanswered filesystem read is separate so each caller can preserve infrastructure retry. */
|
|
12
|
+
export function checkSandboxWorkspace(job, { sandboxDir, fs = nodeFs, resolvedFolder = undefined } = {}) {
|
|
13
|
+
if (job?.kind !== "local" || typeof job.folder !== "string" || typeof sandboxDir !== "string" || !sandboxDir) return null;
|
|
14
|
+
const named = resolve(job.folder), root = resolve(sandboxDir);
|
|
15
|
+
if (within(named, root)) return policy();
|
|
16
|
+
const realpath = fs.realpathSync?.native ?? fs.realpathSync;
|
|
17
|
+
if (typeof realpath !== "function" || typeof fs.statSync !== "function") return { unavailable: true };
|
|
18
|
+
let actualRoot, folder;
|
|
19
|
+
try { actualRoot = realpath(sandboxDir); }
|
|
20
|
+
catch (error) { return absent(error) ? null : { unavailable: true }; }
|
|
21
|
+
if (within(named, actualRoot)) return policy();
|
|
22
|
+
try { folder = resolvedFolder ?? realpath(job.folder); }
|
|
23
|
+
catch (error) { return absent(error) ? null : { unavailable: true }; }
|
|
24
|
+
if (within(folder, root) || within(folder, actualRoot)) return policy();
|
|
25
|
+
let rootId;
|
|
26
|
+
try { rootId = identity(fs.statSync(actualRoot, { bigint: true })); }
|
|
27
|
+
catch (error) { return absent(error) ? null : { unavailable: true }; }
|
|
28
|
+
if (rootId === null) return { unavailable: true };
|
|
29
|
+
// Case variants and macOS firmlinks can name the same directory without matching either spelling above.
|
|
30
|
+
// Compare the root's identity to every resolved ancestor before trusting a text-only "outside" verdict.
|
|
31
|
+
for (let path = folder;; path = dirname(path)) {
|
|
32
|
+
let here;
|
|
33
|
+
try { here = identity(fs.statSync(path, { bigint: true })); }
|
|
34
|
+
catch (error) { return absent(error) ? null : { unavailable: true }; }
|
|
35
|
+
if (here === null) return { unavailable: true };
|
|
36
|
+
if (here === rootId) return policy();
|
|
37
|
+
if (dirname(path) === path) return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/sandbox.mjs
CHANGED
|
@@ -5,10 +5,10 @@ import { release as osRelease } from "node:os";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { execDockerBounded, makeDockerEndpointResolver } from "./backend-local.mjs";
|
|
7
7
|
import { PODMAN_CONF_WIDENS_JOB, PODMAN_JOB_USER_FIX, decidePodmanJobUser, judgePodmanVenue, keeperPreflight, makePodmanInfoReader, resolvePodmanImageUser } from "./backend-podman.mjs";
|
|
8
|
-
import { DEFAULT_BACKEND, PODMAN_BACKEND
|
|
8
|
+
import { DEFAULT_BACKEND, PODMAN_BACKEND } from "./backends.mjs";
|
|
9
9
|
import { configError } from "./config.mjs";
|
|
10
10
|
import { assertJobUser, CONTAINER_HOME, SHIPPED_IMAGE_UID } from "./container-spec.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import { insideDir } from "./docker-run.mjs";
|
|
12
12
|
import { CGROUP_PARENT, cgroupParentFor } from "./cpu-reserve.mjs";
|
|
13
13
|
import { DEFAULT_JOB_SIZE, recordedJobSize } from "./job-size.mjs";
|
|
14
14
|
import { makeImagePreflight } from "./image-preflight.mjs";
|
|
@@ -17,7 +17,12 @@ import { DEFAULT_EGRESS_PROXY, NETWORK_SUFFIX, createJobNetwork, egressArmed, eg
|
|
|
17
17
|
import { NETNS_KEEPER, makeDetachGate, runtimeFromFacts } from "./netns-keeper.mjs";
|
|
18
18
|
import { NETNS_KEEPER_START } from "./podman-stack.mjs";
|
|
19
19
|
import { makePodmanServiceReader, observeRootfulConf, rootfulConfRefusal } from "./runtime-observations.mjs";
|
|
20
|
-
import { isSandboxTombstone, readManifest, readRetained,
|
|
20
|
+
import { isSandboxTombstone, readManifest, readRetained, sandboxEntryName } from "./sandbox-store.mjs";
|
|
21
|
+
import { SANDBOX_LAUNCHERS, SANDBOX_OPEN_GRACE_MS, sandboxLauncher, sandboxSyncRefusal, sandboxVenueOf, sandboxVenuePolicy, sandboxVenueRefusal, sandboxVenues, sandboxWindowRefusal } from "./sandbox-policy.mjs";
|
|
22
|
+
|
|
23
|
+
// The manifest-only policy lives in sandbox-policy.mjs (issue #710), a leaf the operator console imports without this
|
|
24
|
+
// file's graph; re-exported here under the same names, so every existing importer reads the one definition.
|
|
25
|
+
export { SANDBOX_LAUNCHERS, SANDBOX_OPEN_GRACE_MS, sandboxLauncher, sandboxSyncRefusal, sandboxVenueOf, sandboxVenuePolicy, sandboxVenueRefusal, sandboxVenues, sandboxWindowRefusal };
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
28
|
* sandbox.mjs -- the operator session's container shape (INT-SANDBOX-CONTRACT).
|
|
@@ -47,52 +52,6 @@ import { isSandboxTombstone, readManifest, readRetained, sandboxDeadline, sandbo
|
|
|
47
52
|
|
|
48
53
|
const exec = promisify(execFile);
|
|
49
54
|
|
|
50
|
-
/**
|
|
51
|
-
* The venues a sandbox opens on, and how each one spells its container (issue #429): the CLI every spawn for that
|
|
52
|
-
* venue goes through, and the job argv builder the session's argv is built by. Keyed by the venue name the manifest
|
|
53
|
-
* records, so a run is reopened ONLY in the runtime that ran it: a podman run under docker would reproduce none of it
|
|
54
|
-
* (another store, no keep-id, another uid map), and the reverse is as wrong.
|
|
55
|
-
*
|
|
56
|
-
* A TABLE rather than a `bin` read off the backend bundles, and that alternative was the obvious one: the bundles are
|
|
57
|
-
* built by the worker at boot, and the CLI and the admin panel never build them (they would construct a
|
|
58
|
-
* `runContainer`, a reaper and a `podman info` reader to open a shell). What the sandbox needs from a venue is two
|
|
59
|
-
* facts, and both are the same values the bundles are built with (`bin: "podman"` and `buildPodmanRunArgs` in
|
|
60
|
-
* `makePodmanBackend`); `sandbox.test.mjs` pins the pairing. A venue not in this table has no sandbox, by
|
|
61
|
-
* construction: nothing here can reopen a run under a runtime it did not run in.
|
|
62
|
-
*
|
|
63
|
-
* Not "any venue declaring `remote: false`": a future non-remote venue on a third runtime would pass that and be
|
|
64
|
-
* reopened under one of these two. Such a venue must add its own row, deliberately.
|
|
65
|
-
*/
|
|
66
|
-
export const SANDBOX_LAUNCHERS = Object.freeze({
|
|
67
|
-
[DEFAULT_BACKEND]: Object.freeze({ bin: "docker", build: buildDockerRunArgs }),
|
|
68
|
-
[PODMAN_BACKEND]: Object.freeze({ bin: "podman", build: buildPodmanRunArgs }),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
/** The launcher for `venue`, or null. `Object.hasOwn`, so `"toString"` is not a venue. */
|
|
72
|
-
export function sandboxLauncher(venue) {
|
|
73
|
-
return typeof venue === "string" && Object.hasOwn(SANDBOX_LAUNCHERS, venue) ? SANDBOX_LAUNCHERS[venue] : null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The blessed venues a sandbox can open on, in `PI_BACKENDS` order: the venues this host both runs and has a launcher
|
|
78
|
-
* for. The retention reaper asks each one which sandboxes are open, and `--list` draws its RUNNING column from them.
|
|
79
|
-
*/
|
|
80
|
-
export function sandboxVenues(blessed) {
|
|
81
|
-
return (Array.isArray(blessed) ? blessed : []).filter((venue) => sandboxLauncher(venue) !== null);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* `{ blessed, backendFloor }` as a sandbox opened from `env` sees them, through the worker's own two parsers. For the
|
|
86
|
-
* admin panel, which deliberately never calls `loadConfig`; the CLI has the parsed config already. THROWS on a
|
|
87
|
-
* malformed `PI_BACKENDS` or `PI_BACKEND_FLOOR`, exactly as the worker refuses to boot on one: a typo must never read
|
|
88
|
-
* as "podman is not blessed" (a refusal an operator then chases in the wrong place) or as "no floor" (a podman sandbox
|
|
89
|
-
* opened without the observations the deployment asks for). Read from THIS process's environment and never a
|
|
90
|
-
* deployment's `.env`, which `OQ-038` records for the panel.
|
|
91
|
-
*/
|
|
92
|
-
export function sandboxVenuePolicy(env) {
|
|
93
|
-
return { blessed: parseBackendList(env?.PI_BACKENDS), backendFloor: parseBackendFloor(env?.PI_BACKEND_FLOOR) };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
55
|
/**
|
|
97
56
|
* The name namespace, and it is load-bearing. The boot reaper filters `name=pi-job-`
|
|
98
57
|
* (`makeReaper` in backend-local.mjs) and docker matches that as a SUBSTRING, so a sandbox must not contain it --
|
|
@@ -407,11 +366,13 @@ export function makeSandboxRuntimeWatch({ sandboxDir, blessed = [], fs = { lstat
|
|
|
407
366
|
}
|
|
408
367
|
|
|
409
368
|
/** The store this account's Podman uses right now, from one bounded `podman info`, or null when it cannot say. */
|
|
410
|
-
async function
|
|
411
|
-
const
|
|
412
|
-
return
|
|
369
|
+
export async function readPodmanStore({ read = makePodmanInfoReader() } = {}) {
|
|
370
|
+
const result = await read();
|
|
371
|
+
return result?.answered === true ? (result.info?.graphRoot ?? null) : null;
|
|
413
372
|
}
|
|
414
373
|
|
|
374
|
+
const defaultPodmanStore = readPodmanStore;
|
|
375
|
+
|
|
415
376
|
/**
|
|
416
377
|
* The sweep's runner for one runtime: bounded, both streams, never throws. `execDockerBounded` already settles on its
|
|
417
378
|
* own timer and kills with SIGKILL, which matters here for the reason `retention-sweep.mjs` records -- this
|
|
@@ -745,153 +706,6 @@ export async function sandboxKeeperCheck({ proxy, info = makePodmanInfoReader(),
|
|
|
745
706
|
};
|
|
746
707
|
}
|
|
747
708
|
|
|
748
|
-
/**
|
|
749
|
-
* Whether a retained run can be re-opened HERE, judged by the venue it ran in (issue #277). `null` when it
|
|
750
|
-
* can, else `{ refused, message }`. Exported for the admin panel, which asks before advertising the key.
|
|
751
|
-
*
|
|
752
|
-
* WHY THIS IS PER JOB. A sandbox opens a shell on THIS host, in the runtime the job ran in, against the job's retained
|
|
753
|
-
* directory, so it can reproduce only a run whose container was built here. The check used to be deployment-wide
|
|
754
|
-
* (refuse every sandbox when any blessed venue was remote) because the command takes a job id and could not learn its
|
|
755
|
-
* venue; the manifest now records it, and the answer belongs to the job.
|
|
756
|
-
*
|
|
757
|
-
* HELD MEANS A VENUE THIS FILE HAS A LAUNCHER FOR (`SANDBOX_LAUNCHERS`: `local` through the docker CLI, `podman`
|
|
758
|
-
* through this account's rootless podman), AND ONE `blessed` NAMES (issue #429). `blessed` is `PI_BACKENDS` as the
|
|
759
|
-
* CALLER's process reads it: the CLI's `loadConfig`, the panel's own environment (`sandboxVenuePolicy`). A shell whose
|
|
760
|
-
* `PI_BACKENDS` does not bless a venue has said it does not run that runtime, so reopening a run there would spawn a CLI
|
|
761
|
-
* that environment took out of service. It is NOT what keeps an open shell's directory from the retention reaper, and
|
|
762
|
-
* an earlier version of this comment said it was: the opener's `PI_BACKENDS` and the worker's routinely differ
|
|
763
|
-
* (`OQ-038`), so the reaper asks the runtime each retained run records instead, whatever either blesses
|
|
764
|
-
* (`makeSandboxRuntimeWatch`). It defaults to `local` alone, which is what `PI_BACKENDS` unset means, so a caller from
|
|
765
|
-
* before issue #429 refuses and admits exactly as it did.
|
|
766
|
-
*
|
|
767
|
-
* Not "any venue declaring `remote: false`": a future non-remote venue on a third runtime would pass that and be
|
|
768
|
-
* reopened under one of these two, reproducing a run from a runtime it never ran in. Such a venue must add its own
|
|
769
|
-
* launcher row, deliberately.
|
|
770
|
-
*
|
|
771
|
-
* A MANIFEST WITH NO `backend` KEY predates venue attribution and ran on `local` (`UNATTRIBUTED_BACKEND`).
|
|
772
|
-
* A key that is PRESENT but not a name is refused: `typeof` first, because the table's `backendFor(null)`
|
|
773
|
-
* returns `local`, and a null stamp means the venue was never known, not that it was local.
|
|
774
|
-
*/
|
|
775
|
-
export function sandboxVenueRefusal({ jobId, manifest, blessed = [DEFAULT_BACKEND] }) {
|
|
776
|
-
const venue = sandboxVenueOf(manifest);
|
|
777
|
-
if (typeof venue !== "string" || venue === "") {
|
|
778
|
-
return { refused: "venue-unreachable", message: `the manifest for ${jobId} names no backend, so this host cannot tell whether the run happened here` };
|
|
779
|
-
}
|
|
780
|
-
const launcher = sandboxLauncher(venue);
|
|
781
|
-
const held = Array.isArray(blessed) && blessed.includes(venue);
|
|
782
|
-
if (launcher && held) return null;
|
|
783
|
-
if (launcher) {
|
|
784
|
-
// A venue this file CAN open, on a host (as this process reads it) that does not bless it. The fix is an
|
|
785
|
-
// environment, and it is named with where it is read: the CLI and the panel read `PI_BACKENDS` from the process
|
|
786
|
-
// they run in, never from a deployment's `.env`, and "set it where you run this" is the whole remedy.
|
|
787
|
-
return {
|
|
788
|
-
refused: "venue-unreachable",
|
|
789
|
-
message: `${jobId} ran on the ${JSON.stringify(venue)} backend, which PI_BACKENDS in this process's environment does not bless (it reads ${JSON.stringify((Array.isArray(blessed) ? blessed : []).join(","))}), so no sandbox opens on it here: a sandbox opens only on a venue this host blesses, through that venue's own CLI (${launcher.bin}). Set PI_BACKENDS where you run this as the worker's is set; it is read from this environment, not from the deployment's .env.`,
|
|
790
|
-
};
|
|
791
|
-
}
|
|
792
|
-
return {
|
|
793
|
-
refused: "venue-unreachable",
|
|
794
|
-
// Names BOTH sides (issue #354): the venue that ran it, and the venues a sandbox opens through. "Not on this host's
|
|
795
|
-
// docker daemon" was true only while every venue but `local` was elsewhere.
|
|
796
|
-
message: `${jobId} ran on the ${JSON.stringify(venue)} backend, and a sandbox opens a shell only through a venue it has a launcher for on this host (${Object.entries(SANDBOX_LAUNCHERS).map(([name, l]) => `${JSON.stringify(name)} through the ${l.bin} CLI`).join(", ")}) against the retained directory, so it cannot reproduce that run. Open it on the venue that ran it.`,
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/** The venue a retained manifest records: its `backend` key when present (whatever it holds), else `UNATTRIBUTED_BACKEND`. */
|
|
801
|
-
export function sandboxVenueOf(manifest) {
|
|
802
|
-
return Object.hasOwn(manifest ?? {}, "backend") ? manifest.backend : UNATTRIBUTED_BACKEND;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* The refusals `resolveSandbox` decides from the MANIFEST ALONE, in the order an operator should read
|
|
807
|
-
* them. Not every manifest-only refusal on the `b` path: `decideSandboxJobUser` can refuse a run from
|
|
808
|
-
* `manifest.jobUser` too, and it stays where it is for two MEASURED reasons rather than the vaguer "it
|
|
809
|
-
* would change what the CLI refuses and when" this comment used to give (issue #367, item 5).
|
|
810
|
-
*
|
|
811
|
-
* IT IS NOT MANIFEST-ONLY. It returns `{ user: null, home: null }` on `darwin` and `win32` before it ever
|
|
812
|
-
* reads the stamp, so the SAME malformed `jobUser` refuses everywhere else and does not refuse on macOS or
|
|
813
|
-
* Windows. Measured across twelve platform strings: the split is darwin and win32 against every other
|
|
814
|
-
* value, linux, the BSDs, sunos, aix and the empty string alike, which is wider than "linux" and is why
|
|
815
|
-
* this says it that way. Folding it in would make this function's answer, and therefore whether the panel
|
|
816
|
-
* advertises `b` at all, depend on the operator's own OS for an identical run. Every other refusal here is
|
|
817
|
-
* a property of the run, and that is the whole of the argument.
|
|
818
|
-
*
|
|
819
|
-
* NOT because it is async, which is true of the function and is NOT a reason about this refusal: measured,
|
|
820
|
-
* `job-user-stamp-invalid` is reached with zero calls to the endpoint resolver, the daemon-facts reader
|
|
821
|
-
* and the image preflight, on every platform and every malformed shape. It is decided from the manifest
|
|
822
|
-
* before any await that does work. A well-formed stamp does reach the daemon, which is why this function
|
|
823
|
-
* stays async and out of a predicate called on every left and right between runs, but that is a cost of
|
|
824
|
-
* moving the WHOLE function, not of the refusal #367 item 5 is about.
|
|
825
|
-
*
|
|
826
|
-
* Extracted (issue #337) because the admin panel needs the same answer before it advertises `b`, and the
|
|
827
|
-
* alternative is the shape this file's own `openSandbox` docblock warns about: "Two callers assembling
|
|
828
|
-
* the same session from parts is how one of them drops a part." The panel had exactly that, checking the
|
|
829
|
-
* venue and not the other two, so a run whose manifest names no image was offered the key, given two
|
|
830
|
-
* lines of detail about the session it would get, and refused the moment the key was pressed.
|
|
831
|
-
*
|
|
832
|
-
* SYNCHRONOUS AND MANIFEST-ONLY is the boundary, not an accident of what fitted. The panel reads this
|
|
833
|
-
* inside a key handler, once per record; anything needing docker (a proxy that is not running, a sandbox
|
|
834
|
-
* already up) stays in `openSandbox` where it belongs and is `OQ-038`'s residual for the panel.
|
|
835
|
-
*
|
|
836
|
-
* The VENUE comes first, and that ordering is #277's: for a run from another venue the image and the
|
|
837
|
-
* workspace are symptoms, and the first refusal an operator reads should be the cause. A workspace that
|
|
838
|
-
* happens to exist at the same path on this host would otherwise pass and silently reproduce the wrong
|
|
839
|
-
* run.
|
|
840
|
-
*/
|
|
841
|
-
export function sandboxSyncRefusal({ jobId, manifest, fileExists = existsSync, blessed }) {
|
|
842
|
-
const venue = sandboxVenueRefusal({ jobId, manifest, blessed });
|
|
843
|
-
if (venue) return venue;
|
|
844
|
-
if (!manifest?.image) {
|
|
845
|
-
return { refused: "no-image", message: `the manifest for ${jobId} names no image, so the sandbox cannot reproduce the run` };
|
|
846
|
-
}
|
|
847
|
-
if (!manifest?.workspace || !fileExists(manifest.workspace)) {
|
|
848
|
-
// The common cause for a local run: the operator's folder moved or was deleted. Naming the path is
|
|
849
|
-
// the whole diagnosis, so name it.
|
|
850
|
-
return { refused: "workspace-gone", message: `the workspace for ${jobId} is no longer at ${manifest.workspace} — a local folder that moved cannot be re-opened` };
|
|
851
|
-
}
|
|
852
|
-
return null;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/**
|
|
856
|
-
* How close to its deadline a run may be and still open without `--pin` (issue #446): five minutes.
|
|
857
|
-
*
|
|
858
|
-
* WHY A MARGIN AND NOT THE DEADLINE ITSELF. The sweep holds a run whose sandbox a runtime reports open, but an open is
|
|
859
|
-
* not in any `ps` until its container starts, and the opener spends that stretch on its own runtime asks (the running
|
|
860
|
-
* check, the job user, the image, the network), each bounded in seconds. A sweep whose `ps` came before the container
|
|
861
|
-
* and whose clock (`at`, read after its asks) is past the deadline would still delete the directory under the new
|
|
862
|
-
* shell: window 1 of #446, reproduced. A run whose deadline is more than this far away cannot be past it by the time a
|
|
863
|
-
* pass that missed the container decides, so the refusal closes that window with room to spare; inside it, `--pin`
|
|
864
|
-
* writes a new deadline FIRST, which the sweep's own re-reads honour.
|
|
865
|
-
*/
|
|
866
|
-
export const SANDBOX_OPEN_GRACE_MS = 5 * 60 * 1000;
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* The refusal of a run past its deadline, or within `SANDBOX_OPEN_GRACE_MS` of it, unless the open pins it first
|
|
870
|
-
* (issue #446). `null` when the run may open.
|
|
871
|
-
*
|
|
872
|
-
* The deadline is `sandboxDeadline`'s, the sweep's own rule (the pin; else the earlier of the `retainUntil` the worker
|
|
873
|
-
* wrote and `createdAt` plus THIS opener's window), so an opener whose own PI_SANDBOX_RETENTION_HOURS is larger than
|
|
874
|
-
* the worker's is refused by the worker's deadline, not admitted by its own. The one case it cannot see, a worker
|
|
875
|
-
* whose window was lowered after the run was retained, is the post-launch look's (`openSandbox`).
|
|
876
|
-
* Shared by `resolveSandbox` and the admin panel's `readSandboxInfo`, which must not advertise `b` for a run the key
|
|
877
|
-
* press would refuse; the panel has no pin, so its refusal is this one and it names the CLI command.
|
|
878
|
-
*/
|
|
879
|
-
export function sandboxWindowRefusal({ jobId, manifest, retentionHours, at, pin = false }) {
|
|
880
|
-
if (pin) return null;
|
|
881
|
-
const { until } = sandboxDeadline(manifest, retentionHours);
|
|
882
|
-
const fix = `open it with \`pi-dispatch sandbox ${jobId} --pin\`, which extends its retention before anything starts`;
|
|
883
|
-
if (until === null) {
|
|
884
|
-
return { refused: "past-window", message: `the retained workspace for ${jobId} records no creation time, so the retention sweep deletes it on its next pass; ${fix}` };
|
|
885
|
-
}
|
|
886
|
-
if (until - at > SANDBOX_OPEN_GRACE_MS) return null;
|
|
887
|
-
const when = new Date(until).toISOString();
|
|
888
|
-
const where = until <= at ? `is past its retention window (it closed at ${when})` : `is within ${Math.round(SANDBOX_OPEN_GRACE_MS / 60000)} minutes of the end of its retention window (${when})`;
|
|
889
|
-
return {
|
|
890
|
-
refused: "past-window",
|
|
891
|
-
message: `the retained workspace for ${jobId} ${where}, so the retention sweep may delete it under the shell; ${fix}`,
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
|
|
895
709
|
/**
|
|
896
710
|
* Resolve one retained run into a launchable argv, or a NAMED refusal.
|
|
897
711
|
*
|
|
@@ -1028,6 +842,8 @@ export async function openSandbox({
|
|
|
1028
842
|
detachGate = null,
|
|
1029
843
|
// Issue #452, gate round 4: `(network, reason) => void`, told of a teardown the gate refused, which leaves the network.
|
|
1030
844
|
onNetworkKept = () => {},
|
|
845
|
+
// The CLI's detached monitor registers the shell and owns eventual network cleanup (#771).
|
|
846
|
+
monitorSession = null,
|
|
1031
847
|
}) {
|
|
1032
848
|
// The posture is REQUIRED, and a boolean. Every other part of this function defaults safely; this one would
|
|
1033
849
|
// default to the open bridge, which is the dropped part this function exists to stop a caller dropping.
|
|
@@ -1037,6 +853,11 @@ export async function openSandbox({
|
|
|
1037
853
|
const pinning = typeof pin === "function";
|
|
1038
854
|
const resolved = resolveSandbox({ jobId, sandboxDir, retentionHours, publish, blessed, now, pin: pinning, ...(fs ? { fs } : {}), ...(fileExists ? { fileExists } : {}) });
|
|
1039
855
|
if (resolved.refused) return resolved;
|
|
856
|
+
if (resolved.manifest.origin === "prepared") {
|
|
857
|
+
const saved = resolved.manifest.sandboxEgress;
|
|
858
|
+
if (typeof saved?.armed !== "boolean" || typeof saved.proxy !== "string" || saved.proxy !== egress.proxy || (egress.armed && !saved.armed)) return { refused: "sandbox-egress-changed", message: "The prepared sandbox's egress settings changed. Prepare it again with this deployment's current settings." };
|
|
859
|
+
egress = { armed: saved.armed, proxy: saved.proxy };
|
|
860
|
+
}
|
|
1040
861
|
// Admitted by `resolveSandbox`, so never null here.
|
|
1041
862
|
const { bin } = sandboxLauncher(resolved.venue);
|
|
1042
863
|
// Issue #596: the run's size, decided before anything is asked or created. A recorded size that is malformed refuses.
|
|
@@ -1196,6 +1017,7 @@ export async function openSandbox({
|
|
|
1196
1017
|
};
|
|
1197
1018
|
}
|
|
1198
1019
|
let detached = false;
|
|
1020
|
+
let sessionWatch = null;
|
|
1199
1021
|
// THE POST-LAUNCH LOOK (issue #446). The refusal and the pin decide from what was on disk before the launch, and
|
|
1200
1022
|
// two things can still take the run from under the new shell: the accepted residual (a worker whose window was
|
|
1201
1023
|
// lowered below this opener's, whose pass missed the container), and the sweep's own rename and restore. So once
|
|
@@ -1274,6 +1096,10 @@ export async function openSandbox({
|
|
|
1274
1096
|
// the network was created, so a leftover network printed the banner and then refused. Inside the `try`, so a hook
|
|
1275
1097
|
// that throws still has this session's network removed by the `finally`.
|
|
1276
1098
|
await beforeLaunch({ resolved, args, network, runtime: bin });
|
|
1099
|
+
if (monitorSession) {
|
|
1100
|
+
try { sessionWatch = await monitorSession({ resolved, args, network, runtime: bin, admittedRuntime: jobUser?.runtime ?? null }); }
|
|
1101
|
+
catch { return { refused: "sandbox-tracking-unavailable", message: "Sandbox tracking is unavailable. Check Valkey before opening a shell." }; }
|
|
1102
|
+
}
|
|
1277
1103
|
const watching = look().catch(() => {});
|
|
1278
1104
|
let launched;
|
|
1279
1105
|
try {
|
|
@@ -1327,7 +1153,8 @@ export async function openSandbox({
|
|
|
1327
1153
|
: {};
|
|
1328
1154
|
return { code: code ?? null, error: error ?? null, ...(detached ? { detached: true } : {}), ...during };
|
|
1329
1155
|
} finally {
|
|
1330
|
-
|
|
1156
|
+
sessionWatch?.finish?.();
|
|
1157
|
+
if (network && !detached && !sessionWatch?.ownsNetwork) {
|
|
1331
1158
|
// The runtime this session was ADMITTED on (issue #452, gate round 4), never a fresh read at the teardown; a
|
|
1332
1159
|
// refused teardown is said with its token.
|
|
1333
1160
|
await removeJobNetwork(spawnNetwork, {
|