@edgehero/pi-dispatch 4.3.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { realpathSync } from "node:fs";
|
|
17
17
|
import { execDockerBounded } from "./backend-local.mjs";
|
|
18
|
+
import { PODMAN_RESTART_HOLD_EXPIRED } from "./comment-tables.mjs";
|
|
18
19
|
import { DAEMON_APPLIES_BOUNDS, DOCKER_ENDPOINT_LOCAL, PODMAN_CONF_WIDENS_JOB, PODMAN_ROOTFUL_WIDENING_KEYS, RUNTIME_ADDS_NO_MOUNTS } from "./backends.mjs";
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -1123,7 +1124,8 @@ export function rootfulConfResidual(unread) {
|
|
|
1123
1124
|
*/
|
|
1124
1125
|
export const PODMAN_RESTART_HOLD_RECHECK_MS = 60_000;
|
|
1125
1126
|
export const PODMAN_RESTART_HOLD_MAX_MS = 3_600_000;
|
|
1126
|
-
|
|
1127
|
+
// The expiry's reason token lives in comment-tables.mjs beside its comment (issue #710); re-exported under its old name.
|
|
1128
|
+
export { PODMAN_RESTART_HOLD_EXPIRED };
|
|
1127
1129
|
|
|
1128
1130
|
/** Whether a rootful finding holds jobs back only until something heals by itself (a retry), not a configuration fix. */
|
|
1129
1131
|
export function rootfulConfRetries(found) {
|
package/src/runtime-settings.mjs
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
import * as nodeFs from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import { ensureUnderAccountRoot } from "./config.mjs";
|
|
5
|
+
import { expectRefusal } from "./file-expect.mjs";
|
|
4
6
|
import { findDuplicateKey } from "./json-duplicates.mjs";
|
|
7
|
+
import { modelIdProblem, providerIdProblem } from "./model-ref.mjs";
|
|
5
8
|
import { checkDollarInvariant, parseUsdMicros } from "./money.mjs";
|
|
9
|
+
import { BUNDLE_FORMATS } from "./secret-profiles.mjs";
|
|
10
|
+
import { neededVersion, underDeclared, valueNeededVersion, valueUnderDeclared, versionProblem } from "./store-version.mjs";
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Runtime-settings overlay: the shared, durable truth between the admin extension and the worker
|
|
9
|
-
* (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json`
|
|
10
|
-
*
|
|
11
|
-
* `monthlyCap`, `maxTokens`, `dailyTokenCap` (int >= 1), `concurrency` (int 1-10), `softHoldPct`
|
|
14
|
+
* (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json` of optional keys:
|
|
15
|
+
* the fourteen settings in `KNOWN_KEYS` -- `model`, `provider` (non-empty strings), `maxTurns`, `dailyCap`,
|
|
16
|
+
* `weeklyCap`, `monthlyCap`, `maxTokens`, `dailyTokenCap` (int >= 1), `concurrency` (int 1-10), `softHoldPct`
|
|
12
17
|
* (int 1-99), and the dollar keys `maxCostUsd`, `dailyCostUsd`, `weeklyCostUsd`, `monthlyCostUsd`
|
|
13
|
-
* (a decimal string or number, `parseUsdMicros`) --
|
|
18
|
+
* (a decimal string or number, `parseUsdMicros`) -- the two operator-only resolver tables in
|
|
19
|
+
* `OPERATOR_ONLY_KEYS` (`secretProfiles`, `secretBundles`), and an optional `version` that describes the file
|
|
20
|
+
* rather than setting anything (issue #761, `SETTINGS_VERSION`). Read by the worker at each job start and written
|
|
14
21
|
* atomically by the admin extension (tmp + rename). `maxTokens`/`dailyTokenCap` are the optional token
|
|
15
22
|
* controls (issue #25); the dollar keys are issue #501's (a window needs `maxCostUsd`, checked on the merged
|
|
16
23
|
* values by `resolveSettings`).
|
|
17
24
|
*
|
|
25
|
+
* A key this build does not know is dropped from the overlay it hands out and logged, and a writer given the
|
|
26
|
+
* `carry` its read returned writes that key back exactly as it found it, in its place (issue #761): the file is shared
|
|
27
|
+
* by every console, panel and worker of a deployment, and a writer from an older release must not delete a key a newer
|
|
28
|
+
* one added just because it cannot read it.
|
|
29
|
+
*
|
|
18
30
|
* `readOverlay` NEVER throws: a bad settings file returns a discriminated `{ invalid }` rather than an
|
|
19
31
|
* exception, so the processor RETURNS a policy refusal (`settings-overlay-invalid`) instead of letting
|
|
20
32
|
* its catch classify the file as retryable infra and pay to retry a job that can never succeed
|
|
@@ -45,8 +57,43 @@ export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyC
|
|
|
45
57
|
*
|
|
46
58
|
* A profile is declared through the operator-typed `/dispatch` command instead, which pi reaches only from
|
|
47
59
|
* its own user-input path, and every declared path is bounded by PI_SECRET_RESOLVER_ROOTS in the worker.
|
|
60
|
+
*
|
|
61
|
+
* `secretBundles` (issue #704) is the same capability for a bundle resolver, `{ [name]: { path, format } }`, and is
|
|
62
|
+
* operator-only for the same reason: its path is an exec, bounded by the same roots.
|
|
63
|
+
*/
|
|
64
|
+
export const OPERATOR_ONLY_KEYS = ["secretProfiles", "secretBundles"];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The highest overlay version this build reads (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`). A file without
|
|
68
|
+
* `version` is version 1, and a writer writes `version` only when the content needs more than 1, so every overlay of
|
|
69
|
+
* today's keys stays byte-identical.
|
|
70
|
+
*
|
|
71
|
+
* `version` is deliberately NOT in `KNOWN_KEYS`: it describes the file, never a setting, so no tool, command or dialog
|
|
72
|
+
* can set it, and it never reaches the overlay a reader hands out. The writer computes it from what it writes.
|
|
73
|
+
*/
|
|
74
|
+
export const SETTINGS_VERSION = 1;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Each overlay key whose ABSENCE an older build would misread (one that narrows: a cap, a refusal, a list a job is
|
|
78
|
+
* held to), with the version that introduced it. A key that is harmless when absent declares nothing and is simply
|
|
79
|
+
* carried by older writers. Empty until a key needs one; raising `SETTINGS_VERSION` and adding the row is one commit.
|
|
48
80
|
*/
|
|
49
|
-
export const
|
|
81
|
+
export const SETTINGS_KEY_VERSIONS = Object.freeze({});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Each EXISTING key whose range a later release widens, as `key -> (value) => version`: the version a value needs.
|
|
85
|
+
* A key-name table cannot say this, and an older build meeting the wider value refuses the whole overlay (every job
|
|
86
|
+
* refused `settings-overlay-invalid`) with no stamp to say an upgrade is the fix. So a rule here makes the writer stamp
|
|
87
|
+
* the version for exactly the values that need it. The same holds for a new field inside a closed nested shape
|
|
88
|
+
* (a `secretBundles` entry refuses an unknown field). Empty until a value needs one.
|
|
89
|
+
*/
|
|
90
|
+
export const SETTINGS_VALUE_VERSIONS = Object.freeze({});
|
|
91
|
+
|
|
92
|
+
/** The lowest version a VALIDATED overlay needs, from its keys and the value rules: 1 while nothing needs more. */
|
|
93
|
+
export function settingsVersionFor(overlay, { table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
|
|
94
|
+
if (overlay === null || typeof overlay !== "object") return 1;
|
|
95
|
+
return Math.max(neededVersion(Object.keys(overlay), table), valueNeededVersion(overlay, valueTable));
|
|
96
|
+
}
|
|
50
97
|
|
|
51
98
|
// The dollar keys' near-miss spellings (issue #501), already normalised (`overlayKeyShape`), each mapped to the key it
|
|
52
99
|
// meant: the four keys, their env names (`PI_MAX_COST_USD` pasted into settings.json is the likeliest slip of all),
|
|
@@ -81,10 +128,6 @@ function overlayKeyShape(key) {
|
|
|
81
128
|
return out.replace(/[_\-. ]/g, "");
|
|
82
129
|
}
|
|
83
130
|
|
|
84
|
-
function isNonEmptyString(value) {
|
|
85
|
-
return typeof value === "string" && value.trim() !== "";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
131
|
function isIntAtLeast(value, min) {
|
|
89
132
|
return Number.isInteger(value) && value >= min;
|
|
90
133
|
}
|
|
@@ -105,24 +148,63 @@ function isIntInRange(value, min, max) {
|
|
|
105
148
|
export { settingsFilePath } from "./config.mjs";
|
|
106
149
|
|
|
107
150
|
/**
|
|
108
|
-
* Validate a parsed overlay against the key contract,
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
151
|
+
* Validate a parsed overlay FILE against the key contract: `{ overlay, unknownKeys }` (the sanitized overlay of known
|
|
152
|
+
* keys only, and the names this build does not know, in file order), `{ invalid }`, or `{ invalid, newer: true }` for
|
|
153
|
+
* a file written by a newer pi-dispatch. Shared by `readOverlayDocument` (post-parse) and `writeOverlay`'s re-read of
|
|
154
|
+
* its own output, so a single definition governs both directions. Unknown keys are dropped from `overlay` and logged,
|
|
155
|
+
* leaving it valid; any invalid known key fails the WHOLE object.
|
|
156
|
+
*
|
|
157
|
+
* The VERSION IS JUDGED FIRST, before any key (issue #761). A newer file may hold a value this build's rules refuse (a
|
|
158
|
+
* range a later release widened), and judged key by key it would read as broken when the truth is "upgrade". A broken
|
|
159
|
+
* `version` (not an integer of 1 or more) is an ordinary `{ invalid }`. Then, after the keys, a known key or value
|
|
160
|
+
* that needs a higher version than the file declares is refused by name: such a file was written by hand or by a
|
|
161
|
+
* writer that dropped the stamp, and an older build would read it without the key.
|
|
112
162
|
*/
|
|
113
|
-
function validateOverlay(
|
|
163
|
+
export function validateOverlay(parsed, log = () => {}, { supported = SETTINGS_VERSION, table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
|
|
164
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
165
|
+
return { invalid: "root must be a JSON object" };
|
|
166
|
+
}
|
|
167
|
+
const declared = Object.hasOwn(parsed, "version") ? parsed.version : undefined;
|
|
168
|
+
const problem = versionProblem(declared, { supported, what: "settings file" });
|
|
169
|
+
if (problem) return problem.newer ? { invalid: problem.message, newer: true } : { invalid: problem.message };
|
|
170
|
+
const result = validateKeys(parsed, log);
|
|
171
|
+
if (result.invalid) return result;
|
|
172
|
+
const short = underDeclared(Object.keys(result.overlay), table, declared);
|
|
173
|
+
if (short) return { invalid: `${short.field} needs settings version ${short.needs}, and the file declares ${declared ?? 1}` };
|
|
174
|
+
const shortValue = valueUnderDeclared(result.overlay, valueTable, declared);
|
|
175
|
+
if (shortValue) return { invalid: `${shortValue.field} holds a value that needs settings version ${shortValue.needs}, and the file declares ${declared ?? 1}` };
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The per-key half of `validateOverlay`, and all of what `writeOverlay` asks of a CANDIDATE: `{ overlay, unknownKeys }`
|
|
181
|
+
* or `{ invalid }`. `version` is skipped here, never copied: the file's is judged by `validateOverlay`, and a
|
|
182
|
+
* candidate's is ignored because the writer computes the version from what it writes.
|
|
183
|
+
*/
|
|
184
|
+
function validateKeys(candidate, log) {
|
|
114
185
|
if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
115
186
|
return { invalid: "root must be a JSON object" };
|
|
116
187
|
}
|
|
117
188
|
const overlay = {};
|
|
189
|
+
const unknownKeys = [];
|
|
118
190
|
for (const key of Object.keys(candidate)) {
|
|
119
191
|
const value = candidate[key];
|
|
120
192
|
switch (key) {
|
|
193
|
+
case "version":
|
|
194
|
+
break;
|
|
121
195
|
case "model":
|
|
122
|
-
case "provider":
|
|
123
|
-
|
|
196
|
+
case "provider": {
|
|
197
|
+
// Issue #673: the one model-id and provider-id shape a trigger's run.model and run.provider, PI_MODEL and
|
|
198
|
+
// PI_PROVIDER are held to (model-ref.mjs), so a run on the overlay's model is one the console's runs
|
|
199
|
+
// filter can find. Here, not at one admin surface, because every write (dispatch_set, /dispatch set,
|
|
200
|
+
// the panel dialog, the console's setting-set) and every read goes through this function: a bad value
|
|
201
|
+
// is refused at write, and one already on disk makes the overlay invalid, the loud path jobs and doctor
|
|
202
|
+
// already take. The value is never echoed. Kept in its original case: only the check lowercases.
|
|
203
|
+
const problem = key === "model" ? modelIdProblem(value) : providerIdProblem(value);
|
|
204
|
+
if (problem !== null) return { invalid: `${key} ${problem}` };
|
|
124
205
|
overlay[key] = value;
|
|
125
206
|
break;
|
|
207
|
+
}
|
|
126
208
|
case "maxTurns":
|
|
127
209
|
case "dailyCap":
|
|
128
210
|
case "weeklyCap":
|
|
@@ -165,6 +247,37 @@ function validateOverlay(candidate, log) {
|
|
|
165
247
|
overlay[key] = { ...value };
|
|
166
248
|
break;
|
|
167
249
|
}
|
|
250
|
+
case "secretBundles": {
|
|
251
|
+
// Issue #704. `{ [name]: { path: "/abs/path", format: "dotenv" | "json" } }`, the panel-authored half of
|
|
252
|
+
// PI_SECRET_BUNDLES. `secretProfiles`' rules (shape here, the path bound in the worker, an invalid value
|
|
253
|
+
// fails the whole overlay), plus two of its own: the format is REQUIRED and never guessed (secret-profiles.mjs
|
|
254
|
+
// `parseSecretBundleProfiles` says why), and an unknown field refuses rather than drops, because a misspelt
|
|
255
|
+
// `fromat` would otherwise read as declared while the entry has no format at all.
|
|
256
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
257
|
+
return { invalid: "secretBundles must be an object mapping bundle profile names to { path, format }" };
|
|
258
|
+
}
|
|
259
|
+
const bundles = {};
|
|
260
|
+
for (const name of Object.keys(value)) {
|
|
261
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
262
|
+
return { invalid: "secretBundles names may use letters, digits, dot, dash and underscore only" };
|
|
263
|
+
}
|
|
264
|
+
const entry = value[name];
|
|
265
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
266
|
+
return { invalid: `secretBundles.${name} must be an object { path, format }` };
|
|
267
|
+
}
|
|
268
|
+
const extra = Object.keys(entry).find((k) => k !== "path" && k !== "format");
|
|
269
|
+
if (extra !== undefined) return { invalid: `secretBundles.${name} has an unknown field (only path and format are read)` };
|
|
270
|
+
if (typeof entry.path !== "string" || entry.path.trim() === "") {
|
|
271
|
+
return { invalid: `secretBundles.${name}.path must be a non-empty absolute path` };
|
|
272
|
+
}
|
|
273
|
+
if (!BUNDLE_FORMATS.includes(entry.format)) {
|
|
274
|
+
return { invalid: `secretBundles.${name}.format must be ${BUNDLE_FORMATS.join(" or ")}` };
|
|
275
|
+
}
|
|
276
|
+
Object.defineProperty(bundles, name, { value: { path: entry.path, format: entry.format }, enumerable: true, writable: true, configurable: true });
|
|
277
|
+
}
|
|
278
|
+
overlay[key] = bundles;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
168
281
|
case "maxCostUsd":
|
|
169
282
|
case "dailyCostUsd":
|
|
170
283
|
case "weeklyCostUsd":
|
|
@@ -196,29 +309,49 @@ function validateOverlay(candidate, log) {
|
|
|
196
309
|
const meant = Object.hasOwn(DOLLAR_NEAR_MISSES, overlayKeyShape(key)) ? DOLLAR_NEAR_MISSES[overlayKeyShape(key)] : undefined;
|
|
197
310
|
if (meant !== undefined) return { invalid: `${key} is not a settings key -- did you mean ${meant}? A dollar cap the worker drops would not apply while the file reads as set` };
|
|
198
311
|
log("settings_overlay_unknown_key", { key });
|
|
312
|
+
// Issue #761: the ONE place that decides a key is unknown, so the list a writer carries cannot drift from
|
|
313
|
+
// this switch. A near miss never gets here (refused above), so it is never carried either.
|
|
314
|
+
unknownKeys.push(key);
|
|
199
315
|
}
|
|
200
316
|
}
|
|
201
317
|
}
|
|
202
|
-
return { overlay };
|
|
318
|
+
return { overlay, unknownKeys };
|
|
203
319
|
}
|
|
204
320
|
|
|
205
321
|
/**
|
|
206
|
-
* Read the overlay at `path
|
|
207
|
-
* a key-only reason
|
|
322
|
+
* Read the overlay at `path` for a writer (issue #761): `{ overlay, carry: { doc, unknownKeys }, text }` on success,
|
|
323
|
+
* `{ invalid }` with a key-only reason, or `{ invalid, newer: true }` for a file written by a newer pi-dispatch.
|
|
324
|
+
* NEVER throws. `carry` is what `writeOverlay` needs to write back every key this build does not know (the parsed
|
|
325
|
+
* document and the unknown names), and `text` is the file as read, for a writer's `expect` snapshot.
|
|
326
|
+
*
|
|
327
|
+
* A missing file (ENOENT) is the normal empty overlay, `{ overlay: {}, carry: null, text: null }` (`null` is
|
|
328
|
+
* `fileSnapshot`'s encoding of a missing file). Any OTHER read error (EACCES, EISDIR, ...) is `{ invalid }`: a
|
|
329
|
+
* present-but-unreadable file must fail closed, not silently become an empty overlay that restores env's higher
|
|
330
|
+
* `dailyCap`. Unparseable JSON, a duplicate key, a non-object root, a broken `version` or an invalid known key is
|
|
331
|
+
* `{ invalid }`.
|
|
208
332
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* become an empty overlay that restores env's higher `dailyCap`. Unparseable JSON, a non-object root,
|
|
212
|
-
* or an invalid known key is `{ invalid }`.
|
|
333
|
+
* `supported`, `table` and `valueTable` default to this build's and are injectable so a test can run the version
|
|
334
|
+
* machinery while the shipped tables are empty.
|
|
213
335
|
*/
|
|
214
|
-
export function
|
|
336
|
+
export function readOverlayDocument(path, { fs = nodeFs, log = () => {}, supported = SETTINGS_VERSION, table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
|
|
215
337
|
let text;
|
|
216
338
|
try {
|
|
217
339
|
text = fs.readFileSync(path, "utf8");
|
|
218
340
|
} catch (err) {
|
|
219
|
-
if (err?.code === "ENOENT") return { overlay: {} }; // missing file is a normal empty overlay
|
|
341
|
+
if (err?.code === "ENOENT") return { overlay: {}, carry: null, text: null }; // missing file is a normal empty overlay
|
|
220
342
|
return { invalid: `settings file unreadable (${err?.code ?? "read-error"})` };
|
|
221
343
|
}
|
|
344
|
+
const read = judgeOverlayText(text, log, { supported, table, valueTable });
|
|
345
|
+
if (read.invalid) return read;
|
|
346
|
+
return { overlay: read.overlay, carry: { doc: read.doc, unknownKeys: read.unknownKeys }, text };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The settings file's TEXT judged as a reader judges it: `{ overlay, unknownKeys, doc }`, `{ invalid }` or
|
|
351
|
+
* `{ invalid, newer: true }`. Shared by the read and by `writeOverlay`'s re-read of its own output, so the writer can
|
|
352
|
+
* never produce a file the reader refuses.
|
|
353
|
+
*/
|
|
354
|
+
function judgeOverlayText(text, log, versions) {
|
|
222
355
|
let parsed;
|
|
223
356
|
try {
|
|
224
357
|
parsed = JSON.parse(text);
|
|
@@ -230,7 +363,24 @@ export function readOverlay(path, { fs = nodeFs, log = () => {} } = {}) {
|
|
|
230
363
|
// is named, never a value. `writeOverlay` serialises an object, so a file it wrote never has one.
|
|
231
364
|
const duplicate = findDuplicateKey(text);
|
|
232
365
|
if (duplicate) return { invalid: `settings file has a duplicate key ${JSON.stringify(duplicate.key)}: JSON keeps the last value, so the file that reads and the file that runs differ` };
|
|
233
|
-
|
|
366
|
+
const result = validateOverlay(parsed, log, versions);
|
|
367
|
+
if (result.invalid) return result;
|
|
368
|
+
return { ...result, doc: parsed };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Read the overlay at `path`. Returns `{ overlay }` (known keys only) on success, or `{ invalid }` with
|
|
373
|
+
* a key-only reason on failure, plus `newer: true` when the file was written by a newer pi-dispatch (issue #761: the
|
|
374
|
+
* worker defers rather than refuses then). NEVER throws. `readOverlayDocument`'s rules, without the carry.
|
|
375
|
+
*
|
|
376
|
+
* A key this build does not know NEVER appears in `overlay`, and that is load-bearing beyond tidiness: the admin's
|
|
377
|
+
* `withoutOperatorOnly` keeps operator-only values out of model-visible views with a BLOCK list, so a future
|
|
378
|
+
* operator-only key, unknown to this build, is kept out only because it never enters the overlay.
|
|
379
|
+
*/
|
|
380
|
+
export function readOverlay(path, { fs = nodeFs, log = () => {} } = {}) {
|
|
381
|
+
const read = readOverlayDocument(path, { fs, log });
|
|
382
|
+
if (read.invalid) return read.newer === true ? { invalid: read.invalid, newer: true } : { invalid: read.invalid };
|
|
383
|
+
return { overlay: read.overlay };
|
|
234
384
|
}
|
|
235
385
|
|
|
236
386
|
/**
|
|
@@ -274,28 +424,97 @@ export function effectiveSettings(config, overlay) {
|
|
|
274
424
|
* `secretProfiles` (REQ-TRIGGER-SECRETS) rides ALONGSIDE the effective keys rather than inside them:
|
|
275
425
|
* `effectiveSettings` resolves `overlay > env` over a fixed literal whose tests pin its key set, and this key
|
|
276
426
|
* deliberately has no such precedence (a name declared in both sources is refused per delivery, not silently
|
|
277
|
-
* won by either).
|
|
427
|
+
* won by either). `secretBundles` (issue #704) rides beside it for the same reason, under the same rule.
|
|
428
|
+
*
|
|
429
|
+
* A file written by a newer pi-dispatch keeps its `newer: true` (issue #761), so the processor can defer the job for an
|
|
430
|
+
* upgrade instead of refusing it; every other invalid read is exactly `{ invalid }`, as it always was.
|
|
278
431
|
*/
|
|
279
432
|
export function resolveSettings(config, read) {
|
|
280
|
-
if (read.invalid) return { invalid: read.invalid };
|
|
433
|
+
if (read.invalid) return read.newer === true ? { invalid: read.invalid, newer: true } : { invalid: read.invalid };
|
|
281
434
|
const settings = effectiveSettings(config, read.overlay);
|
|
282
435
|
const broken = checkDollarInvariant(settings);
|
|
283
436
|
if (broken) return broken;
|
|
284
|
-
return { ...settings, secretProfiles: read.overlay?.secretProfiles ?? {} };
|
|
437
|
+
return { ...settings, secretProfiles: read.overlay?.secretProfiles ?? {}, secretBundles: read.overlay?.secretBundles ?? {} };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The file text for a validated overlay (issue #761): 2-space JSON and a trailing newline.
|
|
442
|
+
*
|
|
443
|
+
* Without `carry` it is exactly `JSON.stringify(validated, null, 2)` plus the newline, the bytes this writer always
|
|
444
|
+
* wrote. With it, the text follows the FILE's key order: walking `carry.doc`, a key this build does not know is written
|
|
445
|
+
* with its value as read, a known key is written from `validated` only if it is still there (so an unset removes it),
|
|
446
|
+
* and `version` is skipped; then the keys `validated` sets that the file did not have are appended in the candidate's
|
|
447
|
+
* order. So a changed key keeps its line, a file of known keys is rewritten byte for byte, and a key from a later
|
|
448
|
+
* release survives a write by an older console. `version` leads, and only when the content needs more than 1: the
|
|
449
|
+
* writer owns it, so a hand-written `"version": 1` is dropped and a stamp whose key was unset goes with it.
|
|
450
|
+
*
|
|
451
|
+
* Built with `Object.defineProperty`, `secretBundles`' precedent above: an assignment to a carried key named
|
|
452
|
+
* `__proto__` would set the result's PROTOTYPE and drop the key from the text, while a defined property stays data.
|
|
453
|
+
* Known residual: JavaScript lists integer-like keys (`"1"`) first in any object, so such a carried key moves to the top.
|
|
454
|
+
*/
|
|
455
|
+
function overlayText(validated, carry, versions) {
|
|
456
|
+
const out = {};
|
|
457
|
+
const put = (key, value) => Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
|
|
458
|
+
const version = settingsVersionFor(validated, versions);
|
|
459
|
+
if (version > 1) put("version", version);
|
|
460
|
+
const doc = carry?.doc;
|
|
461
|
+
if (doc !== null && typeof doc === "object" && !Array.isArray(doc)) {
|
|
462
|
+
const unknown = new Set(Array.isArray(carry.unknownKeys) ? carry.unknownKeys : []);
|
|
463
|
+
for (const key of Object.keys(doc)) {
|
|
464
|
+
if (key === "version") continue;
|
|
465
|
+
if (unknown.has(key)) put(key, doc[key]);
|
|
466
|
+
else if (Object.hasOwn(validated, key)) put(key, validated[key]);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
for (const key of Object.keys(validated)) {
|
|
470
|
+
if (!Object.hasOwn(out, key)) put(key, validated[key]);
|
|
471
|
+
}
|
|
472
|
+
return `${JSON.stringify(out, null, 2)}\n`;
|
|
285
473
|
}
|
|
286
474
|
|
|
287
475
|
/**
|
|
288
476
|
* Write `candidate` to `path` atomically. Validates with the same rules as `readOverlay` (an invalid
|
|
289
477
|
* candidate returns `{ invalid }` and touches no file; an empty `{}` candidate is valid), serialises
|
|
290
|
-
* the validated overlay with 2-space indent and a trailing newline, writes a same-directory
|
|
291
|
-
*
|
|
478
|
+
* the validated overlay with 2-space indent and a trailing newline, writes a same-directory tmp file of
|
|
479
|
+
* its own, then renames it over `path`. NEVER throws.
|
|
480
|
+
*
|
|
481
|
+
* The tmp name is `<path>.<pid>.<random>.tmp`, created exclusively (`wx`), as `writePauseWindows` and the admin's
|
|
482
|
+
* `replaceFile` do (issue #642). It was a fixed `<path>.tmp`, harmless while one process wrote the overlay; the
|
|
483
|
+
* console and the pi extension are two, and a shared name lets one rename the other's bytes past its own re-check.
|
|
484
|
+
* A write that fails after the tmp exists removes it, so a unique name never becomes litter. A file already at the tmp
|
|
485
|
+
* name (another writer's, or one planted there) refuses the write rather than being overwritten, and is left alone:
|
|
486
|
+
* the exclusive create is what makes the name this writer's. `random` is injectable so a test can aim at that name.
|
|
487
|
+
*
|
|
488
|
+
* `expect` (issue #642): the `{ path, text }` snapshots the change was built from (the admin's `writeInputs`, taken
|
|
489
|
+
* before an operator's confirm). Right before the rename each file is re-read, and a difference refuses with nothing
|
|
490
|
+
* written (`file-expect.mjs`). Without it the write is what it always was.
|
|
292
491
|
*
|
|
293
492
|
* The rename is retried once on EPERM to ride out a Windows AV/indexer briefly locking the destination;
|
|
294
493
|
* a second failure surfaces as `{ invalid }`, never a throw. Returns `{ ok: true }` on success.
|
|
494
|
+
*
|
|
495
|
+
* `carry` (issue #761): `readOverlayDocument`'s `carry` for the file the candidate was built from. With it, the text
|
|
496
|
+
* keeps the FILE's key order and every key this build does not know, exactly as read (`overlayText`); without it, the
|
|
497
|
+
* output is what it always was. Only what came from the file is carried: an unknown key on the candidate itself
|
|
498
|
+
* (anything a `mutate` added) is still dropped and logged, so no caller can write an arbitrary key through here. The
|
|
499
|
+
* output is then judged by the reader's own rules before the tmp file exists, so a write never leaves a file the
|
|
500
|
+
* worker would refuse. `table`, `valueTable` and `supported` are the version rules, injectable for tests.
|
|
295
501
|
*/
|
|
296
|
-
export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {} } = {}) {
|
|
297
|
-
const result =
|
|
502
|
+
export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {}, expect = null, carry = null, random = randomBytes, supported = SETTINGS_VERSION, table = SETTINGS_KEY_VERSIONS, valueTable = SETTINGS_VALUE_VERSIONS } = {}) {
|
|
503
|
+
const result = validateKeys(candidate, log);
|
|
298
504
|
if (result.invalid) return { invalid: result.invalid };
|
|
505
|
+
// A carry is only ever a READABLE file's (`readOverlayDocument` refuses the rest), and this keeps it so for any
|
|
506
|
+
// caller: the text below drops the file's `version` and writes its own, so a newer file's carry would otherwise come
|
|
507
|
+
// out stamped as a version this build vouches for, under keys it cannot read.
|
|
508
|
+
const carried = carry?.doc;
|
|
509
|
+
if (carried !== null && typeof carried === "object" && Object.hasOwn(carried, "version")) {
|
|
510
|
+
const problem = versionProblem(carried.version, { supported, what: "settings file" });
|
|
511
|
+
if (problem) return problem.newer ? { invalid: problem.message, newer: true } : { invalid: problem.message };
|
|
512
|
+
}
|
|
513
|
+
const data = overlayText(result.overlay, carry, { table, valueTable });
|
|
514
|
+
// The reader's own judgement of the bytes about to be written (parse, duplicate keys, version, every key), silently:
|
|
515
|
+
// the candidate's unknown keys were logged above, and the carried ones were logged when the file was read.
|
|
516
|
+
const reread = judgeOverlayText(data, () => {}, { supported, table, valueTable });
|
|
517
|
+
if (reread.invalid) return { invalid: reread.invalid };
|
|
299
518
|
|
|
300
519
|
try {
|
|
301
520
|
// Issue #464: on an account with no home the overlay's default lives in the per-account temp root, which is made
|
|
@@ -306,21 +525,37 @@ export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {} } =
|
|
|
306
525
|
return { invalid: err?.piDispatchConfig ? `settings dir refused: ${err.message}` : `settings dir unwritable (${err?.code ?? "mkdir-error"})` };
|
|
307
526
|
}
|
|
308
527
|
|
|
309
|
-
const tmp = `${path}.tmp`;
|
|
310
|
-
const
|
|
528
|
+
const tmp = `${path}.${process.pid}.${random(6).toString("hex")}.tmp`;
|
|
529
|
+
const drop = () => {
|
|
530
|
+
try {
|
|
531
|
+
fs.unlinkSync?.(tmp);
|
|
532
|
+
} catch {
|
|
533
|
+
// nothing to drop
|
|
534
|
+
}
|
|
535
|
+
};
|
|
311
536
|
try {
|
|
312
|
-
fs.writeFileSync(tmp, data);
|
|
537
|
+
fs.writeFileSync(tmp, data, { flag: "wx" });
|
|
313
538
|
} catch (err) {
|
|
314
539
|
return { invalid: `settings write failed (${err?.code ?? "write-error"})` };
|
|
315
540
|
}
|
|
316
541
|
|
|
542
|
+
const changed = expectRefusal(fs, expect);
|
|
543
|
+
if (changed) {
|
|
544
|
+
drop();
|
|
545
|
+
return { invalid: changed };
|
|
546
|
+
}
|
|
547
|
+
|
|
317
548
|
try {
|
|
318
549
|
fs.renameSync(tmp, path);
|
|
319
550
|
} catch (err) {
|
|
320
|
-
if (err?.code !== "EPERM")
|
|
551
|
+
if (err?.code !== "EPERM") {
|
|
552
|
+
drop();
|
|
553
|
+
return { invalid: `settings rename failed (${err?.code ?? "rename-error"})` };
|
|
554
|
+
}
|
|
321
555
|
try {
|
|
322
556
|
fs.renameSync(tmp, path); // single retry: Windows AV/indexer lock is transient
|
|
323
557
|
} catch (retryErr) {
|
|
558
|
+
drop();
|
|
324
559
|
return { invalid: `settings rename failed (${retryErr?.code ?? "rename-error"})` };
|
|
325
560
|
}
|
|
326
561
|
}
|
package/src/sandbox-cli.mjs
CHANGED
|
@@ -2,8 +2,11 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { loadConfig } from "./config.mjs";
|
|
4
4
|
import { basename } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { SANDBOX_OPEN_GRACE_MS, decideSandboxJobUser, launchSandbox, listRunningSandboxes, openSandbox, parsePublish, resolveSandbox, sandboxContainerName, sandboxLauncher, sandboxVenueOf, sandboxVenueRefusal, sandboxVenues } from "./sandbox.mjs";
|
|
6
7
|
import { listSandboxes, pinSandbox, sandboxDeadline } from "./sandbox-store.mjs";
|
|
8
|
+
import { ensureSandboxMonitor, inspectSandboxSession, makeSandboxMonitor } from "./sandbox-session-watch.mjs";
|
|
9
|
+
import { SANDBOX_SESSION_ID_RE } from "./sandbox-sessions.mjs";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* `pi-dispatch sandbox` -- re-open a finished run's sandbox as an interactive shell
|
|
@@ -43,13 +46,50 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
43
46
|
list: { type: "boolean", default: false },
|
|
44
47
|
publish: { type: "string", multiple: true }, // repeatable; always bound to 127.0.0.1
|
|
45
48
|
pin: { type: "boolean", default: false },
|
|
49
|
+
attach: { type: "boolean", default: false },
|
|
50
|
+
new: { type: "string" },
|
|
51
|
+
ref: { type: "string" },
|
|
52
|
+
as: { type: "string" },
|
|
53
|
+
deployment: { type: "string" },
|
|
54
|
+
"expected-host": { type: "string" },
|
|
55
|
+
"opened-by": { type: "string" },
|
|
46
56
|
},
|
|
47
57
|
}));
|
|
48
58
|
} catch (error) {
|
|
49
59
|
return fail(err, error.message);
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
|
|
62
|
+
if (values.deployment) {
|
|
63
|
+
const { sandboxDeploymentEnv } = await import("./sandbox-deployment.mjs");
|
|
64
|
+
const loaded = await (deps.deploymentEnv ?? sandboxDeploymentEnv)({ dir: values.deployment, env, expectedHost: values["expected-host"] });
|
|
65
|
+
if (loaded.refused) return fail(err, loaded.message);
|
|
66
|
+
// Readers and runtime helpers use their process's environment. Re-exec in the clean environment so none
|
|
67
|
+
// can accidentally consult the SSH shell's DOCKER_HOST, egress or rootless store (#771).
|
|
68
|
+
const next = [];
|
|
69
|
+
for (let i = 0; i < argv.length; i++) {
|
|
70
|
+
if (argv[i] === "--deployment" || argv[i] === "--expected-host") { i++; continue; }
|
|
71
|
+
if (argv[i].startsWith("--deployment=") || argv[i].startsWith("--expected-host=")) continue;
|
|
72
|
+
next.push(argv[i]);
|
|
73
|
+
}
|
|
74
|
+
return (deps.runDeployment ?? runDeploymentSandbox)(next, loaded.env, values.deployment);
|
|
75
|
+
}
|
|
76
|
+
if (values["expected-host"]) return fail(err, "--expected-host requires --deployment");
|
|
77
|
+
let config;
|
|
78
|
+
try { config = loadConfig(env); } catch (error) { return fail(err, error.message); }
|
|
79
|
+
if (values.new !== undefined) {
|
|
80
|
+
if (positionals.length || values.list || values.attach || values.publish?.length || values.ref === undefined || values.as === undefined || !/^(0|[1-9][0-9]*)$/.test(values.as)) return fail(err, "--new takes --ref <branch|pr:N|sha> and --as <trigger index>, with no run id, --list, --attach or --publish");
|
|
81
|
+
try {
|
|
82
|
+
const { createSandboxPreparer } = await import("./sandbox-prepare.mjs");
|
|
83
|
+
const prepare = deps.prepare ?? createSandboxPreparer({ config, env });
|
|
84
|
+
const result = await prepare({ repo: values.new, ref: values.ref, triggerIndex: Number(values.as), host: config.workerName, pin: values.pin });
|
|
85
|
+
if (result.refused) return fail(err, result.message);
|
|
86
|
+
out(`prepared ${result.id}, kept until ${new Date(result.until).toISOString()}; no container started\n`);
|
|
87
|
+
out(`open it with: pi-dispatch sandbox ${result.id}\n`);
|
|
88
|
+
return 0;
|
|
89
|
+
} catch { return fail(err, "sandbox preparation failed; check the repository, forge access and deployment settings, then try again"); }
|
|
90
|
+
}
|
|
91
|
+
if (values.ref !== undefined || values.as !== undefined) return fail(err, "--ref and --as require --new");
|
|
92
|
+
if (positionals.length > 1 || (values.list && (positionals.length || values.attach || values.pin || values.publish?.length))) return fail(err, "choose one sandbox id, or --list");
|
|
53
93
|
// A docker that cannot be reached costs a column, never the command: `listRunningSandboxes` throws so
|
|
54
94
|
// the REAPER can tell "none" from "could not ask", and these callers only draw a marker. Asked only
|
|
55
95
|
// where it is used, and never before the arguments are known good -- a typo should not shell out.
|
|
@@ -75,6 +115,7 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
75
115
|
|
|
76
116
|
const jobId = positionals[0];
|
|
77
117
|
if (!jobId) return fail(err, "a job id is required — `pi-dispatch sandbox --list` shows what is still re-openable");
|
|
118
|
+
if (!SANDBOX_SESSION_ID_RE.test(jobId)) return fail(err, "the sandbox id must contain only letters, digits, dot, underscore, colon and hyphen (at most 200 characters)");
|
|
78
119
|
|
|
79
120
|
// Refused BEFORE anything else that could half-succeed. `-t` against a pipe fails inside docker with
|
|
80
121
|
// "the input device is not a TTY", which names neither the cause nor the fix; a sandbox is an operator
|
|
@@ -82,6 +123,24 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
82
123
|
if (!isTty) {
|
|
83
124
|
return fail(err, "`pi-dispatch sandbox` needs a terminal — it opens an interactive shell, so it cannot run from a pipe, a script without a TTY, or CI");
|
|
84
125
|
}
|
|
126
|
+
const monitorSession = deps.monitorSession ?? makeSandboxMonitor({ env, config, by: values["opened-by"], now });
|
|
127
|
+
if (values.attach) {
|
|
128
|
+
if (values.publish?.length || values.pin) return fail(err, "--attach does not take --publish or --pin; pin the kept workspace separately");
|
|
129
|
+
const resolved = resolveSandbox({ jobId, sandboxDir: config.sandboxDir, retentionHours: config.sandboxRetentionHours, blessed: config.backends, pin: true, now });
|
|
130
|
+
if (resolved.refused) return fail(err, resolved.message);
|
|
131
|
+
const { bin } = sandboxLauncher(resolved.venue);
|
|
132
|
+
const state = await (deps.inspectSession ?? inspectSandboxSession)({ bin, name: resolved.name });
|
|
133
|
+
if (state.unknown) return fail(err, "the runtime could not report this sandbox; check its host and try again");
|
|
134
|
+
if (!state.present || !state.running) return fail(err, "this sandbox is no longer running; open its kept workspace again");
|
|
135
|
+
if (!/^[a-f0-9]{32}$/.test(state.nonce ?? "")) return fail(err, `this shell predates session tracking; return with \`${bin} attach ${resolved.name}\`, or exit it and open it again to register it`);
|
|
136
|
+
const user = await (resolveJobUser ?? decideSandboxJobUser)({ manifest: resolved.manifest, venue: resolved.venue, backendFloor: config.backendFloor });
|
|
137
|
+
if (user?.refused) return fail(err, user.message);
|
|
138
|
+
try { await (deps.ensureMonitor ?? ensureSandboxMonitor)({ resolved, runtime: bin, state, config, env, admittedRuntime: user?.runtime ?? null, by: values["opened-by"], now }); }
|
|
139
|
+
catch { return fail(err, "sandbox tracking could not be recovered; check Valkey before attaching"); }
|
|
140
|
+
out(`attaching to ${resolved.name} (${bin}); exit the shell to end it\n`);
|
|
141
|
+
const attached = await (deps.attach ?? attachSandbox)({ bin, containerId: state.containerId });
|
|
142
|
+
return attached.code ?? 1;
|
|
143
|
+
}
|
|
85
144
|
|
|
86
145
|
let publish;
|
|
87
146
|
try {
|
|
@@ -121,6 +180,7 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
121
180
|
// Issue #429: which venues open here, and what a podman sandbox's observations must show, as the worker reads them.
|
|
122
181
|
blessed: config.backends,
|
|
123
182
|
backendFloor: config.backendFloor,
|
|
183
|
+
monitorSession,
|
|
124
184
|
now,
|
|
125
185
|
// Pin BEFORE the shell, not after: the operator asked to keep this one, and a session that ends in a crashed
|
|
126
186
|
// terminal or a closed laptop lid must not be the reason the pin never landed. And since issue #446 before
|
|
@@ -149,6 +209,22 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
149
209
|
return result.code ?? 0;
|
|
150
210
|
}
|
|
151
211
|
|
|
212
|
+
function runDeploymentSandbox(argv, env, dir) {
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL("./cli.mjs", import.meta.url)), "sandbox", ...argv], { env, ...(dir ? { cwd: dir } : {}), stdio: "inherit" });
|
|
215
|
+
child.once("error", () => resolve(1));
|
|
216
|
+
child.once("exit", (code) => resolve(code ?? 1));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function attachSandbox({ bin, containerId, spawnFn = spawn }) {
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const child = spawnFn(bin, ["attach", containerId], { stdio: "inherit" });
|
|
223
|
+
child.once("error", () => resolve({ code: 1 }));
|
|
224
|
+
child.once("exit", (code) => resolve({ code: code ?? 1 }));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
152
228
|
/**
|
|
153
229
|
* What is still re-openable, newest first, plus what is running right now.
|
|
154
230
|
*
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** An explicit deployment handoff is stronger than a generic deployment pointer: it loads sandbox authority only. */
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { isAbsolute, join, win32 } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { deploymentServiceEnv } from "./service-env.mjs";
|
|
6
|
+
import { workerName } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
export const SANDBOX_DEPLOYMENT_KEYS = Object.freeze([
|
|
9
|
+
"PI_WORKER_NAME", "PI_SANDBOX_DIR", "PI_JOBS_DIR", "PI_SANDBOX_RETENTION_HOURS", "PI_SANDBOX_PIN_DAYS", "PI_SANDBOX_IDLE_MINUTES",
|
|
10
|
+
"PI_BACKENDS", "PI_BACKEND_FLOOR", "PI_EGRESS", "PI_EGRESS_PROXY", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH",
|
|
11
|
+
"VALKEY_URL", "VALKEY_PASSWORD", "PI_VALKEY_SHARED", "PATH", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "CONTAINERS_STORAGE_CONF", "CONTAINERS_CONF", "CONTAINERS_CONF_OVERRIDE", "CONTAINERS_REGISTRIES_CONF", "CONTAINER_HOST", "CONTAINER_CONNECTION",
|
|
12
|
+
]);
|
|
13
|
+
const OS_KEYS = Object.freeze(["HOME", "USERPROFILE", "SystemRoot", "WINDIR", "TEMP", "TMP", "TMPDIR", "TERM", "LANG", "LC_ALL", "PATH"]);
|
|
14
|
+
export function sandboxDeploymentEnv({ dir, env = process.env, expectedHost = null, fs = nodeFs, platform = process.platform, uid = process.geteuid?.(), home = homedir(), read = deploymentServiceEnv } = {}) {
|
|
15
|
+
const refuse = (message) => ({ refused: "sandbox-deployment", message });
|
|
16
|
+
if (typeof dir !== "string" || !isAbsolute(dir) || /[\x00-\x1f\x7f]/.test(dir)) return refuse("--deployment must name an absolute deployment directory");
|
|
17
|
+
// One descriptor-backed read. Ambient values are compared afterwards, never used as defaults for authority.
|
|
18
|
+
const resolved = read({ dir, env: {}, keys: SANDBOX_DEPLOYMENT_KEYS, platform, fs, uid });
|
|
19
|
+
if (resolved.unreadable || resolved.untrusted || resolved.hazard) return refuse("the deployment .env cannot be trusted or read as its service reads it; run pi-dispatch doctor");
|
|
20
|
+
if (!resolved.path || (resolved.fromFile && Object.keys(resolved.fromFile).length === 0 && resolved.env && !fs.existsSync(resolved.path))) return refuse("the deployment .env is missing; point --deployment at the installed deployment");
|
|
21
|
+
if (resolved.unread?.length || resolved.hazardSkipped?.length) return refuse("a sandbox setting cannot be read the way the deployment service reads it; run pi-dispatch doctor");
|
|
22
|
+
const clean = { ...resolved.env };
|
|
23
|
+
for (const key of SANDBOX_DEPLOYMENT_KEYS) {
|
|
24
|
+
if (typeof env[key] !== "string") continue;
|
|
25
|
+
// SSH's PATH routinely lacks the service's Docker/Podman directory. A reviewed deployment PATH wins;
|
|
26
|
+
// when absent, PATH alone keeps the ordinary OS fallback (it grants no sandbox policy or runtime endpoint).
|
|
27
|
+
if (key === "PATH") continue;
|
|
28
|
+
if (typeof clean[key] === "string") {
|
|
29
|
+
if (clean[key] !== env[key]) return refuse(`the shell disagrees with the deployment for ${key}; unset that variable before opening`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
// PAM supplies the account's standard runtime directory on an SSH login, as a user service does. Admit it
|
|
33
|
+
// only after identity/ownership validation; a non-default rootless store remains deployment authority.
|
|
34
|
+
if (key === "XDG_RUNTIME_DIR" && platform === "linux" && Number.isInteger(uid) && env[key] === `/run/user/${uid}`) {
|
|
35
|
+
try { const stat = fs.lstatSync(env[key]); if (stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === uid && !(stat.mode & 0o077)) { clean[key] = env[key]; continue; } } catch {}
|
|
36
|
+
}
|
|
37
|
+
if (key === "XDG_DATA_HOME" && env[key] === join(home, ".local", "share")) { clean[key] = env[key]; continue; }
|
|
38
|
+
if (key === "XDG_CONFIG_HOME" && env[key] === join(home, ".config")) { clean[key] = env[key]; continue; }
|
|
39
|
+
return refuse(`the shell sets sandbox authority absent from the deployment: ${key}; unset that variable before opening`);
|
|
40
|
+
}
|
|
41
|
+
const result = {};
|
|
42
|
+
for (const key of OS_KEYS) if (typeof env[key] === "string") result[key] = env[key];
|
|
43
|
+
// env-internal HOME: compare the launching account's home to the OS identity before opening a rootless store.
|
|
44
|
+
if (platform !== "win32" && env.HOME && env.HOME !== home) return refuse("HOME is not this account's home; open the sandbox as the worker's account");
|
|
45
|
+
for (const key of SANDBOX_DEPLOYMENT_KEYS) if (typeof clean[key] === "string") result[key] = clean[key];
|
|
46
|
+
if (typeof result.PATH === "string" && (/[\x00-\x1f\x7f]/.test(result.PATH) || result.PATH.split(platform === "win32" ? ";" : ":").some((part) => !part || !(platform === "win32" ? win32.isAbsolute(part) : isAbsolute(part))))) return refuse("the sandbox runtime PATH must contain only absolute tool directories; correct PATH in the deployment .env");
|
|
47
|
+
try {
|
|
48
|
+
if (expectedHost !== null && workerName(result) !== expectedHost) return refuse("the deployment worker name does not match the opener host; check Fleet and the local opener configuration");
|
|
49
|
+
} catch { return refuse("the deployment worker name is invalid; run pi-dispatch doctor"); }
|
|
50
|
+
return { env: result };
|
|
51
|
+
}
|