@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
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* Custom: per-scheduler stall accounting; BullMQ's maxStalledCount does not cover scheduler jobs -- CONST-RETRY-INFRA-ONLY carve-out ("BullMQ will never do this for us")
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { cronRunOf, isSchedulerJobId } from "./bullmq-internals.mjs";
|
|
17
|
+
|
|
16
18
|
// The prefix every stall counter lives under, so `KEYS pi-dispatch:sched-stalls*` still shows an operator
|
|
17
19
|
// the whole feature -- the affordance `wait:`, `slot:` and `budget:` all assume. Exported alongside the
|
|
18
20
|
// builder so the admin panel and the integration teardown compose keys through one definition and cannot
|
|
@@ -36,8 +38,8 @@ export const stallKey = (schedulerId) => `${STALL_KEY}:${schedulerId}`;
|
|
|
36
38
|
// stalls, opposite outcome, decided by an unrelated trigger (issue #267).
|
|
37
39
|
//
|
|
38
40
|
// Per-field TTLs would have fixed it in place and are not available: `HEXPIRE` does not exist on the pinned
|
|
39
|
-
// `valkey/valkey:8` (verified, `ERR unknown command`, recorded under DES-HOST-REGISTRY).
|
|
40
|
-
// the only shape that gets per-entity expiry.
|
|
41
|
+
// `valkey/valkey:8.1` (`VALKEY_IMAGE`; verified on 8.1.11, `ERR unknown command`, recorded under DES-HOST-REGISTRY).
|
|
42
|
+
// A key per entity is the only shape that gets per-entity expiry.
|
|
41
43
|
//
|
|
42
44
|
// The EXPIRE still ROLLS on every stall, deliberately, and that is not `budget.mjs`'s set-once rule being
|
|
43
45
|
// broken. A budget window is a CALENDAR window and must not be pushed forward by traffic or a busy day
|
|
@@ -59,16 +61,20 @@ const STALL_WINDOW_SECONDS = 24 * 60 * 60;
|
|
|
59
61
|
export function makeStallGuard({ redis, threshold, removeJobScheduler, log }) {
|
|
60
62
|
return async function onStalled(jobId) {
|
|
61
63
|
try {
|
|
62
|
-
// Ordinary jobs are bounded by `maxStalledCount: 0`; only scheduler jobs reach this accounting.
|
|
63
|
-
|
|
64
|
+
// Ordinary jobs are bounded by `maxStalledCount: 0`; only scheduler jobs reach this accounting. A hand-fired
|
|
65
|
+
// `manual:` run is an ordinary job, so it is not counted here either.
|
|
66
|
+
if (!isSchedulerJobId(jobId)) return;
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
// Any id length: a scheduler an earlier release made for a longer cron id still stalls, and this guard is the
|
|
69
|
+
// one that stops it re-paying.
|
|
70
|
+
const run = cronRunOf(jobId);
|
|
71
|
+
if (run === null) {
|
|
72
|
+
// A `repeat:` id with no scheduler segment (`repeat:<n>`, `repeat::<n>`) or none BullMQ mints: an empty or
|
|
73
|
+
// guessed counter would pool unrelated ids into one, so log and skip rather than count a key.
|
|
69
74
|
log("scheduler_stall_unparsed", { jobId });
|
|
70
75
|
return;
|
|
71
76
|
}
|
|
77
|
+
const schedulerId = run.id;
|
|
72
78
|
|
|
73
79
|
const key = stallKey(schedulerId);
|
|
74
80
|
const count = Number(await redis.incr(key));
|
package/src/schedules.mjs
CHANGED
|
@@ -17,6 +17,8 @@ import { isAbsolute } from "node:path";
|
|
|
17
17
|
import { configError } from "./config.mjs";
|
|
18
18
|
import { triggerCapAboveDeployment } from "./money.mjs";
|
|
19
19
|
import { parseTriggers } from "./triggers.mjs";
|
|
20
|
+
import { withJobDataVersion } from "./job-data-version.mjs";
|
|
21
|
+
import { LOCAL_JOB_KEPT_COMPLETED_S, LOCAL_JOB_KEPT_FAILED_S } from "./retention.mjs";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Parse, validate, and select the cron schedules from the triggers file named by `config.triggersFile`.
|
|
@@ -24,7 +26,7 @@ import { parseTriggers } from "./triggers.mjs";
|
|
|
24
26
|
* valid deployment. `readFileSync`/`existsSync` are injectable so tests exercise the full path with no
|
|
25
27
|
* real filesystem.
|
|
26
28
|
*/
|
|
27
|
-
export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync, fleet = false } = {}) {
|
|
29
|
+
export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync, fleet = false, dataVersions = {} } = {}) {
|
|
28
30
|
const path = config.triggersFile;
|
|
29
31
|
if (path === null || path === undefined) return []; // cron disabled
|
|
30
32
|
|
|
@@ -44,7 +46,7 @@ export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSyn
|
|
|
44
46
|
throw configError(`trigger at index ${above}: run.maxCostUsd is above this deployment's PI_MAX_COST_USD. A trigger can only narrow the per-job cost cap, so lower it or remove it (${path})`);
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync, fleet));
|
|
49
|
+
return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync, fleet, dataVersions));
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -120,7 +122,7 @@ export function cronPlacement(run, { existsSync = fsExistsSync, fleet = false }
|
|
|
120
122
|
return fleet ? "elsewhere" : "refused";
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
125
|
+
function normalizeCronSchedule({ on, run }, path, existsSync, fleet, dataVersions = {}) {
|
|
124
126
|
// The pure validator already guaranteed a non-empty, `:`-free, charset-valid, unique id and a
|
|
125
127
|
// well-formed pattern; folder existence is the one fs-dependent check it deferred to here.
|
|
126
128
|
//
|
|
@@ -182,10 +184,21 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
|
182
184
|
// `portfolio` (issue #505) is conditional like `command`, so an unflagged schedule grows no key, and it rides at JOB
|
|
183
185
|
// level, never inside `trigger`: `trigger` is copied verbatim into `/job/event.json`, and whether a job may send a
|
|
184
186
|
// budget plan is the worker's decision to re-check against the live file, not a fact for the agent to read.
|
|
185
|
-
|
|
187
|
+
//
|
|
188
|
+
// `progress` (issue #644) only when armed, as on a forge job, so an unflagged schedule grows no key. A cron job never
|
|
189
|
+
// carries `statusComment`: the loader refuses `true` there, with no issue or pull request to post on. Nor `recover`
|
|
190
|
+
// (issue #645), for the same posture: the loader refuses `true` on cron, so there is no armed value to carry.
|
|
191
|
+
// `steer` (issue #646) DOES ride a cron job, progress's rule: only when armed, at JOB level (never inside `trigger`,
|
|
192
|
+
// which becomes /job/event.json), and never part of the repeat:<id>:<millis> id. An operator can steer a folder job.
|
|
193
|
+
//
|
|
194
|
+
// Stamped like every builder's (issue #761, `withJobDataVersion`): the template is what every tick of this schedule
|
|
195
|
+
// stores, so a field that needs a version above 1 says so on every job, and nothing is added while none does.
|
|
196
|
+
// `dataVersions` is the version rule, injectable for tests.
|
|
197
|
+
const data = withJobDataVersion({ kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.excludeExtensions !== undefined && { excludeExtensions: run.excludeExtensions }), ...(run.excludeExtensionTools !== undefined && { excludeExtensionTools: run.excludeExtensionTools }), ...(run.mcp !== undefined && { mcp: run.mcp }), ...(run.models !== undefined && { models: run.models }), ...(run.fallbackModels !== undefined && { fallbackModels: run.fallbackModels }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), ...(run.progress === true && { progress: true }), ...(run.steer === true && { steer: true }), trigger: { id: on.id, pattern: on.pattern } }, dataVersions);
|
|
186
198
|
// Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
|
|
187
|
-
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
|
|
188
|
-
|
|
199
|
+
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff. A local job's retention, in
|
|
200
|
+
// SECONDS (BullMQ's `age`), from the one place the console's re-run reads it too (retention.mjs).
|
|
201
|
+
const opts = { removeOnComplete: { age: LOCAL_JOB_KEPT_COMPLETED_S }, removeOnFail: { age: LOCAL_JOB_KEPT_FAILED_S } };
|
|
189
202
|
|
|
190
203
|
return { schedulerId: on.id, name: "local", pattern: on.pattern, data, opts };
|
|
191
204
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project secret bundles (issue #704, REQ-TRIGGER-SECRETS, DES-PROJECT-SECRET-BUNDLES): a project's whole
|
|
3
|
+
* environment, read HOST-SIDE by ONE resolver call per job, before the container starts.
|
|
4
|
+
*
|
|
5
|
+
* `run.secrets` grants per trigger and per field, and its resolver runs once per reference. A bundle grants per
|
|
6
|
+
* project and runs once per JOB: the triggers file's `secretBundles` binds a project to a bundle profile and a
|
|
7
|
+
* reference, and the resolver prints every variable of that environment on stdout. A variable the operator adds
|
|
8
|
+
* or removes in their manager (or file) reaches the next job with no edit to the reviewed file. That is the
|
|
9
|
+
* asked-for trade, recorded where `DES-PER-TRIGGER-SECRET-PROFILE` once rejected it: the reviewed file no longer
|
|
10
|
+
* lists the fields, so the review of WHAT a project holds moves to the manager.
|
|
11
|
+
*
|
|
12
|
+
* THE SEAM IS STILL A COMMAND. `secrets.mjs`' rule holds verbatim: the reference grammar belongs to the
|
|
13
|
+
* operator's script, nothing here recognises a manager's notation, and the resolver's exit code is the runner's
|
|
14
|
+
* (`INT-RUNNER-EXIT-CODE-PROTOCOL`): 0 carries the environment, 2 says the reference is wrong (refused, never
|
|
15
|
+
* retried), anything else says the manager could not be reached (retried). `runResolver` is imported, not
|
|
16
|
+
* copied, so the spawn, the stdio, the timeout, the abort and the kill escalation are the ones `run.secrets` has.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IS DIFFERENT, and each for a reason:
|
|
19
|
+
* - the byte cap is the whole environment's, `MAX_BUNDLE_BYTES`, and every value still holds to
|
|
20
|
+
* `MAX_SECRET_BYTES`, so a bundle cannot carry a value a single reference could not;
|
|
21
|
+
* - the output is PARSED, by a declared format, strictly (`parseBundleOutput`);
|
|
22
|
+
* - a reserved name is a REFUSAL naming the variable, never a drop. A variable an operator added in their
|
|
23
|
+
* manager that silently never arrives is this project's worst outcome, and dropping is that;
|
|
24
|
+
* - errors and log lines name VARIABLES (and dotenv line numbers), never a value, a reference or a byte the
|
|
25
|
+
* resolver printed. A variable name is the operator's own choice from their own manager, the class
|
|
26
|
+
* `run.secrets` names freely in its logs; a line that failed to parse is not named even by its first word,
|
|
27
|
+
* because a malformed line can be the second half of a value.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import { providerKeyCandidates } from "./env-allowlist.mjs";
|
|
32
|
+
import { findDuplicateKey } from "./json-duplicates.mjs";
|
|
33
|
+
import { mergeSecretProfiles, withinRoots } from "./secret-profiles.mjs";
|
|
34
|
+
import { MAX_SECRET_BYTES, realExecutable, runResolver } from "./secrets.mjs";
|
|
35
|
+
import { ENV_NAME, RESERVED_ENV_NAMES, SECRET_BUNDLE_VARS_MAX } from "./triggers.mjs";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The largest output a bundle resolver may print: 256 KiB. Four `MAX_SECRET_BYTES`, so a bundle holds a few PEM
|
|
39
|
+
* chains and a service-account JSON beside its plain keys, and the whole of it sits in one worker's memory once
|
|
40
|
+
* per job start (never per reference). On overflow the child is killed and the job refuses, NEVER truncated, the
|
|
41
|
+
* rule `MAX_SECRET_BYTES` states: a cut environment is a variable silently missing.
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_BUNDLE_BYTES = 256 * 1024;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Names a BUNDLE may not carry, beyond the ones `run.secrets` may not bind (issue #704 review). A bundle's contents are
|
|
47
|
+
* not reviewed: they are whatever the operator's manager holds at the job's start, written by whoever can write that
|
|
48
|
+
* manager, so a variable that changes what CODE runs in the job, or how it trusts the network, is refused by name
|
|
49
|
+
* rather than injected. Each one steers a loader or a tool the job image runs, whatever the agent does:
|
|
50
|
+
* - the dynamic loaders (`LD_*`, `DYLD_*`) and `PATH`, which choose the binaries and libraries every process loads;
|
|
51
|
+
* - Node's `NODE_OPTIONS` (the runner gives its children a metering preload through it, `image/runner/src/child-watch.mjs`,
|
|
52
|
+
* so a bundle value would also blind the child meter) and `NODE_PATH`, and TLS trust (`NODE_EXTRA_CA_CERTS`,
|
|
53
|
+
* `NODE_TLS_REJECT_UNAUTHORIZED`, `SSL_CERT_FILE`, `SSL_CERT_DIR`), which `provider-steering.mjs` deliberately leaves
|
|
54
|
+
* bindable by a REVIEWED trigger and which an unreviewed bundle must not set;
|
|
55
|
+
* - the shells' start-up hooks (`BASH_ENV`, `ENV`, `PROMPT_COMMAND`), run by every non-interactive shell the agent opens;
|
|
56
|
+
* - git's command and config hooks (`GIT_SSH`, `GIT_SSH_COMMAND`, `GIT_ASKPASS`, `GIT_EXEC_PATH`, `GIT_PROXY_COMMAND`,
|
|
57
|
+
* `SSH_ASKPASS`, and every `GIT_CONFIG*`, which can set `core.sshCommand` or a credential helper), which run on the
|
|
58
|
+
* push of the job's own branch with its forge token in hand;
|
|
59
|
+
* - the interpreters' start-up hooks (`PYTHONPATH`, `PYTHONHOME`, `PYTHONSTARTUP`, `PERL5OPT`, `PERL5LIB`, `RUBYOPT`,
|
|
60
|
+
* `RUBYLIB`, `JAVA_TOOL_OPTIONS`), git's template directory (`GIT_TEMPLATE_DIR`, hooks copied into every clone);
|
|
61
|
+
* - the other tools' TLS trust (`GIT_SSL_NO_VERIFY`, `GIT_SSL_CAINFO`, `GIT_SSL_CAPATH`, `REQUESTS_CA_BUNDLE`,
|
|
62
|
+
* `CURL_CA_BUNDLE`) and npm's whole configuration (`npm_config_*`, matched CASE-INSENSITIVELY because npm reads it
|
|
63
|
+
* so: a registry, a script shell, an `ignore-scripts` turned off).
|
|
64
|
+
* The table is held to `docs/secrets.md`'s list between its markers by a test, both directions.
|
|
65
|
+
* A REFUSAL naming the variable (`secret-name-reserved`), never a drop, the header's rule. `run.secrets` keeps its own
|
|
66
|
+
* narrower set: a trigger is reviewed, and a deploy flow may legitimately bind, say, `PYTHONPATH`.
|
|
67
|
+
*/
|
|
68
|
+
export const BUNDLE_LOADER_NAMES = Object.freeze(["PATH", "NODE_OPTIONS", "NODE_PATH", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_FILE", "SSL_CERT_DIR", "BASH_ENV", "ENV", "PROMPT_COMMAND", "GIT_SSH", "GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_EXEC_PATH", "GIT_PROXY_COMMAND", "GIT_TEMPLATE_DIR", "GIT_SSL_NO_VERIFY", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", "SSH_ASKPASS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PERL5OPT", "PERL5LIB", "RUBYOPT", "RUBYLIB", "JAVA_TOOL_OPTIONS"]);
|
|
69
|
+
/** Prefixes, exact case; and `npm_config_`, compared case-insensitively. */
|
|
70
|
+
export const BUNDLE_LOADER_PREFIXES = Object.freeze(["LD_", "DYLD_", "GIT_CONFIG"]);
|
|
71
|
+
export const BUNDLE_LOADER_PREFIXES_ANY_CASE = Object.freeze(["npm_config_"]);
|
|
72
|
+
const LOADER_NAME_SET = new Set(BUNDLE_LOADER_NAMES);
|
|
73
|
+
|
|
74
|
+
/** Whether a bundle may not carry `name` for the loader reason above. Exported for doctor and the docs' test. */
|
|
75
|
+
export function bundleLoaderName(name) {
|
|
76
|
+
return LOADER_NAME_SET.has(name) || BUNDLE_LOADER_PREFIXES.some((prefix) => name.startsWith(prefix)) || BUNDLE_LOADER_PREFIXES_ANY_CASE.some((prefix) => name.toLowerCase().startsWith(prefix));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a bundle resolver's stdout by its DECLARED format. `{ vars }` (an object of own string properties, in the
|
|
81
|
+
* resolver's order), or `{ failure, name?, line? }` with `failure` one of OUR words:
|
|
82
|
+
*
|
|
83
|
+
* - `malformed` a dotenv line that is not `NAME=value`, a blank or a `#` comment; JSON that is not one object;
|
|
84
|
+
* - `quoted` a dotenv value that starts with a quote: no quoting is the grammar, and a manager that quotes
|
|
85
|
+
* (or an operator who did) would otherwise hand the container the quotes as part of the value;
|
|
86
|
+
* - `not-string` a JSON value that is not a string (a number, a nested object), named by its variable;
|
|
87
|
+
* - `bad-name` a name that is not an environment variable name (ENV_NAME), or `__proto__`; not named;
|
|
88
|
+
* - `duplicate` one name twice: which value wins would be the parser's choice, never the operator's;
|
|
89
|
+
* - `empty-value` an empty value: a set-but-empty variable is how a broken manager reads as configured;
|
|
90
|
+
* - `nul` a value with a NUL, which `-e NAME=VALUE` would silently cut;
|
|
91
|
+
* - `value-too-large` a value over `MAX_SECRET_BYTES`;
|
|
92
|
+
* - `too-many` over `SECRET_BUNDLE_VARS_MAX` variables;
|
|
93
|
+
* - `empty` no variable at all: exit 0 that grants nothing is the silent-no-op class `runResolver` refuses
|
|
94
|
+
* for a single value, and an operator who emptied an environment by accident must hear it.
|
|
95
|
+
*
|
|
96
|
+
* DOTENV IS STRICT: `NAME=value` per line, the value verbatim to the end of the line, no quoting, no `export `, no
|
|
97
|
+
* spaces around `=`, no expansion, no continuation. Every dotenv dialect differs on exactly those, and a parser
|
|
98
|
+
* that accepts one dialect's quotes silently mangles another's values. One trailing `\r` per line is removed, so a
|
|
99
|
+
* resolver on Windows (CRLF) reads as it was written; a value that needs a `\r`, a newline or a leading quote goes
|
|
100
|
+
* through `json`, which is what that format is for (PEMs and other multi-line values).
|
|
101
|
+
*/
|
|
102
|
+
export function parseBundleOutput(text, format) {
|
|
103
|
+
const vars = {};
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const add = (name, value, line = null) => {
|
|
106
|
+
if (typeof name !== "string" || !ENV_NAME.test(name) || name === "__proto__") return { failure: "bad-name", line };
|
|
107
|
+
if (seen.has(name)) return { failure: "duplicate", name };
|
|
108
|
+
if (typeof value !== "string") return { failure: "not-string", name };
|
|
109
|
+
if (value === "") return { failure: "empty-value", name };
|
|
110
|
+
if (value.includes("\u0000")) return { failure: "nul", name };
|
|
111
|
+
if (Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) return { failure: "value-too-large", name };
|
|
112
|
+
seen.add(name);
|
|
113
|
+
if (seen.size > SECRET_BUNDLE_VARS_MAX) return { failure: "too-many" };
|
|
114
|
+
// An own data property whatever the name (the charset is checked above, `__proto__` refused): never `vars[name] =`.
|
|
115
|
+
Object.defineProperty(vars, name, { value, enumerable: true, writable: true, configurable: true });
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
if (format === "json") {
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(text);
|
|
122
|
+
} catch {
|
|
123
|
+
return { failure: "malformed" };
|
|
124
|
+
}
|
|
125
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { failure: "malformed" };
|
|
126
|
+
// A non-string value first, so a duplicate key INSIDE a nested object is reported as what it is (a value that is
|
|
127
|
+
// not a string), never as a duplicate variable named after a key no variable has.
|
|
128
|
+
const nested = Object.keys(parsed).find((name) => typeof parsed[name] !== "string");
|
|
129
|
+
if (nested !== undefined) return ENV_NAME.test(nested) && nested !== "__proto__" ? { failure: "not-string", name: nested } : { failure: "bad-name" };
|
|
130
|
+
// JSON keeps the LAST of two equal keys, so a duplicate would be a value silently won: the triggers file's rule.
|
|
131
|
+
const duplicate = findDuplicateKey(text);
|
|
132
|
+
if (duplicate) return ENV_NAME.test(duplicate.key) ? { failure: "duplicate", name: duplicate.key } : { failure: "bad-name" };
|
|
133
|
+
for (const name of Object.keys(parsed)) {
|
|
134
|
+
const bad = add(name, parsed[name]);
|
|
135
|
+
if (bad) return bad.failure === "bad-name" ? { failure: "bad-name" } : bad;
|
|
136
|
+
}
|
|
137
|
+
} else if (format === "dotenv") {
|
|
138
|
+
const lines = text.split("\n");
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
const raw = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
|
|
141
|
+
if (/^[ \t]*$/.test(raw) || /^[ \t]*#/.test(raw)) continue;
|
|
142
|
+
const eq = raw.indexOf("=");
|
|
143
|
+
if (eq <= 0) return { failure: "malformed", line: i + 1 };
|
|
144
|
+
const name = raw.slice(0, eq);
|
|
145
|
+
const value = raw.slice(eq + 1);
|
|
146
|
+
if (!ENV_NAME.test(name)) return { failure: name.trim() !== name || name.startsWith("export ") ? "malformed" : "bad-name", line: i + 1 };
|
|
147
|
+
if (value.startsWith('"') || value.startsWith("'")) return { failure: "quoted", name };
|
|
148
|
+
// A carriage return left after the one a CRLF line ends with is part of no dotenv value anyone means to write;
|
|
149
|
+
// a value that needs one goes through json.
|
|
150
|
+
if (value.includes("\r")) return { failure: "malformed", line: i + 1 };
|
|
151
|
+
const bad = add(name, value, i + 1);
|
|
152
|
+
if (bad) return bad;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
// Unreachable through the declaration parsers, which refuse any other format at boot or overlay read.
|
|
156
|
+
return { failure: "malformed" };
|
|
157
|
+
}
|
|
158
|
+
if (seen.size === 0) return { failure: "empty" };
|
|
159
|
+
return { vars };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build the bundle seam the processor injects: `{ lookup, resolve }`.
|
|
164
|
+
*
|
|
165
|
+
* - `lookup(profile, { overlayBundles })` answers, with no spawn, whether this host can serve the profile:
|
|
166
|
+
* `{ ok }`, `{ profileUnknown }` or `{ ambiguous }`. FREE, so the processor asks it before the mint, with the
|
|
167
|
+
* other free refusals (CONST-BUDGET-BEFORE-TOKENS); its rule is `makeSecretsResolver`'s lookup verbatim (own
|
|
168
|
+
* names only, the realpath probe, an overlay path bounded by PI_SECRET_RESOLVER_ROOTS, a name declared in both
|
|
169
|
+
* sources ambiguous).
|
|
170
|
+
* - `resolve(job, binding, { signal, overlayBundles })` runs the resolver ONCE for `binding.ref` and returns
|
|
171
|
+
* `{ ok, vars }`, `{ profileUnknown }`, `{ ambiguous }`, `{ reserved: name }`, `{ unresolved, failure, ... }`
|
|
172
|
+
* or `{ unreachable, failure, ... }`. `unresolved` and `unreachable` are `true`, never a name: one call holds
|
|
173
|
+
* every variable, so there is no single variable to blame for an exit code.
|
|
174
|
+
*
|
|
175
|
+
* `envBundles` is parsed at BOOT (config.mjs `secretBundleProfiles`), `overlayBundles` per job, `secretProfiles`'
|
|
176
|
+
* split and its reason. NOTHING IS ZEROED, `makeSecretsResolver`'s honest half, and the same property holds: the
|
|
177
|
+
* returned map is a local of `runJob`, never attached to the job, never logged, never in a result or a record
|
|
178
|
+
* (the record carries names, rebuilt in run-history.mjs).
|
|
179
|
+
*/
|
|
180
|
+
export function makeBundleResolver({ envBundles = {}, roots = [], forwardEnv = [], timeoutMs = 10000, spawnFn = spawn, hostEnv = process.env, realExecutablePath = realExecutable, log = () => {} } = {}) {
|
|
181
|
+
function lookup(profile, { overlayBundles = {} } = {}) {
|
|
182
|
+
const merged = mergeSecretProfiles(envBundles, overlayBundles);
|
|
183
|
+
if (merged.ambiguous) return { ambiguous: merged.ambiguous };
|
|
184
|
+
const declared = Object.hasOwn(merged.profiles, profile) ? merged.profiles[profile] : undefined;
|
|
185
|
+
if (declared === null || typeof declared !== "object" || typeof declared.path !== "string") return { profileUnknown: profile };
|
|
186
|
+
let path;
|
|
187
|
+
try {
|
|
188
|
+
path = realExecutablePath(declared.path);
|
|
189
|
+
} catch {
|
|
190
|
+
path = null;
|
|
191
|
+
}
|
|
192
|
+
if (!path) return { profileUnknown: profile };
|
|
193
|
+
// The bound on the REALPATH, in the worker, for env-declared profiles exempt: secrets.mjs gives the reasons.
|
|
194
|
+
if (!Object.hasOwn(envBundles, profile) && !withinRoots(path, roots)) return { profileUnknown: profile };
|
|
195
|
+
return { ok: true, path, format: declared.format };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function resolve(job, binding, { signal, overlayBundles = {} } = {}) {
|
|
199
|
+
const found = lookup(binding?.profile, { overlayBundles });
|
|
200
|
+
if (!found.ok) return found;
|
|
201
|
+
const outcome = await runResolver(spawnFn, found.path, binding.ref, { timeoutMs, hostEnv, signal, maxBytes: MAX_BUNDLE_BYTES });
|
|
202
|
+
if (outcome.failure) {
|
|
203
|
+
// The bundle ID, our own failure vocabulary, the script's small integer exit and a byte COUNT; never the
|
|
204
|
+
// reference, the path, or the resolver's words.
|
|
205
|
+
log("secret_bundle_resolve_failed", { bundle: binding.id ?? null, failure: outcome.failure, detail: outcome.detail, code: outcome.code ?? null, stderrBytes: outcome.stderrBytes });
|
|
206
|
+
const shape = { failure: outcome.detail, code: outcome.code ?? null, stderrBytes: outcome.stderrBytes };
|
|
207
|
+
return outcome.failure === "infra" ? { unreachable: true, ...shape } : { unresolved: true, ...shape };
|
|
208
|
+
}
|
|
209
|
+
const parsed = parseBundleOutput(outcome.value, found.format);
|
|
210
|
+
if (parsed.failure) {
|
|
211
|
+
log("secret_bundle_resolve_failed", { bundle: binding.id ?? null, failure: "policy", detail: parsed.failure, name: parsed.name ?? null, line: parsed.line ?? null });
|
|
212
|
+
return { unresolved: true, failure: parsed.failure, name: parsed.name ?? null, line: parsed.line ?? null, code: 0, stderrBytes: outcome.stderrBytes };
|
|
213
|
+
}
|
|
214
|
+
// The reserved names, all three sets `run.secrets` is held to, asked of the WHOLE bundle: the statically
|
|
215
|
+
// knowable set the triggers file refuses at load (it cannot see a bundle's names, which exist only once read),
|
|
216
|
+
// this job's provider credential variables, and PI_FORWARD_ENV. A REFUSAL naming the first one in the
|
|
217
|
+
// resolver's order, never a drop (see the header), and never resolved by ordering: buildContainerEnv writes
|
|
218
|
+
// the provider credential BEFORE the secrets loop, so a bundle carrying ANTHROPIC_API_KEY would silently
|
|
219
|
+
// replace the key every job of the project spends (secrets.mjs records the class).
|
|
220
|
+
const reserved = new Set([...RESERVED_ENV_NAMES, ...providerKeyCandidates(job?.provider), ...forwardEnv]);
|
|
221
|
+
const clash = Object.keys(parsed.vars).find((name) => reserved.has(name) || bundleLoaderName(name));
|
|
222
|
+
if (clash !== undefined) return { reserved: clash };
|
|
223
|
+
return { ok: true, vars: parsed.vars };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { lookup, resolve };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Merge a bundle's variables with the trigger's own `run.secrets` values (issue #704). `run.secrets` WINS a name
|
|
231
|
+
* clash: it is the narrower grant, written for this one trigger in the reviewed file, and the bundle is the
|
|
232
|
+
* project's default. The shadowed names are returned, sorted, for the record, so an override is visible rather
|
|
233
|
+
* than inferred. Own properties throughout, so no name can reach a prototype.
|
|
234
|
+
*/
|
|
235
|
+
export function mergeBundleSecrets(bundleVars = {}, triggerSecrets = {}) {
|
|
236
|
+
const merged = {};
|
|
237
|
+
const define = (name, value) => Object.defineProperty(merged, name, { value, enumerable: true, writable: true, configurable: true });
|
|
238
|
+
const shadowed = [];
|
|
239
|
+
for (const [name, value] of Object.entries(bundleVars)) {
|
|
240
|
+
if (Object.hasOwn(triggerSecrets, name)) shadowed.push(name);
|
|
241
|
+
else define(name, value);
|
|
242
|
+
}
|
|
243
|
+
for (const [name, value] of Object.entries(triggerSecrets)) define(name, value);
|
|
244
|
+
return { secrets: merged, shadowed: shadowed.sort() };
|
|
245
|
+
}
|
package/src/secret-profiles.mjs
CHANGED
|
@@ -60,17 +60,27 @@ export function parseSecretProfiles(raw) {
|
|
|
60
60
|
if (!PROFILE_NAME.test(name)) {
|
|
61
61
|
throw configError(`PI_SECRET_PROFILES profile name ${JSON.stringify(name)} may use letters, digits, dot, dash and underscore only`);
|
|
62
62
|
}
|
|
63
|
-
if (name
|
|
63
|
+
if (Object.hasOwn(profiles, name)) {
|
|
64
64
|
throw configError(`PI_SECRET_PROFILES declares ${JSON.stringify(name)} twice -- one of the two is not the resolver you think is running`);
|
|
65
65
|
}
|
|
66
66
|
if (path === "" || !isAbsolutePath(path)) {
|
|
67
67
|
throw configError(`PI_SECRET_PROFILES profile ${JSON.stringify(name)} needs an ABSOLUTE path to its resolver -- a service manager's working directory is not your shell's, so a relative path is a different file on every host`);
|
|
68
68
|
}
|
|
69
|
-
profiles
|
|
69
|
+
defineProfile(profiles, name, path);
|
|
70
70
|
}
|
|
71
71
|
return profiles;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* OWN properties only, here and in every lookup over a profile table (issue #704 review). The charset admits
|
|
76
|
+
* `toString`, `constructor` and `__proto__`, so `name in table` answers "declared" for a name nobody wrote,
|
|
77
|
+
* and `table[name] = path` with `__proto__` sets the object's prototype instead of declaring a profile. A
|
|
78
|
+
* defined property is an own, enumerable data property whatever the name.
|
|
79
|
+
*/
|
|
80
|
+
function defineProfile(table, name, path) {
|
|
81
|
+
Object.defineProperty(table, name, { value: path, enumerable: true, writable: true, configurable: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
/** Absolute on this platform, accepting a Windows drive letter or UNC root as `service.mjs` does. */
|
|
75
85
|
function isAbsolutePath(path) {
|
|
76
86
|
return isAbsolute(path) || /^([A-Za-z]:[\\/]|\\\\)/.test(path);
|
|
@@ -92,7 +102,7 @@ function isAbsolutePath(path) {
|
|
|
92
102
|
*/
|
|
93
103
|
export function mergeSecretProfiles(envProfiles = {}, overlayProfiles = {}) {
|
|
94
104
|
for (const name of Object.keys(overlayProfiles)) {
|
|
95
|
-
if (name
|
|
105
|
+
if (Object.hasOwn(envProfiles, name)) return { ambiguous: name };
|
|
96
106
|
}
|
|
97
107
|
return { profiles: { ...envProfiles, ...overlayProfiles } };
|
|
98
108
|
}
|
|
@@ -118,3 +128,93 @@ export function withinRoots(candidate, roots = []) {
|
|
|
118
128
|
return target === base || target.startsWith(base + sep);
|
|
119
129
|
});
|
|
120
130
|
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The profiles a host would actually resolve, for its capability row and for doctor (issue #704): every
|
|
134
|
+
* env-declared profile, plus each overlay-declared one whose resolver is an executable file whose REALPATH
|
|
135
|
+
* sits inside `roots`. That is the lookup `secrets.mjs -> makeSecretsResolver` makes per job, so a host
|
|
136
|
+
* never advertises an overlay profile it would answer `secret-profile-unknown` for, and never hides one it
|
|
137
|
+
* would serve.
|
|
138
|
+
*
|
|
139
|
+
* `realExecutablePath` is REQUIRED and injected, which keeps this module fs-free: the worker passes
|
|
140
|
+
* `secrets.mjs -> realExecutable`, the probe its resolver uses, and that module is not imported here
|
|
141
|
+
* because it loads pi through `env-allowlist.mjs`.
|
|
142
|
+
*
|
|
143
|
+
* Env profiles are taken as declared, with no probe, which is what the row always published: doctor is
|
|
144
|
+
* where a missing env resolver is reported, and probing it here would change an existing host's row.
|
|
145
|
+
*
|
|
146
|
+
* `{ ambiguous }` when a name is declared in both sources (`mergeSecretProfiles`). The resolver then
|
|
147
|
+
* refuses EVERY secrets job on this host, whatever profile it names, so the caller advertises no secret
|
|
148
|
+
* profile at all rather than one it would refuse.
|
|
149
|
+
*/
|
|
150
|
+
export function servableSecretProfiles({ envProfiles = {}, overlayProfiles = {}, roots = [], realExecutablePath }) {
|
|
151
|
+
const merged = mergeSecretProfiles(envProfiles, overlayProfiles);
|
|
152
|
+
if (merged.ambiguous) return { ambiguous: merged.ambiguous };
|
|
153
|
+
const profiles = { ...envProfiles };
|
|
154
|
+
for (const [name, declared] of Object.entries(overlayProfiles)) {
|
|
155
|
+
if (typeof declared !== "string") continue;
|
|
156
|
+
let path;
|
|
157
|
+
try {
|
|
158
|
+
path = realExecutablePath(declared);
|
|
159
|
+
} catch {
|
|
160
|
+
path = null;
|
|
161
|
+
}
|
|
162
|
+
if (path && withinRoots(path, roots)) defineProfile(profiles, name, declared);
|
|
163
|
+
}
|
|
164
|
+
return { profiles };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The output formats a bundle resolver may declare (issue #704). DECLARED, never sniffed: `{"A":"1"}` is one
|
|
169
|
+
* malformed dotenv line and `A=1` is malformed JSON, so a sniffer would have to pick, and the pick would change
|
|
170
|
+
* the day a manager prints a JSON-looking value. The operator says which one their script prints.
|
|
171
|
+
*/
|
|
172
|
+
export const BUNDLE_FORMATS = Object.freeze(["dotenv", "json"]);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse `PI_SECRET_BUNDLES`: `name:/abs/path:format,other:/abs/other:format` (issue #704). Returns
|
|
176
|
+
* `{ [name]: { path, format } }`, empty when unset. Throws (config-tagged, so the worker refuses to boot) on
|
|
177
|
+
* anything malformed, `parseSecretProfiles`' doctrine and for its reason.
|
|
178
|
+
*
|
|
179
|
+
* ITS OWN NAMESPACE, never a flag on a `PI_SECRET_PROFILES` entry. The two scripts answer different questions
|
|
180
|
+
* (one prints one value for one reference, the other prints a whole environment) and are called with different
|
|
181
|
+
* caps and parsers: a profile reachable as a bundle would have its single value parsed as dotenv and refused
|
|
182
|
+
* as `malformed` on every job, and a bundle reachable as a profile would put its whole environment into ONE
|
|
183
|
+
* variable. Two variables keep each script reachable only as what it was written to be, the reason
|
|
184
|
+
* `PI_WAIT_PROFILES` is separate from both.
|
|
185
|
+
*
|
|
186
|
+
* The NAME splits on the FIRST colon and the FORMAT on the LAST one, so `shop:C:\pi\shop.cmd:json` keeps its
|
|
187
|
+
* drive letter (the hazard `parseSecretProfiles` states). The format is REQUIRED: a missing one is a boot
|
|
188
|
+
* refusal naming the two choices, never a default, because a default would be the sniffing above with a
|
|
189
|
+
* slower fuse (the first operator whose script prints JSON gets every job refused as `malformed`).
|
|
190
|
+
*/
|
|
191
|
+
export function parseSecretBundleProfiles(raw) {
|
|
192
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") return {};
|
|
193
|
+
const bundles = {};
|
|
194
|
+
for (const entry of String(raw).split(",")) {
|
|
195
|
+
const text = entry.trim();
|
|
196
|
+
if (text === "") continue;
|
|
197
|
+
const cut = text.indexOf(":");
|
|
198
|
+
const last = text.lastIndexOf(":");
|
|
199
|
+
if (cut <= 0 || last === cut) {
|
|
200
|
+
throw configError(`PI_SECRET_BUNDLES entries must be name:/absolute/path:format (format ${BUNDLE_FORMATS.join(" or ")}), got ${JSON.stringify(text)}`);
|
|
201
|
+
}
|
|
202
|
+
const name = text.slice(0, cut).trim();
|
|
203
|
+
const path = text.slice(cut + 1, last).trim();
|
|
204
|
+
const format = text.slice(last + 1).trim();
|
|
205
|
+
if (!PROFILE_NAME.test(name)) {
|
|
206
|
+
throw configError(`PI_SECRET_BUNDLES profile name ${JSON.stringify(name)} may use letters, digits, dot, dash and underscore only`);
|
|
207
|
+
}
|
|
208
|
+
if (!BUNDLE_FORMATS.includes(format)) {
|
|
209
|
+
throw configError(`PI_SECRET_BUNDLES profile ${JSON.stringify(name)} must end in :dotenv or :json, the format its resolver prints -- it is never guessed from the output`);
|
|
210
|
+
}
|
|
211
|
+
if (Object.hasOwn(bundles, name)) {
|
|
212
|
+
throw configError(`PI_SECRET_BUNDLES declares ${JSON.stringify(name)} twice -- one of the two is not the resolver you think is running`);
|
|
213
|
+
}
|
|
214
|
+
if (path === "" || !isAbsolutePath(path)) {
|
|
215
|
+
throw configError(`PI_SECRET_BUNDLES profile ${JSON.stringify(name)} needs an ABSOLUTE path to its resolver -- a service manager's working directory is not your shell's, so a relative path is a different file on every host`);
|
|
216
|
+
}
|
|
217
|
+
Object.defineProperty(bundles, name, { value: Object.freeze({ path, format }), enumerable: true, writable: true, configurable: true });
|
|
218
|
+
}
|
|
219
|
+
return bundles;
|
|
220
|
+
}
|
package/src/secrets.mjs
CHANGED
|
@@ -84,8 +84,12 @@ export function secretsArmed(job) {
|
|
|
84
84
|
*
|
|
85
85
|
* An unrecognised exit code is INFRA on `decideRetry`'s own reasoning: a runner that exits with something
|
|
86
86
|
* we do not recognise is one we cannot reason about, and retrying-then-alerting beats accepting it as done.
|
|
87
|
+
*
|
|
88
|
+
* EXPORTED for the bundle resolver (issue #704, `secret-bundles.mjs`), which is this same seam with a larger
|
|
89
|
+
* `maxBytes` (a whole environment, not one value): one spawn, one exit mapping, one timeout and one abort, so a
|
|
90
|
+
* fix to any of them reaches both rather than one of two copies.
|
|
87
91
|
*/
|
|
88
|
-
function runResolver(spawnFn, path, reference, { timeoutMs, hostEnv, signal }) {
|
|
92
|
+
export function runResolver(spawnFn, path, reference, { timeoutMs, hostEnv, signal, maxBytes = MAX_SECRET_BYTES }) {
|
|
89
93
|
return new Promise((resolve) => {
|
|
90
94
|
let child;
|
|
91
95
|
try {
|
|
@@ -149,7 +153,7 @@ function runResolver(spawnFn, path, reference, { timeoutMs, hostEnv, signal }) {
|
|
|
149
153
|
|
|
150
154
|
child.stdout?.on("data", (d) => {
|
|
151
155
|
bytes += d.length;
|
|
152
|
-
if (bytes >
|
|
156
|
+
if (bytes > maxBytes) {
|
|
153
157
|
overflowed = true;
|
|
154
158
|
stop("overflow");
|
|
155
159
|
return;
|
|
@@ -196,6 +200,15 @@ function stripOneNewline(text) {
|
|
|
196
200
|
return text;
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
/** The resolver's executable probe: the realpath when it is an executable file, else null. Throws when
|
|
204
|
+
* the path does not resolve, which every caller folds into "not usable". Exported so the capability row
|
|
205
|
+
* (`servableSecretProfiles`, start.mjs) and doctor ask the question this resolver asks, not a copy of it. */
|
|
206
|
+
export function realExecutable(p) {
|
|
207
|
+
const real = realpathSync(p);
|
|
208
|
+
const st = statSync(real);
|
|
209
|
+
return st.isFile() && (st.mode & 0o111) !== 0 ? real : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
199
212
|
/**
|
|
200
213
|
* Build the pre-spend resolver the processor injects.
|
|
201
214
|
*
|
|
@@ -235,11 +248,7 @@ export function makeSecretsResolver({
|
|
|
235
248
|
// follows symlinks -- so `lstat` would refuse an ordinary /usr/local/bin/op -> ../Cellar/... The mode is
|
|
236
249
|
// deliberately NOT checked here: a group- or world-writable resolver is a doctor WARNING, on the
|
|
237
250
|
// env-setup precedent, because refusing a job over a mode bit at job time is a new class of refusal.
|
|
238
|
-
realExecutablePath =
|
|
239
|
-
const real = realpathSync(p);
|
|
240
|
-
const st = statSync(real);
|
|
241
|
-
return st.isFile() && (st.mode & 0o111) !== 0 ? real : null;
|
|
242
|
-
},
|
|
251
|
+
realExecutablePath = realExecutable,
|
|
243
252
|
log = () => {},
|
|
244
253
|
} = {}) {
|
|
245
254
|
return async function resolveSecrets(job, { signal, overlayProfiles = {} } = {}) {
|
|
@@ -247,7 +256,8 @@ export function makeSecretsResolver({
|
|
|
247
256
|
if (merged.ambiguous) return { ambiguous: merged.ambiguous };
|
|
248
257
|
|
|
249
258
|
const wanted = typeof job?.secretsProfile === "string" && job.secretsProfile !== "" ? job.secretsProfile : DEFAULT_SECRETS_PROFILE;
|
|
250
|
-
|
|
259
|
+
// Own properties only: the charset admits `toString` and `constructor`, which every object inherits.
|
|
260
|
+
const declared = Object.hasOwn(merged.profiles, wanted) ? merged.profiles[wanted] : undefined;
|
|
251
261
|
// A LOOKUP, never a fallback. There is deliberately no "if only one profile is declared, use it"
|
|
252
262
|
// rule: env-allowlist.mjs records what the last `else` of that shape cost, and a default whose
|
|
253
263
|
// meaning changes the day a second profile appears is that mistake with a slower fuse.
|
|
@@ -267,7 +277,7 @@ export function makeSecretsResolver({
|
|
|
267
277
|
// executable on the host". An env-declared profile is exempt: PI_SECRET_PROFILES already lives
|
|
268
278
|
// beside the forge tokens and the App key, so requiring roots for it would bound nothing and would
|
|
269
279
|
// break every deployment that never wanted panel authoring.
|
|
270
|
-
const fromOverlay = !(wanted
|
|
280
|
+
const fromOverlay = !Object.hasOwn(envProfiles, wanted);
|
|
271
281
|
if (fromOverlay && !withinRoots(path, roots)) return { profileUnknown: wanted };
|
|
272
282
|
|
|
273
283
|
const references = job?.secrets ?? {};
|
package/src/service-env.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Nothing here loads a `.env` into a process (`docs/secrets.md`): the result is an object the caller judges from.
|
|
23
23
|
*/
|
|
24
|
-
import { constants as fsConstants } from "node:fs";
|
|
24
|
+
import { constants as fsConstants, readFileSync } from "node:fs";
|
|
25
25
|
import { userInfo } from "node:os";
|
|
26
26
|
import { dirname, join } from "node:path";
|
|
27
27
|
import { SYSTEMD_HAZARD_SHAPES, decodeEnvFile, envFileHazard, readEnvAssignments } from "./env-file.mjs";
|
|
@@ -36,6 +36,8 @@ export function serviceEnvLoader(platform) {
|
|
|
36
36
|
* The deployment `.env` as the service is judged by (issue #464, gate round 3): decoded from its BYTES with the
|
|
37
37
|
* hardened reader, and `hazard` set to the first thing the service's loader reads differently from this reader, or
|
|
38
38
|
* refuses to load (a NUL, invalid UTF-8, an environment too big to exec, a lone CR), as `{ line, what, fix }`, or null.
|
|
39
|
+
* `raw` is the content as given, for a reader that judges the bytes itself (`readValkeyKeys`), since decoding erases
|
|
40
|
+
* invalid UTF-8.
|
|
39
41
|
*/
|
|
40
42
|
export function serviceEnvFileOf(raw, path, loader) {
|
|
41
43
|
const { text, loadHazard } = decodeEnvFile(raw, { loader });
|
|
@@ -43,9 +45,9 @@ export function serviceEnvFileOf(raw, path, loader) {
|
|
|
43
45
|
let hazard = null;
|
|
44
46
|
if (h !== null) {
|
|
45
47
|
const shape = h.shape !== undefined ? SYSTEMD_HAZARD_SHAPES[h.shape] : null;
|
|
46
|
-
hazard = shape ? { line: h.line, what: `has ${shape.what}${h.detail ? ` (${h.detail})` : ""}`, fix: shape.fix } : { line: h.line, what: "is one this command cannot read the way the service's loader will (an open quote, a continuation, or a line that runs)", fix: "fix that line, then re-run doctor" };
|
|
48
|
+
hazard = shape ? { line: h.line, shape: h.shape, what: `has ${shape.what}${h.detail ? ` (${h.detail})` : ""}`, fix: shape.fix } : { line: h.line, what: "is one this command cannot read the way the service's loader will (an open quote, a continuation, or a line that runs)", fix: "fix that line, then re-run doctor" };
|
|
47
49
|
}
|
|
48
|
-
return { text, path, loader, hazard };
|
|
50
|
+
return { text, raw, path, loader, hazard };
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -236,12 +238,43 @@ export function readDeploymentEnv({ dir, fs, uid = process.geteuid?.(), names =
|
|
|
236
238
|
* The `.env` of the deployment in `dir`, read the way doctor reads it (a regular file only, as bytes, one read) and
|
|
237
239
|
* resolved for `keys` (`resolveServiceEnv`). For a caller that has no file of its own in hand (the admin panel).
|
|
238
240
|
* Returns the resolution plus `path`, `unreadable` (a reason, when something is at the path and could not be read) and
|
|
239
|
-
* `untrusted` (`envFileTrust`'s reason); in either case nothing was taken from it.
|
|
241
|
+
* `untrusted` (`envFileTrust`'s reason); in either case nothing was taken from it. `shellRunsLine` is the line a sourcing shell does
|
|
242
|
+
* not read as an assignment, whatever keys were asked for: it can set any of them without spelling one (`. ./more.env`, `eval`), so
|
|
243
|
+
* `hazardSkipped`, which needs the key spelled, says nothing of it. Only for that loader: systemd ignores such a line and the cmd wrapper
|
|
244
|
+
* splits it on its `=`. A CR on a comment line is left out, since the shell reads past it and only a writer of this file minds.
|
|
240
245
|
*/
|
|
241
246
|
export function deploymentServiceEnv({ env, dir, keys, platform = process.platform, fs, uid = process.geteuid?.() }) {
|
|
242
247
|
// Issue #471 (gate rounds 1 and 2): one descriptor, and nothing taken from a file another account can change.
|
|
243
248
|
const { path, bytes, unreadable, untrusted } = readDeploymentEnv({ dir, fs, uid });
|
|
244
249
|
const raw = untrusted ? null : bytes;
|
|
245
250
|
const file = raw === null ? null : serviceEnvFileOf(raw, path, serviceEnvLoader(platform));
|
|
246
|
-
|
|
251
|
+
const shellRunsLine = file?.loader === "shell" && file.hazard !== null && file.hazard.shape !== "shell-crlf-comment" ? file.hazard.line : null;
|
|
252
|
+
return { ...resolveServiceEnv({ env, file, keys }), path, unreadable, untrusted, hazard: file?.hazard ?? null, shellRunsLine };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Deployment keys for a CLI verb that acts for the deployment (review of PR #575): `keys` resolved by issue #471's rule
|
|
257
|
+
* (`resolveServiceEnv`, above), the one doctor and `up` read the service's keys by. This shell's value where
|
|
258
|
+
* it sets the key, else the `.env` in `cwd` from a line the service's loader reads as written. Where the two disagree,
|
|
259
|
+
* or a line naming the key cannot be read as the loader reads it, or the file cannot be read and this shell sets none,
|
|
260
|
+
* the answer is unknown and the verb refuses (`problem`), as `up` refuses on a PI_JOB_IMAGE disagreement: a job queued
|
|
261
|
+
* on a queue no worker drains, or from a triggers file the worker does not read, is a silent no-op. Returns
|
|
262
|
+
* `{ env }` (this shell's with the file's values filled in) or `{ problem }`.
|
|
263
|
+
*/
|
|
264
|
+
export function cliDeploymentEnv(env, keys, { cwd = process.cwd(), platform = process.platform, readFile = (p) => readFileSync(p) } = {}) {
|
|
265
|
+
const envPath = join(cwd, ".env");
|
|
266
|
+
let file;
|
|
267
|
+
try {
|
|
268
|
+
file = serviceEnvFileOf(readFile(envPath), envPath, serviceEnvLoader(platform));
|
|
269
|
+
} catch (error) {
|
|
270
|
+
const unset = keys.filter((k) => typeof env[k] !== "string");
|
|
271
|
+
if (error?.code !== "ENOENT" && unset.length > 0) return { problem: `${envPath} could not be read (${error?.code ?? "error"}), so ${unset.join(" and ")} cannot be told` };
|
|
272
|
+
return { env };
|
|
273
|
+
}
|
|
274
|
+
const read = resolveServiceEnv({ env, file, keys });
|
|
275
|
+
const [d] = read.disagreements;
|
|
276
|
+
if (d) return { problem: `${d.key} is ${JSON.stringify(d.shell)} in this shell and ${JSON.stringify(d.file)} in ${envPath}: make them agree (the service runs the file's)` };
|
|
277
|
+
const unread = [...read.unread.map((u) => u.key), ...read.hazardSkipped];
|
|
278
|
+
if (unread.length > 0) return { problem: `${envPath} has a line for ${unread.join(" and ")} that the service's loader may read differently, so it cannot be told (pi-dispatch doctor names the line)` };
|
|
279
|
+
return { env: read.env };
|
|
247
280
|
}
|