@edgehero/pi-dispatch 1.10.2 → 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.
- package/.env.example +300 -148
- package/README.md +50 -0
- package/deploy/com.pi-dispatch.worker.plist +9 -3
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +11 -0
- package/deploy/worker-env-wrapper.sh +60 -34
- package/deploy/worker.service +18 -8
- package/package.json +14 -4
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4701 -414
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +455 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +222 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-registry.mjs +32 -5
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +387 -17
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/processor.mjs +505 -26
- package/src/provider-key.mjs +41 -0
- package/src/provider-steering.mjs +144 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +23 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1395 -268
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +176 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1034
|
-
*
|
|
1035
|
-
* absence `validateWaitFor` refuses near-misses for, one field
|
|
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
|
|