@edgehero/pi-dispatch-receiver 4.3.0 → 5.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/README.md +12 -2
- package/package.json +2 -2
- package/src/azure-members.mjs +6 -3
- package/src/boot-retry.mjs +1 -1
- package/src/cli.mjs +9 -1
- package/src/config.mjs +45 -57
- package/src/filter-azure.mjs +150 -99
- package/src/filter-forgejo.mjs +148 -88
- package/src/filter-gitlab.mjs +149 -90
- package/src/filter.mjs +168 -113
- package/src/forgejo-members.mjs +43 -9
- package/src/github-members.mjs +2 -1
- package/src/gitlab-members.mjs +2 -1
- package/src/held.mjs +120 -0
- package/src/poller.mjs +62 -15
- package/src/predicate.mjs +37 -0
- package/src/receiver.mjs +290 -60
- package/src/start.mjs +31 -6
- package/src/steer.mjs +221 -0
- package/src/trigger-groups.mjs +57 -0
- package/src/verify-azure.mjs +1 -1
- package/src/verify-gitlab.mjs +5 -4
- package/src/verify-watch.mjs +40 -0
- package/src/verify.mjs +1 -1
- package/src/watch-pull.mjs +94 -0
- package/src/watch-puller.mjs +82 -0
- package/src/watch.mjs +60 -0
- package/src/http-body.mjs +0 -73
package/src/filter.mjs
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* user); `deliveryId` is the `X-GitHub-Delivery` GUID, carried into the job for downstream dedup.
|
|
47
47
|
*/
|
|
48
48
|
import { findCloseRule } from "./close.mjs";
|
|
49
|
-
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
49
|
+
import { escapeRegExp, firstMatchingRule, heldCandidateOr, labelSet, matchedLabel, matchesRule, steerModeOf } from "./predicate.mjs";
|
|
50
50
|
import { hasParkedLabel } from "@edgehero/pi-dispatch/parked";
|
|
51
51
|
|
|
52
52
|
const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
@@ -119,103 +119,128 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
|
|
|
119
119
|
return { enqueue: false, reason: "unhandled-event" };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
// A drop is returned as it is, except a phrase-free reply that failed ONLY for the missing parked marker (issue #638,
|
|
123
|
+
// DES-AWAIT-INPUT-ENDS-THE-JOB): that one also carries `heldCandidate`, the job the parked-reply route would have
|
|
124
|
+
// built, through the SAME `build` below, so the receiver can hold it for a run that is parking right now and the job
|
|
125
|
+
// that run starts is byte for byte the one the marker would have produced. Still a drop: nothing here enqueues it.
|
|
126
|
+
if (!resolved.enqueue) return heldCandidateOr(resolved, build);
|
|
127
|
+
return build(resolved);
|
|
123
128
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
...(resolved.
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
129
|
+
// The job from a resolved route. One function for every route and for the held candidate (issue #638), declared
|
|
130
|
+
// here so it closes over this delivery's subset and ids.
|
|
131
|
+
function build(resolved) {
|
|
132
|
+
// (3) Build the job from the INT-WEBHOOK-PAYLOAD-SUBSET fields only. No sender.login (not in the
|
|
133
|
+
// subset), provider/model/maxTurns only when the matched rule named them (absent, the worker fills the
|
|
134
|
+
// deployment default, #502), no field outside the subset --
|
|
135
|
+
// with two trigger-context additions (issue #49): `matched` is harness-computed, the filter's own
|
|
136
|
+
// decision record naming the triggers.json entry that fired (not payload data at all); `comment`,
|
|
137
|
+
// present only on the comment route, carries the invoking comment's body/author_association, both
|
|
138
|
+
// fields INT-WEBHOOK-PAYLOAD-SUBSET already names.
|
|
139
|
+
//
|
|
140
|
+
// `packages` and `image` are harness-computed EXECUTION knobs read off the matched triggers.json entry
|
|
141
|
+
// (like `matched`), never payload data -- REQ-GLOBAL-PI-OVERLAY's per-trigger control over loading the
|
|
142
|
+
// operator-staged pi packages, and INT-TRIGGERS-FILE-CONTRACT's per-trigger container image. Both sit at
|
|
143
|
+
// JOB level, NOT inside `trigger`: `trigger` is the descriptive context object carried verbatim into
|
|
144
|
+
// /job/event.json (INT-CONTAINER-JOB-INPUTS) and must stay descriptive, so an execution switch has no
|
|
145
|
+
// business there. Both are omitted entirely when the matched rule did not set them, so an unflagged
|
|
146
|
+
// trigger's job literal is byte-identical to today's.
|
|
147
|
+
const job = {
|
|
148
|
+
repo: subset.repository?.full_name,
|
|
149
|
+
target: resolved.target,
|
|
150
|
+
// EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
|
|
151
|
+
// the exclusivity at load). A command rule dispatches a registered pi extension command instead of
|
|
152
|
+
// resolving a flow, and its job must carry NO flow key at all -- a present-and-undefined flow would
|
|
153
|
+
// change the enqueued bytes for every consumer that serializes the job. The spread keeps a flow
|
|
154
|
+
// rule's literal byte-identical to what it always was.
|
|
155
|
+
...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
|
|
156
|
+
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
157
|
+
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
158
|
+
// #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
|
|
159
|
+
// conditional above, a trigger that named a venue but no image had its venue silently dropped here --
|
|
160
|
+
// it loaded, validated, reached this function on the rule, and then ran on the default. That is
|
|
161
|
+
// exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
|
|
162
|
+
// arriving through the plumbing instead of the spelling, one file downstream of the guard.
|
|
163
|
+
...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
|
|
164
|
+
// #291. A separate spread, the backend line's rule one field over: folded into a neighbour's
|
|
165
|
+
// conditional, a trigger that named only exclusions would have them silently dropped here -- and a
|
|
166
|
+
// dropped exclusion runs the job WITH the tool, the destructive absence the loader's own sweep
|
|
167
|
+
// refuses a misspelling for, arriving through the plumbing instead of the spelling.
|
|
168
|
+
...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
|
|
169
|
+
// Issue #800, each its own spread by the same rule: a dropped list runs the job with what the file takes away.
|
|
170
|
+
...(resolved.excludeExtensions !== undefined ? { excludeExtensions: resolved.excludeExtensions } : {}),
|
|
171
|
+
...(resolved.excludeExtensionTools !== undefined ? { excludeExtensionTools: resolved.excludeExtensionTools } : {}),
|
|
172
|
+
// REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
|
|
173
|
+
// the job without its servers.
|
|
174
|
+
...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
|
|
175
|
+
// #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
|
|
176
|
+
// that named only a model would have its provider's absence decide whether the model survives,
|
|
177
|
+
// and a dropped model runs the job on the deployment default while the file reads as chosen.
|
|
178
|
+
...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
|
|
179
|
+
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
180
|
+
...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
|
|
181
|
+
...(resolved.models !== undefined ? { models: resolved.models } : {}), ...(resolved.fallbackModels !== undefined ? { fallbackModels: resolved.fallbackModels } : {}),
|
|
182
|
+
// #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
|
|
183
|
+
// while the file reads as narrowed.
|
|
184
|
+
...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
|
|
185
|
+
// The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
|
|
186
|
+
// NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
|
|
187
|
+
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
188
|
+
// basename(folder) restraint already exists to prevent.
|
|
189
|
+
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
190
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
191
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
192
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
193
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
194
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
195
|
+
...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
|
|
196
|
+
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
197
|
+
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
198
|
+
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
199
|
+
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
200
|
+
// REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
|
|
201
|
+
// resume's rule, so an unflagged job's data stays byte-identical.
|
|
202
|
+
...(resolved.awaitInput === true ? { awaitInput: true } : {}),
|
|
203
|
+
...(resolved.gates !== undefined ? { gates: resolved.gates, ...(resolved.gateTimeout !== undefined ? { gateTimeout: resolved.gateTimeout } : {}), ...(resolved.gateBudgetRaiseUsd !== undefined ? { gateBudgetRaiseUsd: resolved.gateBudgetRaiseUsd } : {}) } : {}),
|
|
204
|
+
// Issue #644: the progress tool and the forge status comment, each present only when armed, awaitInput's rule.
|
|
205
|
+
...(resolved.progress === true ? { progress: true } : {}),
|
|
206
|
+
...(resolved.statusComment === true ? { statusComment: true } : {}),
|
|
207
|
+
// Issue #645: durable runs, present only when armed, the same rule.
|
|
208
|
+
...(resolved.recover === true ? { recover: true } : {}),
|
|
209
|
+
// Issue #646: steering a running job, present only when armed, the same rule.
|
|
210
|
+
...(resolved.steer === true ? { steer: true } : {}),
|
|
211
|
+
// Issue #646: how this phrase comment steers a job of its trigger that is already running, set only on the comment
|
|
212
|
+
// route of a steer-armed trigger (never a parked reply, never a command). The receiver reads it to try the run's
|
|
213
|
+
// inbox before it enqueues, and `enqueueForgeJob` names its fields, so it never reaches a queue. At JOB level, never
|
|
214
|
+
// inside `trigger`, which is copied verbatim into /job/event.json.
|
|
215
|
+
...(resolved.steerMode !== undefined ? { steerMode: resolved.steerMode } : {}),
|
|
216
|
+
// A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
|
|
217
|
+
...(resolved.parkedReply === true ? { parkedReply: true } : {}),
|
|
218
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level like the rest, never
|
|
219
|
+
// inside `trigger`, and for the sharpest version of that reason: this is the caller's fanout count --
|
|
220
|
+
// receiver.mjs reads it to decide how many times to enqueue -- and a descriptive context object copied
|
|
221
|
+
// into /job/event.json is no place for the number of jobs to create.
|
|
222
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
223
|
+
trigger: {
|
|
224
|
+
event: eventName,
|
|
225
|
+
action,
|
|
226
|
+
deliveryId,
|
|
227
|
+
sender: { id: subset.sender.id },
|
|
228
|
+
matched: resolved.matched,
|
|
229
|
+
...(resolved.comment ? { comment: resolved.comment } : {}),
|
|
230
|
+
// The invoking review, present only on the review route (issue #66), sibling of `comment` and
|
|
231
|
+
// carried for the same reason: without it a "Request changes: rename the helper" review starts a
|
|
232
|
+
// job that cannot know what was asked. All four fields are named by INT-WEBHOOK-PAYLOAD-SUBSET and
|
|
233
|
+
// the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
|
|
234
|
+
//
|
|
235
|
+
// NOTE the pair above: `event` is `pull_request_review` and `action` is the raw `submitted`,
|
|
236
|
+
// byte-for-byte what GitHub sent. `matched.action` is the canonical `review_submitted`, because
|
|
237
|
+
// `matched` names the triggers.json entry that fired rather than the payload. This is the first
|
|
238
|
+
// GitHub case where the two differ, and INT-CONTAINER-JOB-INPUTS says so.
|
|
239
|
+
...(resolved.review ? { review: resolved.review } : {}),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
return { enqueue: true, job };
|
|
243
|
+
}
|
|
219
244
|
}
|
|
220
245
|
|
|
221
246
|
/** Issue label path: the label allowlist IS the human approval gate -- only collaborators can label. */
|
|
@@ -230,15 +255,19 @@ function routeIssueLabel(subset, triggers) {
|
|
|
230
255
|
// A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
|
|
231
256
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
232
257
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
233
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
234
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
258
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
259
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
235
260
|
skillsDir: rule.skillsDir,
|
|
236
261
|
secrets: rule.secrets,
|
|
237
262
|
secretsProfile: rule.secretsProfile,
|
|
238
263
|
waitFor: rule.waitFor,
|
|
239
264
|
instructions: rule.instructions,
|
|
240
265
|
resume: rule.resume,
|
|
241
|
-
awaitInput: rule.awaitInput,
|
|
266
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
267
|
+
progress: rule.progress,
|
|
268
|
+
statusComment: rule.statusComment,
|
|
269
|
+
recover: rule.recover,
|
|
270
|
+
steer: rule.steer,
|
|
242
271
|
replicas: rule.replicas,
|
|
243
272
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
244
273
|
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
@@ -251,7 +280,7 @@ function routeIssueLabel(subset, triggers) {
|
|
|
251
280
|
* `knownFlows` is passed separately because it is NOT a per-forge rule -- it is the whole file's flow
|
|
252
281
|
* vocabulary, and it bounds which names a comment may summon rather than which rules may match.
|
|
253
282
|
*/
|
|
254
|
-
function routeComment(subset, triggers, knownFlows) {
|
|
283
|
+
function routeComment(subset, triggers, knownFlows, assumeParked = false) {
|
|
255
284
|
if (!AUTHOR_ALLOWLIST.has(subset.comment?.author_association)) {
|
|
256
285
|
return { enqueue: false, reason: "author-not-allowed" };
|
|
257
286
|
}
|
|
@@ -263,8 +292,17 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
263
292
|
// trigger arms run.resume and is not a command, and only when the forge says the issue carries the marker. The
|
|
264
293
|
// author gate above already ran: this widens WHICH comments start a job, never WHO may. The marker is a hint; the
|
|
265
294
|
// worker refuses the job before any spend unless the key's `parked` sidecar exists.
|
|
266
|
-
const
|
|
295
|
+
const parkedCandidate = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.issue?.pull_request !== true;
|
|
296
|
+
const parkedReply = parkedCandidate && (assumeParked || hasParkedLabel(subset.issue?.labels));
|
|
267
297
|
if (!phraseHit && !parkedReply) {
|
|
298
|
+
// Issue #638: a reply that fails ONLY for the missing marker (the run asked its question and is still parking, so
|
|
299
|
+
// the label is not on yet) is still a drop, but it carries the route the marker would have taken, built by this
|
|
300
|
+
// same function with the marker assumed (so every other rule above still decided it). The receiver holds it for a
|
|
301
|
+
// run that is parking on this target, or drops it exactly as today.
|
|
302
|
+
if (parkedCandidate) {
|
|
303
|
+
const held = routeComment(subset, triggers, knownFlows, true);
|
|
304
|
+
if (held.enqueue) return { enqueue: false, reason: "no-trigger-phrase", heldResolved: held };
|
|
305
|
+
}
|
|
268
306
|
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
269
307
|
}
|
|
270
308
|
// On a COMMAND rule (issue #189) the whole flow-resolution block below -- default flow, the `<phrase>
|
|
@@ -299,18 +337,27 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
299
337
|
// The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
|
|
300
338
|
// <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
|
|
301
339
|
packages: triggers.comment.packages,
|
|
302
|
-
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, mcp: triggers.comment.mcp,
|
|
303
|
-
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, maxCostUsd: triggers.comment.maxCostUsd,
|
|
340
|
+
image: triggers.comment.image, backend: triggers.comment.backend, excludeTools: triggers.comment.excludeTools, excludeExtensions: triggers.comment.excludeExtensions, excludeExtensionTools: triggers.comment.excludeExtensionTools, mcp: triggers.comment.mcp,
|
|
341
|
+
provider: triggers.comment.provider, model: triggers.comment.model, maxTurns: triggers.comment.maxTurns, models: triggers.comment.models, fallbackModels: triggers.comment.fallbackModels, maxCostUsd: triggers.comment.maxCostUsd,
|
|
304
342
|
skillsDir: triggers.comment.skillsDir,
|
|
305
343
|
secrets: triggers.comment.secrets,
|
|
306
344
|
secretsProfile: triggers.comment.secretsProfile,
|
|
307
345
|
waitFor: triggers.comment.waitFor,
|
|
308
346
|
instructions: triggers.comment.instructions,
|
|
309
347
|
resume: triggers.comment.resume,
|
|
310
|
-
awaitInput: triggers.comment.awaitInput,
|
|
348
|
+
awaitInput: triggers.comment.awaitInput, gates: triggers.comment.gates, gateTimeout: triggers.comment.gateTimeout, gateBudgetRaiseUsd: triggers.comment.gateBudgetRaiseUsd,
|
|
349
|
+
progress: triggers.comment.progress,
|
|
350
|
+
statusComment: triggers.comment.statusComment,
|
|
351
|
+
recover: triggers.comment.recover,
|
|
352
|
+
steer: triggers.comment.steer,
|
|
311
353
|
replicas: triggers.comment.replicas,
|
|
312
354
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
313
355
|
...(parkedReply && { parkedReply: true }),
|
|
356
|
+
// Issue #646: a phrase comment on a steer-armed trigger may steer a run of it that is already going. Only with the
|
|
357
|
+
// phrase (a parked reply is an answer to a parked run, never a steer) and never on a command rule (the loader refuses
|
|
358
|
+
// `steer` beside `command`; checked again here so a drifted file cannot reach a run). The author gate above already
|
|
359
|
+
// ran: this changes where an admitted comment goes, never who may send one. The `later` grammar is `steerModeOf`'s.
|
|
360
|
+
...(phraseHit && command === undefined && triggers.comment.steer === true && { steerMode: steerModeOf(body, phrase) }),
|
|
314
361
|
// The invoking comment rides on the trigger: body and author_association are both named by
|
|
315
362
|
// INT-WEBHOOK-PAYLOAD-SUBSET, and the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
|
|
316
363
|
comment: { body: subset.comment.body, author_association: subset.comment.author_association },
|
|
@@ -400,15 +447,19 @@ function routePullRequest(subset, triggers, action) {
|
|
|
400
447
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
401
448
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
402
449
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
403
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
404
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
450
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
451
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
405
452
|
skillsDir: rule.skillsDir,
|
|
406
453
|
secrets: rule.secrets,
|
|
407
454
|
secretsProfile: rule.secretsProfile,
|
|
408
455
|
waitFor: rule.waitFor,
|
|
409
456
|
instructions: rule.instructions,
|
|
410
457
|
resume: rule.resume,
|
|
411
|
-
awaitInput: rule.awaitInput,
|
|
458
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
459
|
+
progress: rule.progress,
|
|
460
|
+
statusComment: rule.statusComment,
|
|
461
|
+
recover: rule.recover,
|
|
462
|
+
steer: rule.steer,
|
|
412
463
|
replicas: rule.replicas,
|
|
413
464
|
matched: { index: rule.index, type: "pull_request", action },
|
|
414
465
|
...(isReview ? { review: { id: review.id, body: review.body, state: review.state, author_association: review.author_association } } : {}),
|
|
@@ -518,15 +569,19 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
|
|
|
518
569
|
// A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
|
|
519
570
|
...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
|
|
520
571
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
521
|
-
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, mcp: rule.mcp,
|
|
522
|
-
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, maxCostUsd: rule.maxCostUsd,
|
|
572
|
+
image: rule.image, backend: rule.backend, excludeTools: rule.excludeTools, excludeExtensions: rule.excludeExtensions, excludeExtensionTools: rule.excludeExtensionTools, mcp: rule.mcp,
|
|
573
|
+
provider: rule.provider, model: rule.model, maxTurns: rule.maxTurns, models: rule.models, fallbackModels: rule.fallbackModels, maxCostUsd: rule.maxCostUsd,
|
|
523
574
|
skillsDir: rule.skillsDir,
|
|
524
575
|
secrets: rule.secrets,
|
|
525
576
|
secretsProfile: rule.secretsProfile,
|
|
526
577
|
waitFor: rule.waitFor,
|
|
527
578
|
instructions: rule.instructions,
|
|
528
579
|
resume: rule.resume,
|
|
529
|
-
awaitInput: rule.awaitInput,
|
|
580
|
+
awaitInput: rule.awaitInput, gates: rule.gates, gateTimeout: rule.gateTimeout, gateBudgetRaiseUsd: rule.gateBudgetRaiseUsd,
|
|
581
|
+
progress: rule.progress,
|
|
582
|
+
statusComment: rule.statusComment,
|
|
583
|
+
recover: rule.recover,
|
|
584
|
+
steer: rule.steer,
|
|
530
585
|
replicas: rule.replicas,
|
|
531
586
|
matched: matchedFor(rule),
|
|
532
587
|
target: targetFor(),
|
|
@@ -553,8 +608,8 @@ export function wantsCloserAuthority(eventName, subset, group, selfId) {
|
|
|
553
608
|
// Filter's own step-0/1 guards, replicated FIRST and in the same order (fail-closed identity, then
|
|
554
609
|
// the unconditional bot-loop guard): the harness closing its own issue -- the natural last act of
|
|
555
610
|
// the very flow a close trigger arms -- must not cost a lookup, and an INDETERMINATE lookup on a
|
|
556
|
-
// self-delivery would 503 (
|
|
557
|
-
// `self`.
|
|
611
|
+
// self-delivery would answer 503 (a failed delivery in the hook's history and a `lookup-failed` count on the
|
|
612
|
+
// Connections page) for traffic the filter would only ever drop as `self`.
|
|
558
613
|
if (typeof subset?.sender?.id !== "number") return false;
|
|
559
614
|
if (subset.sender.id === selfId) return false;
|
|
560
615
|
if (eventName === "issues" && subset.action === CLOSE_ACTION) {
|
package/src/forgejo-members.mjs
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Forgejo has no `author_association`; the concept is GitHub's. What it has instead is
|
|
6
6
|
* `GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission`, which answers
|
|
7
|
-
* `{ permission: "admin"|"write"|"read"|"none" }`. `admin` and `write` are the honest
|
|
8
|
-
* `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the
|
|
9
|
-
* the constitution actually requires.
|
|
7
|
+
* `{ permission: "owner"|"admin"|"write"|"read"|"none" }`. `owner`, `admin` and `write` are the honest
|
|
8
|
+
* analogue of `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the
|
|
9
|
+
* property the constitution actually requires. `owner` is not hypothetical and not rare: measured on
|
|
10
|
+
* Forgejo 14.0.5, the owner of a personal repository asking about themselves gets
|
|
11
|
+
* `{"permission":"owner","role_name":"owner",...}`, and a set without it dropped every trigger they set
|
|
12
|
+
* as `author-not-allowed`.
|
|
10
13
|
*
|
|
11
14
|
* The shape of this module is the GitLab resolver's, for the same three reasons, and they are worth
|
|
12
15
|
* restating rather than cross-referencing:
|
|
@@ -16,7 +19,8 @@
|
|
|
16
19
|
* - it runs AFTER verification, so an unauthenticated flood cannot make this project call Forgejo;
|
|
17
20
|
* - it returns a two-armed verdict, because "not a collaborator" and "could not tell" are different
|
|
18
21
|
* answers. A 404 is determinate and refuses; anything else is INDETERMINATE and the receiver answers
|
|
19
|
-
* 503
|
|
22
|
+
* 503. Forgejo never resends that on its own, and its replay is a new delivery id (receiver.mjs, WHAT A
|
|
23
|
+
* 503 MEANS), so a person replays what still needs to run. Collapsing
|
|
20
24
|
* indeterminate to "deny" would drop real work during an outage behind a 204 that looks exactly like a
|
|
21
25
|
* stranger being correctly refused.
|
|
22
26
|
*
|
|
@@ -31,18 +35,31 @@ const API_PREFIX = "/api/v1";
|
|
|
31
35
|
/**
|
|
32
36
|
* The permission levels that can push to the repository. `read` and `none` cannot, so a job started on
|
|
33
37
|
* their say-so would be doing work the actor could not do themselves -- the line CONST-TRIGGER-AUTHOR-GATE
|
|
34
|
-
* draws.
|
|
38
|
+
* draws. These are the strings of Forgejo's access modes (none, read, write, admin, owner); `owner` is what
|
|
39
|
+
* a personal repository's owner is answered with (measured, see the header).
|
|
35
40
|
*/
|
|
36
|
-
const WRITE_PERMISSIONS = new Set(["admin", "write"]);
|
|
41
|
+
const WRITE_PERMISSIONS = new Set(["owner", "admin", "write"]);
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
44
|
+
* What a 403 tells the operator to fix, by which variable's token asked. Fixed strings, never the body.
|
|
45
|
+
* With the dedicated lookup token, its account is the one that must be an admin. With the fallback, the right repair
|
|
46
|
+
* is NOT to make the job's account an admin (that hands every job a token able to lift branch protection, OQ-043) but
|
|
47
|
+
* to give the lookup its own token.
|
|
48
|
+
*/
|
|
49
|
+
const FORBIDDEN_REASON = Object.freeze({
|
|
50
|
+
FORGEJO_PERMISSION_TOKEN: "collaborator permission lookup returned 403 (the account behind FORGEJO_PERMISSION_TOKEN must be an admin or the owner of the repository, and the token must carry read:repository)",
|
|
51
|
+
FORGEJO_TOKEN: "collaborator permission lookup returned 403 (FORGEJO_TOKEN's account is not an admin of the repository: set FORGEJO_PERMISSION_TOKEN to a read:repository token of an account that is, docs/forgejo.md)",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the resolver. `token` is the lookup token (FORGEJO_PERMISSION_TOKEN, or FORGEJO_TOKEN as the fallback) and
|
|
56
|
+
* `tokenVar` names which, for a 403's reason; `fetchFn` is injected so the whole module is testable offline.
|
|
41
57
|
*
|
|
42
58
|
* Returns `resolveAuthority(repoFullName, login)` -> `{ authorized: boolean }` | `{ indeterminate: string }`
|
|
43
59
|
* -- the same shape every forge's resolver returns.
|
|
44
60
|
*/
|
|
45
|
-
export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch }) {
|
|
61
|
+
export function makeResolveForgejoAuthority({ apiUrl, token, tokenVar = "FORGEJO_TOKEN", fetchFn = fetch }) {
|
|
62
|
+
const forbidden = FORBIDDEN_REASON[tokenVar] ?? FORBIDDEN_REASON.FORGEJO_TOKEN;
|
|
46
63
|
return async function resolveAuthority(repoFullName, login) {
|
|
47
64
|
// Both halves have to be present AND well-formed before they become path segments. A slash or a `..`
|
|
48
65
|
// in either would reach a different endpoint than the one this function believes it is asking, and
|
|
@@ -68,6 +85,23 @@ export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch })
|
|
|
68
85
|
// a refusal rather than a failure.
|
|
69
86
|
return { authorized: false };
|
|
70
87
|
}
|
|
88
|
+
if (res.status === 403) {
|
|
89
|
+
// A CONFIGURATION error, named as one. Forgejo lets only a site admin, a repository admin, or the
|
|
90
|
+
// user themselves read a permission (measured on 14.0.5: a `write` collaborator's token gets 403
|
|
91
|
+
// {"message":"Only admins can query all permissions, repo admins can query all repo permissions,
|
|
92
|
+
// collaborators can query only their own"}; the same account raised to repo admin gets 200). The
|
|
93
|
+
// other 403s this route can give are the token's scope (`token does not have at least one of
|
|
94
|
+
// required scope(s)`, `token scope is limited to public repos`), so the dedicated token's reason names its scope too.
|
|
95
|
+
// Still indeterminate, and that is a decision, not an oversight. A replay cannot heal it, but the
|
|
96
|
+
// replay costs nothing (no token is minted and nothing spends before this answer), while the
|
|
97
|
+
// alternative is worse in the way this file's header names: a determinate refusal answers 204, which
|
|
98
|
+
// Forgejo records as a successful delivery, so the one place the operator looks would show every
|
|
99
|
+
// trigger "delivered" while nothing ran. A 503 marks each delivery failed there, the log line below
|
|
100
|
+
// names the repair, and a delivery that failed this way still describes real work to redo once the
|
|
101
|
+
// account is fixed. The body is still not read: the fixed reason is enough, and a body is not ours
|
|
102
|
+
// to log. The reason names the variable whose token asked (FORBIDDEN_REASON).
|
|
103
|
+
return { indeterminate: forbidden };
|
|
104
|
+
}
|
|
71
105
|
if (!res.ok) {
|
|
72
106
|
// Note what is NOT here: the response body. A Forgejo error body can echo the request, and the
|
|
73
107
|
// request carried the token.
|
package/src/github-members.mjs
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* payload-only and lookup-free, and an unauthenticated flood cannot make this project call GitHub;
|
|
21
21
|
* - it returns a two-armed verdict, because "not a collaborator" and "could not tell" are different
|
|
22
22
|
* answers. A 404 is determinate and refuses; anything unrecognised is INDETERMINATE and the receiver
|
|
23
|
-
* answers 503
|
|
23
|
+
* answers 503. GitHub never resends that on its own (receiver.mjs, WHAT A 503 MEANS): a redelivery by
|
|
24
|
+
* hand within 3 days keeps the `X-GitHub-Delivery` GUID, so it queues at most once.
|
|
24
25
|
* Collapsing indeterminate to "deny" would drop real work during an outage behind a 204 that looks
|
|
25
26
|
* exactly like a stranger being correctly refused.
|
|
26
27
|
*
|
package/src/gitlab-members.mjs
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* API calls on an attacker's behalf.
|
|
14
14
|
* - It distinguishes "not a member" from "could not tell", and those are different answers. A 404 is
|
|
15
15
|
* determinate and yields `authorized: false`, which the gate refuses. Anything else -- a 5xx, a dead
|
|
16
|
-
* socket, a revoked token -- is INDETERMINATE, and the receiver answers 503
|
|
16
|
+
* socket, a revoked token -- is INDETERMINATE, and the receiver answers 503. GitLab never resends that on
|
|
17
|
+
* its own (receiver.mjs, WHAT A 503 MEANS); a "Resend Request" keeps the id, so it queues at most once.
|
|
17
18
|
* Collapsing indeterminate to "deny" would silently drop legitimate work during an outage, with a 204
|
|
18
19
|
* that reads exactly like a correctly-refused stranger.
|
|
19
20
|
*
|