@edgehero/pi-dispatch 4.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- 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 +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- 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 +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/dollar-budget.mjs
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* `redis` is any ioredis-compatible client, injected so the logic is testable without a server.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
31
32
|
import { DAY_TTL_SECONDS, MONTH_TTL_SECONDS, WEEK_TTL_SECONDS, dayKey, monthKey, weekKey } from "./budget.mjs";
|
|
32
33
|
import { MICROS_PER_USD, optionalUsdMicros } from "./money.mjs";
|
|
33
34
|
|
|
@@ -366,8 +367,216 @@ export const MODEL_BASIS = Object.freeze(["metered", "floor", "refunded"]);
|
|
|
366
367
|
* The run record's `dollars` object (INT-RUN-HISTORY-FILE-CONTRACT): an explicit literal of integers and fixed
|
|
367
368
|
* tokens. `modelBasis` is how the job's MODEL windows settled (issue #502 part 6): `metered` when every one settled
|
|
368
369
|
* to its own usage row, `floor` when any kept at least its reservation, `refunded` when they were given back, and
|
|
369
|
-
* `null` when the job held no model window.
|
|
370
|
+
* `null` when the job held no model window. `reservedIn` (issue #761) is `deploymentWindowsOf`'s answer, carried
|
|
371
|
+
* only when it is one, so a record of a job that reserved in no deployment window keeps the shape it had.
|
|
370
372
|
*/
|
|
371
|
-
export function dollarsRecord({ reservedMicros, settledMicros, basis, modelBasis = null }) {
|
|
372
|
-
return { reservedMicros, settledMicros, basis, modelBasis: MODEL_BASIS.includes(modelBasis) ? modelBasis : null };
|
|
373
|
+
export function dollarsRecord({ reservedMicros, settledMicros, basis, modelBasis = null, reservedIn = null }) {
|
|
374
|
+
return { reservedMicros, settledMicros, basis, modelBasis: MODEL_BASIS.includes(modelBasis) ? modelBasis : null, ...(reservedIn ? { reservedIn } : {}) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---- which deployment windows a record's money is in (issue #761, the dollar window reset check) ----
|
|
378
|
+
//
|
|
379
|
+
// A dollar counter holds the history a cap is judged against, and Valkey is its only copy: a flushed, replaced or
|
|
380
|
+
// restored Valkey, an evicted key or a hand edit reopens the cap with nothing saying so. A record cannot be bucketed by
|
|
381
|
+
// its own times to find out (the reservation's clock is neither `startedAt` nor `endedAt`, and a window whose cap was
|
|
382
|
+
// set mid-window was never written by the records before it), so a record says which deployment windows its settled
|
|
383
|
+
// amount is in, `dollars.reservedIn`, and doctor sums this host's records per window against the counter
|
|
384
|
+
// (`dollarWindowFloors`). Only the deployment's day, week and month: a scoped, project or model window's key is a hash
|
|
385
|
+
// per ledger, and a job-count window holds no amount a record states.
|
|
386
|
+
|
|
387
|
+
/** The deployment's three dollar windows, in the order a hold reserves them (`activeWindows`). */
|
|
388
|
+
export const DEPLOYMENT_WINDOWS = Object.freeze(["day", "week", "month"]);
|
|
389
|
+
|
|
390
|
+
/** A Valkey endpoint tag: 16 lowercase hex characters (`valkeyEndpointTag`). */
|
|
391
|
+
export const VALKEY_TAG_RE = /^[0-9a-f]{16}$/;
|
|
392
|
+
|
|
393
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
394
|
+
// The inverse of budget.mjs's `dayKey`, `weekKey` and `monthKey` under the deployment's own prefix, exactly: a scoped
|
|
395
|
+
// or model key (`budget:usd:s:<hash>`, `budget:usd:mdl:<hash>`) starts with the same prefix and matches none of them.
|
|
396
|
+
const WINDOW_KEY_RES = Object.freeze({
|
|
397
|
+
day: new RegExp(`^${escapeRe(DOLLAR_KEY_PREFIX)}:(\\d{4}-\\d{2}-\\d{2})$`),
|
|
398
|
+
week: new RegExp(`^${escapeRe(DOLLAR_KEY_PREFIX)}:w:(\\d{4}-\\d{2}-\\d{2})$`),
|
|
399
|
+
month: new RegExp(`^${escapeRe(DOLLAR_KEY_PREFIX)}:m:(\\d{4}-\\d{2})$`),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/** Which deployment window `key` is and its bucket id (`{ window, bucket }`, the text after the prefix), or null. */
|
|
403
|
+
export function deploymentWindowOfKey(key) {
|
|
404
|
+
if (typeof key !== "string") return null;
|
|
405
|
+
for (const window of DEPLOYMENT_WINDOWS) {
|
|
406
|
+
const m = key.match(WINDOW_KEY_RES[window]);
|
|
407
|
+
if (m) return { window, bucket: m[1] };
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The deployment's three windows at `now`, `{ day, week, month }`, each `{ key, bucket, startMs, endMs, ttlSeconds }`:
|
|
414
|
+
* the key a reservation at `now` writes (budget.mjs's key functions, so a reader and the worker cannot disagree about a
|
|
415
|
+
* boundary), its bucket id, when the bucket begins and ends (UTC: the day, the week from Monday, the month), and the
|
|
416
|
+
* TTL the worker sets on the key's first write.
|
|
417
|
+
*/
|
|
418
|
+
export function deploymentWindowsAt(now) {
|
|
419
|
+
const d = new Date(now);
|
|
420
|
+
const y = d.getUTCFullYear();
|
|
421
|
+
const mo = d.getUTCMonth();
|
|
422
|
+
const day = Date.UTC(y, mo, d.getUTCDate());
|
|
423
|
+
const monday = day - ((new Date(day).getUTCDay() + 6) % 7) * 86_400_000;
|
|
424
|
+
const at = (key, startMs, endMs, ttlSeconds) => ({ key, bucket: deploymentWindowOfKey(key)?.bucket ?? null, startMs, endMs, ttlSeconds });
|
|
425
|
+
return {
|
|
426
|
+
day: at(dayKey(d, DOLLAR_KEY_PREFIX), day, Date.UTC(y, mo, d.getUTCDate() + 1), DAY_TTL_SECONDS),
|
|
427
|
+
week: at(weekKey(d, DOLLAR_KEY_PREFIX), monday, monday + 7 * 86_400_000, WEEK_TTL_SECONDS),
|
|
428
|
+
month: at(monthKey(d, DOLLAR_KEY_PREFIX), Date.UTC(y, mo, 1), Date.UTC(y, mo + 1, 1), MONTH_TTL_SECONDS),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The tag of the Valkey a deployment's counters live in: the first 16 hex characters of SHA-256 over `host:port/db`, so
|
|
434
|
+
* two deployments that share a logs directory and a worker name (the default per-account `~/.pi-dispatch/logs` and the
|
|
435
|
+
* hostname) but not a Valkey never add up each other's records. `endpoint` is a VALKEY_URL or a client's connection
|
|
436
|
+
* options (`{ host, port, db }`, connection.mjs `parseConnection`, which reads them off the URL by the rules below), so
|
|
437
|
+
* the worker, which holds the options its clients connect with, and doctor, which holds the URL it talks to, compute the
|
|
438
|
+
* same tag for the same endpoint. Both take the JUDGED address (`pinnedValkeyUrl`: the literal a local Valkey is pinned
|
|
439
|
+
* to on Linux), the one the worker really dials. A unix socket (`{ path }`, which no client of this project builds
|
|
440
|
+
* today) is tagged by its path. Credentials and the scheme are never part of it; the host is lowercased, an IPv6
|
|
441
|
+
* literal's brackets dropped, and a missing host, port or database is ioredis's and `parseConnection`'s default
|
|
442
|
+
* (127.0.0.1, 6379, 0). Null for anything else (a URL that does not parse, a database that is not a number). A tag that
|
|
443
|
+
* differs where the endpoint is the same (`localhost` against `127.0.0.1` on a host that pins nothing) only leaves
|
|
444
|
+
* records out of the floor, which can miss a reset and never invents one. The console's Spend page tags the
|
|
445
|
+
* VALKEY_URL it reads the counters at as it holds it: its client pins the address on connect but never rewrites the
|
|
446
|
+
* URL. There a tag that differs only leaves a record counted by its end time, as the page counted before (issue #807).
|
|
447
|
+
*/
|
|
448
|
+
export function valkeyEndpointTag(endpoint) {
|
|
449
|
+
let host;
|
|
450
|
+
let port;
|
|
451
|
+
let db;
|
|
452
|
+
if (typeof endpoint === "string") {
|
|
453
|
+
let u;
|
|
454
|
+
try {
|
|
455
|
+
u = new URL(endpoint);
|
|
456
|
+
} catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
host = u.hostname;
|
|
460
|
+
port = u.port;
|
|
461
|
+
db = u.pathname && u.pathname !== "/" ? u.pathname.slice(1) : "";
|
|
462
|
+
} else if (endpoint !== null && typeof endpoint === "object") {
|
|
463
|
+
if (typeof endpoint.path === "string" && endpoint.path !== "") return hash16(`unix:${endpoint.path}`);
|
|
464
|
+
({ host, port, db } = endpoint);
|
|
465
|
+
} else {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
const h = String(host ?? "").replace(/^\[(.*)\]$/, "$1").toLowerCase() || "127.0.0.1";
|
|
469
|
+
const p = port === undefined || port === null || port === "" ? 6379 : Number(port);
|
|
470
|
+
const n = db === undefined || db === null || db === "" ? 0 : Number(db);
|
|
471
|
+
if (!Number.isSafeInteger(p) || p < 0 || !Number.isSafeInteger(n) || n < 0) return null;
|
|
472
|
+
return hash16(`${h}:${p}/${n}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function hash16(text) {
|
|
476
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The record's `dollars.reservedIn` for a hold (issue #761): `{ day?, week?, month?, valkey }`, the bucket of each
|
|
481
|
+
* DEPLOYMENT window whose counter holds at least the amount the record states, and the endpoint tag of the Valkey it is
|
|
482
|
+
* in (`valkeyEndpointTag`). Null when there is no such window: a zero hold (no `ledgers`), a hold with no deployment
|
|
483
|
+
* ledger, a malformed tag, or every key below the record. PURE.
|
|
484
|
+
*
|
|
485
|
+
* - `hold` is the hold the settlement or release WALKED (`settleDollars`' `hold`: the job's part for a settle, the
|
|
486
|
+
* whole hold for a release), so `applied` counts along its `keys`. The deployment ledger is the one whose
|
|
487
|
+
* `keyPrefix` is exactly `DOLLAR_KEY_PREFIX`, never a prefix match: every dollar key starts with it.
|
|
488
|
+
* - `applied` is how many of those keys the step completed (0 when no step ran); `target` what this job's share of an
|
|
489
|
+
* adjusted key now is (the settled amount, 0 for a release); `recorded` the record's `settledMicros`.
|
|
490
|
+
*
|
|
491
|
+
* A window is in only when its key holds at least `recorded` of this job's money: `target` for a key the step already
|
|
492
|
+
* moved, the reservation otherwise. That is what keeps doctor's sum below the counter on a healthy deployment where a
|
|
493
|
+
* step stopped part-way: a settle whose fault left later keys at the reservation while the record states a metered cost
|
|
494
|
+
* ABOVE it (an overshoot is charged in full), and a release that gave back the first keys before the fault while the
|
|
495
|
+
* record states the whole reservation. Both leave their window out, which only lowers the floor.
|
|
496
|
+
*/
|
|
497
|
+
export function deploymentWindowsOf(hold, { applied = 0, target, recorded, valkey } = {}) {
|
|
498
|
+
if (typeof valkey !== "string" || !VALKEY_TAG_RE.test(valkey)) return null;
|
|
499
|
+
const ledger = (Array.isArray(hold?.ledgers) ? hold.ledgers : []).find((l) => l?.keyPrefix === DOLLAR_KEY_PREFIX);
|
|
500
|
+
if (!ledger || !Array.isArray(ledger.keys)) return null;
|
|
501
|
+
const reserved = hold.amountMicros;
|
|
502
|
+
if (!Number.isSafeInteger(reserved) || !Number.isSafeInteger(recorded) || !Number.isSafeInteger(applied)) return null;
|
|
503
|
+
const walked = Array.isArray(hold.keys) ? hold.keys : [];
|
|
504
|
+
const found = {};
|
|
505
|
+
for (const key of ledger.keys) {
|
|
506
|
+
const at = deploymentWindowOfKey(key);
|
|
507
|
+
if (at === null) continue;
|
|
508
|
+
const i = walked.indexOf(key);
|
|
509
|
+
const holds = i >= 0 && i < applied ? target : reserved;
|
|
510
|
+
if (Number.isSafeInteger(holds) && holds >= recorded) found[at.window] = at.bucket;
|
|
511
|
+
}
|
|
512
|
+
const windows = DEPLOYMENT_WINDOWS.filter((w) => Object.hasOwn(found, w));
|
|
513
|
+
return windows.length === 0 ? null : { ...Object.fromEntries(windows.map((w) => [w, found[w]])), valkey };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
517
|
+
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
|
|
518
|
+
/** `text` as a real UTC calendar date (`2026-02-30` is none), or null. */
|
|
519
|
+
function calendarDate(text) {
|
|
520
|
+
if (typeof text !== "string" || !DATE_RE.test(text)) return null;
|
|
521
|
+
const d = new Date(`${text}T00:00:00.000Z`);
|
|
522
|
+
return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === text ? d : null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A record's `dollars.reservedIn` rebuilt from its own keys, or null (INT-RUN-HISTORY-FILE-CONTRACT): `day` a real
|
|
527
|
+
* calendar date, `week` a real date that is a Monday, `month` a real month, at least one of them, and `valkey` a tag
|
|
528
|
+
* (`VALKEY_TAG_RE`). Anything else, a member of the wrong shape included, is null as a whole: a record that carries one
|
|
529
|
+
* no release wrote says nothing trustworthy about where its money is. Only OWN keys are read, so a value inherited
|
|
530
|
+
* through the prototype is no member. The one rule the record writer, doctor's sum and the console's Spend page
|
|
531
|
+
* (admin `spendSummaryOf`, issue #807) read it by.
|
|
532
|
+
*/
|
|
533
|
+
export function reservedInOf(value) {
|
|
534
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
535
|
+
const own = (key) => (Object.hasOwn(value, key) ? value[key] : undefined);
|
|
536
|
+
const valkey = own("valkey");
|
|
537
|
+
if (typeof valkey !== "string" || !VALKEY_TAG_RE.test(valkey)) return null;
|
|
538
|
+
const out = {};
|
|
539
|
+
const day = own("day");
|
|
540
|
+
if (day !== undefined) {
|
|
541
|
+
if (calendarDate(day) === null) return null;
|
|
542
|
+
out.day = day;
|
|
543
|
+
}
|
|
544
|
+
const week = own("week");
|
|
545
|
+
if (week !== undefined) {
|
|
546
|
+
if (calendarDate(week)?.getUTCDay() !== 1) return null;
|
|
547
|
+
out.week = week;
|
|
548
|
+
}
|
|
549
|
+
const month = own("month");
|
|
550
|
+
if (month !== undefined) {
|
|
551
|
+
if (typeof month !== "string" || !MONTH_RE.test(month)) return null;
|
|
552
|
+
out.month = month;
|
|
553
|
+
}
|
|
554
|
+
return Object.keys(out).length === 0 ? null : { ...out, valkey };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Per deployment window in `buckets` (`{ day?, week?, month? }`, each the CURRENT bucket id), the micro-dollars this
|
|
559
|
+
* host's own records settled into it: the sum of `dollars.settledMicros` over the records whose `host` is `host`, whose
|
|
560
|
+
* `reservedIn` (`reservedInOf`) names that bucket for that window, and whose `valkey` is `valkey`. PURE.
|
|
561
|
+
*
|
|
562
|
+
* The counter is at least this sum on a healthy deployment: a reservation adds R, a settlement adds S - R and the record
|
|
563
|
+
* carrying S is written after it, a refunded record is 0, and nothing else lowers a key (`reservedIn` already leaves out
|
|
564
|
+
* a window a step stopped part-way in). Everything that is not counted (another host's records, a record from before
|
|
565
|
+
* `reservedIn`, an earlier attempt a retry overwrote, a hold that crashed, a record over the read's size cap, retention
|
|
566
|
+
* shorter than the window) makes the sum smaller, never larger. So a counter below it lost history.
|
|
567
|
+
*/
|
|
568
|
+
export function dollarWindowFloors(records, { host, valkey, buckets }) {
|
|
569
|
+
const floors = {};
|
|
570
|
+
for (const w of DEPLOYMENT_WINDOWS) if (typeof buckets?.[w] === "string") floors[w] = 0;
|
|
571
|
+
for (const r of Array.isArray(records) ? records : []) {
|
|
572
|
+
if (r === null || typeof r !== "object" || r.host !== host) continue;
|
|
573
|
+
const d = r.dollars;
|
|
574
|
+
if (d === null || typeof d !== "object" || !Number.isSafeInteger(d.settledMicros) || d.settledMicros < 0) continue;
|
|
575
|
+
const at = reservedInOf(d.reservedIn);
|
|
576
|
+
if (at === null || at.valkey !== valkey) continue;
|
|
577
|
+
for (const w of Object.keys(floors)) {
|
|
578
|
+
if (at[w] === buckets[w] && Number.isSafeInteger(floors[w] + d.settledMicros)) floors[w] += d.settledMicros;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return floors;
|
|
373
582
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Endpoint occupancy is a display of the admission map, never another source of admission state (#775). */
|
|
2
|
+
import { isPerMachineHost } from "./backends.mjs";
|
|
3
|
+
import { HOST_SET, HOST_TTL_MS, hostKey } from "./host-registry.mjs";
|
|
4
|
+
import { MODEL_ENDPOINT_ID_RE, MAX_SLOTS } from "./model-endpoints.mjs";
|
|
5
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
6
|
+
|
|
7
|
+
export const ENDPOINT_STATUS_VERSION = 1;
|
|
8
|
+
export const ENDPOINT_STATUS_MAX = 32;
|
|
9
|
+
export const ENDPOINT_STATUS_HOSTS_MAX = 64;
|
|
10
|
+
const integer = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
11
|
+
|
|
12
|
+
/** Re-read the loaded file and the SAME map both queue processors claim on every registry beat. */
|
|
13
|
+
export function endpointStatusFields(endpoints, slots, multiHost) {
|
|
14
|
+
const rows = [...(endpoints ?? [])].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
15
|
+
const listed = rows.slice(0, ENDPOINT_STATUS_MAX).map((ep) => ({ id: ep.id, slots: ep.slots, used: slots.count(ep.id), fleet: multiHost && !isPerMachineHost(ep.host) ? 1 : 0 }));
|
|
16
|
+
return { endpointsVersion: ENDPOINT_STATUS_VERSION, endpointsStartedAt: ({ startedAt } = {}) => startedAt ?? "", endpoints: listed.length ? JSON.stringify(listed) : "", endpointsMore: Math.max(0, rows.length - listed.length) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Unknown versions/fields are never interpreted as an empty host. No path, address or operator text survives. */
|
|
20
|
+
export function parseEndpointStatus(row) {
|
|
21
|
+
const absent = { state: "not-reported", endpoints: null, endpointsMore: null };
|
|
22
|
+
if (!row || row.endpointsVersion === undefined || row.endpointsVersion === "") return absent;
|
|
23
|
+
if (String(row.endpointsVersion) !== String(ENDPOINT_STATUS_VERSION)) return { ...absent, state: /^\d+$/.test(String(row.endpointsVersion)) && Number(row.endpointsVersion) > ENDPOINT_STATUS_VERSION ? "unsupported" : "unreadable" };
|
|
24
|
+
// HSET preserves fields unknown to an older worker. Its new startedAt must invalidate old occupancy.
|
|
25
|
+
if (!/^(?:0|[1-9]\d*)$/.test(String(row.endpointsStartedAt)) || String(row.endpointsStartedAt) !== String(row.startedAt)) return { ...absent, state: "unreadable" };
|
|
26
|
+
if (row.endpoints === undefined) return absent;
|
|
27
|
+
const more = /^(?:0|[1-9]\d*)$/.test(String(row.endpointsMore)) ? Number(row.endpointsMore) : NaN;
|
|
28
|
+
if (!integer(more)) return { ...absent, state: "unreadable" };
|
|
29
|
+
if (row.endpoints === "" && more === 0) return { ...absent, endpointsMore: 0 };
|
|
30
|
+
try {
|
|
31
|
+
if (typeof row.endpoints !== "string" || row.endpoints.length > 8192) throw Error();
|
|
32
|
+
const entries = JSON.parse(row.endpoints);
|
|
33
|
+
if (!Array.isArray(entries) || entries.length > ENDPOINT_STATUS_MAX) throw Error();
|
|
34
|
+
let previous = "";
|
|
35
|
+
const endpoints = entries.map((ep) => {
|
|
36
|
+
if (!ep || typeof ep !== "object" || Array.isArray(ep) || typeof ep.id !== "string" || !MODEL_ENDPOINT_ID_RE.test(ep.id) || ep.id <= previous || !Number.isInteger(ep.slots) || ep.slots < 1 || ep.slots > MAX_SLOTS || !integer(ep.used) || ep.used > MAX_SLOTS || ![0, 1].includes(ep.fleet)) throw Error();
|
|
37
|
+
previous = ep.id;
|
|
38
|
+
return { id: ep.id, slots: ep.slots, used: ep.used, fleet: ep.fleet };
|
|
39
|
+
});
|
|
40
|
+
return { state: "reported", endpoints, endpointsMore: more };
|
|
41
|
+
} catch { return { ...absent, state: "unreadable" }; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function bounded(promise, ms) {
|
|
45
|
+
let timer;
|
|
46
|
+
return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(Error("endpoint status timeout")), ms); })]).finally(() => clearTimeout(timer));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read at most 64 rows, known live hosts first, without pruning or touching the registry's other projections. */
|
|
50
|
+
export async function readEndpointStatus(redis, options = {}) {
|
|
51
|
+
let stopped = false;
|
|
52
|
+
try { return await bounded(readEndpointStatusInner(redis, { ...options, stopped: () => stopped }), options.timeoutMs ?? 2500); }
|
|
53
|
+
catch { return { state: "unknown", hosts: [], more: 0, partial: true }; }
|
|
54
|
+
finally { stopped = true; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readEndpointStatusInner(redis, { hosts = [], now = () => Date.now(), timeoutMs = 2500, stopped } = {}) {
|
|
58
|
+
let indexed = [];
|
|
59
|
+
let partial = false;
|
|
60
|
+
let cardinality = null;
|
|
61
|
+
try {
|
|
62
|
+
cardinality = await bounded(redis.scard(HOST_SET), timeoutMs);
|
|
63
|
+
if (!integer(cardinality)) throw Error();
|
|
64
|
+
let cursor = "0";
|
|
65
|
+
for (let page = 0; page < 4 && !stopped(); page++) {
|
|
66
|
+
const answer = await bounded(redis.sscan(HOST_SET, cursor, "COUNT", ENDPOINT_STATUS_HOSTS_MAX), timeoutMs);
|
|
67
|
+
if (!Array.isArray(answer) || typeof answer[0] !== "string" || !Array.isArray(answer[1])) throw Error();
|
|
68
|
+
indexed.push(...answer[1].slice(0, ENDPOINT_STATUS_HOSTS_MAX));
|
|
69
|
+
if (answer[1].length > ENDPOINT_STATUS_HOSTS_MAX) partial = true;
|
|
70
|
+
cursor = answer[0];
|
|
71
|
+
if (cursor === "0") break;
|
|
72
|
+
if (page === 3) partial = true;
|
|
73
|
+
}
|
|
74
|
+
} catch { partial = true; }
|
|
75
|
+
const valid = (name) => typeof name === "string" && WORKER_NAME_RE.test(name);
|
|
76
|
+
if (indexed.some((name) => !valid(name))) partial = true;
|
|
77
|
+
const known = [...new Set(hosts.map((h) => typeof h === "string" ? h : h?.name).filter(valid))].sort();
|
|
78
|
+
const names = [...new Set([...known, ...indexed.filter(valid).sort()])];
|
|
79
|
+
const more = Math.max(0, Math.max(cardinality ?? 0, names.length) - Math.min(names.length, ENDPOINT_STATUS_HOSTS_MAX));
|
|
80
|
+
const selected = names.slice(0, ENDPOINT_STATUS_HOSTS_MAX);
|
|
81
|
+
if (!selected.length) return { state: partial ? "unknown" : "reported", hosts: [], more, partial: partial || more > 0 };
|
|
82
|
+
if (stopped()) return { state: "unknown", hosts: [], more, partial: true };
|
|
83
|
+
const rows = await Promise.all(selected.map(async (name) => {
|
|
84
|
+
let row;
|
|
85
|
+
let ttl;
|
|
86
|
+
try {
|
|
87
|
+
[row, ttl] = await bounded(Promise.all([redis.hgetall(hostKey(name)), redis.pttl(hostKey(name))]), timeoutMs);
|
|
88
|
+
} catch { partial = true; return { name, at: null, ageMs: null, state: "unreadable", endpoints: null, endpointsMore: null }; }
|
|
89
|
+
if (!row || Object.keys(row).length === 0 || ttl === -2) {
|
|
90
|
+
return known.includes(name) ? { name, at: null, ageMs: null, state: "not-reported", endpoints: null, endpointsMore: null } : null;
|
|
91
|
+
}
|
|
92
|
+
const at = /^(?:0|[1-9]\d*)$/.test(String(row.beatAt)) && integer(Number(row.beatAt)) ? Number(row.beatAt) : null;
|
|
93
|
+
const ageMs = Number.isSafeInteger(ttl) && ttl >= 0 && ttl <= HOST_TTL_MS ? HOST_TTL_MS - ttl : null;
|
|
94
|
+
const parsed = parseEndpointStatus(row);
|
|
95
|
+
if (ageMs === null || row.name !== name) { parsed.state = "unreadable"; parsed.endpoints = null; partial = true; }
|
|
96
|
+
if (["unsupported", "unreadable"].includes(parsed.state) || parsed.endpointsMore > 0) partial = true;
|
|
97
|
+
return { name, at, ageMs, ...parsed };
|
|
98
|
+
}));
|
|
99
|
+
return { state: partial && !rows.filter(Boolean).length ? "unknown" : "reported", hosts: rows.filter(Boolean), more, partial: partial || more > 0 };
|
|
100
|
+
}
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// env-internal PI_SUBSCRIPTION_AUTH: host-approved access snapshot metadata, never an operator override.
|
|
1
2
|
/**
|
|
2
3
|
* Build the EXACT environment a job container receives. Never a pass-through.
|
|
3
4
|
*
|
|
@@ -29,8 +30,9 @@ import { homedir } from "node:os";
|
|
|
29
30
|
import { join } from "node:path";
|
|
30
31
|
import { findEnvKeys } from "@earendil-works/pi-ai/compat";
|
|
31
32
|
import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
33
|
+
import { codemodeWorkersOf, DEFAULT_JOB_SIZE } from "./job-size.mjs";
|
|
32
34
|
import { egressEnv } from "./egress.mjs";
|
|
33
|
-
import { forgeSpec } from "./forges.mjs";
|
|
35
|
+
import { RECEIVER_ONLY_VARS, forgeSpec } from "./forges.mjs";
|
|
34
36
|
import { apiKeyVariable } from "./provider-key.mjs";
|
|
35
37
|
import { isDeterminateFsCode } from "./transient.mjs";
|
|
36
38
|
import { KEYLESS_HOW, keylessVerdict, providerRenameHint, readOverlayModels, RENAMED_PROVIDERS } from "./model-endpoints.mjs";
|
|
@@ -351,6 +353,13 @@ function resolveEnvName(provider) {
|
|
|
351
353
|
return apiKeyVariable(providerKeyCandidates(provider));
|
|
352
354
|
}
|
|
353
355
|
|
|
356
|
+
/** PI_JOB_LAYOUT's value for `layout`: absent for the default, the name for `sandbox`, refused for anything else. */
|
|
357
|
+
function layoutEnv(layout) {
|
|
358
|
+
if (layout === "mounts") return undefined;
|
|
359
|
+
if (layout === "sandbox") return "sandbox";
|
|
360
|
+
throw new Error(`buildContainerEnv: unknown job layout ${JSON.stringify(layout)} (want "mounts" or "sandbox")`);
|
|
361
|
+
}
|
|
362
|
+
|
|
354
363
|
/**
|
|
355
364
|
* Assemble the container env. `hostEnv` is the worker's process.env; `job` carries the resolved
|
|
356
365
|
* config and the per-job scoped token (GitHub-backed jobs, and local cron jobs that opted in via
|
|
@@ -367,18 +376,25 @@ function resolveEnvName(provider) {
|
|
|
367
376
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
368
377
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
369
378
|
*/
|
|
370
|
-
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
379
|
+
export function buildContainerEnv({ size = DEFAULT_JOB_SIZE, provider, secondaryProvider = null, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], packageCanaryPath = undefined, forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], excludeExtensions = [], excludeExtensionTools = [], excludeToolsStrict = false, mcpServers = [], awaitInput = false, approvalGates = null, approvalDecision = null, reportProgress = false, recover = false, recovering = false, steer = false, layout = "mounts", allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false, bundleEnvFile = false, observableRunner = false, fork = null, thinkingLevel = null, subscriptionAuth = [] }) {
|
|
371
380
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
372
381
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
373
382
|
// neither source yields one, which the processor turns into a policy refusal that refunds any reserve
|
|
374
383
|
// (issue #310); the same call is made by its free credential gate, before anything is reserved at all.
|
|
375
384
|
// `modelEndpoints` is the pickup's snapshot (issue #503): the keyless branch answers `PI_DISPATCH_KEYLESS` in this
|
|
376
385
|
// map, so the variable is set exactly when the gate passed keyless and is absent on every other job.
|
|
377
|
-
const
|
|
386
|
+
const selectedAuth = subscriptionAuth.find((entry) => entry.provider === provider);
|
|
387
|
+
const credEnv = selectedAuth ? selectedAuth.env : resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv, modelEndpoints });
|
|
388
|
+
// A reviewed watch classifier may use another provider. Resolve only that provider, never a blanket key copy.
|
|
389
|
+
const secondaryAuth = subscriptionAuth.find((entry) => entry.provider === secondaryProvider);
|
|
390
|
+
const secondaryCredEnv = secondaryAuth ? secondaryAuth.env : secondaryProvider && secondaryProvider !== provider ? resolveProviderCredential({ provider: secondaryProvider, hostEnv, authFromPi, agentDir, readFile, forwardEnv: [], modelEndpoints }) : {};
|
|
378
391
|
|
|
379
392
|
const env = {
|
|
380
393
|
PI_PROVIDER: provider,
|
|
381
394
|
PI_MODEL: model,
|
|
395
|
+
PI_FORK_ENTRY: recovering === true ? undefined : fork?.fromEntry,
|
|
396
|
+
PI_FORK_POSITION: recovering === true ? undefined : fork?.position,
|
|
397
|
+
PI_THINKING_LEVEL: thinkingLevel ?? undefined,
|
|
382
398
|
PI_MAX_TURNS: String(maxTurns),
|
|
383
399
|
// The optional per-job token budget (issue #25). Absent/null => variable omitted (docker-run skips
|
|
384
400
|
// undefined), so the runner attaches a pure meter with no cap. Never an empty string.
|
|
@@ -400,9 +416,14 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
400
416
|
// never PI_PACKAGES=. The delimiter is ":" because these are CONTAINER (POSIX) paths -- never the
|
|
401
417
|
// host's path.delimiter, which is ";" on Windows.
|
|
402
418
|
PI_PACKAGES: packagePaths.length > 0 ? packagePaths.join(":") : undefined,
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
//
|
|
419
|
+
// Issue #796: which of those paths is a canary's candidate, so the runner can say whether it loaded. Only on a canary job.
|
|
420
|
+
PI_PACKAGE_CANARY: packageCanaryPath,
|
|
421
|
+
// The transcript file inside the /session mount (REQ-RESUMABLE-SESSION). Emitted ONLY when this job
|
|
422
|
+
// actually has one -- absent means the runner builds pi's ephemeral in-memory session. Since issue #643
|
|
423
|
+
// every prepared job has one (a resume job's staged transcript, or every other job's empty live file,
|
|
424
|
+
// which the worker tails for the live stream and, since issue #644, for run progress), with one exception: with
|
|
425
|
+
// both PI_LIVE_TRANSCRIPTS=0 and PI_RUN_PROGRESS=0 no live file is staged (start.mjs hands the preparer
|
|
426
|
+
// `stageLive: null`), so a non-resume job then has none, as before the feature. Otherwise absence is a caller that staged none, or a staging that failed. Never
|
|
406
427
|
// an empty string, for PI_PACKAGES' reason: an empty value is a third state neither side reads the
|
|
407
428
|
// same way, and the one reading a container must not have to infer which was meant.
|
|
408
429
|
PI_SESSION_FILE: sessionFile || undefined,
|
|
@@ -429,6 +450,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
429
450
|
// second-validator rule directly above). Absent means the full pinned default set, never an empty
|
|
430
451
|
// string, for PI_PACKAGES' reason.
|
|
431
452
|
PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
|
|
453
|
+
// The trigger's run.excludeExtensions and run.excludeExtensionTools (issue #800): the overlay names the runner leaves out
|
|
454
|
+
// before any factory runs, and the tools it adds to the list above. Comma-safe for that list's reason (the loader's grammar
|
|
455
|
+
// holds no comma). Absent means none, never an empty string, for PI_PACKAGES' reason. PI_EXCLUDE_TOOLS_STRICT is "1" only
|
|
456
|
+
// where a name of the second list that no staged package or overlay extension brought can only be wrong (run-container.mjs says when).
|
|
457
|
+
PI_EXCLUDE_EXTENSIONS: excludeExtensions.length > 0 ? excludeExtensions.join(",") : undefined,
|
|
458
|
+
PI_EXCLUDE_EXTENSION_TOOLS: excludeExtensionTools.length > 0 ? excludeExtensionTools.join(",") : undefined,
|
|
459
|
+
PI_EXCLUDE_TOOLS_STRICT: excludeToolsStrict === true ? "1" : undefined,
|
|
432
460
|
// The trigger's run.mcp (REQ-MCP-OPERATOR-SERVERS): the overlay servers the job's session connects, so the
|
|
433
461
|
// runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
|
|
434
462
|
// admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
|
|
@@ -436,6 +464,26 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
436
464
|
// The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
|
|
437
465
|
// armed, never "0" or an empty string, for PI_PACKAGES' reason.
|
|
438
466
|
PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
|
|
467
|
+
PI_APPROVAL_GATES: approvalGates ? JSON.stringify(approvalGates) : undefined,
|
|
468
|
+
// Human text travels in the existing read-only event file, never the gateway's logged exec argv.
|
|
469
|
+
PI_APPROVAL_DECISION: approvalDecision ? JSON.stringify({ requestId: approvalDecision.requestId, action: approvalDecision.action, toolCallId: approvalDecision.toolCallId, decision: approvalDecision.decision }) : undefined,
|
|
470
|
+
// The trigger's run.progress (issue #644): "1" offers the job the report_progress tool. Absent means not armed,
|
|
471
|
+
// PI_AWAIT_INPUT's rule.
|
|
472
|
+
PI_REPORT_PROGRESS: reportProgress === true ? "1" : undefined,
|
|
473
|
+
// Issue #645, durable runs, two inputs the runner reads apart. PI_RECOVER: the trigger armed run.recover, so the
|
|
474
|
+
// runner keeps checkpoints a lost run can continue from. PI_RECOVERING: THIS container is that continuation, so
|
|
475
|
+
// the runner resumes the checkpoint instead of starting the prompt over. Each "1" or absent, PI_AWAIT_INPUT's rule;
|
|
476
|
+
// strictly `=== true`, so a truthy non-boolean an upstream bug passed arms nothing.
|
|
477
|
+
PI_RECOVER: recover === true ? "1" : undefined,
|
|
478
|
+
PI_RECOVERING: recovering === true ? "1" : undefined,
|
|
479
|
+
// Issue #646: the trigger armed run.steer, so the runner delivers the files the worker writes into its read-only
|
|
480
|
+
// /inbox mount. "1" or absent, PI_AWAIT_INPUT's rule, strictly `=== true` for PI_RECOVER's reason.
|
|
481
|
+
PI_STEER: steer === true ? "1" : undefined,
|
|
482
|
+
// Issue #683: which file layout the runner reads (worker/src/job-layout.mjs), set only for a venue that copies the
|
|
483
|
+
// job's files (`sandbox`). Absent is `mounts`, so every binding venue's env is byte-identical to one built before
|
|
484
|
+
// the variable existed. Any other value is the caller's bug and is refused rather than written: the runner would
|
|
485
|
+
// refuse it too, but only inside a container the budget already paid for.
|
|
486
|
+
PI_JOB_LAYOUT: layoutEnv(layout),
|
|
439
487
|
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
440
488
|
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
441
489
|
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|
|
@@ -453,6 +501,7 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
453
501
|
|
|
454
502
|
// The provider credential(s), under pi's expected variable name(s), so pi's own auth resolution finds them.
|
|
455
503
|
Object.assign(env, credEnv);
|
|
504
|
+
Object.assign(env, secondaryCredEnv);
|
|
456
505
|
|
|
457
506
|
// Operator-declared extra vars (PI_FORWARD_ENV), forwarded by EXACT name -- the allowlist
|
|
458
507
|
// no-broad-env-into-container prescribes, not a host pass-through. This is how a CUSTOM provider's
|
|
@@ -494,9 +543,20 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
494
543
|
// PI_SESSION_FILE and PI_FLOW already keep. The resolver guarantees non-empty; this is the defense in
|
|
495
544
|
// depth at the DI seam that the empty-token guard keeps for the mint.
|
|
496
545
|
for (const [name, value] of Object.entries(secrets ?? {})) {
|
|
546
|
+
if (secondaryProvider && providerKeyCandidates(secondaryProvider).includes(name)) throw configError(`watch classifier credential variable ${name} is reserved`);
|
|
497
547
|
if (typeof value === "string" && value !== "") env[name] = value;
|
|
498
548
|
}
|
|
499
549
|
|
|
550
|
+
// An explicit subscription binding is authoritative, including against host API keys and trigger secrets.
|
|
551
|
+
// Only access snapshots cross this boundary; the runner receives closed metadata to check before loading code.
|
|
552
|
+
if (subscriptionAuth.length > 0) {
|
|
553
|
+
for (const snapshot of subscriptionAuth) {
|
|
554
|
+
for (const name of providerKeyCandidates(snapshot.provider)) delete env[name];
|
|
555
|
+
Object.assign(env, snapshot.env);
|
|
556
|
+
}
|
|
557
|
+
env.PI_SUBSCRIPTION_AUTH = JSON.stringify(subscriptionAuth.map(({ provider, connectionId, adapter, generation, expiresAt, deadlineAt, root, digest }) => ({ provider, connectionId, adapter, generation, expiresAt, deadlineAt, ...(root ? { root, digest } : {}) })));
|
|
558
|
+
}
|
|
559
|
+
|
|
500
560
|
// The shipped egress policy's variables (REQ-EGRESS-ALLOWLIST), AFTER the PI_FORWARD_ENV loop so a
|
|
501
561
|
// forwarded name can never override them -- the same ordering, for the same reason, as the minted token
|
|
502
562
|
// below (and loadConfig refuses those names outright while the policy is armed anyway).
|
|
@@ -544,9 +604,39 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
544
604
|
// the cap above, and ONLY for a run the worker actually hands a key, so a runner never blocks on a stdin no one
|
|
545
605
|
// writes. `=== true`, so only run-container's own boolean asks for it.
|
|
546
606
|
// env-internal PI_EXIT_AUTH: written into the job's closed env map here, never read from the worker's environment.
|
|
547
|
-
|
|
607
|
+
// Issue #686: `stdin-frames` names the stdin frame channel instead, the key as frame 1 and the pipe left open for
|
|
608
|
+
// message frames: only for an attempt run-container armed for stdin steering (`steerStdin`, a copying venue and an
|
|
609
|
+
// image declaring it), and only beside PI_STEER=1, so every other attempt's value stays `stdin`, byte-identical.
|
|
610
|
+
if (exitAuth === true) env.PI_EXIT_AUTH = steerStdin === true && steer === true ? "stdin-frames" : "stdin";
|
|
548
611
|
else delete env.PI_EXIT_AUTH;
|
|
549
612
|
|
|
613
|
+
// Issue #686: the runner mirrors its session file over stderr as signed records, for an attempt run-container armed
|
|
614
|
+
// (a copying venue, an image that declares `transcriptStderr`, and a key). Settled here like PI_EXIT_AUTH, and only
|
|
615
|
+
// beside it: a record is signed with that key, so without one the runner would mirror nothing anyway. `=== true`.
|
|
616
|
+
// env-internal PI_TRANSCRIPT: written into the job's closed env map here, never read from the worker's environment.
|
|
617
|
+
if (transcriptStderr === true && exitAuth === true) env.PI_TRANSCRIPT = "stderr";
|
|
618
|
+
else delete env.PI_TRANSCRIPT;
|
|
619
|
+
|
|
620
|
+
// Issue #704 part 3: the runner reads a project secret bundle's plain variables from the job directory's
|
|
621
|
+
// bundle-env.json, for an attempt run-container armed (a venue that hands them by file, `openshell`, and a bundle
|
|
622
|
+
// with plain variables). Settled after both loops like the two above, so no forwarded or secret value can set it.
|
|
623
|
+
// env-internal PI_BUNDLE_ENV: written into the job's closed env map here, never read from the worker's environment.
|
|
624
|
+
if (bundleEnvFile === true) env.PI_BUNDLE_ENV = "1";
|
|
625
|
+
else delete env.PI_BUNDLE_ENV;
|
|
626
|
+
|
|
627
|
+
// Issue #730: the readable runner node (image/entrypoint.sh), for a venue whose connections are mediated by a broker
|
|
628
|
+
// that reads the caller's memory (OpenShell, both modes), and no other. Settled after both loops like the flags above,
|
|
629
|
+
// so no forwarded or secret value can set it, and deleted otherwise: on every other venue the key holders stay on
|
|
630
|
+
// the exec-only node.
|
|
631
|
+
// env-internal PI_RUNNER_OBSERVABLE: written into the job's closed env map here, never read from the worker's environment.
|
|
632
|
+
if (observableRunner === true) env.PI_RUNNER_OBSERVABLE = "1";
|
|
633
|
+
else delete env.PI_RUNNER_OBSERVABLE;
|
|
634
|
+
|
|
635
|
+
// Issue #693: only the resolved container size chooses this concurrency limit. Set after forwarding and secrets
|
|
636
|
+
// so even a caller bypassing their reservation cannot replace it with a host or trigger value.
|
|
637
|
+
// env-internal PI_CODEMODE_MAX_WORKERS: written into the job's closed env map here, never read from the worker's environment.
|
|
638
|
+
env.PI_CODEMODE_MAX_WORKERS = String(codemodeWorkersOf(size.memMiB));
|
|
639
|
+
|
|
550
640
|
// Issue #500: the runner sets these in its own environment, for its child processes, and no value from outside
|
|
551
641
|
// may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
|
|
552
642
|
// run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
|
|
@@ -558,7 +648,7 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
558
648
|
delete env.PI_DISPATCH_PRICE_TABLE;
|
|
559
649
|
|
|
560
650
|
// Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
|
|
561
|
-
// no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into
|
|
651
|
+
// no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into EVERY one of its forge's variables because
|
|
562
652
|
// each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
|
|
563
653
|
// -- and mirroring forecloses any precedence surprise inside the container.
|
|
564
654
|
//
|
|
@@ -583,8 +673,12 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
583
673
|
}
|
|
584
674
|
for (const name of spec.tokenVars) env[name] = githubToken;
|
|
585
675
|
const host = forgeHosts?.[forgeKind];
|
|
586
|
-
if (spec.
|
|
676
|
+
if (host) for (const name of spec.hostVars) env[name] = host;
|
|
587
677
|
}
|
|
588
678
|
|
|
679
|
+
// OQ-043: the receiver's own lookup credential (FORGEJO_PERMISSION_TOKEN) is an admin account's token and never a
|
|
680
|
+
// job's. PI_FORWARD_ENV and run.secrets both refuse it upstream; this is the line that holds if either is bypassed.
|
|
681
|
+
for (const name of RECEIVER_ONLY_VARS) delete env[name];
|
|
682
|
+
|
|
589
683
|
return env;
|
|
590
684
|
}
|
package/src/env-file.mjs
CHANGED
|
@@ -775,7 +775,7 @@ function keyAlreadySet(text, key, platform) {
|
|
|
775
775
|
* a `# KEY=` comment inside a multi-line value, each left the key unset while the writer reported it written.
|
|
776
776
|
* `verify` lets a caller add its own condition on the new text.
|
|
777
777
|
*/
|
|
778
|
-
function planEnvEdit(raw, path, key, value, { overwrite = false, platform = process.platform, verify } = {}) {
|
|
778
|
+
export function planEnvEdit(raw, path, key, value, { overwrite = false, platform = process.platform, verify } = {}) {
|
|
779
779
|
const refusal = envFileEditRefusal(raw, path);
|
|
780
780
|
if (refusal !== null) return { error: refusal };
|
|
781
781
|
const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
|
|
@@ -2065,14 +2065,16 @@ export function decodeEnvFile(content, { loader = "systemd" } = {}) {
|
|
|
2065
2065
|
* effect, and the reader and writer name it rather than let an operator believe it does anything. PI_ENV_SETUP is on
|
|
2066
2066
|
* both lists: it is unit configuration and never honoured from the file. The cmd list is compared ignoring case, as
|
|
2067
2067
|
* `set` does. Kept beside the wrappers' text by a test, which fails when either wrapper's own variables change.
|
|
2068
|
+
* PI_WRAPPER_NO_DOTENV (issue #708) joins both: the console's unit sets it, the wrappers test it BEFORE the load and
|
|
2069
|
+
* unset it after, so a `.env` line naming it can neither start nor stop the load.
|
|
2068
2070
|
*/
|
|
2069
2071
|
export const WRAPPER_INTERNAL_KEYS = Object.freeze({
|
|
2070
|
-
shell: Object.freeze(["PI_ENV_SETUP", "env_setup", "signaled", "child", "rc"]),
|
|
2071
|
-
cmd: Object.freeze(["PI_ENV_SETUP", "ENV_SETUP", "RC", "ERRORLEVEL"]),
|
|
2072
|
+
shell: Object.freeze(["PI_ENV_SETUP", "PI_WRAPPER_NO_DOTENV", "env_setup", "signaled", "child", "rc"]),
|
|
2073
|
+
cmd: Object.freeze(["PI_ENV_SETUP", "PI_WRAPPER_NO_DOTENV", "ENV_SETUP", "RC", "ERRORLEVEL"]),
|
|
2072
2074
|
});
|
|
2073
2075
|
|
|
2074
2076
|
/** What a line assigning one of the wrapper's own variables is, and what to do, in the words doctor and the writer use. */
|
|
2075
|
-
const WRAPPER_INTERNAL_WHAT = (name) => `assigns ${name}, a variable the service wrapper keeps for itself (deploy/worker-env-wrapper.sh on macOS, .cmd on Windows) and assigns again after loading this file, so the line has no effect${/^pi_env_setup$/i.test(name) ? "; PI_ENV_SETUP is never honoured from this file" : ""}`;
|
|
2077
|
+
const WRAPPER_INTERNAL_WHAT = (name) => `assigns ${name}, a variable the service wrapper keeps for itself (deploy/worker-env-wrapper.sh on macOS, .cmd on Windows) and assigns again after loading this file, so the line has no effect${/^pi_env_setup$/i.test(name) ? "; PI_ENV_SETUP is never honoured from this file" : /^pi_wrapper_no_dotenv$/i.test(name) ? "; PI_WRAPPER_NO_DOTENV is unit configuration (the console's service sets it) and never honoured from this file" : ""}`;
|
|
2076
2078
|
const WRAPPER_INTERNAL_FIX = "remove the line (a setup script is named with pi-dispatch service install --env-setup <path>, never in .env)";
|
|
2077
2079
|
|
|
2078
2080
|
/** A `envFileWrapperInternal` finding as one sentence: the line, what it is, and the fix. */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The re-check a confirmed write makes right before its rename (issue #642): the files the change was BUILT from, as
|
|
3
|
+
* `{ path, text }` snapshots taken before an operator's confirm (the admin's `writeInputs`, stored in the console's
|
|
4
|
+
* ticket), compared with the files as they are now. A difference refuses, with nothing written, so a confirm applies
|
|
5
|
+
* exactly what was previewed and a second session's write in between is a refusal, never a silent lost update.
|
|
6
|
+
*
|
|
7
|
+
* One encoding, the admin's `fileSnapshot` (admin/src/read-model.mjs), and a test holds the two to it: the text, `null`
|
|
8
|
+
* for a file that does not exist (ENOENT and only ENOENT), or `UNREADABLE` plus the error code for any other read
|
|
9
|
+
* failure, a string no file's text can equal. A snapshot taken by one and compared by the other must agree, because the
|
|
10
|
+
* console takes it through the admin and the triggers and settings writers compare it here.
|
|
11
|
+
*
|
|
12
|
+
* Pure apart from the reads, and import-free, so `runtime-settings.mjs` (which the admin bundles) can take it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What `fileSnapshot` returns for a file that is there but cannot be read: a string no file's text can equal. */
|
|
16
|
+
const UNREADABLE = "\u0000unreadable:";
|
|
17
|
+
|
|
18
|
+
/** A file's text, `null` when it does not exist, or `UNREADABLE` + the error code for any other read failure. */
|
|
19
|
+
export function fileSnapshot(fs, path) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.readFileSync(path, "utf8");
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e?.code === "ENOENT") return null;
|
|
24
|
+
return `${UNREADABLE}${typeof e?.code === "string" ? e.code : "error"}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The refusal for the first snapshot in `expect` whose file changed, or null when every one still holds (and when
|
|
30
|
+
* `expect` is not an array: no expectation, nothing to refuse). Every confirmed write says a lost race in these words:
|
|
31
|
+
* the triggers and settings writers here, `writePauseWindows` and `replaceFile` through the package's `./file-expect`.
|
|
32
|
+
*
|
|
33
|
+
* A difference one side of which is a failed read is no lost race, and "another session wrote it" would send a person
|
|
34
|
+
* looking for a session that never ran, so it says what happened. Two failed reads with one code still compare equal: a
|
|
35
|
+
* writer that means to replace the file has read it first, and refuses there.
|
|
36
|
+
*/
|
|
37
|
+
export function expectRefusal(fs, expect) {
|
|
38
|
+
if (!Array.isArray(expect)) return null;
|
|
39
|
+
for (const input of expect) {
|
|
40
|
+
const now = fileSnapshot(fs, input?.path);
|
|
41
|
+
if (now === input?.text) continue;
|
|
42
|
+
const failed = [now, input?.text].find((snapshot) => typeof snapshot === "string" && snapshot.startsWith(UNREADABLE));
|
|
43
|
+
if (failed !== undefined) return `${input?.path} could not be read (${failed.slice(UNREADABLE.length)}) when this change was checked against it; nothing was written. Look again and retry`;
|
|
44
|
+
return `${input?.path} changed after this change was built from it (another session wrote it); nothing was written. Look again and retry`;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
package/src/forgejo-auth.mjs
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* - host-held YES; it lives in the worker's env and reaches a container only as an env value.
|
|
17
17
|
* - env-injected YES; never written to /workspace, .git/config, argv, or a log.
|
|
18
18
|
* - not merge-capable YES in practice; branch protection is the barrier and no merge API is called.
|
|
19
|
+
* Provided this account stays a WRITE collaborator: the author gate's lookup needs a
|
|
20
|
+
* repository admin, and asks with the receiver's own FORGEJO_PERMISSION_TOKEN for that.
|
|
21
|
+
* Without it the lookup falls back to this token, whose account must then be an admin,
|
|
22
|
+
* and an admin's write:repository token can lift that protection (OQ-043).
|
|
19
23
|
* - short-lived NO. The operator mints it by hand. Forgejo offers no installation-token
|
|
20
24
|
* equivalent, so there is no path to a bounded expiry at all.
|
|
21
25
|
*
|