@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
package/src/host-registry.mjs
CHANGED
|
@@ -33,9 +33,17 @@
|
|
|
33
33
|
* `jobs` (issue #599, phase 2) is the one field that carries a list: JSON of the jobs this host runs now, ids and
|
|
34
34
|
* integers only, built and read back by `live-jobs.mjs`, whose header argues each field against this rule. The reader
|
|
35
35
|
* below parses it through that module's allowlist, so a row's `jobs` is an array (or null), never the raw string.
|
|
36
|
+
*
|
|
37
|
+
* VALKEY'S CLOCK IS USED ONLY AS A DURATION (issue #737). The reader asks each row's `PTTL` beside its `HGETALL`, and
|
|
38
|
+
* `ttlMs - PTTL` is how long ago the row's last PEXPIRE landed, counted by the server that holds it. That is a length
|
|
39
|
+
* of time, never an instant: no field here is ever compared with Valkey's own time of day. Set beside `beatAt` (an
|
|
40
|
+
* instant on the HOST's clock) and the reader's own `now()`, it gives a third party's estimate of how far the two
|
|
41
|
+
* machines' clocks disagree (`skewMs`), and it says when a row has no expiry at all (`noExpiry`), which is a row the TTL
|
|
42
|
+
* will never remove.
|
|
36
43
|
*/
|
|
37
44
|
|
|
38
45
|
import { parseJobsMore, parseLiveJobs } from "./live-jobs.mjs";
|
|
46
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
39
47
|
|
|
40
48
|
/** The index. A SET cannot expire its members, so the leak is handled by the reader, as `wait:held` does. */
|
|
41
49
|
export const HOST_SET = "host:live";
|
|
@@ -54,6 +62,14 @@ export const HOST_BEAT_MS = 15_000;
|
|
|
54
62
|
*/
|
|
55
63
|
export const HOST_TTL_MS = 90_000;
|
|
56
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The smallest `skewMs` a reader may call a clock skew (issue #737). TWO BEATS, and the margin is not decoration:
|
|
67
|
+
* `skewMs` is measured from the row's last PEXPIRE, and a read landing between a beat's HSET (the new `beatAt`) and its
|
|
68
|
+
* PEXPIRE sees the previous beat's expiry, so the estimate can be off by one beat period, and by a round trip besides.
|
|
69
|
+
* Below this, a difference is noise a reader must not report.
|
|
70
|
+
*/
|
|
71
|
+
export const HOST_SKEW_CLAIM_MS = 30_000;
|
|
72
|
+
|
|
57
73
|
/**
|
|
58
74
|
* Build the registry accessor. `redis` is the client the budget and the wait state already share.
|
|
59
75
|
*
|
|
@@ -109,7 +125,8 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
|
|
|
109
125
|
for (const [k, v] of Object.entries(fields)) {
|
|
110
126
|
let resolved;
|
|
111
127
|
try {
|
|
112
|
-
|
|
128
|
+
// A dependent display fact can bind to this row's generation without guessing another clock.
|
|
129
|
+
resolved = typeof v === "function" ? v(fields) : v;
|
|
113
130
|
} catch {
|
|
114
131
|
resolved = ""; // a fact that cannot be computed is absent, never a reason to skip the beat
|
|
115
132
|
}
|
|
@@ -275,8 +292,22 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
|
|
|
275
292
|
* `{ unreachable }` and `[]` are DIFFERENT ANSWERS and must never be collapsed by a caller, even where
|
|
276
293
|
* one treats them alike: "there are no other hosts" and "I could not find out" differ, and a panel that
|
|
277
294
|
* renders the second as the first tells an operator their fleet is gone when Valkey merely blinked.
|
|
295
|
+
*
|
|
296
|
+
* Each row also carries three fields read off the key's `PTTL` (issue #737), all null where the client has no `pttl`,
|
|
297
|
+
* where the PTTL failed or timed out, or where the key went between the two reads (-2):
|
|
298
|
+
* - `ttlAgeMs`: how long ago the row's last PEXPIRE landed, `ttlMs - PTTL` (never below 0), on Valkey's clock as a
|
|
299
|
+
* duration. `ttlMs` is the TTL the writer sets, `HOST_TTL_MS` for every worker.
|
|
300
|
+
* - `skewMs`: `(now() - beatAt) - ttlAgeMs`, the reader's clock minus the host's, as a third party measures it.
|
|
301
|
+
* Positive: the host's clock is behind. A round trip and up to one beat period of noise; a reader claims a skew only
|
|
302
|
+
* past `HOST_SKEW_CLAIM_MS`. Null without a readable `beatAt`.
|
|
303
|
+
* - `noExpiry`: true when the key has no expiry at all (-1), which the TTL will never remove; false when it has one.
|
|
304
|
+
* A reader walks `host:live`, and a beat SADDs only after its PEXPIRE, so a reader meets it only under a name an
|
|
305
|
+
* EARLIER beat indexed: for a moment when a beat re-creates a row that expired while its name stayed, and for good
|
|
306
|
+
* when the process died between that HSET and its PEXPIRE.
|
|
307
|
+
* The PTTL goes out beside the HGETALL, in the same tick and each under its own bound, so it costs no round trip; a
|
|
308
|
+
* failed PTTL costs these three fields and never the row.
|
|
278
309
|
*/
|
|
279
|
-
export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS, prune = true } = {}) {
|
|
310
|
+
export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS, prune = true, ttlMs = HOST_TTL_MS } = {}) {
|
|
280
311
|
let names;
|
|
281
312
|
try {
|
|
282
313
|
names = await bounded(redis.smembers(HOST_SET), timeoutMs);
|
|
@@ -287,9 +318,23 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
|
|
|
287
318
|
return { unreachable: err?.message ?? "registry unreadable" };
|
|
288
319
|
}
|
|
289
320
|
const hosts = [];
|
|
321
|
+
const withTtl = typeof redis?.pttl === "function";
|
|
290
322
|
for (const member of names ?? []) {
|
|
323
|
+
// A name outside the worker name charset (`WORKER_NAME_RE`: a letter or a digit, then letters, digits, `.`, `_` and
|
|
324
|
+
// `-`, at most 64 in all) is no host's: anything with the Valkey's URL can SADD one, and a reader that took it would
|
|
325
|
+
// print it wherever rows are drawn (a 3,000-character member widened the console's page to 43,000 px). The charset
|
|
326
|
+
// alone, looser than the boot rule, which also refuses a `.json` or `.log` tail on `PI_WORKER_NAME` (`config.mjs`).
|
|
327
|
+
// Skipped before its row is even read, and never pruned: removing what someone else wrote is not a reader's call,
|
|
328
|
+
// and the member costs only its place in the set.
|
|
329
|
+
if (typeof member !== "string" || !WORKER_NAME_RE.test(member)) continue;
|
|
291
330
|
try {
|
|
292
|
-
const
|
|
331
|
+
const key = hostKey(member);
|
|
332
|
+
// Both issued before either is awaited. The PTTL's own failure (a throw, a rejection, a hang past its bound)
|
|
333
|
+
// is null here, so only the HGETALL decides whether the row is read at all.
|
|
334
|
+
const [row, pttl] = await Promise.all([
|
|
335
|
+
bounded(redis.hgetall(key), timeoutMs),
|
|
336
|
+
withTtl ? bounded((async () => redis.pttl(key))(), timeoutMs).catch(() => null) : null,
|
|
337
|
+
]);
|
|
293
338
|
if (!row || Object.keys(row).length === 0) {
|
|
294
339
|
// `prune: false` for a reader that promises to write nothing (the capacity report, issue #599): the stale member
|
|
295
340
|
// is skipped the same, and left for the next pruning reader.
|
|
@@ -297,9 +342,13 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
|
|
|
297
342
|
continue;
|
|
298
343
|
}
|
|
299
344
|
const beatAt = typeof row.beatAt === "string" && row.beatAt.trim() !== "" ? Number(row.beatAt) : NaN;
|
|
345
|
+
const nowMs = now();
|
|
300
346
|
// The running jobs through the allowlist (issue #599, phase 2): an array, or null where the row publishes none (a
|
|
301
347
|
// worker from before the field) or none that parses. A listed entry that does not read is a running job not
|
|
302
348
|
// counted, so it joins `jobsMore` rather than vanishing.
|
|
349
|
+
// The row's own `name` stands for the member only when it holds to the same rule, else the row is no host's.
|
|
350
|
+
const name = row.name || member;
|
|
351
|
+
if (typeof name !== "string" || !WORKER_NAME_RE.test(name)) continue;
|
|
303
352
|
const live = parseLiveJobs(row.jobs);
|
|
304
353
|
const more = parseJobsMore(row.jobsMore);
|
|
305
354
|
hosts.push({
|
|
@@ -309,11 +358,12 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
|
|
|
309
358
|
// A row that HAS a `jobs` value that is not a list: how many it runs is unknown, which a reader must say
|
|
310
359
|
// rather than read as a worker from before the field.
|
|
311
360
|
jobsUnreadable: live.jobs === null && typeof row.jobs === "string" && row.jobs !== "",
|
|
312
|
-
name
|
|
361
|
+
name,
|
|
313
362
|
// Derived rather than stored, so the panel can say "stale 2m" about a row that still lives.
|
|
314
363
|
// A row whose clock is AHEAD of ours reads as 0 rather than negative: the difference is the
|
|
315
|
-
// skew between two machines, which is its own signal and not this field's to report.
|
|
316
|
-
staleMs: Number.isFinite(beatAt) ? Math.max(0,
|
|
364
|
+
// skew between two machines, which is its own signal and not this field's to report (`skewMs` is).
|
|
365
|
+
staleMs: Number.isFinite(beatAt) ? Math.max(0, nowMs - beatAt) : null,
|
|
366
|
+
...expiryFields(pttl, { ttlMs, beatAt, nowMs }),
|
|
317
367
|
});
|
|
318
368
|
} catch {
|
|
319
369
|
// One unreadable row degrades that row, never the listing.
|
|
@@ -322,3 +372,13 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
|
|
|
322
372
|
hosts.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
323
373
|
return { hosts };
|
|
324
374
|
}
|
|
375
|
+
|
|
376
|
+
/** `readLiveHosts`'s three PTTL fields (see there). `pttl` is the reply, or null where none was read. */
|
|
377
|
+
function expiryFields(pttl, { ttlMs, beatAt, nowMs }) {
|
|
378
|
+
if (pttl === -1) return { ttlAgeMs: null, skewMs: null, noExpiry: true };
|
|
379
|
+
// -2 (the key went between the two reads), a failed read, or a reply that is not a whole number: nothing is known.
|
|
380
|
+
if (!Number.isSafeInteger(pttl) || pttl < 0) return { ttlAgeMs: null, skewMs: null, noExpiry: null };
|
|
381
|
+
const ttl = Number.isSafeInteger(ttlMs) && ttlMs > 0 ? ttlMs : HOST_TTL_MS;
|
|
382
|
+
const ttlAgeMs = Math.max(0, ttl - pttl);
|
|
383
|
+
return { ttlAgeMs, skewMs: Number.isFinite(beatAt) ? nowMs - beatAt - ttlAgeMs : null, noExpiry: false };
|
|
384
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Host self-description only: no consumer may use these facts to admit or refuse a job (#748). */
|
|
2
|
+
import { BACKEND_NAMES } from "./backends.mjs";
|
|
3
|
+
import { LIVE_JOB_ID_RE, publishedJobId } from "./live-jobs.mjs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { imageRefProblem } from "./image-ref.mjs";
|
|
6
|
+
import { RUN_KINDS } from "./forges.mjs";
|
|
7
|
+
|
|
8
|
+
export const SANDBOXES_MAX = 32;
|
|
9
|
+
export const SANDBOX_READ_MS = 60_000;
|
|
10
|
+
export const CPU_RESERVE_WORDS = Object.freeze(["held", "no-parent", "unmanaged", "budget-unknown", "unreadable", "unset", "differs"]);
|
|
11
|
+
const integer = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
12
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
13
|
+
const decoded = (raw) => {
|
|
14
|
+
if (typeof raw !== "string") return raw;
|
|
15
|
+
if (raw.length > 32 * 1024) return null;
|
|
16
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Unknown and unreadable are null, never an invented empty inventory. */
|
|
20
|
+
export function parseSandboxes(raw) {
|
|
21
|
+
const list = decoded(raw);
|
|
22
|
+
if (!Array.isArray(list) || list.length > SANDBOXES_MAX) return null;
|
|
23
|
+
// Corruption must not turn a nonempty inventory into a false claim that no workspace remains.
|
|
24
|
+
if (list.some((r) => !object(r) || typeof r.id !== "string" || !LIVE_JOB_ID_RE.test(r.id) || !BACKEND_NAMES.includes(r.v) || !(r.until === null || integer(r.until)) || typeof r.pin !== "boolean")) return null;
|
|
25
|
+
if (list.some((r) => (r.prepared !== undefined && typeof r.prepared !== "boolean") || (r.canOpen !== undefined && typeof r.canOpen !== "boolean") || (r.generation !== undefined && !/^[a-f0-9]{64}$/.test(r.generation)))) return null;
|
|
26
|
+
if (list.some((r) => (r.image !== undefined && imageRefProblem(r.image)) || (r.kind !== undefined && !RUN_KINDS.includes(r.kind)))) return null;
|
|
27
|
+
return list.map(({ id, v, until, pin, prepared, canOpen, generation, image, kind }) => ({ id, v, until, pin, ...(prepared !== undefined ? { prepared } : {}), ...(canOpen !== undefined ? { canOpen } : {}), ...(generation !== undefined ? { generation } : {}), ...(image !== undefined ? { image } : {}), ...(kind !== undefined ? { kind } : {}) }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An opaque incarnation binds a confirmation to the workspace, without publishing its host path. */
|
|
31
|
+
export function sandboxManifestGeneration(m) {
|
|
32
|
+
if (typeof m?.jobId !== "string" || typeof m?.image !== "string" || typeof m?.workspace !== "string" || typeof m?.createdAt !== "string") return null;
|
|
33
|
+
return createHash("sha256").update(JSON.stringify([m.jobId, m.image, m.workspace, m.createdAt, m.backend ?? "local", m.baseSha ?? null, m.prepared?.requestDigest ?? null])).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseBudgetFrom(raw) {
|
|
37
|
+
const r = decoded(raw);
|
|
38
|
+
if (!object(r)) return null;
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const key of ["memMode", "cpuMode"]) {
|
|
41
|
+
if (!["auto", "value", "off"].includes(r[key])) return null;
|
|
42
|
+
out[key] = r[key];
|
|
43
|
+
}
|
|
44
|
+
for (const key of ["memTotalMiB", "memReserveMiB", "cpuTotalCenti", "cpuReserveCenti"]) {
|
|
45
|
+
if (r[key] !== null && !integer(r[key])) return null;
|
|
46
|
+
out[key] = r[key];
|
|
47
|
+
}
|
|
48
|
+
for (const key of ["memFloored", "cpuFloored"]) {
|
|
49
|
+
if (typeof r[key] !== "boolean") return null;
|
|
50
|
+
out[key] = r[key];
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseCpuReserve(raw) {
|
|
56
|
+
const list = decoded(raw);
|
|
57
|
+
if (!Array.isArray(list)) return null;
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
return list.filter((r) => object(r) && BACKEND_NAMES.includes(r.venue) && CPU_RESERVE_WORDS.includes(r.status) && !seen.has(r.venue) && seen.add(r.venue)).map((r) => ({ venue: r.venue, status: r.status, wantCenti: integer(r.wantCenti) ? r.wantCenti : null, quotaCenti: integer(r.quotaCenti) ? r.quotaCenti : null }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Cache filesystem work, not the heartbeat. The caller supplies the store's actual deadline rule. */
|
|
63
|
+
export function makeSandboxTelemetry({ list, deadline, retentionHours, canOpen = null, now = () => Date.now(), log = () => {} }) {
|
|
64
|
+
let readAt = null;
|
|
65
|
+
let unreadable = false;
|
|
66
|
+
let cached = { sandboxes: "", sandboxesMore: "", sandboxRetentionHours: String(retentionHours) };
|
|
67
|
+
return () => {
|
|
68
|
+
const at = now();
|
|
69
|
+
if (readAt !== null && at >= readAt && at - readAt < SANDBOX_READ_MS) return cached;
|
|
70
|
+
readAt = at;
|
|
71
|
+
try {
|
|
72
|
+
const all = list().map((m) => {
|
|
73
|
+
const id = publishedJobId(m.jobId);
|
|
74
|
+
const venue = Object.hasOwn(m, "backend") ? m.backend : "local";
|
|
75
|
+
if (!id || !BACKEND_NAMES.includes(venue)) return null;
|
|
76
|
+
// Old manifests may lack these facts. Corrupt present facts make the inventory unknown, never invented.
|
|
77
|
+
if ((m.image !== undefined && imageRefProblem(m.image)) || (m.kind !== undefined && !RUN_KINDS.includes(m.kind))) throw new Error("invalid sandbox image or kind");
|
|
78
|
+
const d = deadline(m, retentionHours);
|
|
79
|
+
const generation = sandboxManifestGeneration(m);
|
|
80
|
+
return { id, v: venue, until: integer(d.until) ? d.until : null, pin: d.source === "pin", ...(m.origin === "prepared" ? { prepared: true } : {}), ...(generation ? { generation } : {}), ...(typeof canOpen === "function" ? { canOpen: canOpen(m) === true } : {}), ...(m.image !== undefined ? { image: m.image } : {}), ...(m.kind !== undefined ? { kind: m.kind } : {}), created: Date.parse(m.createdAt) || 0 };
|
|
81
|
+
}).filter(Boolean).sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
|
|
82
|
+
unreadable = false;
|
|
83
|
+
cached = { sandboxes: JSON.stringify(all.slice(0, SANDBOXES_MAX).map(({ created, ...r }) => r)), sandboxesMore: String(Math.max(0, all.length - SANDBOXES_MAX)), sandboxRetentionHours: String(retentionHours) };
|
|
84
|
+
} catch {
|
|
85
|
+
// A failed read is absent telemetry, never a claim that the host has no retained workspaces.
|
|
86
|
+
if (!unreadable) log("host_sandboxes_unreadable", { reason: "read-failed" });
|
|
87
|
+
unreadable = true;
|
|
88
|
+
cached = { sandboxes: "", sandboxesMore: "", sandboxRetentionHours: String(retentionHours) };
|
|
89
|
+
}
|
|
90
|
+
return cached;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forge-neutral half of a webhook endpoint: read a bounded raw body, recognise a JSON content type,
|
|
3
|
+
* and write a small JSON response.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from verify.mjs when a second forge arrived (issue #42), and extracted rather than copied for
|
|
6
|
+
* one reason: `readRawBody`'s per-chunk accounting is a memory bound on attacker-controlled input. Two
|
|
7
|
+
* copies of a security limit drift -- one gets the fix, the other keeps the bug -- and the copy that keeps
|
|
8
|
+
* it is the one nobody is looking at. Verification itself is NOT here; each forge's gate owns its own,
|
|
9
|
+
* because that is exactly the part that differs.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here reads a header that identifies a forge, and nothing here decides whether a request is
|
|
12
|
+
* trusted. A caller must still verify before parsing a single field (CONST-HMAC-OVER-RAW-BODY).
|
|
13
|
+
*
|
|
14
|
+
* Moved from the receiver to the worker's exports (issue #640) for the same reason it was extracted: the operator
|
|
15
|
+
* console reads request bodies too, and the console must not import the receiver (its only export is `./start`, and an
|
|
16
|
+
* admin surface on the receiver is the rejected option `DES-OPERATOR-CONSOLE` keeps). `respond` sets no security
|
|
17
|
+
* headers; the console wraps every response in its own header set and never calls it directly.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Accept `application/json`, ignoring any parameters and case. */
|
|
21
|
+
export function isJsonContentType(contentType) {
|
|
22
|
+
return String(contentType ?? "").split(";")[0].trim().toLowerCase() === "application/json";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collect the request stream into a Buffer, bounded by `limit`. Accumulation is checked on every
|
|
27
|
+
* chunk so the buffer can never grow past the cap: exceeding it destroys the request and rejects
|
|
28
|
+
* with `code: "E_PAYLOAD_TOO_LARGE"` rather than reading an unbounded body into memory.
|
|
29
|
+
*/
|
|
30
|
+
export function readRawBody(req, limit) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let size = 0;
|
|
34
|
+
let settled = false;
|
|
35
|
+
|
|
36
|
+
const cleanup = () => {
|
|
37
|
+
req.removeListener("data", onData);
|
|
38
|
+
req.removeListener("end", onEnd);
|
|
39
|
+
req.removeListener("error", onError);
|
|
40
|
+
};
|
|
41
|
+
const onData = (chunk) => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
size += chunk.length;
|
|
44
|
+
if (size > limit) {
|
|
45
|
+
settled = true;
|
|
46
|
+
cleanup();
|
|
47
|
+
req.destroy();
|
|
48
|
+
const err = new Error("payload too large");
|
|
49
|
+
err.code = "E_PAYLOAD_TOO_LARGE";
|
|
50
|
+
reject(err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
chunks.push(chunk);
|
|
54
|
+
};
|
|
55
|
+
const onEnd = () => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
cleanup();
|
|
59
|
+
resolve(Buffer.concat(chunks));
|
|
60
|
+
};
|
|
61
|
+
const onError = (err) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
cleanup();
|
|
65
|
+
reject(err);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
req.on("data", onData);
|
|
69
|
+
req.on("end", onEnd);
|
|
70
|
+
req.on("error", onError);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Write a small JSON body with an explicit status. `writeHead` sets the status code on the response. */
|
|
75
|
+
export function respond(res, status, body) {
|
|
76
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
77
|
+
res.end(JSON.stringify(body));
|
|
78
|
+
}
|
package/src/image-preflight.mjs
CHANGED
|
@@ -16,10 +16,9 @@ import { FORGE_MCP_SERVER_NAME } from "./mcp-config.mjs";
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* The image this job runs in: the job's own `image` when it carries one
|
|
20
|
-
* (`PI_JOB_IMAGE`).
|
|
21
|
-
*
|
|
22
|
-
* is how that is guaranteed rather than hoped for.
|
|
19
|
+
* The image this job runs in: the job's own `image` when it carries one (a trigger's `run.image`, the CLI's `--image`),
|
|
20
|
+
* otherwise the deployment default (`PI_JOB_IMAGE`). The preflight and the argv builder must never disagree about which
|
|
21
|
+
* tag they mean, and one function is how that is guaranteed rather than hoped for.
|
|
23
22
|
*/
|
|
24
23
|
export function resolveJobImage(job, defaultImage) {
|
|
25
24
|
const image = job?.image ?? defaultImage;
|
|
@@ -45,8 +44,9 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
45
44
|
* { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
|
|
46
45
|
* { <gate.result>, declared } -- present, but does not declare a capability this job needs, one key per
|
|
47
46
|
* CAPABILITY_GATES row (replicaUnsupported, commandUnsupported,
|
|
48
|
-
* excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
|
|
49
|
-
* forgeMcpUnsupported
|
|
47
|
+
* excludeToolsUnsupported, loadExclusionsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
|
|
48
|
+
* forgeMcpUnsupported, awaitInputUnsupported, progressUnsupported, recoverUnsupported,
|
|
49
|
+
* steerUnsupported)
|
|
50
50
|
* => POLICY
|
|
51
51
|
*
|
|
52
52
|
* A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
|
|
@@ -60,10 +60,19 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
60
60
|
* `unavailable` and retry; if it comes up between them we report `missing` on an image that is genuinely
|
|
61
61
|
* absent. Nothing is cached, deliberately -- see the note at the call site in start.mjs.
|
|
62
62
|
*/
|
|
63
|
-
export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
|
|
63
|
+
export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker", copies = false, brokered = false }) {
|
|
64
|
+
// Issue #683: `copies` is the venue's, not the job's: a venue that copies the job's files into the box (`binds:
|
|
65
|
+
// false`, `openshell`) needs the `sandboxLayout` row below on EVERY job, and a binding venue never does. A boolean
|
|
66
|
+
// and nothing else, so a truthy string from a hand-built bundle cannot arm a gate it did not mean.
|
|
67
|
+
if (typeof copies !== "boolean") throw new TypeError("makeImagePreflight: `copies` must be a boolean");
|
|
68
|
+
// Issue #730: `brokered` is the venue's too: a venue whose every connection is mediated by a broker that reads the
|
|
69
|
+
// caller's memory (both OpenShell venues, copy and bind) needs the `observableRunner` row below on EVERY job. A
|
|
70
|
+
// boolean, for the same reason.
|
|
71
|
+
if (typeof brokered !== "boolean") throw new TypeError("makeImagePreflight: `brokered` must be a boolean");
|
|
64
72
|
// Issue #354: `bin` is the venue's CLI. Both probes go through it, because a `podman image inspect` answered by one
|
|
65
73
|
// store and an `info` answered by another daemon would tell "absent" from "unavailable" about two different hosts.
|
|
66
|
-
return async function imagePreflight(job) {
|
|
74
|
+
return async function imagePreflight(job, { subscriptionAuthRequired = false } = {}) {
|
|
75
|
+
if (typeof subscriptionAuthRequired !== "boolean") throw new TypeError("imagePreflight: subscriptionAuthRequired must be a boolean");
|
|
67
76
|
const wanted = resolveJobImage(job, image);
|
|
68
77
|
// --format keeps docker from serialising the whole image manifest to a pipe we ignore.
|
|
69
78
|
//
|
|
@@ -103,13 +112,16 @@ export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
|
|
|
103
112
|
// the job actually carries the feature it guards. The gates, their order and why each exists are
|
|
104
113
|
// the CAPABILITY_GATES table; the first one this job needs and the image lacks refuses.
|
|
105
114
|
for (const gate of CAPABILITY_GATES) {
|
|
106
|
-
if (gate.needed(job) && !(capabilities ?? []).includes(gate.token)) {
|
|
115
|
+
if (gate.needed(job, { copies, brokered, subscriptionAuthRequired }) && !(capabilities ?? []).includes(gate.token)) {
|
|
107
116
|
return { [gate.result]: wanted, declared: capabilities ?? [] };
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
// `capabilities` rides the ok result for issue #341's job-user gate, which needs `anyUid` and runs after
|
|
111
120
|
// this preflight; an image that declares nothing reads as [].
|
|
112
|
-
|
|
121
|
+
// `fromJob`: a run on an image its job named says nothing of the image this host runs everything else on, which only
|
|
122
|
+
// this function can tell, since it alone holds both (the record's `imageFromJob`, loaded-record.mjs). A job naming this
|
|
123
|
+
// host's own default ran on this host's own image, so it is no such run.
|
|
124
|
+
return { ok: true, image: wanted, piVersion, imageDigest, capabilities: capabilities ?? [], ...(wanted !== image ? { fromJob: true } : {}) };
|
|
113
125
|
}
|
|
114
126
|
if ((await runDocker(bin, spawnFn, ["info"])).code === 0) return { missing: wanted };
|
|
115
127
|
return { unavailable: wanted };
|
|
@@ -124,7 +136,8 @@ export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
|
|
|
124
136
|
* row whose `needed(job)` holds and whose `token` the image does not declare, before any spend.
|
|
125
137
|
*
|
|
126
138
|
* token the capability the image must declare
|
|
127
|
-
* needed whether this job carries the feature (a job that does not pays nothing for the row)
|
|
139
|
+
* needed whether this job carries the feature (a job that does not pays nothing for the row), given the job and
|
|
140
|
+
* the venue's `{ copies }` (issue #683: the one row a venue rather than a trigger arms)
|
|
128
141
|
* result the key the preflight result names the image under, which the processor branches on
|
|
129
142
|
* reason the run record's reason for the refusal
|
|
130
143
|
* event the worker log event
|
|
@@ -135,8 +148,7 @@ export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
|
|
|
135
148
|
* A new image-gated feature (`costCap`, `modelPolicy`) is a row here, not another branch in two files. Order
|
|
136
149
|
* is the order the branches had, so a job carrying several features is refused for the same one as before.
|
|
137
150
|
*/
|
|
138
|
-
export const CAPABILITY_GATES = Object.freeze([
|
|
139
|
-
// REQ-REPLICA-RUNS: an image built before it bakes a HARD_RULES.md whose rule 3 hard-codes `pi/issue-<n>`
|
|
151
|
+
export const CAPABILITY_GATES = Object.freeze([ // REQ-REPLICA-RUNS: an image built before it bakes a HARD_RULES.md whose rule 3 hard-codes `pi/issue-<n>`
|
|
140
152
|
// as a SYSTEM rule, which the model treats as authoritative over the user prompt naming `pi/issue-<n>-r2`.
|
|
141
153
|
// Both replicas would push to one branch: the push race the feature exists to avoid, two runs billed.
|
|
142
154
|
Object.freeze({
|
|
@@ -225,6 +237,107 @@ export const CAPABILITY_GATES = Object.freeze([
|
|
|
225
237
|
event: "refused_image_await_input_unsupported",
|
|
226
238
|
comment: (image, declared) => `Refused: the job image "${image}" does not declare support for stopping to wait for an answer (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.awaitInput\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
227
239
|
}),
|
|
240
|
+
// Issue #644: a runner that predates run.progress reads no PI_REPORT_PROGRESS, so the agent is never offered the
|
|
241
|
+
// report_progress tool and the trigger's phases never appear, on a run that otherwise reads as fine. Refused for the
|
|
242
|
+
// awaitInput row's reason: the trigger asked for something this image cannot give.
|
|
243
|
+
Object.freeze({
|
|
244
|
+
token: "reportProgress",
|
|
245
|
+
needed: (job) => job?.progress === true,
|
|
246
|
+
result: "progressUnsupported",
|
|
247
|
+
reason: "job-image-progress-unsupported",
|
|
248
|
+
event: "refused_image_progress_unsupported",
|
|
249
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare support for reporting progress (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.progress\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
250
|
+
}),
|
|
251
|
+
// Issue #645: a runner that predates run.recover reads no PI_RECOVER and keeps no checkpoint, so a run the worker
|
|
252
|
+
// loses would fail exactly as an unarmed one does while the trigger promised it would continue. Refused for the
|
|
253
|
+
// awaitInput row's reason: the trigger asked for something this image cannot give, and saying so costs nothing.
|
|
254
|
+
Object.freeze({
|
|
255
|
+
token: "recover",
|
|
256
|
+
needed: (job) => job?.recover === true,
|
|
257
|
+
result: "recoverUnsupported",
|
|
258
|
+
reason: "job-image-recover-unsupported",
|
|
259
|
+
event: "refused_image_recover_unsupported",
|
|
260
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare support for continuing a lost run from its checkpoint (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.recover\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
261
|
+
}),
|
|
262
|
+
// Issue #646: a runner that predates run.steer reads no PI_STEER and never looks at /inbox, so every message an
|
|
263
|
+
// operator sent would be accepted and then never reach the agent. Refused for the recover row's reason: the trigger
|
|
264
|
+
// asked for something this image cannot give, and saying so costs nothing.
|
|
265
|
+
Object.freeze({
|
|
266
|
+
token: "steer",
|
|
267
|
+
needed: (job) => job?.steer === true,
|
|
268
|
+
result: "steerUnsupported",
|
|
269
|
+
reason: "job-image-steer-unsupported",
|
|
270
|
+
event: "refused_image_steer_unsupported",
|
|
271
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare support for receiving messages while it runs (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.steer\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
272
|
+
}),
|
|
273
|
+
// Issue #683: on a venue that copies the job's files (`openshell`), a runner that predates PI_JOB_LAYOUT looks for
|
|
274
|
+
// /job, finds nothing in a sandbox whose files sit under /sandbox, and exits "missing job input" after the sandbox
|
|
275
|
+
// was made and the budget reserved; and without /opt/pi-dispatch/pi-landlock its /job would be writable by the
|
|
276
|
+
// agent. Armed by the VENUE, on every job there, never by a trigger, so a binding venue never reads the token.
|
|
277
|
+
Object.freeze({
|
|
278
|
+
token: "sandboxLayout",
|
|
279
|
+
needed: (_job, { copies } = {}) => copies === true,
|
|
280
|
+
result: "sandboxLayoutUnsupported",
|
|
281
|
+
reason: "job-image-sandbox-layout-unsupported",
|
|
282
|
+
event: "refused_image_sandbox_layout_unsupported",
|
|
283
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare support for a venue that copies the job's files into its sandbox (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would not find them, and could not keep the agent from rewriting them. Rebuild the image from a version that has this feature. Not run.`,
|
|
284
|
+
}),
|
|
285
|
+
// Issue #730: on a venue whose connections are mediated by a broker that must read the caller's memory (OpenShell,
|
|
286
|
+
// both modes), a runner started from the exec-only node cannot be read, so its own provider call and every tool's
|
|
287
|
+
// connection below it are refused: the job would reserve, start, and fail its first model call. An image that has
|
|
288
|
+
// the readable copy and the entrypoint that chooses it declares `observableRunner`. Armed by the VENUE, on every job
|
|
289
|
+
// there, never by a trigger.
|
|
290
|
+
Object.freeze({
|
|
291
|
+
token: "observableRunner",
|
|
292
|
+
needed: (_job, { brokered } = {}) => brokered === true,
|
|
293
|
+
result: "observableRunnerUnsupported",
|
|
294
|
+
reason: "job-image-observable-runner-unsupported",
|
|
295
|
+
event: "refused_image_observable_runner_unsupported",
|
|
296
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare support for a venue whose sandbox must observe the runner to let it reach the network (\`dev.pi-dispatch.capabilities\` ${declared}), so the job could reach neither its model nor anything else. Rebuild the image from a version that has this feature. Not run.`,
|
|
297
|
+
}),
|
|
298
|
+
Object.freeze({
|
|
299
|
+
token: "fork",
|
|
300
|
+
needed: (job) => job?.fork !== undefined || job?.thinkingLevel !== undefined,
|
|
301
|
+
result: "forkUnsupported",
|
|
302
|
+
reason: "job-image-fork-unsupported",
|
|
303
|
+
event: "refused_image_fork_unsupported",
|
|
304
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare managed session forks (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore the selected entry or thinking level. Rebuild the image from a version that has this feature. Not run.`,
|
|
305
|
+
}),
|
|
306
|
+
Object.freeze({
|
|
307
|
+
token: "approvalGates",
|
|
308
|
+
needed: (job) => Array.isArray(job?.gates) && job.gates.length > 0,
|
|
309
|
+
result: "approvalGatesUnsupported",
|
|
310
|
+
reason: "job-image-approval-gates-unsupported",
|
|
311
|
+
event: "refused_image_approval_gates_unsupported",
|
|
312
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare approval gates (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.gates\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
313
|
+
}),
|
|
314
|
+
|
|
315
|
+
Object.freeze({
|
|
316
|
+
token: "modelFailover",
|
|
317
|
+
needed: (job) => job?.fallbackModels !== undefined,
|
|
318
|
+
result: "modelFailoverUnsupported",
|
|
319
|
+
reason: "job-image-model-failover-unsupported",
|
|
320
|
+
event: "refused_image_model_failover_unsupported",
|
|
321
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare model-failover support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner cannot report the authenticated failure and replay evidence this job requires. Rebuild the image from a version that has this feature. Not run.`,
|
|
322
|
+
}),
|
|
323
|
+
Object.freeze({ token: "modelRoles", needed: (job) => job?.modelRoles !== undefined, result: "modelRolesUnsupported", reason: "job-image-model-roles-unsupported", event: "refused_image_model_roles_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare modelRoles support (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot resolve reviewed roles before child calls. Rebuild the image from a version that has this feature. Not run.` }),
|
|
324
|
+
Object.freeze({ token: "watch", needed: (job) => Boolean(job?.watch), result: "watchUnsupported", reason: "job-image-watch-unsupported", event: "refused_image_watch_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare watch isolation (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot enforce read-only investigations. Rebuild the image from a version that has this feature. Not run.` }),
|
|
325
|
+
Object.freeze({ token: "questionGrouping", needed: (job) => Boolean(job?.questionGrouping), result: "questionGroupingUnsupported", reason: "job-image-question-grouping-unsupported", event: "refused_image_question_grouping_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare isolated question grouping (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot run a classifier without loading an agent. Rebuild the image from a version that has this feature. Not run.` }),
|
|
326
|
+
Object.freeze({ token: "answerDraft", needed: (job) => Boolean(job?.answerDraft), result: "answerDraftUnsupported", reason: "job-image-answer-draft-unsupported", event: "refused_image_answer_draft_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare restricted answer drafts (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot enforce source-only drafting without posting tools. Rebuild the image from a version that has this feature. Not run.` }),
|
|
327
|
+
// Only trusted host connection policy arms this gate. Queued input cannot claim or suppress authentication.
|
|
328
|
+
Object.freeze({ token: "subscriptionAuth", needed: (_job, { subscriptionAuthRequired = false } = {}) => subscriptionAuthRequired === true, result: "subscriptionAuthUnsupported", reason: "job-image-subscription-auth-unsupported", event: "refused_image_subscription_auth_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare subscription authentication (\`dev.pi-dispatch.capabilities\` ${declared}), so it cannot verify the admitted access snapshot, community adapter or final request cost. Rebuild the image from a version that has this feature. Not run.` }),
|
|
329
|
+
// Issue #800: a runner that predates them reads no PI_EXCLUDE_EXTENSIONS and no PI_EXCLUDE_EXTENSION_TOOLS, so the overlay extension loads, the tool
|
|
330
|
+
// stays, and the job records a clean exit. A permission quietly not enforced, as the `excludeTools` row says. A package the worker left out of
|
|
331
|
+
// PI_PACKAGES stays out on any image, but the worker cannot know a name is only that, so either field arms this.
|
|
332
|
+
// `run.excludeTools` beside a role map arms it too. That runner reads PI_EXCLUDE_TOOLS for its own session, so the `excludeTools` row above passes, and then
|
|
333
|
+
// starts a role child on pi's CLI with no `--exclude-tools`, so the child keeps what the trigger removed and the job reads clean. `job.modelRoles` is what says
|
|
334
|
+
// a child can start: `resolveModelRoles` set it at this pickup, a queued one never survives that, and with no map there is no `subagent` tool. An image with no
|
|
335
|
+
// role machinery at all never reaches this for that reason: the `modelRoles` row stands above and answers first, in its own words. A list of only a server's
|
|
336
|
+
// names arms it as well, though a child connects no server: the row's words hold of it, the fix is the same rebuild, and this module holds no table of those names.
|
|
337
|
+
// One sentence for all of these. REJECTED: naming the keys and the role child, which said "would start a role child" of a job with no role map on an image with no role machinery.
|
|
338
|
+
Object.freeze({ token: "loadExclusions", needed: (job) => job?.excludeExtensions !== undefined || job?.excludeExtensionTools !== undefined || (job?.excludeTools !== undefined && job?.modelRoles !== undefined), result: "loadExclusionsUnsupported", reason: "job-image-load-exclusions-unsupported", event: "refused_image_load_exclusions_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare support for this job's exclusions (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would not honour them. Rebuild the image from a version that has this feature. Not run.` }),
|
|
339
|
+
// Issue #796: an image too old to report whether a canary's candidate loaded could only ever read "not reported", on a paid run.
|
|
340
|
+
Object.freeze({ token: "packageCanary", needed: (job) => job?.packageCanary !== undefined, result: "packageCanaryUnsupported", reason: "job-image-package-canary-unsupported", event: "refused_image_package_canary_unsupported", comment: (image, declared) => `Refused: the job image "${image}" does not declare package canaries (\`dev.pi-dispatch.capabilities\` ${declared}), so it could not report whether the candidate loaded. Rebuild the image from a version that has this feature. Not run.` }),
|
|
228
341
|
]);
|
|
229
342
|
|
|
230
343
|
/**
|
|
@@ -235,6 +348,36 @@ export const CAPABILITY_GATES = Object.freeze([
|
|
|
235
348
|
*/
|
|
236
349
|
export const EXIT_AUTH_CAPABILITY = "exitAuth";
|
|
237
350
|
|
|
351
|
+
/**
|
|
352
|
+
* The capability an image declares when its runner mirrors the session file over stderr as records signed with the exit
|
|
353
|
+
* key, on PI_TRANSCRIPT=stderr (issue #686, image/runner/src/transcript-mirror.mjs). NOT a gate, like `exitAuth`: no job
|
|
354
|
+
* is refused for lacking it. On a venue that copies the job's files (`openshell`) it is what the live transcript, run
|
|
355
|
+
* progress and the status comment come from; an image without it runs there without them (`live_transcript_skipped`,
|
|
356
|
+
* reason `no-capability`). A binding venue never reads it.
|
|
357
|
+
*/
|
|
358
|
+
export const TRANSCRIPT_STDERR_CAPABILITY = "transcriptStderr";
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The capability an image declares when its runner reads steer messages as MAC'd frames on stdin, on
|
|
362
|
+
* PI_EXIT_AUTH=stdin-frames (issue #686, image/runner/src/steer-frames.mjs): the key as frame 1, read before pi loads,
|
|
363
|
+
* the stream moved off fd 0 so a tool child inherits /dev/null. NOT a gate, like `transcriptStderr`: `steer` stays the
|
|
364
|
+
* gate a `run.steer` job needs. On a venue that copies the job's files (`openshell`) it is what steering comes over; an
|
|
365
|
+
* image without it runs there unsteered (`steerSkipped: "venue-copies"`, as before it existed). A binding venue never
|
|
366
|
+
* reads it: it keeps the read-only `/inbox` mount, which the agent cannot withdraw a message from.
|
|
367
|
+
*/
|
|
368
|
+
export const STEER_STDIN_CAPABILITY = "steerStdin";
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The capability an image declares when its runner reads PI_BUNDLE_ENV=1: a project secret bundle's plain variables
|
|
372
|
+
* from `bundle-env.json` in the job directory into its own environment (issue #704 part 3, image/runner/src/
|
|
373
|
+
* bundle-env.mjs). A gate on the OpenShell venues only, judged by their bundle gate rather than a CAPABILITY_GATES row
|
|
374
|
+
* (the need is known only once the bundle is read, which is after those rows run): a job there that would carry plain
|
|
375
|
+
* bundle variables on an image without it is refused before spend (`openshell-bundle-image-too-old`), since an older
|
|
376
|
+
* runner would ignore the file and the job would run without them. Every other venue hands the values as env and never
|
|
377
|
+
* reads it.
|
|
378
|
+
*/
|
|
379
|
+
export const BUNDLE_ENV_FILE_CAPABILITY = "bundleEnvFile";
|
|
380
|
+
|
|
238
381
|
/**
|
|
239
382
|
* Exported so the test cannot drift from the format string above. `|` because an image id is
|
|
240
383
|
* `sha256:<hex>` and a version is a version, so neither can contain one -- and because a literal control
|