@edgehero/pi-dispatch 1.1.0 → 1.3.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 +22 -1
- package/deploy/worker-env-wrapper.cmd +8 -0
- package/deploy/worker-env-wrapper.sh +58 -5
- package/package.json +1 -1
- package/src/config.mjs +33 -0
- package/src/doctor.mjs +105 -4
- package/src/env-allowlist.mjs +31 -1
- package/src/forges.mjs +14 -0
- package/src/index.mjs +11 -0
- package/src/outbox.mjs +8 -0
- package/src/processor.mjs +138 -6
- package/src/queue.mjs +14 -2
- package/src/reserved-env.mjs +40 -0
- package/src/run-container.mjs +16 -7
- package/src/run-history.mjs +40 -2
- package/src/runtime-settings.mjs +40 -0
- package/src/schedules.mjs +1 -1
- package/src/secret-profiles.mjs +119 -0
- package/src/secrets.mjs +319 -0
- package/src/session-store.mjs +294 -15
- package/src/start.mjs +19 -1
- package/src/triggers.mjs +166 -6
package/src/processor.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync } from "node:fs";
|
|
2
2
|
import { checkTokenCap, recordTokenSpend, releaseBudget, reserveBudget } from "./budget.mjs";
|
|
3
3
|
import { configError } from "./config.mjs";
|
|
4
|
+
import { DEFAULT_SECRETS_PROFILE, secretsArmed } from "./secrets.mjs";
|
|
4
5
|
import { EXIT_COMPLETED, EXIT_INFRA, EXIT_POLICY } from "./exit-code.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -44,7 +45,7 @@ export async function runJob(job, deps) {
|
|
|
44
45
|
// REQ-EGRESS-ALLOWLIST. Default admits everything, so a wiring that omits it behaves exactly as a
|
|
45
46
|
// deployment with no egress policy does -- which is also what the real factory returns when unarmed.
|
|
46
47
|
egressPreflight = async () => ({ ok: true }),
|
|
47
|
-
// (session, { piVersion }) => { promoted, reason, bytes }. Promotes this job's transcript back into
|
|
48
|
+
// (session, { piVersion, context }) => { promoted, reason, bytes }. Promotes this job's transcript back into
|
|
48
49
|
// the store, on a COMPLETED exit only. Never throws. The default is a no-op so a wiring that omits
|
|
49
50
|
// it behaves exactly as before -- no store, no promotion, no session in the record.
|
|
50
51
|
promoteSession = () => null,
|
|
@@ -70,6 +71,20 @@ export async function runJob(job, deps) {
|
|
|
70
71
|
return false;
|
|
71
72
|
}
|
|
72
73
|
},
|
|
74
|
+
// (job) => { ok: true, secrets } | { profileUnknown } | { unresolved, ... }. The wiring binds the
|
|
75
|
+
// abort signal into it (index.mjs), the way it binds name+signal into runContainer.
|
|
76
|
+
// REQ-TRIGGER-SECRETS. Resolves this trigger's `run.secrets` references through the operator's own
|
|
77
|
+
// resolver, HOST-SIDE, before anything spends. Injected so the gate is testable without a real script.
|
|
78
|
+
//
|
|
79
|
+
// The default FAILS CLOSED, deliberately unlike imagePreflight's and egressPreflight's
|
|
80
|
+
// admit-everything defaults, and for the reason the sessionsDir default states above: an admitting
|
|
81
|
+
// default would let a job that armed run.secrets start with those variables UNSET under any wiring
|
|
82
|
+
// that omits this key. That is a false success which looks exactly like the feature working, and it
|
|
83
|
+
// is the inversion this whole gate exists to prevent. It can be UNCONDITIONALLY refusing because the
|
|
84
|
+
// gate below only calls it when the job is armed -- putting the arming test in the default instead
|
|
85
|
+
// would leave an INJECTED resolver running on every job, which is how a probe nobody wanted starts
|
|
86
|
+
// spawning a subprocess per delivery to learn nothing.
|
|
87
|
+
resolveSecrets = async (job) => ({ profileUnknown: job.secretsProfile ?? DEFAULT_SECRETS_PROFILE }),
|
|
73
88
|
// (job) => scoped short-lived token. Takes the JOB, not the repo: which forge mints -- and therefore
|
|
74
89
|
// which credential the container gets -- is a property of `job.kind`, and only the wiring knows the
|
|
75
90
|
// map. Called for forge-backed jobs and for local jobs opted in via `github: true`; unflagged local
|
|
@@ -77,7 +92,8 @@ export async function runJob(job, deps) {
|
|
|
77
92
|
mintToken,
|
|
78
93
|
isDefaultBranchProtected, // (job, token) => boolean; same reason -- the forge is the job's, not the process's
|
|
79
94
|
prepareWorkspace, // (job, token) => { workspaceDir, jobDir } (clone+materialise+prompt)
|
|
80
|
-
// runContainer({ job, token, prepared, name, signal }) => { code, aborted, turns, tokens, session, usage }.
|
|
95
|
+
// runContainer({ job, token, prepared, secrets, name, signal }) => { code, aborted, turns, tokens, session, usage, context }.
|
|
96
|
+
// `secrets` is the resolved map from the gate above: values, already fetched, host-side. It MUST honour
|
|
81
97
|
// `signal`: stop the container on abort, and reject/exit promptly if `signal.aborted` is already
|
|
82
98
|
// true at entry (the timeout can fire during a slow prepare). The wiring injects name + signal.
|
|
83
99
|
runContainer,
|
|
@@ -279,6 +295,90 @@ export async function runJob(job, deps) {
|
|
|
279
295
|
return { outcome: "policy", reason: "skills-dir-missing", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
|
|
280
296
|
}
|
|
281
297
|
|
|
298
|
+
// REQ-TRIGGER-SECRETS. A trigger that named secret references the worker cannot resolve would run its
|
|
299
|
+
// flow with those variables UNSET, get a 401 from whatever it was meant to reach, write a plausible
|
|
300
|
+
// report about why the integration is down, and exit 0.
|
|
301
|
+
//
|
|
302
|
+
// PLACEMENT. Strictly before the mint below, the clone in prepareWorkspace, the token-cap read and
|
|
303
|
+
// reserveBudget, so a refusal here costs nothing (CONST-BUDGET-BEFORE-TOKENS). But it is NOT one of the
|
|
304
|
+
// "free, determinate, credential-less and I/O-less" gates above, and this comment must not claim it is:
|
|
305
|
+
// resolving spawns a subprocess per reference against the operator's manager, which is none of those
|
|
306
|
+
// four things. The precedent it actually follows is one gate LATER -- prepareWorkspace already performs
|
|
307
|
+
// a full network clone before the budget is reserved. An I/O-bound pre-budget step is established here;
|
|
308
|
+
// a free one it is not. It sits last among the pre-mint gates because it is both the narrowest and the
|
|
309
|
+
// only one that can block, so every cheaper answer is already in hand when it runs.
|
|
310
|
+
//
|
|
311
|
+
// There is deliberately no cheap "would this resolve?" probe. The reference grammar belongs to the
|
|
312
|
+
// resolver (#206, #209: the seam is a command), so this project has nothing it could validate short of
|
|
313
|
+
// asking -- the same shape the branch-protection check takes, where the check IS the real call.
|
|
314
|
+
//
|
|
315
|
+
// Guarded by `secretsArmed` at the CALL SITE, not inside the resolver: an unflagged job must not reach
|
|
316
|
+
// it under ANY wiring, and a guard that lives in the default is a guard an injected resolver skips.
|
|
317
|
+
const resolved = secretsArmed(job) ? await resolveSecrets(job) : { ok: true, secrets: {} };
|
|
318
|
+
if (resolved.profileUnknown) {
|
|
319
|
+
await comment(job, "Refused: this trigger set `run.secrets`, and the resolver profile it names is not usable on this worker host. No profile of that name is declared, or its resolver is absent or not executable. The job would have started with those variables unset, and an agent that gets a 401 writes a plausible report and exits 0. Run `pi-dispatch doctor` on the worker to see which profiles it has. Not run.");
|
|
320
|
+
// The operator's own profile LABEL, and never a path, a reference, or a byte the resolver printed.
|
|
321
|
+
// `comment` posts publicly on the issue: a resolver path there publishes the operator's filesystem
|
|
322
|
+
// layout, and the DECLARED profile names would publish their vault topology -- which is why the
|
|
323
|
+
// message points at doctor for the list instead of enumerating it. The label itself is
|
|
324
|
+
// operator-authored trigger config, the same class as the image ref the job-image refusals name.
|
|
325
|
+
log("refused_secret_profile_unknown", { kind: job.kind ?? null, profile: resolved.profileUnknown });
|
|
326
|
+
return { outcome: "policy", reason: "secret-profile-unknown", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (resolved.ambiguous) {
|
|
330
|
+
// Two sources declared one profile name. Neither wins, deliberately: runtime-settings documents the
|
|
331
|
+
// overlay's precedence as overlay > env, so inverting it here would leave two rules disagreeing about
|
|
332
|
+
// what an overlay is, while honouring it would let a settings file in a world-writable default
|
|
333
|
+
// directory redirect a profile the operator wrote in .env. This project already refuses ambiguity
|
|
334
|
+
// rather than resolving it (PI_EGRESS: "a typo must never leave you believing you have a policy you
|
|
335
|
+
// do not"), and an operator who sees this fixes it in seconds.
|
|
336
|
+
await comment(job, "Refused: this trigger set `run.secrets`, and the resolver profile it names is declared twice on this worker host, once in the environment and once in the settings overlay. Neither wins, on purpose: the job would otherwise run against whichever one happened to be picked. Remove one of the two. Not run.");
|
|
337
|
+
log("refused_secret_profile_ambiguous", { kind: job.kind ?? null, profile: resolved.ambiguous });
|
|
338
|
+
return { outcome: "policy", reason: "secret-profile-ambiguous", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (resolved.reserved) {
|
|
342
|
+
// A key the worker itself writes, and one parseTriggers could not have caught: the provider
|
|
343
|
+
// credential's variable names depend on this job's resolved provider and on what this host has set,
|
|
344
|
+
// and PI_FORWARD_ENV is an operator env list. Both are deployment state, so this is the same
|
|
345
|
+
// load-time / pre-spend split run.resume makes against PI_SESSIONS_DIR.
|
|
346
|
+
//
|
|
347
|
+
// It matters most in the direction that is hardest to see: buildContainerEnv writes the provider
|
|
348
|
+
// credential BEFORE this feature's values, so a trigger binding ANTHROPIC_API_KEY would silently
|
|
349
|
+
// redirect which credential every job of that trigger spends.
|
|
350
|
+
await comment(job, `Refused: this trigger's \`run.secrets\` binds \`${resolved.reserved}\`, and the worker sets that variable itself for every job. The container would receive the worker's value rather than this trigger's, and the trigger would look like it worked. Rename it in the triggers file. Not run.`);
|
|
351
|
+
// The variable NAME only. It is the operator's own choice of name, not payload, and naming it is what
|
|
352
|
+
// makes the refusal actionable -- but the REFERENCE behind it never appears.
|
|
353
|
+
log("refused_secret_name_reserved", { kind: job.kind ?? null, name: resolved.reserved });
|
|
354
|
+
return { outcome: "policy", reason: "secret-name-reserved", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (resolved.unresolved) {
|
|
358
|
+
// DETERMINATE: the resolver said exit 2, printed nothing, overran the size cap, or returned a value
|
|
359
|
+
// with a NUL in it. Retrying cannot change any of those, so this RETURNS (CONST-RETRY-INFRA-ONLY).
|
|
360
|
+
const why = SECRET_FAILURES[resolved.failure] ?? "did not return a value";
|
|
361
|
+
await comment(job, `Refused: this trigger set \`run.secrets\`, and the resolver for \`${resolved.unresolved}\` ${why}. The job would have started with that variable unset, and an agent that gets a 401 writes a plausible report and exits 0. Run your profile's resolver by hand against that reference to see why: this comment carries neither the reference, nor the resolver's path, nor a byte of what it printed. Not run.`);
|
|
362
|
+
// The variable NAME, never a value -- the restraint refused_sessions_dir_unset keeps, and the name is
|
|
363
|
+
// the operator's own choice rather than payload. `failure` is OUR enum, `code` the script's small
|
|
364
|
+
// integer exit, `stderrBytes` a COUNT: never the resolver's words.
|
|
365
|
+
log("refused_secret_unresolved", { kind: job.kind ?? null, name: resolved.unresolved, failure: resolved.failure ?? null, code: resolved.code ?? null, stderrBytes: resolved.stderrBytes ?? 0 });
|
|
366
|
+
return { outcome: "policy", reason: "secret-unresolved", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (resolved.unreachable) {
|
|
370
|
+
// INDETERMINATE: exit 1 ("could not reach my manager"), an exit code we do not recognise, a spawn
|
|
371
|
+
// fault, or a timeout. THROWS, so BullMQ retries per `attempts` -- the determinate/indeterminate split
|
|
372
|
+
// the image and egress preflights already draw, decided here by the resolver's own exit code rather
|
|
373
|
+
// than by matching its stderr, which image-preflight.mjs forbids for good reason. Folding this into
|
|
374
|
+
// the refusal above would permanently burn a delivery over a twenty-second vault blip, and a webhook
|
|
375
|
+
// does not redeliver itself. Nothing has spent: `budgetReserved` computes false in the catch below
|
|
376
|
+
// because `reserved` is still false here.
|
|
377
|
+
log("secret_resolver_unreachable", { kind: job.kind ?? null, name: resolved.unreachable, failure: resolved.failure ?? null, code: resolved.code ?? null, stderrBytes: resolved.stderrBytes ?? 0 });
|
|
378
|
+
throw new InfraRetry(`secret resolver could not answer for ${resolved.unreachable}`, { reason: "secret-resolver-unreachable", provider: job.provider ?? null, model: job.model ?? null });
|
|
379
|
+
}
|
|
380
|
+
const secrets = resolved.secrets ?? {};
|
|
381
|
+
|
|
282
382
|
if (wantsForgeToken) {
|
|
283
383
|
token = await mintToken(job);
|
|
284
384
|
|
|
@@ -351,7 +451,7 @@ export async function runJob(job, deps) {
|
|
|
351
451
|
return { outcome: "policy", reason: budget.reason, exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: true }; // return => not retried
|
|
352
452
|
}
|
|
353
453
|
|
|
354
|
-
const { code, aborted, turns, tokens, session, usage } = await runContainer({ job, token, prepared });
|
|
454
|
+
const { code, aborted, turns, tokens, session, usage, context } = await runContainer({ job, token, prepared, secrets });
|
|
355
455
|
log("container_exit", { exitCode: code, aborted });
|
|
356
456
|
|
|
357
457
|
// Record token spend post-run (the check-AFTER half of the lagging token cap). The container ran,
|
|
@@ -388,7 +488,7 @@ export async function runJob(job, deps) {
|
|
|
388
488
|
// (CONST-RETRY-INFRA-ONLY). Same completed-only rule INT-OUTBOX-CONTRACT already uses, and
|
|
389
489
|
// it sits beside the chain collection for the same reason: both must happen before the
|
|
390
490
|
// `finally` deletes jobDir. Never throws.
|
|
391
|
-
const promoted = prepared.session ? promoteSession(prepared.session, { piVersion }) : null;
|
|
491
|
+
const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context }) : null;
|
|
392
492
|
return {
|
|
393
493
|
outcome: "completed",
|
|
394
494
|
exitCode: code,
|
|
@@ -455,7 +555,8 @@ export async function runJob(job, deps) {
|
|
|
455
555
|
* and with one number alone it is indistinguishable from an ordinary cold start.
|
|
456
556
|
*
|
|
457
557
|
* The runner's verdict WINS on `resumed`, because it is the one that observed the outcome. The host's
|
|
458
|
-
* reason is kept when the runner has none to give (a container that died before its exit line)
|
|
558
|
+
* reason is kept when the runner has none to give (a container that died before its exit line), AND when
|
|
559
|
+
* the host itself refused -- see the second precedence rule below.
|
|
459
560
|
*
|
|
460
561
|
* PII-free by construction: a boolean, a fixed enum, an integer. The key and the branch name are
|
|
461
562
|
* deliberately absent -- this record holds no attacker-chosen string, and a branch name is one.
|
|
@@ -463,16 +564,47 @@ export async function runJob(job, deps) {
|
|
|
463
564
|
function mergeSession(prepared, fromRunner, promoted = null) {
|
|
464
565
|
const host = prepared?.session;
|
|
465
566
|
if (!host && !fromRunner) return null;
|
|
567
|
+
// A HOST GATE THAT REFUSED OUTRANKS THE RUNNER'S `absent`, and without this rule it never reached a
|
|
568
|
+
// record at all. A refused read stages a 0-byte file rather than nothing (session-store.mjs, where the
|
|
569
|
+
// reasoning is pi's EEXIST race), the container is handed that file either way, and pi opens it and
|
|
570
|
+
// finds no messages -- so the runner reports `absent` on EVERY host refusal. Letting that win overwrote
|
|
571
|
+
// the answer with a restatement of the question: `expired` and `pi-version-changed` reached no
|
|
572
|
+
// completed record in the feature's whole life, and `docs/sessions.md`'s promise that every cold start
|
|
573
|
+
// is nameable in the record was false for them.
|
|
574
|
+
//
|
|
575
|
+
// Narrow on purpose, `host.resume === false` and the runner's token exactly `absent`. When the host
|
|
576
|
+
// DID stage a transcript and the runner still reports `absent`, the two genuinely disagree, and that
|
|
577
|
+
// disagreement is the event this object exists to show; the runner keeps winning there. So does its
|
|
578
|
+
// `unparseable`, which reports a degrade the host could not see.
|
|
579
|
+
const hostRefused = host?.resume === false && typeof host.reason === "string";
|
|
466
580
|
return {
|
|
467
581
|
resumed: fromRunner ? fromRunner.resumed : false,
|
|
468
582
|
// A promotion that was refused is the more useful reason to surface: "locked" or
|
|
469
583
|
// "not-a-regular-file" says why the NEXT run will cold-start, which is the thing an operator
|
|
470
584
|
// chasing "it never resumes" needs. It only ever replaces a reason on the completed path.
|
|
471
|
-
reason:
|
|
585
|
+
reason:
|
|
586
|
+
(promoted && !promoted.promoted ? promoted.reason : null) ??
|
|
587
|
+
(hostRefused && fromRunner?.reason === "absent" ? host.reason : null) ??
|
|
588
|
+
fromRunner?.reason ??
|
|
589
|
+
host?.reason ??
|
|
590
|
+
null,
|
|
472
591
|
bytes: promoted?.bytes ?? host?.bytes ?? null,
|
|
473
592
|
};
|
|
474
593
|
}
|
|
475
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Our own words for why a resolver did not produce a value, turned into a phrase at the refusal site --
|
|
597
|
+
* the shape the egress refusal already uses for its `state` discriminator. The `??` fallback in the caller
|
|
598
|
+
* is not decoration: a failure code added in secrets.mjs must degrade to a generic sentence rather than
|
|
599
|
+
* print `undefined` on a public issue.
|
|
600
|
+
*/
|
|
601
|
+
const SECRET_FAILURES = {
|
|
602
|
+
exit: "refused that reference",
|
|
603
|
+
empty: "printed nothing",
|
|
604
|
+
overflow: "printed more than the size cap allows",
|
|
605
|
+
nul: "printed a value containing a NUL byte, which cannot survive the container's argv",
|
|
606
|
+
};
|
|
607
|
+
|
|
476
608
|
export class InfraRetry extends Error {
|
|
477
609
|
constructor(message, { cause, reason, exitCode, turns, tokens, session, usage, provider, model, budgetReserved } = {}) {
|
|
478
610
|
super(message, cause ? { cause } : undefined);
|
package/src/queue.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export function makeQueue(connection) {
|
|
|
16
16
|
* removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
|
|
17
17
|
* redelivered, so a modest window is enough.
|
|
18
18
|
*/
|
|
19
|
-
export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = new Date() }) {
|
|
19
|
+
export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = new Date() }) {
|
|
20
20
|
const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
|
|
21
21
|
// A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
|
|
22
22
|
// minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
|
|
@@ -47,6 +47,11 @@ export async function enqueueLocalJob(queue, { folder, flow, task, command, prov
|
|
|
47
47
|
// than inside `trigger` because a worker-host path is an execution knob, not a fact about the
|
|
48
48
|
// delivery -- and `trigger` is the object copied into /job/event.json.
|
|
49
49
|
...(skillsDir !== undefined && { skillsDir }),
|
|
50
|
+
// REQ-TRIGGER-SECRETS, on the forge path's terms: references only, resolved by the worker at job
|
|
51
|
+
// start. A cron trigger may bind secrets (a nightly deploy is the obvious user), which is where
|
|
52
|
+
// this differs from `replicas` -- that one is refused on a local job and this one is not.
|
|
53
|
+
...(secrets !== undefined && { secrets }),
|
|
54
|
+
...(secretsProfile !== undefined && { secretsProfile }),
|
|
50
55
|
...(chainDepth !== undefined && { chainDepth }),
|
|
51
56
|
...(parentJobId !== undefined && { parentJobId }),
|
|
52
57
|
};
|
|
@@ -124,7 +129,7 @@ export async function enqueueGitLabJob(queue, fields) {
|
|
|
124
129
|
* window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
|
|
125
130
|
* has always been.
|
|
126
131
|
*/
|
|
127
|
-
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, skillsDir, instructions, resume, replica, replicas }) {
|
|
132
|
+
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replica, replicas }) {
|
|
128
133
|
const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
|
|
129
134
|
// `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
|
|
130
135
|
// come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
|
|
@@ -162,6 +167,13 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
162
167
|
// delivery, and `trigger` is what /job/event.json is built from.
|
|
163
168
|
...(instructions !== undefined && { instructions }),
|
|
164
169
|
...(resume !== undefined && { resume }),
|
|
170
|
+
// REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
|
|
171
|
+
// them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
|
|
172
|
+
// credential in Redis would outlive the container it was scoped to. The worker resolves them at job
|
|
173
|
+
// start, pre-spend. At JOB level rather than inside `trigger` for image/skillsDir's reason: `trigger`
|
|
174
|
+
// is copied verbatim into /job/event.json, which an agent reads.
|
|
175
|
+
...(secrets !== undefined && { secrets }),
|
|
176
|
+
...(secretsProfile !== undefined && { secretsProfile }),
|
|
165
177
|
// Conditional for the same reason packages/image/resume are: an unflagged job's data must keep
|
|
166
178
|
// exactly the keys it has today. `replica` is this job's 1-based index and `replicas` the set size;
|
|
167
179
|
// both are integers, so the run record they land in stays PII-free by construction.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment variable names `buildContainerEnv` writes itself, spelled once (issue #225).
|
|
3
|
+
*
|
|
4
|
+
* This exists because `run.secrets` lets a trigger name env variables, and a trigger that names one the
|
|
5
|
+
* closed map already owns would either be silently overwritten (the job runs without the value it asked
|
|
6
|
+
* for, on a clean exit 0) or silently WIN (a trigger redirecting `PI_OFFLINE` or `PI_MAX_TURNS`). Both
|
|
7
|
+
* are the inversion `env-allowlist.mjs`'s header exists to prevent, arriving through a new door.
|
|
8
|
+
*
|
|
9
|
+
* A SEPARATE MODULE, and it has no imports at all, on purpose. `triggers.mjs` is the shared validator:
|
|
10
|
+
* it is pure and fs-free, the receiver loads it, and `admin/build.mjs` INLINES it into the published
|
|
11
|
+
* console. Importing `env-allowlist.mjs` to reach these names would drag `node:fs`, `node:os` and pi's
|
|
12
|
+
* compat shim into all three, to read a list of strings. So the list moves down here, where both can
|
|
13
|
+
* have it for free.
|
|
14
|
+
*
|
|
15
|
+
* Only the STATIC names live here. The rest of the closed map is deployment state and cannot be known
|
|
16
|
+
* from a triggers file at all: the provider credential's variable names come from `findEnvKeys(provider,
|
|
17
|
+
* hostEnv)`, and `PI_FORWARD_ENV` is an operator env list. Those two are refused PRE-SPEND, in the
|
|
18
|
+
* processor, where the resolved provider and the host env are both in hand. `MINTED_TOKEN_VARS` and
|
|
19
|
+
* `FORGE_HOST_VARS` (forges.mjs) and `EGRESS_ENV_VARS`/`WORKER_ONLY_SECRET_VARS` (config.mjs) stay in
|
|
20
|
+
* their own modules and are imported by the validator beside this one, never copied into it.
|
|
21
|
+
*
|
|
22
|
+
* `worker/test/env-allowlist.test.mjs` pins this set against what `buildContainerEnv` actually emits, so
|
|
23
|
+
* a variable added to the closed map and not to this list fails there rather than becoming a hole here.
|
|
24
|
+
*/
|
|
25
|
+
export const CONTAINER_ENV_NAMES = new Set([
|
|
26
|
+
"PI_PROVIDER",
|
|
27
|
+
"PI_MODEL",
|
|
28
|
+
"PI_MAX_TURNS",
|
|
29
|
+
"PI_MAX_TOKENS",
|
|
30
|
+
"PI_JOB_ID",
|
|
31
|
+
"PI_GLOBAL_ALLOW_EXTENSIONS",
|
|
32
|
+
"PI_PACKAGES",
|
|
33
|
+
"PI_SESSION_FILE",
|
|
34
|
+
"PI_FLOW",
|
|
35
|
+
"PI_COMMAND",
|
|
36
|
+
"PI_OFFLINE",
|
|
37
|
+
"PLAYWRIGHT_BROWSERS_PATH",
|
|
38
|
+
"PLAYWRIGHT_MCP_BROWSER",
|
|
39
|
+
"PLAYWRIGHT_MCP_SANDBOX",
|
|
40
|
+
]);
|
package/src/run-container.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { InfraRetry } from "./processor.mjs";
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The real `runContainer` the processor injects. Launches one job container and returns
|
|
10
|
-
* `{ code, aborted, turns, tokens, session, usage }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
|
|
10
|
+
* `{ code, aborted, turns, tokens, session, usage, context }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
|
|
11
11
|
* the 30-min timeout or graceful shutdown), which the processor classifies as POLICY (no retry) per
|
|
12
12
|
* INT-RUNNER-EXIT-CODE-PROTOCOL. The numeric `code` alone cannot say this: a worker SIGKILL and a
|
|
13
13
|
* kernel OOM both surface as 137, so the abort FLAG -- not the code -- is the discriminator.
|
|
@@ -30,7 +30,7 @@ export function makeRunContainer({
|
|
|
30
30
|
image, // the DEPLOYMENT default (PI_JOB_IMAGE); a trigger's own run.image overrides it per job
|
|
31
31
|
hostEnv = process.env,
|
|
32
32
|
onOutput = (c) => process.stdout.write(c),
|
|
33
|
-
openJobLog = () => ({ write() {}, close: async () => ({ turns: null, tokens: null, session: null, usage: null }) }),
|
|
33
|
+
openJobLog = () => ({ write() {}, close: async () => ({ turns: null, tokens: null, session: null, usage: null, context: null }) }),
|
|
34
34
|
spawnFn = spawn,
|
|
35
35
|
globalPiDir = null, // REQ-GLOBAL-PI-OVERLAY: operator's global pi overlay dir, mounted :ro; null = off
|
|
36
36
|
allowGlobalExtensions = true, // REQ-GLOBAL-PI-OVERLAY: the staged overlay's extensions load unless PI_GLOBAL_ALLOW_EXTENSIONS=0
|
|
@@ -46,8 +46,8 @@ export function makeRunContainer({
|
|
|
46
46
|
}) {
|
|
47
47
|
// async so a synchronous throw (e.g. buildContainerEnv on an unconfigured provider) surfaces as
|
|
48
48
|
// a rejection, uniformly awaitable by the processor and by tests.
|
|
49
|
-
return async function runContainer({ job, token, prepared, name, signal }) {
|
|
50
|
-
if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null }; // killed before it could start
|
|
49
|
+
return async function runContainer({ job, token, prepared, secrets = {}, name, signal }) {
|
|
50
|
+
if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null }; // killed before it could start
|
|
51
51
|
|
|
52
52
|
// Closed env allowlist: only the provider key + the declared PI_* vars. Throws (config) if
|
|
53
53
|
// the provider is unconfigured -- the processor turns that into a pre-spend refusal.
|
|
@@ -87,6 +87,10 @@ export function makeRunContainer({
|
|
|
87
87
|
// mutually exclusive with it by parse -- a job carries one or the other, never both.
|
|
88
88
|
command: typeof job.command === "string" && job.command.trim() !== "" ? job.command : undefined,
|
|
89
89
|
authFromPi, // source the provider key from pi's auth.json when the env has none
|
|
90
|
+
// REQ-TRIGGER-SECRETS: this trigger's resolved secrets, fetched by the processor BEFORE anything
|
|
91
|
+
// spent. Off the call bag rather than off `job` or the closure: it is neither a per-job fact the
|
|
92
|
+
// record may carry nor a deployment setting, it is a live credential, and `token` is its precedent.
|
|
93
|
+
secrets,
|
|
90
94
|
});
|
|
91
95
|
|
|
92
96
|
// `-net` on this container's own name (egress.mjs). null when no policy is armed, and docker-run's
|
|
@@ -147,20 +151,25 @@ export function makeRunContainer({
|
|
|
147
151
|
});
|
|
148
152
|
child.on("close", async (code) => {
|
|
149
153
|
const aborted = signal?.aborted === true; // capture BEFORE the await
|
|
150
|
-
// A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage fall back to null.
|
|
154
|
+
// A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage/context fall back to null.
|
|
151
155
|
let turns = null;
|
|
152
156
|
let tokens = null;
|
|
153
157
|
let session = null;
|
|
154
158
|
let usage = null;
|
|
159
|
+
let context = null;
|
|
155
160
|
try {
|
|
156
|
-
|
|
161
|
+
// `context = null` is a DEFAULT rather than a plain destructure: an injected sink that
|
|
162
|
+
// predates the field returns no such key, and `undefined` would then reach the record's
|
|
163
|
+
// shape where every other absence is spelled `null`.
|
|
164
|
+
({ turns, tokens, session, usage, context = null } = await sink.close());
|
|
157
165
|
} catch {
|
|
158
166
|
turns = null;
|
|
159
167
|
tokens = null;
|
|
160
168
|
session = null;
|
|
161
169
|
usage = null;
|
|
170
|
+
context = null;
|
|
162
171
|
}
|
|
163
|
-
resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage } : { code: code ?? 1, aborted: false, turns, tokens, session, usage });
|
|
172
|
+
resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context });
|
|
164
173
|
});
|
|
165
174
|
});
|
|
166
175
|
|
package/src/run-history.mjs
CHANGED
|
@@ -110,6 +110,43 @@ export function parseExitSession(text) {
|
|
|
110
110
|
return null;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The container's report of how full its context was when the run ended (issue #186). Shaped exactly
|
|
115
|
+
* like `parseExitSession` above and, like it, PASSED THROUGH rather than rebuilt: both integers are
|
|
116
|
+
* host-bounded numbers, so `parseExitUsage`'s revalidating rebuild is not what this needs -- that one
|
|
117
|
+
* exists because the ledger carries id STRINGS.
|
|
118
|
+
*
|
|
119
|
+
* Absent means ABSENT, never zero. A runner predating this field, a run pi could give no context window
|
|
120
|
+
* for, and a compaction that left the count unknown all produce no key at all, and the session store
|
|
121
|
+
* reads that as "no measurement" and passes rather than inventing a denominator.
|
|
122
|
+
*/
|
|
123
|
+
export function parseExitContext(text) {
|
|
124
|
+
if (typeof text !== "string") return null;
|
|
125
|
+
const lines = text.split("\n");
|
|
126
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
127
|
+
const line = lines[i].trim();
|
|
128
|
+
if (line === "") continue;
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(line);
|
|
132
|
+
} catch {
|
|
133
|
+
continue; // docker/agent noise or a truncated final line
|
|
134
|
+
}
|
|
135
|
+
if (parsed?.event !== "exit") continue;
|
|
136
|
+
const c = parsed?.context;
|
|
137
|
+
// A window of 0 is not a denominator, and a negative count is not a measurement. SAFE integers
|
|
138
|
+
// specifically: `Number.isInteger` accepts up to ~1.8e308, and anything from 1e21 up stringifies to
|
|
139
|
+
// exponential notation, which the session store's own decimal round-trip then rejects on read --
|
|
140
|
+
// so a value in that range would be written into the store and be unreadable forever after, with
|
|
141
|
+
// the gate failing open on a measurement that said the context was full.
|
|
142
|
+
if (c && typeof c === "object" && !Array.isArray(c) && Number.isSafeInteger(c.tokens) && Number.isSafeInteger(c.window) && c.tokens >= 0 && c.window > 0) {
|
|
143
|
+
return { tokens: c.tokens, window: c.window };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
113
150
|
export function parseExitTokens(text) {
|
|
114
151
|
if (typeof text !== "string") return null;
|
|
115
152
|
const lines = text.split("\n");
|
|
@@ -389,11 +426,12 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
|
|
|
389
426
|
}
|
|
390
427
|
|
|
391
428
|
async function close({ timeoutMs = 2000 } = {}) {
|
|
392
|
-
// Capture turns/tokens/session/usage from the tail first, so they survive even if the flush errors or times out.
|
|
429
|
+
// Capture turns/tokens/session/usage/context from the tail first, so they survive even if the flush errors or times out.
|
|
393
430
|
const turns = parseExitTurns(tail);
|
|
394
431
|
const tokens = parseExitTokens(tail);
|
|
395
432
|
const session = parseExitSession(tail);
|
|
396
433
|
const usage = parseExitUsage(tail);
|
|
434
|
+
const context = parseExitContext(tail);
|
|
397
435
|
try {
|
|
398
436
|
if (stream !== null) {
|
|
399
437
|
const s = stream;
|
|
@@ -417,7 +455,7 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
|
|
|
417
455
|
} catch (err) {
|
|
418
456
|
log("log_sink_error", { jobId, reason: err?.message });
|
|
419
457
|
}
|
|
420
|
-
return { turns, tokens, session, usage };
|
|
458
|
+
return { turns, tokens, session, usage, context };
|
|
421
459
|
}
|
|
422
460
|
|
|
423
461
|
return { write, close };
|
package/src/runtime-settings.mjs
CHANGED
|
@@ -27,6 +27,22 @@ import { defaultSettingsFile } from "./config.mjs";
|
|
|
27
27
|
|
|
28
28
|
export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct"];
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Overlay keys the OVERLAY accepts but no model-callable tool may set. `secretProfiles` maps a profile name
|
|
32
|
+
* to an absolute path to a script the worker executes, which is plainly "a capability the model would
|
|
33
|
+
* GAIN" -- the test `admin/src/index.ts` already applies to `run.image` and `run.resume`.
|
|
34
|
+
*
|
|
35
|
+
* The mechanism is the OMISSION from KNOWN_KEYS above: `dispatch_set` narrows its open string parameter
|
|
36
|
+
* with `KNOWN_KEYS.includes(...)`, and the panel's generic settings dialog is a `ui.select` over the same
|
|
37
|
+
* array, so leaving the key out closes both doors at once. This constant exists so that omission has a NAME
|
|
38
|
+
* and a reason attached to it. Without one, `KNOWN_KEYS` silently stops meaning "keys the overlay accepts"
|
|
39
|
+
* and the next contributor tidies the gap away as an oversight.
|
|
40
|
+
*
|
|
41
|
+
* A profile is declared through the operator-typed `/dispatch` command instead, which pi reaches only from
|
|
42
|
+
* its own user-input path, and every declared path is bounded by PI_SECRET_RESOLVER_ROOTS in the worker.
|
|
43
|
+
*/
|
|
44
|
+
export const OPERATOR_ONLY_KEYS = ["secretProfiles"];
|
|
45
|
+
|
|
30
46
|
function isNonEmptyString(value) {
|
|
31
47
|
return typeof value === "string" && value.trim() !== "";
|
|
32
48
|
}
|
|
@@ -85,6 +101,30 @@ function validateOverlay(candidate, log) {
|
|
|
85
101
|
if (!isIntInRange(value, 1, 10)) return { invalid: "concurrency must be an integer 1-10" };
|
|
86
102
|
overlay[key] = value;
|
|
87
103
|
break;
|
|
104
|
+
case "secretProfiles": {
|
|
105
|
+
// REQ-TRIGGER-SECRETS. `{ [name]: "/abs/path" }`, the panel-authored half of the resolver
|
|
106
|
+
// table. The shape is checked here and the PATH BOUND is not: whether a path is allowed is
|
|
107
|
+
// PI_SECRET_RESOLVER_ROOTS' question, and it is asked in the worker at resolution time rather
|
|
108
|
+
// than here, because this same validator runs inside the admin extension where a "yes" would
|
|
109
|
+
// prove nothing about the host the job will run on.
|
|
110
|
+
//
|
|
111
|
+
// An invalid value fails the WHOLE overlay, which is this function's documented rule and is
|
|
112
|
+
// deliberate here too: a half-read profile table is a deployment that believes it has a
|
|
113
|
+
// resolver it does not.
|
|
114
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
115
|
+
return { invalid: "secretProfiles must be an object mapping profile names to resolver paths" };
|
|
116
|
+
}
|
|
117
|
+
for (const name of Object.keys(value)) {
|
|
118
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
119
|
+
return { invalid: "secretProfiles names may use letters, digits, dot, dash and underscore only" };
|
|
120
|
+
}
|
|
121
|
+
if (typeof value[name] !== "string" || value[name].trim() === "") {
|
|
122
|
+
return { invalid: `secretProfiles.${name} must be a non-empty absolute path` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
overlay[key] = { ...value };
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
88
128
|
case "softHoldPct":
|
|
89
129
|
// A percentage of each active cap; 100 would equal the hard wall (no band) and 0 has no meaning,
|
|
90
130
|
// so the enforced band is 1-99. Absence disables the soft-hold entirely.
|
package/src/schedules.mjs
CHANGED
|
@@ -74,7 +74,7 @@ function normalizeCronSchedule({ on, run }, path, existsSync) {
|
|
|
74
74
|
// key. A command trigger carries no flow/task at all (the validator enforces the XOR), so those two
|
|
75
75
|
// keys hold undefined here and drop at JSON serialization -- the command schedule's data is exactly
|
|
76
76
|
// kind/folder/command plus the shared fields.
|
|
77
|
-
const data = { 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.skillsDir !== undefined && { skillsDir: run.skillsDir }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
|
|
77
|
+
const data = { 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.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
|
|
78
78
|
// Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
|
|
79
79
|
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
|
|
80
80
|
const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's declared secret-resolver profiles: parsing, merging, and the path bound (issue #225).
|
|
3
|
+
*
|
|
4
|
+
* A PROFILE is a name and an absolute path to a script the operator wrote. A trigger names the NAME; it
|
|
5
|
+
* can never name the path. That distinction is the whole reason `run.secretsProfile` is allowed to exist:
|
|
6
|
+
* `DES-SERVICE-ENV-SETUP-SEAM` rejected "making this reachable from configuration, which would turn a
|
|
7
|
+
* boot-time root-adjacent exec into something a trigger file could name", and selecting among execs the
|
|
8
|
+
* operator already declared is not naming one.
|
|
9
|
+
*
|
|
10
|
+
* PURE AND FS-FREE, like `triggers.mjs` and for a weaker but real version of its reason: `config.mjs`
|
|
11
|
+
* imports this module, `runtime-settings.mjs` imports `config.mjs`, and `admin/build.mjs` inlines that
|
|
12
|
+
* chain into the published console. Whether a path EXISTS is asked once, in `secrets.mjs`, on the worker
|
|
13
|
+
* that is about to spawn it.
|
|
14
|
+
*
|
|
15
|
+
* `configError` is a local copy rather than an import from `config.mjs`, which imports this module: the
|
|
16
|
+
* cycle would resolve (function declarations hoist) but would be a trap for the next reader. The same
|
|
17
|
+
* duplication, for a related reason, is in `env-allowlist.mjs`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isAbsolute, normalize, sep } from "node:path";
|
|
21
|
+
|
|
22
|
+
function configError(message) {
|
|
23
|
+
const error = new Error(message);
|
|
24
|
+
error.piDispatchConfig = true;
|
|
25
|
+
return error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A profile name. Deliberately the same charset `triggers.mjs` allows in `run.secretsProfile`, and it has
|
|
30
|
+
* to be: a name that fails here after passing there would be an operator-visible contradiction between two
|
|
31
|
+
* files that are meant to agree. Excluding `,` and `:` is load-bearing rather than tidy, since those are
|
|
32
|
+
* this variable's own separators -- a name carrying either could not round-trip through its declaration.
|
|
33
|
+
*/
|
|
34
|
+
const PROFILE_NAME = /^[A-Za-z0-9._-]+$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse `PI_SECRET_PROFILES`: `name:/abs/path,other:/abs/other`. Returns `{ [name]: path }`, empty when
|
|
38
|
+
* unset. Throws (config-tagged, so the worker refuses to boot) on anything malformed.
|
|
39
|
+
*
|
|
40
|
+
* SET-BUT-GARBLED FAILS LOUD, which is `parsePollRepos`' doctrine and matters more here: a silently
|
|
41
|
+
* dropped entry is a profile the operator believes is wired, and every trigger naming it would refuse at
|
|
42
|
+
* delivery time with the operator looking at a line that appears to declare it.
|
|
43
|
+
*
|
|
44
|
+
* Each entry splits on its FIRST colon, so `prod:C:\pi\resolve.cmd` parses on Windows. That is the same
|
|
45
|
+
* drive-letter hazard `config.mjs`'s `delimitedList` exists for, arriving from the other side: there the
|
|
46
|
+
* colon must not be read as a separator, here exactly one of them must be.
|
|
47
|
+
*/
|
|
48
|
+
export function parseSecretProfiles(raw) {
|
|
49
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") return {};
|
|
50
|
+
const profiles = {};
|
|
51
|
+
for (const entry of String(raw).split(",")) {
|
|
52
|
+
const text = entry.trim();
|
|
53
|
+
if (text === "") continue; // a trailing comma is a typo, not a declaration
|
|
54
|
+
const cut = text.indexOf(":");
|
|
55
|
+
if (cut <= 0) {
|
|
56
|
+
throw configError(`PI_SECRET_PROFILES entries must be name:/absolute/path, got ${JSON.stringify(text)}`);
|
|
57
|
+
}
|
|
58
|
+
const name = text.slice(0, cut).trim();
|
|
59
|
+
const path = text.slice(cut + 1).trim();
|
|
60
|
+
if (!PROFILE_NAME.test(name)) {
|
|
61
|
+
throw configError(`PI_SECRET_PROFILES profile name ${JSON.stringify(name)} may use letters, digits, dot, dash and underscore only`);
|
|
62
|
+
}
|
|
63
|
+
if (name in profiles) {
|
|
64
|
+
throw configError(`PI_SECRET_PROFILES declares ${JSON.stringify(name)} twice -- one of the two is not the resolver you think is running`);
|
|
65
|
+
}
|
|
66
|
+
if (path === "" || !isAbsolutePath(path)) {
|
|
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
|
+
}
|
|
69
|
+
profiles[name] = path;
|
|
70
|
+
}
|
|
71
|
+
return profiles;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Absolute on this platform, accepting a Windows drive letter or UNC root as `service.mjs` does. */
|
|
75
|
+
function isAbsolutePath(path) {
|
|
76
|
+
return isAbsolute(path) || /^([A-Za-z]:[\\/]|\\\\)/.test(path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Union the env-declared profiles with the overlay-declared ones, refusing a name that appears in both.
|
|
81
|
+
*
|
|
82
|
+
* NEITHER SOURCE WINS, and that is a deliberate third answer. `runtime-settings.mjs` documents the
|
|
83
|
+
* overlay's precedence as `overlay > env`, so quietly inverting it for this one key would leave two rules
|
|
84
|
+
* in the codebase disagreeing about what an overlay is. But honouring it would let a settings file -- which
|
|
85
|
+
* defaults into the OS temp directory -- redirect a profile the operator wrote in `.env`. So a collision is
|
|
86
|
+
* refused instead, per delivery and naming only the profile name. This project already refuses ambiguity
|
|
87
|
+
* rather than resolving it: `PI_EGRESS` refuses any value but 0 or 1 because "a typo must never leave you
|
|
88
|
+
* believing you have a policy you do not", and two declarations of one profile is exactly that.
|
|
89
|
+
*
|
|
90
|
+
* Returns `{ profiles }` or `{ ambiguous }` naming the first colliding profile.
|
|
91
|
+
*/
|
|
92
|
+
export function mergeSecretProfiles(envProfiles = {}, overlayProfiles = {}) {
|
|
93
|
+
for (const name of Object.keys(overlayProfiles)) {
|
|
94
|
+
if (name in envProfiles) return { ambiguous: name };
|
|
95
|
+
}
|
|
96
|
+
return { profiles: { ...envProfiles, ...overlayProfiles } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether `candidate` sits inside one of `roots`. Empty roots means NOTHING passes.
|
|
101
|
+
*
|
|
102
|
+
* FAIL-CLOSED BY DEFAULT is the whole design: with `PI_SECRET_RESOLVER_ROOTS` unset, an overlay-declared
|
|
103
|
+
* profile resolves to no usable path, so the panel can declare nothing and the deployment is env-only. The
|
|
104
|
+
* operator opts in to panel authoring by naming the directory their resolvers already live in. This is
|
|
105
|
+
* `PI_DISPATCH_RUN_ROOTS`' shape, and `DES-PER-TRIGGER-JOB-IMAGE` predicted it: "If a future tool ever
|
|
106
|
+
* takes an image parameter, the allowlist arrives with that tool, and this row is the reason it must."
|
|
107
|
+
*
|
|
108
|
+
* The comparison is on NORMALIZED paths with a separator boundary, so `/opt/pi-evil` does not pass for the
|
|
109
|
+
* root `/opt/pi`. Callers pass a path they have already realpath'd, because normalization alone cannot see
|
|
110
|
+
* through a symlink and this is a boundary, not a hint.
|
|
111
|
+
*/
|
|
112
|
+
export function withinRoots(candidate, roots = []) {
|
|
113
|
+
if (!Array.isArray(roots) || roots.length === 0) return false;
|
|
114
|
+
const target = normalize(candidate);
|
|
115
|
+
return roots.some((root) => {
|
|
116
|
+
const base = normalize(root).replace(new RegExp(`${sep === "\\" ? "\\\\" : sep}+$`), "");
|
|
117
|
+
return target === base || target.startsWith(base + sep);
|
|
118
|
+
});
|
|
119
|
+
}
|