@edgehero/pi-dispatch 1.10.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +29 -2
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +363 -13
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1348 -326
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
package/src/triggers.mjs CHANGED
@@ -25,11 +25,14 @@
25
25
 
26
26
  import { BACKEND_NAMES, backendFor } from "./backends.mjs";
27
27
  import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
28
+ import { imageRefProblem } from "./image-ref.mjs";
28
29
  // SKILL_NAME_RE is the single-sourced skill charset (flow-gate exports it for exactly this reason:
29
30
  // materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
30
31
  // traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
31
32
  import { SKILL_NAME_RE } from "./flow-gate.mjs";
32
33
  import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
34
+ import { findDuplicateKey } from "./json-duplicates.mjs";
35
+ import { PROVIDER_STEERING_VARS } from "./provider-steering.mjs";
33
36
  import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
34
37
  // The wait grammar's two shared halves (issue #230). `afterInstantMs` is imported rather than restated so
35
38
  // the loader and the pickup gate cannot disagree about what a legal instant is: a second spelling here is
@@ -37,7 +40,8 @@ import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
37
40
  // fs-free, so importing it keeps parseTriggers pure.
38
41
  import { WAIT_CONDITION_KEYS, WAIT_CONDITION_MAX, afterInstantMs } from "./wait-for.mjs";
39
42
 
40
- const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
43
+ // EXPORTED for the same reason as PR_ACTIONS: the admin re-states this vocabulary to a MODEL.
44
+ export const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
41
45
 
42
46
  /**
43
47
  * The `on.type` a DISARMED one-shot normalizes to (issue #231). Producible only by this validator:
@@ -82,7 +86,9 @@ export { FORGE_KINDS };
82
86
  * have made one forge's review a type and the other's an action. The gate on it is the REVIEWER's
83
87
  * `author_association`, never the PR author's -- see filter.mjs and CONST-TRIGGER-AUTHOR-GATE.
84
88
  */
85
- const PR_ACTIONS = {
89
+ // EXPORTED for the receiver's four route gates, which each carry this vocabulary MINUS their own named
90
+ // exclusions and must be able to prove that relation rather than restate it (issue #286's sweep).
91
+ export const PR_ACTIONS = {
86
92
  github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
87
93
  // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
88
94
  // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
@@ -132,7 +138,10 @@ export const PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "c
132
138
  * guess at. Not yet covered, not impossible -- validateResumeFlag's distinction, kept for the same
133
139
  * reason.
134
140
  */
135
- const ISSUE_ACTIONS = {
141
+ // EXPORTED beside PR_ACTIONS: the admin's ISSUE_CLOSE_WORD is derived from THIS table and not from
142
+ // PR_CLOSE_ACTIONS. The two hold identical values today and are separately justified, so deriving the
143
+ // issue word from the pull-request one would be a coincidence dressed as a derivation.
144
+ export const ISSUE_ACTIONS = {
136
145
  github: new Set(["closed"]),
137
146
  gitlab: new Set(["close"]),
138
147
  forgejo: new Set(["closed"]),
@@ -151,11 +160,25 @@ const ISSUE_ACTIONS = {
151
160
  * `dismissed` is absent because it is an ACTION on the `pull_request_review` event, not a state a
152
161
  * submitted review carries.
153
162
  */
154
- const REVIEW_STATES = new Set(["approved", "changes_requested", "commented"]);
163
+ // EXPORTED so the admin's tool description can state the vocabulary rather than retype it.
164
+ export const REVIEW_STATES = new Set(["approved", "changes_requested", "commented"]);
155
165
 
156
166
  /** The one action `on.reviewState` can narrow. Spelled once, read by the validator and named in its error. */
157
167
  const REVIEW_ACTION = "review_submitted";
158
168
 
169
+ /**
170
+ * The pi tool names a trigger may exclude (issue #291) -- the built-in set of the PINNED pi, 0.80.7.
171
+ * Hand-written because this validator is pure and pi-free (the worker does not depend on the agent
172
+ * package), and therefore BOLTED twice to the artifact it restates: `worker/test/exclude-tools.pinned.test.mjs`
173
+ * and `image/runner/test/pinned-api.test.mjs` both derive the set from the pinned package and fail with a
174
+ * grow-these-together message when a pin bump moves it. Extension and custom tool names are deliberately
175
+ * NOT here: they register at container start (a staged package, a serviced repo's own `.pi/extensions`),
176
+ * so the loader cannot know them, and admitting free strings would re-open the silent no-op this field's
177
+ * validation exists to close -- pi ignores unknown names in `excludeTools` without a diagnostic.
178
+ */
179
+ // EXPORTED for the two pinned-set bolts and for the admin, which states the vocabulary to an operator.
180
+ export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
181
+
159
182
  // A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
160
183
  // parse; the charset also excludes `:` and the dedicated check names the reason.
161
184
  const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
@@ -193,8 +216,8 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
193
216
  * The env variable names a trigger may NOT bind, and why each set is here.
194
217
  *
195
218
  * These are the STATICALLY KNOWABLE half. `parseTriggers` is pure, fs-free and env-free, so it cannot see
196
- * the resolved provider's credential variable names (they come from `findEnvKeys(provider, hostEnv)`) or
197
- * the deployment's `PI_FORWARD_ENV` list. Those two are refused PRE-SPEND in the processor, where both are
219
+ * the resolved provider's credential variable names (they depend on which provider the job runs on, which
220
+ * is settings state) or the deployment's `PI_FORWARD_ENV` list. Those two are refused PRE-SPEND in the processor, where both are
198
221
  * in hand -- the same load-time / deployment-state split `run.resume` already makes against
199
222
  * `PI_SESSIONS_DIR`. Refusing here what can be answered here keeps the file's own mistakes in the file's
200
223
  * own error.
@@ -202,7 +225,7 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
202
225
  * Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
203
226
  * forge added to the table later must not need a second edit here to stay covered.
204
227
  */
205
- const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES]);
228
+ const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
206
229
 
207
230
  function isNonEmptyString(value) {
208
231
  return typeof value === "string" && value.trim() !== "";
@@ -221,6 +244,23 @@ export function parseTriggers(text, path) {
221
244
  throw configError(`triggers file is not valid JSON: ${path} (${error.message})`);
222
245
  }
223
246
 
247
+ // AFTER the parse, never before (issue #313). The scanner's whole safety argument is that it only ever
248
+ // sees text `JSON.parse` has already accepted, so it never has to decide whether malformed input is
249
+ // malformed; and this order keeps "not valid JSON" the first thing an operator is told, unchanged.
250
+ //
251
+ // REFUSED, not repaired, and the whole file rather than the entry. `JSON.parse` keeps the LAST value
252
+ // for a duplicated key, so the reviewed file and the running file differ with nothing to say so, and
253
+ // the reach is the whole schema rather than one field. This file's posture everywhere else is that
254
+ // something which does not say one thing is not something to run half of, and the closest precedent is
255
+ // its own refusal of an unknown key inside a CONDITION: elsewhere a dropped key is a field that does
256
+ // nothing, there and here it is a term of a gate that does nothing while the job still runs.
257
+ const duplicate = findDuplicateKey(text);
258
+ if (duplicate) {
259
+ throw configError(
260
+ `triggers file has a duplicate key ${JSON.stringify(duplicate.key)} at ${duplicate.at}: JSON keeps the LAST value, so the file that was reviewed and the file that runs are not the same file: ${path}`,
261
+ );
262
+ }
263
+
224
264
  const entries = parsed?.triggers;
225
265
  if (!Array.isArray(entries)) {
226
266
  throw configError(`triggers file must have a "triggers" array: ${path}`);
@@ -355,6 +395,9 @@ function normalizeCron(on, run, index, path, state) {
355
395
  // Called for its refusal only: the returned `run` below deliberately grows no `waitFor` key, exactly as
356
396
  // it grows no `replicas` one, because a cron entry can never carry either (issue #230).
357
397
  validateWaitFor(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
398
+ // A cron job's session narrows like any other: the field selects what the container's agent can do,
399
+ // which is orthogonal to what triggered it (issue #291).
400
+ const excludeTools = validateExcludeTools(on, run, `cron trigger "${id}"`, path);
358
401
 
359
402
  // provider/model/maxTurns stay absent when omitted so the value resolves at job start against the
360
403
  // settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). github/packages/image stay
@@ -363,7 +406,7 @@ function normalizeCron(on, run, index, path, state) {
363
406
  // freeze today's default into every stored repeatable.
364
407
  return {
365
408
  on: { type: "cron", id, pattern },
366
- 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 }), ...(backend !== undefined && { backend }) },
409
+ 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 }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
367
410
  };
368
411
  }
369
412
 
@@ -481,15 +524,18 @@ function validateResumeFlag(run, at, path) {
481
524
  function validateImageRef(run, at, path) {
482
525
  const image = run.image;
483
526
  if (image === undefined) return undefined;
484
- if (typeof image !== "string" || image.trim() === "") {
527
+ // The shared rule (`image-ref.mjs`, issue #471), so PI_JOB_IMAGE at boot and doctor refuse exactly what this does.
528
+ const problem = imageRefProblem(image);
529
+ if (problem?.code === "blank") {
485
530
  throw configError(`${at}: run.image must be a non-empty string when present: ${path}`);
486
531
  }
487
- if (image !== image.trim()) {
532
+ if (problem?.code === "padded") {
488
533
  throw configError(`${at}: run.image must not have leading or trailing whitespace (got ${JSON.stringify(image)}): ${path}`);
489
534
  }
490
- if (image.startsWith("-")) {
535
+ if (problem?.code === "dash") {
491
536
  throw configError(`${at}: run.image must not start with "-" -- it is passed as the image positional in the docker argv, where a leading dash parses as a flag (got ${JSON.stringify(image)}): ${path}`);
492
537
  }
538
+ if (problem) throw configError(`${at}: run.image ${problem.reason} (got ${JSON.stringify(image)}): ${path}`);
493
539
  return image;
494
540
  }
495
541
 
@@ -938,8 +984,27 @@ function validateSecrets(run, at, path) {
938
984
  if (!ENV_NAME.test(name)) {
939
985
  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}`);
940
986
  }
987
+ // `__proto__` passes ENV_NAME and is in no reserved set, and it is the one name that cannot survive the
988
+ // journey: `JSON.parse` gives it as an own property and the spread below preserves it, but every later
989
+ // hop assigns with `=` into a plain object (`secrets[name]` in secrets.mjs, `env[name]` in
990
+ // env-allowlist.mjs), where it hits `Object.prototype`'s setter and does nothing at all. The resolver
991
+ // still runs, so the operator's vault is read for a value the container never receives, and the job
992
+ // proceeds to a clean exit with the variable unset: the silent no-op this whole feature refuses
993
+ // everywhere else. Refused here rather than repaired downstream, because a name that cannot be carried
994
+ // is a fact about the file, and the file is the reviewed artifact.
995
+ if (name === "__proto__") {
996
+ throw configError(`${at}: run.secrets key "__proto__" cannot be carried into a container -- it is swallowed by the prototype setter on every object between here and the job, so the resolver would run and the variable would still be unset: ${path}`);
997
+ }
941
998
  if (RESERVED_ENV_NAMES.has(name)) {
942
- 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}`);
999
+ // TWO reasons a name can be in here, opposite in direction, and the message asserts NEITHER,
1000
+ // because asserting the wrong one is worse than asserting neither: that is the mistake issue
1001
+ // #309 corrected in the pre-spend gate's message, and #314 widened the set far enough that a
1002
+ // disjunction would be false in both branches for some members. The worker writes some of
1003
+ // these; pi or a provider SDK reads others; a few belong to a provider this deployment does not
1004
+ // even use, and a few (`OPENAI_LOG`, `PI_CACHE_RETENTION`) steer nothing dangerous at all and
1005
+ // are reserved only because the set is a derivation rather than a judgement. The one thing
1006
+ // true of every member is that the runtime already owns the name.
1007
+ throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is reserved: this deployment's own runtime reads or writes it, so a value bound here would not do what the trigger intends: ${path}`);
943
1008
  }
944
1009
  const reference = secrets[name];
945
1010
  if (!isNonEmptyString(reference)) {
@@ -1030,9 +1095,10 @@ export function refusesLocalWorkspace(entry, localWorkspace) {
1030
1095
  *
1031
1096
  * A NEAR-MISS SPELLING IS REFUSED, which puts this in `waitFor`'s class rather than `run.imgae`'s. A
1032
1097
  * misspelled image gives you the default image and a job that ran; a misspelled `backend` gives you the
1033
- * DEFAULT VENUE and a job that ran -- byte-identical in the record, the panel and the log to one that
1034
- * correctly named a venue, while the operator reads the file as though it chose. That is the destructive
1035
- * absence `validateWaitFor` refuses near-misses for, one field over.
1098
+ * DEFAULT VENUE and a job that ran, while the operator reads the file as though it chose. Since #277 the run
1099
+ * record and RUN_DETAIL name the venue the job fell back to, but only AFTER it ran and spent: a record is an
1100
+ * audit, not a guard. That is the destructive absence `validateWaitFor` refuses near-misses for, one field
1101
+ * over.
1036
1102
  */
1037
1103
  function validateBackend(on, run, at, path, { localWorkspace }) {
1038
1104
  // The near-miss sweep, exactly `validateWaitFor`'s: on `run` the exact spelling is the field, on `on`
@@ -1103,6 +1169,93 @@ function validateBackend(on, run, at, path, { localWorkspace }) {
1103
1169
  return backend;
1104
1170
  }
1105
1171
 
1172
+ /**
1173
+ * `run.excludeTools` -- pi tool names this trigger's session must NOT have (issue #291).
1174
+ *
1175
+ * The first field here that changes what the agent CAN DO inside the container rather than what the
1176
+ * container holds: the runner passes it to `createAgentSession`, which filters the tool registry itself,
1177
+ * so a "read-only" trigger stops being prompt text. NARROWING ONLY, by decision: the allowlist form
1178
+ * (`run.tools`) inverts the question to "which tools exist", which is the pinned package's answer and
1179
+ * drifts with every pin bump -- a bump that adds a tool would silently grant it to every allowlisted
1180
+ * trigger, while naming what to take away cannot widen on a bump.
1181
+ *
1182
+ * MEMBERSHIP IS VALIDATED against `EXCLUDABLE_TOOL_NAMES` because pi ignores unknown names in
1183
+ * `excludeTools` silently (verified at the pin: the set is only ever consulted by a filter), which puts
1184
+ * a misspelled exclusion in `run.backend`'s destructive-absence class -- the job runs WITH the tool
1185
+ * while the file reads as though it was off. The near-miss sweep covers the KEY for the same reason.
1186
+ * No charset check: membership subsumes it, and no known name carries the comma the container env
1187
+ * encoding uses. No cap: membership plus the duplicate refusal bound any accepted array at the set's
1188
+ * own size. Excluding every builtin is legal -- extension and custom tools still load, which is a
1189
+ * documented bound of the field, not a gap.
1190
+ */
1191
+ function validateExcludeTools(on, run, at, path) {
1192
+ // The near-miss sweep, validateBackend's shape (its comment carries the homoglyph rationale): on
1193
+ // `run` the exact spelling is the field, on `on` every spelling is wrong including the correct one,
1194
+ // because what the session can do is a property of the run.
1195
+ for (const [label, source, exactIsLegal] of [
1196
+ ["run", run, true],
1197
+ ["on", on, false],
1198
+ ]) {
1199
+ for (const key of Object.keys(source ?? {})) {
1200
+ if (exactIsLegal && key === "excludeTools") continue;
1201
+ // Three targets. The first catches every case and separator variant (exclude_tools,
1202
+ // exclude-tools, EXCLUDETOOLS). The SINGULAR is the likeliest miss of all: an operator
1203
+ // removing one tool writes `excludeTool` the way English does, and unlike `backend` there is
1204
+ // no env-var mnemonic anchoring the right form. The past participle is how the field reads
1205
+ // back in prose ("the excluded tools"), one grammatical step with no other plausible meaning.
1206
+ // Deliberately NOT a synonym hunt (denyTools, toolsOff): that is the general unknown-key
1207
+ // sweep this file's forward-compatibility posture rejects.
1208
+ const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
1209
+ const targets = ["excludetools", "excludetool", "excludedtools"];
1210
+ const isSubsequence = (needle, hay) => {
1211
+ let i = 0;
1212
+ for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
1213
+ return i === needle.length;
1214
+ };
1215
+ const suspicious = /^[\x20-\x7E]*$/.test(key)
1216
+ ? targets.includes(normalized)
1217
+ : normalized.length >= 4 && targets.some((t) => isSubsequence(normalized, t));
1218
+ if (!suspicious) continue;
1219
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.excludeTools? An exclusion the loader drops runs the job WITH the tool while the file reads as though it was off, so a near miss is refused rather than dropped: ${path}`);
1220
+ }
1221
+ }
1222
+
1223
+ // pi's OWN option names are refused by name, before the absent-field return, so a file carrying only
1224
+ // the wrong field still hears why. `tools` because the allowlist inversion above is a decision, not
1225
+ // an oversight; `noTools` because it is the other tool switch on the same options object and
1226
+ // "notools" shares no subsequence with "excludetools", so the sweep above can never catch it -- and
1227
+ // a dropped tool switch is the exact destructive absence this validator refuses.
1228
+ if ("tools" in (run ?? {})) {
1229
+ throw configError(`${at}: run.tools is not a field -- pi-dispatch narrows only. An allowlist inverts the question to "which tools exist", which is the pinned pi's answer, not this file's: a pin bump that adds a tool would silently grant it to every allowlisted trigger. Name what to take away in run.excludeTools: ${path}`);
1230
+ }
1231
+ if ("noTools" in (run ?? {})) {
1232
+ throw configError(`${at}: run.noTools is not a field -- it is pi's own session option, and here it would be dropped silently, which for a tool switch is the destructive absence this validator refuses; spell the removal in run.excludeTools: ${path}`);
1233
+ }
1234
+
1235
+ const excludeTools = run?.excludeTools;
1236
+ if (excludeTools === undefined) return undefined;
1237
+
1238
+ if (!Array.isArray(excludeTools) || excludeTools.length === 0) {
1239
+ throw configError(`${at}: run.excludeTools must be a non-empty array of tool names when present (got ${JSON.stringify(excludeTools)}) -- an empty exclusion is a field that does nothing, and accepted-and-ignored is how an operator comes to trust one that does: ${path}`);
1240
+ }
1241
+ const seen = new Set();
1242
+ for (let i = 0; i < excludeTools.length; i++) {
1243
+ const name = excludeTools[i];
1244
+ if (!isNonEmptyString(name)) {
1245
+ throw configError(`${at}: run.excludeTools[${i}] must be a non-empty string naming a pi tool (got ${JSON.stringify(name)}): ${path}`);
1246
+ }
1247
+ if (!EXCLUDABLE_TOOL_NAMES.has(name)) {
1248
+ throw configError(`${at}: run.excludeTools[${i}] ${JSON.stringify(name)} is not a tool the pinned pi knows (known: ${[...EXCLUDABLE_TOOL_NAMES].join(", ")}) -- pi ignores unknown names silently, so a misspelled exclusion would exclude nothing while the file reads as though it did: ${path}`);
1249
+ }
1250
+ if (seen.has(name)) {
1251
+ throw configError(`${at}: run.excludeTools names ${JSON.stringify(name)} twice -- the entries are a removal set, so the second can never change the answer: ${path}`);
1252
+ }
1253
+ seen.add(name);
1254
+ }
1255
+ // A freshly built array, validateWaitFor's rule: nothing unvalidated rides through.
1256
+ return [...excludeTools];
1257
+ }
1258
+
1106
1259
  /**
1107
1260
  * `run.waitFor` -- the conditions that must all clear before this trigger's job starts (issue #230).
1108
1261
  *
@@ -1260,9 +1413,10 @@ function normalizeLabel(on, run, index, path) {
1260
1413
  const secretsProfile = validateSecretsProfile(run, at, path);
1261
1414
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1262
1415
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1416
+ const excludeTools = validateExcludeTools(on, run, at, path);
1263
1417
  return {
1264
1418
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
1265
- 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }) },
1419
+ 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
1266
1420
  };
1267
1421
  }
1268
1422
 
@@ -1300,9 +1454,10 @@ function normalizeComment(on, run, index, path, state) {
1300
1454
  const secretsProfile = validateSecretsProfile(run, at, path);
1301
1455
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1302
1456
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1457
+ const excludeTools = validateExcludeTools(on, run, at, path);
1303
1458
  return {
1304
1459
  on: { type: "comment", phrase: on.phrase },
1305
- 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }) },
1460
+ 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
1306
1461
  };
1307
1462
  }
1308
1463
 
@@ -1367,6 +1522,7 @@ function normalizeIssue(on, run, index, path) {
1367
1522
  const secretsProfile = validateSecretsProfile(run, at, path);
1368
1523
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1369
1524
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1525
+ const excludeTools = validateExcludeTools(on, run, at, path);
1370
1526
  return {
1371
1527
  on: {
1372
1528
  type: "issue",
@@ -1376,7 +1532,7 @@ function normalizeIssue(on, run, index, path) {
1376
1532
  ...(number !== undefined && { number }),
1377
1533
  ...(once !== undefined && { once }),
1378
1534
  },
1379
- 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }) },
1535
+ 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
1380
1536
  };
1381
1537
  }
1382
1538
 
@@ -1452,6 +1608,7 @@ function normalizePullRequest(on, run, index, path) {
1452
1608
  const secretsProfile = validateSecretsProfile(run, at, path);
1453
1609
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1454
1610
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1611
+ const excludeTools = validateExcludeTools(on, run, at, path);
1455
1612
  return {
1456
1613
  on: {
1457
1614
  type: "pull_request",
@@ -1466,7 +1623,7 @@ function normalizePullRequest(on, run, index, path) {
1466
1623
  ...(number !== undefined && { number }),
1467
1624
  ...(once !== undefined && { once }),
1468
1625
  },
1469
- 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }) },
1626
+ 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 }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
1470
1627
  };
1471
1628
  }
1472
1629