@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/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 }. It MUST honour
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: (promoted && !promoted.promoted ? promoted.reason : null) ?? fromRunner?.reason ?? host?.reason ?? null,
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
+ ]);
@@ -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
- ({ turns, tokens, session, usage } = await sink.close());
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
 
@@ -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 };
@@ -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
+ }