@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/triggers.mjs CHANGED
@@ -23,8 +23,9 @@
23
23
  * Custom: triggers validated inline per config.mjs/schedules.mjs precedent; zod not in deps
24
24
  */
25
25
 
26
- import { configError } from "./config.mjs";
27
- import { FORGE_KINDS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
26
+ import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
27
+ import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
28
+ import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
28
29
 
29
30
  const ON_TYPES = new Set(["cron", "label", "comment", "pull_request"]);
30
31
 
@@ -100,6 +101,41 @@ const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
100
101
  */
101
102
  const REPLICAS_MAX = 3;
102
103
 
104
+ /**
105
+ * The ceiling on how many references one trigger may name (issue #225). Not tidiness: the worker resolves
106
+ * them SEQUENTIALLY, before the container, each with its own timeout, so N references hold a
107
+ * `PI_CONCURRENCY` slot for up to N x PI_SECRET_RESOLVE_TIMEOUT_MS inside the job's own 30-minute kill
108
+ * timer. A cap at load turns an unbounded slot occupancy into a bounded one for free, before anything
109
+ * runs. It bounds the host argv too: every resolved value is pushed as `-e NAME=VALUE` (docker-run.mjs).
110
+ *
111
+ * Sixteen rather than three: unlike `REPLICAS_MAX` this multiplies no spend, and a deploy job legitimately
112
+ * wants a handful of credentials. A literal for REPLICAS_MAX's reason -- this validator is pure and fs-free.
113
+ */
114
+ const SECRETS_MAX = 16;
115
+
116
+ /**
117
+ * A POSIX environment variable name. Nothing in this repo validated one before `run.secrets` (checked), so
118
+ * this is the definition rather than a copy of one. Deliberately stricter than what `execve` would accept:
119
+ * a name is `-e NAME=VALUE` in the docker argv, so `=` would split in the wrong place, and the leading
120
+ * digit is excluded because a shell cannot expand `$1FOO` as that variable.
121
+ */
122
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
123
+
124
+ /**
125
+ * The env variable names a trigger may NOT bind, and why each set is here.
126
+ *
127
+ * These are the STATICALLY KNOWABLE half. `parseTriggers` is pure, fs-free and env-free, so it cannot see
128
+ * the resolved provider's credential variable names (they come from `findEnvKeys(provider, hostEnv)`) or
129
+ * the deployment's `PI_FORWARD_ENV` list. Those two are refused PRE-SPEND in the processor, where both are
130
+ * in hand -- the same load-time / deployment-state split `run.resume` already makes against
131
+ * `PI_SESSIONS_DIR`. Refusing here what can be answered here keeps the file's own mistakes in the file's
132
+ * own error.
133
+ *
134
+ * Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
135
+ * forge added to the table later must not need a second edit here to stay covered.
136
+ */
137
+ const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES]);
138
+
103
139
  function isNonEmptyString(value) {
104
140
  return typeof value === "string" && value.trim() !== "";
105
141
  }
@@ -228,6 +264,8 @@ function normalizeCron(on, run, index, path, state) {
228
264
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
229
265
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
230
266
  validateReplicas(run, `cron trigger "${id}"`, path);
267
+ const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
268
+ const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
231
269
 
232
270
  // provider/model/maxTurns stay absent when omitted so the value resolves at job start against the
233
271
  // settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). github/packages/image stay
@@ -236,7 +274,7 @@ function normalizeCron(on, run, index, path, state) {
236
274
  // freeze today's default into every stored repeatable.
237
275
  return {
238
276
  on: { type: "cron", id, pattern },
239
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }) },
277
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
240
278
  };
241
279
  }
242
280
 
@@ -635,6 +673,122 @@ function validateReplicas(run, at, path) {
635
673
  return replicas;
636
674
  }
637
675
 
676
+ /**
677
+ * `run.secrets` -- the env variables this trigger's job receives, and the opaque references an operator's
678
+ * own resolver turns into values (REQ-TRIGGER-SECRETS, issue #225).
679
+ *
680
+ * THE GRAMMAR OF THE VALUES IS NOT OURS. `op://vault/item/field`, `secret/data/ci#stripe` and a bare name
681
+ * are all valid here, because what parses them is a script the operator wrote and named in
682
+ * `PI_SECRET_PROFILES`. This file validates the SHAPE of the map and never the meaning of a reference --
683
+ * the posture #206 and #209 already set ("the seam is a command"), and the same one
684
+ * `DES-SERVICE-ENV-SETUP-SEAM` implements one layer up. A regex over `op://` here would bless a vendor.
685
+ *
686
+ * WHY EACH REFUSAL:
687
+ * - not a plain object, or a value that is not a non-empty string. An array or a nested object is an
688
+ * operator writing a different feature than the one that exists.
689
+ * - more than SECRETS_MAX entries -- see that constant: it bounds a worker slot, not a preference.
690
+ * - a key that is not an environment variable name. There is no downstream check: `-e NAME=VALUE` goes
691
+ * into the docker argv as written, so a key with an `=` in it silently binds a different variable.
692
+ * - a key in RESERVED_ENV_NAMES. The mint, the egress policy and the closed map all write AFTER this
693
+ * feature does, so such a key would be accepted, overwritten, and the job would run without the value
694
+ * it named on a clean exit 0. Ordering is the backstop; this is the refusal, which is the same
695
+ * division of labour `PI_FORWARD_ENV` already keeps (config.mjs refuses the names, env-allowlist.mjs
696
+ * orders the assignments so a slip cannot matter).
697
+ * - a reference starting with `-`. It is passed as `argv[1]` of the resolver, where a leading dash
698
+ * parses as a flag -- exactly `validateImageRef`'s reason for the same refusal on `run.image`. We do
699
+ * NOT pass `--` before it instead: that would be a claim about the resolver's option parser, and the
700
+ * option parser is the operator's.
701
+ * - `run.resume: true`. `assertResumeAllowedOnGhSource` (get-token.mjs) already refuses the `gh` token
702
+ * source for a resumed job, and states the argument this inherits whole: every property
703
+ * CONST-TOKEN-SCOPED-PER-JOB relies on assumes the credential is an ENV VALUE that dies with the
704
+ * container, while a persisted transcript is a FILE on host disk, replayed into the next job on that
705
+ * key. A resolved vault value is an env value the agent can echo, and nothing here redacts a
706
+ * transcript. Refused with NO escape hatch, unlike that one's PI_SESSIONS_ALLOW_GH_SOURCE: a pure
707
+ * validator cannot read an env var to find one, and the reason is stronger anyway, since that refusal
708
+ * complains a `gh` login is "full-scope and NON-EXPIRING" and a vault password does not expire either.
709
+ *
710
+ * Called from ALL FOUR normalizers, cron included. Unlike `run.replicas` this is NOT refused on a local
711
+ * job: nothing about resolving a secret is forge-specific, and a nightly deploy is the obvious user. The
712
+ * replica refusal turns on a local `/workspace` being the operator's own folder with no clone, which is a
713
+ * fact about two agents sharing a working tree, not about a credential. That folder does bring its own
714
+ * hazard (an agent that writes a credential into `.env` writes it into the operator's real repository),
715
+ * and `doctor` warns about exactly that rather than this validator refusing the use case outright.
716
+ *
717
+ * Returns the map, undefined when absent, so an unflagged trigger normalizes byte-identically.
718
+ */
719
+ function validateSecrets(run, at, path) {
720
+ const secrets = run.secrets;
721
+ if (secrets === undefined) return undefined;
722
+ if (secrets === null || typeof secrets !== "object" || Array.isArray(secrets)) {
723
+ throw configError(`${at}: run.secrets must be an object mapping environment variable names to references when present (got ${JSON.stringify(secrets)}): ${path}`);
724
+ }
725
+ const names = Object.keys(secrets);
726
+ if (names.length === 0) {
727
+ throw configError(`${at}: run.secrets is empty -- an empty map is a field that does nothing, and this trigger reads as though it binds a secret: ${path}`);
728
+ }
729
+ if (names.length > SECRETS_MAX) {
730
+ throw configError(`${at}: run.secrets names ${names.length} variables, over the ${SECRETS_MAX} cap -- each one is resolved before the container starts, holding a concurrency slot while it runs: ${path}`);
731
+ }
732
+ for (const name of names) {
733
+ if (!ENV_NAME.test(name)) {
734
+ throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is not an environment variable name (letters, digits and underscore, not starting with a digit): ${path}`);
735
+ }
736
+ if (RESERVED_ENV_NAMES.has(name)) {
737
+ throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is a variable the worker sets itself -- the job container would receive the worker's value, not this trigger's, and the trigger would look like it worked: ${path}`);
738
+ }
739
+ const reference = secrets[name];
740
+ if (!isNonEmptyString(reference)) {
741
+ throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
742
+ }
743
+ if (reference !== reference.trim()) {
744
+ throw configError(`${at}: run.secrets.${name} must not have leading or trailing whitespace -- the file is the reviewed artifact and silently trimming it would make the file disagree with what the resolver is asked for: ${path}`);
745
+ }
746
+ if (reference.startsWith("-")) {
747
+ throw configError(`${at}: run.secrets.${name} must not start with "-" -- it is passed as the resolver's first argument, where a leading dash parses as a flag: ${path}`);
748
+ }
749
+ }
750
+ if (run.resume === true) {
751
+ throw configError(`${at}: run.secrets and run.resume cannot be combined -- a resumed job replays a transcript kept on host disk, and any command the agent ran that echoed a resolved value wrote it into that transcript, which is then prefilled into every later job on the same key: ${path}`);
752
+ }
753
+ return { ...secrets };
754
+ }
755
+
756
+ /**
757
+ * `run.secretsProfile` -- WHICH of the operator's declared resolvers reads this trigger's references.
758
+ *
759
+ * A NAME, never a path, and that distinction is the whole reason this field is allowed to exist.
760
+ * `DES-SERVICE-ENV-SETUP-SEAM` rejected "making this reachable from configuration, which would turn a
761
+ * boot-time root-adjacent exec into something a trigger file could name". A profile name selects among
762
+ * execs the operator already declared in `PI_SECRET_PROFILES`; it cannot introduce one, cannot name a
763
+ * path, and cannot reach a script nobody wired. The rejected thing is a trigger file NAMING an exec.
764
+ *
765
+ * The charset is `ID_CHARSET`, shared with cron ids, for two reasons that both bite: the name is echoed in
766
+ * a refusal that `comment` posts PUBLICLY on the issue, and `PI_SECRET_PROFILES` is a `,`-separated list
767
+ * of `name:path` pairs, so a name containing either separator could not round-trip through the very
768
+ * variable that declares it.
769
+ *
770
+ * WHETHER the named profile exists is NOT checked here and cannot be: which profiles a deployment declares
771
+ * is env and overlay state, and this validator is pure and fs-free. That is refused pre-spend, per
772
+ * delivery, as `secret-profile-unknown` -- the same split `run.resume` makes against `PI_SESSIONS_DIR`,
773
+ * where the file answers what the file knows and `doctor` plus a per-delivery refusal carry the rest.
774
+ *
775
+ * Absent selects the profile named `default`, so a single-manager deployment never writes the field.
776
+ */
777
+ function validateSecretsProfile(run, at, path) {
778
+ const profile = run.secretsProfile;
779
+ if (profile === undefined) return undefined;
780
+ if (!isNonEmptyString(profile)) {
781
+ throw configError(`${at}: run.secretsProfile must be a non-empty string naming one of the resolver profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
782
+ }
783
+ if (!ID_CHARSET.test(profile)) {
784
+ throw configError(`${at}: run.secretsProfile ${JSON.stringify(profile)} may use letters, digits, dot, dash and underscore only -- PI_SECRET_PROFILES is a comma-separated list of name:path pairs, so a name carrying either separator cannot be declared: ${path}`);
785
+ }
786
+ if (run.secrets === undefined) {
787
+ throw configError(`${at}: run.secretsProfile is set but run.secrets names nothing -- a profile that resolves no references is a field that does nothing: ${path}`);
788
+ }
789
+ return profile;
790
+ }
791
+
638
792
  function normalizeLabel(on, run, index, path) {
639
793
  const at = `trigger at index ${index}`;
640
794
  const predicate = validatePredicate(on, index, path, true);
@@ -650,9 +804,11 @@ function normalizeLabel(on, run, index, path) {
650
804
  const resume = validateResumeFlag(run, at, path);
651
805
  const repository = validateRepository(run, "label", at, path);
652
806
  const replicas = validateReplicas(run, at, path);
807
+ const secrets = validateSecrets(run, at, path);
808
+ const secretsProfile = validateSecretsProfile(run, at, path);
653
809
  return {
654
810
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
655
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
811
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
656
812
  };
657
813
  }
658
814
 
@@ -682,9 +838,11 @@ function normalizeComment(on, run, index, path, state) {
682
838
  const resume = validateResumeFlag(run, at, path);
683
839
  const repository = validateRepository(run, "comment", at, path);
684
840
  const replicas = validateReplicas(run, at, path);
841
+ const secrets = validateSecrets(run, at, path);
842
+ const secretsProfile = validateSecretsProfile(run, at, path);
685
843
  return {
686
844
  on: { type: "comment", phrase: on.phrase },
687
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
845
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
688
846
  };
689
847
  }
690
848
 
@@ -737,6 +895,8 @@ function normalizePullRequest(on, run, index, path) {
737
895
  const resume = validateResumeFlag(run, at, path);
738
896
  validateRepository(run, "pull_request", at, path);
739
897
  const replicas = validateReplicas(run, at, path);
898
+ const secrets = validateSecrets(run, at, path);
899
+ const secretsProfile = validateSecretsProfile(run, at, path);
740
900
  return {
741
901
  on: {
742
902
  type: "pull_request",
@@ -748,7 +908,7 @@ function normalizePullRequest(on, run, index, path) {
748
908
  all: predicate.all,
749
909
  none: predicate.none,
750
910
  },
751
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
911
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
752
912
  };
753
913
  }
754
914