@edgehero/pi-dispatch 1.2.0 → 1.4.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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared trigger-file schema + validator (issue #20). One `triggers.json` of `{ on, run }` entries is
3
3
  * the single reviewed source of standing triggers for BOTH services: the worker owns `on.type:"cron"`
4
- * (local jobs), the receiver owns the webhook types (`label|comment|pull_request` -> a forge job). Each
4
+ * (local jobs), the receiver owns the webhook types (`label|comment|pull_request|issue` -> a forge job). Each
5
5
  * service validates the WHOLE file, then selects the `on.type` it owns, so a malformed file fails both
6
6
  * identically and the two cannot drift.
7
7
  *
@@ -23,10 +23,26 @@
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
+ // SKILL_NAME_RE is the single-sourced skill charset (flow-gate exports it for exactly this reason:
28
+ // materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
29
+ // traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
30
+ import { SKILL_NAME_RE } from "./flow-gate.mjs";
31
+ import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
32
+ import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
28
33
 
29
- const ON_TYPES = new Set(["cron", "label", "comment", "pull_request"]);
34
+ const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
35
+
36
+ /**
37
+ * The `on.type` a DISARMED one-shot normalizes to (issue #231). Producible only by this validator:
38
+ * `ON_TYPES` excludes it, so an authored `on.type: "disarmed"` refuses like any unknown type. The
39
+ * sentinel keeps the entry's raw array position (deleting it would shift `triggerIndex` attribution
40
+ * for every later entry) while making it unmatchable BY CONSTRUCTION -- it carries no selectors, no
41
+ * actions, no run.kind and no flow, so no receiver group, no schedule and no flow allowlist can ever
42
+ * pick it up. A marker flag on the original type was rejected: one consumer forgetting to check it
43
+ * is a spent one-shot firing again, and this shape makes that bug unwritable.
44
+ */
45
+ export const DISARMED_TYPE = "disarmed";
30
46
 
31
47
  // `RUN_KINDS` and `FORGE_KINDS` come from the forge table (forges.mjs) rather than being written out
32
48
  // here. They differ by exactly `local`, and that difference IS the on x run matrix below: a webhook
@@ -40,8 +56,17 @@ export { FORGE_KINDS };
40
56
  * what their forge's documentation says and can grep for it there.
41
57
  *
42
58
  * GitLab has no `labeled`: adding a label to a merge request arrives as `update` carrying a
43
- * `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`. `merge` and
44
- * `close` are omitted on purpose: a job started by a merge or a close has nothing left to act on.
59
+ * `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`.
60
+ *
61
+ * The close words (issue #231) carry a distinction the old "nothing left to act on" wording folded
62
+ * flat. A job ABOUT the closed thing still has nothing left to act on, and `run.replicas`' neighbour
63
+ * refusal still stands; what a close CAN do is release separately-armed work -- an operator wrote
64
+ * "when this closes, run that" into this file, and the close is the starting gun, not the subject.
65
+ * So `closed`/`close` are close-ONLY action lists (mixing them with other actions is refused below:
66
+ * the two families gate on different actors), while `merge` stays omitted everywhere -- GitHub and
67
+ * Forgejo emit `closed` for a merged PR so close rules cover merges there, and GitLab's `merge` is
68
+ * its own action no rule takes (an explicit close is what fires a GitLab close rule; the spec names
69
+ * the gap).
45
70
  *
46
71
  * `review_submitted` (issue #66) is github's fifth and the one compound word here. It names the
47
72
  * `pull_request_review` event's `submitted` action, so both halves are greppable in GitHub's own docs, the
@@ -52,23 +77,61 @@ export { FORGE_KINDS };
52
77
  * `author_association`, never the PR author's -- see filter.mjs and CONST-TRIGGER-AUTHOR-GATE.
53
78
  */
54
79
  const PR_ACTIONS = {
55
- github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
80
+ github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
56
81
  // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
57
82
  // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
58
- // what `on.reviewState` below exists to narrow.
59
- gitlab: new Set(["open", "update", "reopen", "approved"]),
83
+ // what `on.reviewState` below exists to narrow. `close` is GitLab's own spelling of the close action.
84
+ gitlab: new Set(["open", "update", "reopen", "approved", "close"]),
60
85
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
61
86
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
62
87
  // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
63
88
  // start a paid run, and it has no GitHub counterpart to inherit that rule from.
64
- forgejo: new Set(["label_updated", "opened", "synchronized", "reopened"]),
89
+ forgejo: new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
65
90
  // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
66
- // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
67
- // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
68
- // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
91
+ // is omitted for the reason the close words' own comment above records, and there is no close word here at
92
+ // all: an abandon arrives as `git.pullrequest.updated` with nothing in the projected subset to tell it from
93
+ // any other update, so an azure close rule could only ever fire on the wrong events or never. Widening
94
+ // INT-AZURE-PAYLOAD-SUBSET (a PR status field) is the gap to close before this set can grow. There is no
95
+ // label action either -- Azure attaches tags to WORK ITEMS, never to pull requests -- which is why azure's
96
+ // `prLabelAction` is null and a predicated PR rule is refused below.
69
97
  azure: new Set(["created", "updated"]),
70
98
  };
71
99
 
100
+ /**
101
+ * The one action per forge that closes a pull request, in that forge's own words (issue #231). Spelled
102
+ * once because three places turn on it: the close-only refusal in `normalizePullRequest` (a close rule
103
+ * gates on the CLOSER's write access, every other PR rule gates on the author's association or a
104
+ * collaborator's label, and one rule cannot gate on two different actors), the `capable` switch that
105
+ * admits `on.number`/`on.once` there, and the queue's semantic-key discriminant. Azure is absent for
106
+ * the subset reason the table above records, so `PR_ACTIONS.azure` simply never grows the word and the
107
+ * existing vocabulary refusal names azure on its own.
108
+ *
109
+ * EXPORTED for the two consumers that must never re-derive it: the receiver's grouping (a close-only
110
+ * rule routes through the close gate, every other PR rule through the author gate, and the split must
111
+ * be THIS table's) and the queue's semantic-key discriminant (a matched close action word is what
112
+ * marks a close job).
113
+ */
114
+ export const PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "closed" };
115
+
116
+ /**
117
+ * The `issue` action vocabulary, per forge, in each forge's own words (issue #231). One word each so
118
+ * far: the type exists for "when issue #40 closes, run deploy", and every other issue event already
119
+ * has a home (`label` for label predicates, `comment` for phrases). GitLab's word is `close` (its
120
+ * `object_attributes.action`), GitHub's and Forgejo's is `closed`.
121
+ *
122
+ * Azure is REFUSED rather than absent-and-unhandled, with its own message: a work item's close is a
123
+ * `System.State` transition whose terminal names vary by process template (Agile "Closed", Scrum
124
+ * "Done", plus "Resolved"), and the projected subset carries only `System.Tags` -- so matching a
125
+ * close needs both an INT-AZURE-PAYLOAD-SUBSET widening and a state vocabulary this version does not
126
+ * guess at. Not yet covered, not impossible -- validateResumeFlag's distinction, kept for the same
127
+ * reason.
128
+ */
129
+ const ISSUE_ACTIONS = {
130
+ github: new Set(["closed"]),
131
+ gitlab: new Set(["close"]),
132
+ forgejo: new Set(["closed"]),
133
+ };
134
+
72
135
  /**
73
136
  * The verdicts a submitted GitHub review can carry, in the webhook's own (lower-case) spelling, and the
74
137
  * vocabulary of the optional `on.reviewState` narrowing (issue #66).
@@ -100,6 +163,41 @@ const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
100
163
  */
101
164
  const REPLICAS_MAX = 3;
102
165
 
166
+ /**
167
+ * The ceiling on how many references one trigger may name (issue #225). Not tidiness: the worker resolves
168
+ * them SEQUENTIALLY, before the container, each with its own timeout, so N references hold a
169
+ * `PI_CONCURRENCY` slot for up to N x PI_SECRET_RESOLVE_TIMEOUT_MS inside the job's own 30-minute kill
170
+ * timer. A cap at load turns an unbounded slot occupancy into a bounded one for free, before anything
171
+ * runs. It bounds the host argv too: every resolved value is pushed as `-e NAME=VALUE` (docker-run.mjs).
172
+ *
173
+ * Sixteen rather than three: unlike `REPLICAS_MAX` this multiplies no spend, and a deploy job legitimately
174
+ * wants a handful of credentials. A literal for REPLICAS_MAX's reason -- this validator is pure and fs-free.
175
+ */
176
+ const SECRETS_MAX = 16;
177
+
178
+ /**
179
+ * A POSIX environment variable name. Nothing in this repo validated one before `run.secrets` (checked), so
180
+ * this is the definition rather than a copy of one. Deliberately stricter than what `execve` would accept:
181
+ * a name is `-e NAME=VALUE` in the docker argv, so `=` would split in the wrong place, and the leading
182
+ * digit is excluded because a shell cannot expand `$1FOO` as that variable.
183
+ */
184
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
185
+
186
+ /**
187
+ * The env variable names a trigger may NOT bind, and why each set is here.
188
+ *
189
+ * These are the STATICALLY KNOWABLE half. `parseTriggers` is pure, fs-free and env-free, so it cannot see
190
+ * the resolved provider's credential variable names (they come from `findEnvKeys(provider, hostEnv)`) or
191
+ * the deployment's `PI_FORWARD_ENV` list. Those two are refused PRE-SPEND in the processor, where both are
192
+ * in hand -- the same load-time / deployment-state split `run.resume` already makes against
193
+ * `PI_SESSIONS_DIR`. Refusing here what can be answered here keeps the file's own mistakes in the file's
194
+ * own error.
195
+ *
196
+ * Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
197
+ * forge added to the table later must not need a second edit here to stay covered.
198
+ */
199
+ const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES]);
200
+
103
201
  function isNonEmptyString(value) {
104
202
  return typeof value === "string" && value.trim() !== "";
105
203
  }
@@ -139,8 +237,12 @@ function normalizeTrigger(entry, index, path, state) {
139
237
  if (run === null || typeof run !== "object") {
140
238
  throw configError(`${at}: "run" must be an object: ${path}`);
141
239
  }
240
+ // Joined from the set for the joined-vocabulary rule stated over the run.kind check below -- a type
241
+ // added to the vocabulary can never be
242
+ // refused by a message that does not mention it. This is also what keeps `DISARMED_TYPE` refused
243
+ // when authored: it is deliberately not in ON_TYPES, so it reads here as any other unknown type.
142
244
  if (!ON_TYPES.has(on.type)) {
143
- throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
245
+ throw configError(`${at}: on.type must be one of ${[...ON_TYPES].join("|")} (got ${JSON.stringify(on.type)}): ${path}`);
144
246
  }
145
247
  // The legal-values half of every message below is JOINED from the table rather than typed out, so a
146
248
  // forge added to the table can never be refused by a message that does not mention it -- which reads
@@ -159,9 +261,18 @@ function normalizeTrigger(entry, index, path, state) {
159
261
  if (!isForgeKind(run.kind)) {
160
262
  throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
161
263
  }
162
- if (on.type === "label") return normalizeLabel(on, run, index, path);
163
- if (on.type === "comment") return normalizeComment(on, run, index, path, state);
164
- return normalizePullRequest(on, run, index, path);
264
+ let normalized;
265
+ if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
266
+ else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
267
+ else if (on.type === "issue") normalized = normalizeIssue(on, run, index, path);
268
+ else normalized = normalizePullRequest(on, run, index, path);
269
+
270
+ // A disarmed one-shot has validated IN FULL above (a disarmed entry with a malformed run still
271
+ // refuses the file -- writeTriggers' fail-closed contract needs the whole file valid, and the
272
+ // worker's disarm only ever ADDS one key to an entry that already passed). Only then does it
273
+ // normalize to the sentinel, so its raw index survives and nothing downstream can match it.
274
+ if (on.disarmed !== undefined) return { on: { type: DISARMED_TYPE }, run: {} };
275
+ return normalized;
165
276
  }
166
277
 
167
278
  function normalizeCron(on, run, index, path, state) {
@@ -228,6 +339,12 @@ function normalizeCron(on, run, index, path, state) {
228
339
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
229
340
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
230
341
  validateReplicas(run, `cron trigger "${id}"`, path);
342
+ // Same posture for the close-trigger fields (issue #231): none has a legal value on cron.
343
+ validateNumber(on, `cron trigger "${id}"`, path, { onType: "cron" });
344
+ validateOnce(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
345
+ validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
346
+ const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
347
+ const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
231
348
 
232
349
  // provider/model/maxTurns stay absent when omitted so the value resolves at job start against the
233
350
  // settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). github/packages/image stay
@@ -236,7 +353,7 @@ function normalizeCron(on, run, index, path, state) {
236
353
  // freeze today's default into every stored repeatable.
237
354
  return {
238
355
  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 }) },
356
+ 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
357
  };
241
358
  }
242
359
 
@@ -562,6 +679,122 @@ function validatePredicate(on, index, path, requirePositive) {
562
679
  return { any: on.any, all: on.all, none: on.none };
563
680
  }
564
681
 
682
+ /**
683
+ * `on.number` -- narrow a close-capable trigger to ONE item, by the number the forge itself assigns
684
+ * (issue #231). Legal with or without `on.once`: a standing "every close of #40" rule is coherent
685
+ * narrowing, exactly as `on.reviewState` narrows without changing what the rule is. Called from ALL
686
+ * normalizers, `validateReplicas`' posture -- where it cannot apply it refuses, never ignores.
687
+ * `capable` is passed only by the close-capable paths (the `issue` normalizer, and a `pull_request`
688
+ * rule whose only action is the forge's close word).
689
+ */
690
+ function validateNumber(on, at, path, { capable = false, onType } = {}) {
691
+ const number = on.number;
692
+ if (number === undefined) return undefined;
693
+ if (!capable) {
694
+ if (onType === "cron") {
695
+ throw configError(`${at}: on.number is not available on a cron trigger -- a schedule fires on time, not on an item, so there is no delivery for a number to narrow: ${path}`);
696
+ }
697
+ throw configError(`${at}: on.number is not yet covered for ${onType} triggers here -- only the close routes read it, so on a rule they never serve it would sit in the file looking configured; narrowing labels, comments or non-close pull_request rules is a gap to close, not a limit: ${path}`);
698
+ }
699
+ if (!Number.isInteger(number) || number < 1) {
700
+ throw configError(`${at}: on.number must be an integer >= 1 when present -- the item number the forge itself assigns (on GitLab, the iid) (got ${JSON.stringify(number)}): ${path}`);
701
+ }
702
+ return number;
703
+ }
704
+
705
+ /**
706
+ * `on.once` -- a one-shot: the trigger fires, produces a run record, and the worker disarms it by
707
+ * adding `on.disarmed` to this entry (issue #231, DES-ONE-SHOT-DISARM-IN-THE-FILE). Strictly boolean
708
+ * and fail-loud, the house rule; `false` is legal and carried, validateResumeFlag's argument -- an
709
+ * operator who wrote down today's default must not be refused for stating present behaviour.
710
+ *
711
+ * `once: true` REQUIRES `on.number`, and the requirement is a race analysis, not taste: a numberless
712
+ * one-shot matched by two different items' closes inside one dedup window enqueues both before either
713
+ * disarm lands, and which item "spent" the trigger is a coin flip. With a number, concurrent
714
+ * duplicates are duplicates of the SAME item, which is what the delivery GUID and the semantic window
715
+ * actually bound -- and the number is the identity the disarm re-checks before it writes, so a file
716
+ * edited between enqueue and disarm refuses loudly instead of disarming a stranger.
717
+ *
718
+ * `once: true` is refused beside `run.replicas` (`false`, the written-down default, is not): "exactly
719
+ * one run" and "N sandboxes race" contradict on their face,
720
+ * and with N run records the disarm no longer says which one spent the trigger.
721
+ */
722
+ function validateOnce(on, run, at, path, { capable = false, onType } = {}) {
723
+ const once = on.once;
724
+ if (once === undefined) return undefined;
725
+ if (!capable) {
726
+ if (onType === "cron") {
727
+ throw configError(`${at}: on.once is not available on a cron trigger -- a one-shot that can re-arm is a schedule, and a cron job carries no matched delivery for the disarm to name; delete the entry when the work is done: ${path}`);
728
+ }
729
+ // The close-word hint is JOINED from the table (the same rule the vocabulary messages follow), so a
730
+ // forge gaining a close word later can never be pointed away from it by a stale sentence here.
731
+ const closeWords = Object.entries(PR_CLOSE_ACTIONS).map(([k, w]) => `${k} has ${JSON.stringify(w)}`).join(", ");
732
+ throw configError(`${at}: on.once is not yet covered here -- the disarm writes back to the one entry a delivery matched, and only a close delivery names the single item that spends it; use on.type "issue", or a pull_request rule whose only action is the close word (${closeWords}; azure has no close trigger yet): ${path}`);
733
+ }
734
+ if (typeof once !== "boolean") {
735
+ throw configError(`${at}: on.once must be true or false when present -- a truthy string arming a one-shot is exactly the drift this validator exists to refuse (got ${JSON.stringify(once)}): ${path}`);
736
+ }
737
+ if (once === true && on.number === undefined) {
738
+ throw configError(`${at}: on.once requires on.number -- a one-shot without a named item is spent by whichever close arrives first, and two different items closing inside one dedup window would race for it; the number is also the identity the disarm re-checks before it writes: ${path}`);
739
+ }
740
+ if (once === true && run.replicas !== undefined) {
741
+ throw configError(`${at}: on.once and run.replicas cannot be combined -- "exactly one run" and "N sandboxes race" contradict, and with N run records the disarm no longer says which one spent the trigger: ${path}`);
742
+ }
743
+ return once;
744
+ }
745
+
746
+ /**
747
+ * `on.disarmed` -- the mark the worker writes when a one-shot fires: `{ at, jobId? }`, provenance an
748
+ * operator can read a year later when the run record is long reaped (issue #231). Hand-writable too
749
+ * (jobId optional), which is how an operator disarms deliberately; deleting the key is how they
750
+ * re-arm. The entry it sits on normalizes to the DISARMED_TYPE sentinel -- see normalizeTrigger --
751
+ * but only AFTER this shape check and the full entry validation pass, so a corrupted disarm mark is
752
+ * a load refusal, never a silently-still-armed rule.
753
+ */
754
+ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
755
+ const disarmed = on.disarmed;
756
+ if (disarmed === undefined) return undefined;
757
+ if (!capable) {
758
+ throw configError(`${at}: on.disarmed marks a spent one-shot, and on.once is not ${onType === "cron" ? "available on a cron trigger" : "yet covered here"}, so there is nothing this entry could have spent: ${path}`);
759
+ }
760
+ if (disarmed === null || typeof disarmed !== "object" || Array.isArray(disarmed)) {
761
+ throw configError(`${at}: on.disarmed must be an object with a non-empty string "at" (and optionally a non-empty string "jobId") -- the worker writes it when the one-shot fires, and a hand-written one disarms the entry deliberately (got ${JSON.stringify(disarmed)}): ${path}`);
762
+ }
763
+ for (const key of Object.keys(disarmed)) {
764
+ if (key !== "at" && key !== "jobId") {
765
+ throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
766
+ }
767
+ }
768
+ if (!isNonEmptyString(disarmed.at)) {
769
+ throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
770
+ }
771
+ if (disarmed.jobId !== undefined && !isNonEmptyString(disarmed.jobId)) {
772
+ throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
773
+ }
774
+ if (on.once !== true) {
775
+ throw configError(`${at}: on.disarmed is only meaningful beside on.once: true -- an entry that was never a one-shot has nothing to spend: ${path}`);
776
+ }
777
+ return disarmed;
778
+ }
779
+
780
+ /**
781
+ * `run.flow` charset for the WEBHOOK kinds (issue #231). materialize.mjs refuses a name outside
782
+ * SKILL_NAME_RE at job start, AFTER the budget slot is reserved -- so until now a charset-invalid
783
+ * forge flow loaded clean and could only ever fail in-container (graph-model already renders it as
784
+ * the `charset-invalid` defect). Refusing at load turns that paid failure into a free one, and it is
785
+ * also what keeps `:` out of the flow slot of the queue's semantic dedup key, where the command jobs'
786
+ * `cmd:` prefix lives and the close jobs' discriminant sits beside it once close routing lands.
787
+ * Deliberately NOT called on cron:
788
+ * a local flow resolves inside the operator's own folder by pi itself, and the semantic key does not
789
+ * apply to repeat jobs -- narrowing there would refuse deployments this hazard cannot reach.
790
+ */
791
+ function validateFlowName(run, at, path) {
792
+ if (run.flow === undefined) return;
793
+ if (!SKILL_NAME_RE.test(run.flow)) {
794
+ throw configError(`${at}: run.flow ${JSON.stringify(run.flow)} fails the skill-name charset (lowercase letters, digits, dash and underscore, 1-64 chars, starting and ending alphanumeric) -- a name outside it can never materialise, so this trigger could only ever fail after the budget slot was reserved: ${path}`);
795
+ }
796
+ }
797
+
565
798
 
566
799
  /**
567
800
  * `run.repository` -- WHICH repository a job clones, for a forge whose trigger subject does not name one.
@@ -635,14 +868,134 @@ function validateReplicas(run, at, path) {
635
868
  return replicas;
636
869
  }
637
870
 
871
+ /**
872
+ * `run.secrets` -- the env variables this trigger's job receives, and the opaque references an operator's
873
+ * own resolver turns into values (REQ-TRIGGER-SECRETS, issue #225).
874
+ *
875
+ * THE GRAMMAR OF THE VALUES IS NOT OURS. `op://vault/item/field`, `secret/data/ci#stripe` and a bare name
876
+ * are all valid here, because what parses them is a script the operator wrote and named in
877
+ * `PI_SECRET_PROFILES`. This file validates the SHAPE of the map and never the meaning of a reference --
878
+ * the posture #206 and #209 already set ("the seam is a command"), and the same one
879
+ * `DES-SERVICE-ENV-SETUP-SEAM` implements one layer up. A regex over `op://` here would bless a vendor.
880
+ *
881
+ * WHY EACH REFUSAL:
882
+ * - not a plain object, or a value that is not a non-empty string. An array or a nested object is an
883
+ * operator writing a different feature than the one that exists.
884
+ * - more than SECRETS_MAX entries -- see that constant: it bounds a worker slot, not a preference.
885
+ * - a key that is not an environment variable name. There is no downstream check: `-e NAME=VALUE` goes
886
+ * into the docker argv as written, so a key with an `=` in it silently binds a different variable.
887
+ * - a key in RESERVED_ENV_NAMES. The mint, the egress policy and the closed map all write AFTER this
888
+ * feature does, so such a key would be accepted, overwritten, and the job would run without the value
889
+ * it named on a clean exit 0. Ordering is the backstop; this is the refusal, which is the same
890
+ * division of labour `PI_FORWARD_ENV` already keeps (config.mjs refuses the names, env-allowlist.mjs
891
+ * orders the assignments so a slip cannot matter).
892
+ * - a reference starting with `-`. It is passed as `argv[1]` of the resolver, where a leading dash
893
+ * parses as a flag -- exactly `validateImageRef`'s reason for the same refusal on `run.image`. We do
894
+ * NOT pass `--` before it instead: that would be a claim about the resolver's option parser, and the
895
+ * option parser is the operator's.
896
+ * - `run.resume: true`. `assertResumeAllowedOnGhSource` (get-token.mjs) already refuses the `gh` token
897
+ * source for a resumed job, and states the argument this inherits whole: every property
898
+ * CONST-TOKEN-SCOPED-PER-JOB relies on assumes the credential is an ENV VALUE that dies with the
899
+ * container, while a persisted transcript is a FILE on host disk, replayed into the next job on that
900
+ * key. A resolved vault value is an env value the agent can echo, and nothing here redacts a
901
+ * transcript. Refused with NO escape hatch, unlike that one's PI_SESSIONS_ALLOW_GH_SOURCE: a pure
902
+ * validator cannot read an env var to find one, and the reason is stronger anyway, since that refusal
903
+ * complains a `gh` login is "full-scope and NON-EXPIRING" and a vault password does not expire either.
904
+ *
905
+ * Called from ALL FOUR normalizers, cron included. Unlike `run.replicas` this is NOT refused on a local
906
+ * job: nothing about resolving a secret is forge-specific, and a nightly deploy is the obvious user. The
907
+ * replica refusal turns on a local `/workspace` being the operator's own folder with no clone, which is a
908
+ * fact about two agents sharing a working tree, not about a credential. That folder does bring its own
909
+ * hazard (an agent that writes a credential into `.env` writes it into the operator's real repository),
910
+ * and `doctor` warns about exactly that rather than this validator refusing the use case outright.
911
+ *
912
+ * Returns the map, undefined when absent, so an unflagged trigger normalizes byte-identically.
913
+ */
914
+ function validateSecrets(run, at, path) {
915
+ const secrets = run.secrets;
916
+ if (secrets === undefined) return undefined;
917
+ if (secrets === null || typeof secrets !== "object" || Array.isArray(secrets)) {
918
+ throw configError(`${at}: run.secrets must be an object mapping environment variable names to references when present (got ${JSON.stringify(secrets)}): ${path}`);
919
+ }
920
+ const names = Object.keys(secrets);
921
+ if (names.length === 0) {
922
+ 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}`);
923
+ }
924
+ if (names.length > SECRETS_MAX) {
925
+ 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}`);
926
+ }
927
+ for (const name of names) {
928
+ if (!ENV_NAME.test(name)) {
929
+ 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}`);
930
+ }
931
+ if (RESERVED_ENV_NAMES.has(name)) {
932
+ 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}`);
933
+ }
934
+ const reference = secrets[name];
935
+ if (!isNonEmptyString(reference)) {
936
+ throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
937
+ }
938
+ if (reference !== reference.trim()) {
939
+ 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}`);
940
+ }
941
+ if (reference.startsWith("-")) {
942
+ 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}`);
943
+ }
944
+ }
945
+ if (run.resume === true) {
946
+ 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}`);
947
+ }
948
+ return { ...secrets };
949
+ }
950
+
951
+ /**
952
+ * `run.secretsProfile` -- WHICH of the operator's declared resolvers reads this trigger's references.
953
+ *
954
+ * A NAME, never a path, and that distinction is the whole reason this field is allowed to exist.
955
+ * `DES-SERVICE-ENV-SETUP-SEAM` rejected "making this reachable from configuration, which would turn a
956
+ * boot-time root-adjacent exec into something a trigger file could name". A profile name selects among
957
+ * execs the operator already declared in `PI_SECRET_PROFILES`; it cannot introduce one, cannot name a
958
+ * path, and cannot reach a script nobody wired. The rejected thing is a trigger file NAMING an exec.
959
+ *
960
+ * The charset is `ID_CHARSET`, shared with cron ids, for two reasons that both bite: the name is echoed in
961
+ * a refusal that `comment` posts PUBLICLY on the issue, and `PI_SECRET_PROFILES` is a `,`-separated list
962
+ * of `name:path` pairs, so a name containing either separator could not round-trip through the very
963
+ * variable that declares it.
964
+ *
965
+ * WHETHER the named profile exists is NOT checked here and cannot be: which profiles a deployment declares
966
+ * is env and overlay state, and this validator is pure and fs-free. That is refused pre-spend, per
967
+ * delivery, as `secret-profile-unknown` -- the same split `run.resume` makes against `PI_SESSIONS_DIR`,
968
+ * where the file answers what the file knows and `doctor` plus a per-delivery refusal carry the rest.
969
+ *
970
+ * Absent selects the profile named `default`, so a single-manager deployment never writes the field.
971
+ */
972
+ function validateSecretsProfile(run, at, path) {
973
+ const profile = run.secretsProfile;
974
+ if (profile === undefined) return undefined;
975
+ if (!isNonEmptyString(profile)) {
976
+ 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}`);
977
+ }
978
+ if (!ID_CHARSET.test(profile)) {
979
+ 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}`);
980
+ }
981
+ if (run.secrets === undefined) {
982
+ 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}`);
983
+ }
984
+ return profile;
985
+ }
986
+
638
987
  function normalizeLabel(on, run, index, path) {
639
988
  const at = `trigger at index ${index}`;
640
989
  const predicate = validatePredicate(on, index, path, true);
990
+ validateNumber(on, at, path, { onType: "label" });
991
+ validateOnce(on, run, at, path, { onType: "label" });
992
+ validateDisarmed(on, at, path, { onType: "label" });
641
993
  // First among the run checks, before the flow-required check -- validateCommand says why.
642
994
  const command = validateCommand(run, at, path, { onType: "label" });
643
995
  if (command === undefined && !isNonEmptyString(run.flow)) {
644
996
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
645
997
  }
998
+ validateFlowName(run, at, path);
646
999
  const packages = validatePackagesFlag(run, at, path);
647
1000
  const image = validateImageRef(run, at, path);
648
1001
  const skillsDir = validateSkillsDir(run, at, path);
@@ -650,9 +1003,11 @@ function normalizeLabel(on, run, index, path) {
650
1003
  const resume = validateResumeFlag(run, at, path);
651
1004
  const repository = validateRepository(run, "label", at, path);
652
1005
  const replicas = validateReplicas(run, at, path);
1006
+ const secrets = validateSecrets(run, at, path);
1007
+ const secretsProfile = validateSecretsProfile(run, at, path);
653
1008
  return {
654
1009
  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 }) },
1010
+ 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
1011
  };
657
1012
  }
658
1013
 
@@ -661,6 +1016,9 @@ function normalizeComment(on, run, index, path, state) {
661
1016
  if (!isNonEmptyString(on.phrase)) {
662
1017
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
663
1018
  }
1019
+ validateNumber(on, at, path, { onType: "comment" });
1020
+ validateOnce(on, run, at, path, { onType: "comment" });
1021
+ validateDisarmed(on, at, path, { onType: "comment" });
664
1022
  // First among the run checks, before the flow-required check -- validateCommand says why. A command
665
1023
  // trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
666
1024
  // token inert is the receiver filter's job, not a shape this validator can see.
@@ -668,6 +1026,7 @@ function normalizeComment(on, run, index, path, state) {
668
1026
  if (command === undefined && !isNonEmptyString(run.flow)) {
669
1027
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
670
1028
  }
1029
+ validateFlowName(run, at, path);
671
1030
  // At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
672
1031
  // per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
673
1032
  // and a deployment serving GitHub and GitLab is entitled to the same `@pi` phrase on each.
@@ -682,9 +1041,83 @@ function normalizeComment(on, run, index, path, state) {
682
1041
  const resume = validateResumeFlag(run, at, path);
683
1042
  const repository = validateRepository(run, "comment", at, path);
684
1043
  const replicas = validateReplicas(run, at, path);
1044
+ const secrets = validateSecrets(run, at, path);
1045
+ const secretsProfile = validateSecretsProfile(run, at, path);
685
1046
  return {
686
1047
  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 }) },
1048
+ 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 }) },
1049
+ };
1050
+ }
1051
+
1052
+ /**
1053
+ * `on.type: "issue"` (issue #231): fire when an ISSUE's lifecycle action happens -- one word so far,
1054
+ * the close. The type exists because every other issue event already has a home (`label` for label
1055
+ * predicates, `comment` for phrases) and neither of those shapes fits a close: there is no label diff
1056
+ * to match and no phrase to read, only an action, an item number, and the actor who performed it.
1057
+ * A PULL REQUEST's close is deliberately NOT this type -- it rides `on.type: "pull_request"` as an
1058
+ * action, the #66 rule (one forge's PR lifecycle must not be a type while another's is an action),
1059
+ * and because GitLab and Azure number issues and merge/pull requests from SEPARATE sequences, a
1060
+ * both-kinds type narrowed by `on.number` would be ambiguous exactly where a one-shot spending
1061
+ * itself on the wrong #5 hurts most. Under the split, the type IS the discriminator.
1062
+ */
1063
+ function normalizeIssue(on, run, index, path) {
1064
+ const at = `trigger at index ${index}`;
1065
+
1066
+ // Kind first: on azure the whole TYPE is unsupported, and hearing that beats hearing that one
1067
+ // action word is. See ISSUE_ACTIONS for why, and validateResumeFlag for the "not yet covered"
1068
+ // vocabulary this reuses.
1069
+ if (run.kind === "azure") {
1070
+ throw configError(`${at}: an issue trigger is not yet covered for azure -- a work item's close is a System.State transition whose terminal names vary by process template (Agile "Closed", Scrum "Done"), and the projected subset carries only System.Tags, so nothing in the delivery says the item closed; widening the payload subset is the gap to close, not a limit: ${path}`);
1071
+ }
1072
+
1073
+ const actions = on.action;
1074
+ if (!Array.isArray(actions) || actions.length === 0) {
1075
+ throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
1076
+ }
1077
+ // Validated against THIS entry's forge, in that forge's own words -- normalizePullRequest's rule.
1078
+ const allowed = ISSUE_ACTIONS[run.kind];
1079
+ const expected = [...allowed].join("|");
1080
+ for (const a of actions) {
1081
+ if (!allowed.has(a)) {
1082
+ throw configError(`${at}: issue on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
1083
+ }
1084
+ }
1085
+
1086
+ // The close route matches action and number alone, so a predicate here would be accepted-and-
1087
+ // ignored -- the exact thing validateRepository's posture forbids. Refused, not dropped.
1088
+ if (on.any !== undefined || on.all !== undefined || on.none !== undefined) {
1089
+ throw configError(`${at}: an issue trigger cannot carry a label predicate -- it fires on a lifecycle action, not a label diff, so any/all/none could never match; a label-predicated rule is on.type "label": ${path}`);
1090
+ }
1091
+
1092
+ const number = validateNumber(on, at, path, { capable: true, onType: "issue" });
1093
+ const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
1094
+ validateDisarmed(on, at, path, { capable: true, onType: "issue" });
1095
+
1096
+ // First among the run checks, before the flow-required check -- validateCommand says why.
1097
+ const command = validateCommand(run, at, path, { onType: "issue" });
1098
+ if (command === undefined && !isNonEmptyString(run.flow)) {
1099
+ throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
1100
+ }
1101
+ validateFlowName(run, at, path);
1102
+ const packages = validatePackagesFlag(run, at, path);
1103
+ const image = validateImageRef(run, at, path);
1104
+ const skillsDir = validateSkillsDir(run, at, path);
1105
+ const instructions = validateInstructions(run, at, path);
1106
+ const resume = validateResumeFlag(run, at, path);
1107
+ validateRepository(run, "issue", at, path);
1108
+ const replicas = validateReplicas(run, at, path);
1109
+ const secrets = validateSecrets(run, at, path);
1110
+ const secretsProfile = validateSecretsProfile(run, at, path);
1111
+ return {
1112
+ on: {
1113
+ type: "issue",
1114
+ action: [...actions],
1115
+ // Absent rather than present-and-undefined, reviewState's rule below: an unnarrowed rule's
1116
+ // normalized shape must not grow keys.
1117
+ ...(number !== undefined && { number }),
1118
+ ...(once !== undefined && { once }),
1119
+ },
1120
+ 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 }) },
688
1121
  };
689
1122
  }
690
1123
 
@@ -705,6 +1138,16 @@ function normalizePullRequest(on, run, index, path) {
705
1138
  }
706
1139
  }
707
1140
 
1141
+ // The close-only split (issue #231). A close rule gates on the CLOSER's write access; every other
1142
+ // PR action gates on the author's association or a collaborator-applied label. One rule cannot
1143
+ // gate on two different actors, so a list mixing the close word with anything else is refused --
1144
+ // which is also what lets the receiver group close rules separately without re-deriving this.
1145
+ const closeWord = PR_CLOSE_ACTIONS[run.kind];
1146
+ const isClose = closeWord !== undefined && actions.includes(closeWord);
1147
+ if (isClose && actions.some((a) => a !== closeWord)) {
1148
+ throw configError(`${at}: pull_request on.action cannot mix ${JSON.stringify(closeWord)} with other actions -- a close rule is gated on the closer's write access and every other action on the author's, and one rule cannot gate on two different actors; split it into two entries: ${path}`);
1149
+ }
1150
+
708
1151
  const reviewState = validateReviewState(on, actions, run, at, path);
709
1152
 
710
1153
  // A `labeled` PR trigger is gated by its label predicate (the collaborator-applied label is the
@@ -724,12 +1167,21 @@ function normalizePullRequest(on, run, index, path) {
724
1167
  if (run.kind === "azure" && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
725
1168
  throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
726
1169
  }
1170
+ // A close-only rule reads no label diff either -- its route matches action and number alone -- so
1171
+ // a predicate on it is normalizeIssue's refusal arriving on the PR side.
1172
+ if (isClose && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
1173
+ throw configError(`${at}: a close pull_request rule cannot carry a label predicate -- the close route matches the action and on.number alone, so any/all/none would sit in the file looking configured and never match anything: ${path}`);
1174
+ }
727
1175
  const predicate = validatePredicate(on, index, path, requirePositive);
1176
+ const number = validateNumber(on, at, path, { capable: isClose, onType: "pull_request" });
1177
+ const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
1178
+ validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
728
1179
  // First among the run checks, before the flow-required check -- validateCommand says why.
729
1180
  const command = validateCommand(run, at, path, { onType: "pull_request" });
730
1181
  if (command === undefined && !isNonEmptyString(run.flow)) {
731
1182
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
732
1183
  }
1184
+ validateFlowName(run, at, path);
733
1185
  const packages = validatePackagesFlag(run, at, path);
734
1186
  const image = validateImageRef(run, at, path);
735
1187
  const skillsDir = validateSkillsDir(run, at, path);
@@ -737,6 +1189,8 @@ function normalizePullRequest(on, run, index, path) {
737
1189
  const resume = validateResumeFlag(run, at, path);
738
1190
  validateRepository(run, "pull_request", at, path);
739
1191
  const replicas = validateReplicas(run, at, path);
1192
+ const secrets = validateSecrets(run, at, path);
1193
+ const secretsProfile = validateSecretsProfile(run, at, path);
740
1194
  return {
741
1195
  on: {
742
1196
  type: "pull_request",
@@ -747,8 +1201,11 @@ function normalizePullRequest(on, run, index, path) {
747
1201
  any: predicate.any,
748
1202
  all: predicate.all,
749
1203
  none: predicate.none,
1204
+ // Same rule for the close-narrowing pair (issue #231): only a close-only rule can carry them.
1205
+ ...(number !== undefined && { number }),
1206
+ ...(once !== undefined && { once }),
750
1207
  },
751
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
1208
+ 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
1209
  };
753
1210
  }
754
1211