@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/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
- if (!resolved.enqueue) return resolved; // carries the drop reason
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
- // (3) Build the job from the INT-WEBHOOK-PAYLOAD-SUBSET fields only. No sender.login (not in the
125
- // subset), provider/model/maxTurns only when the matched rule named them (absent, the worker fills the
126
- // deployment default, #502), no field outside the subset --
127
- // with two trigger-context additions (issue #49): `matched` is harness-computed, the filter's own
128
- // decision record naming the triggers.json entry that fired (not payload data at all); `comment`,
129
- // present only on the comment route, carries the invoking comment's body/author_association, both
130
- // fields INT-WEBHOOK-PAYLOAD-SUBSET already names.
131
- //
132
- // `packages` and `image` are harness-computed EXECUTION knobs read off the matched triggers.json entry
133
- // (like `matched`), never payload data -- REQ-GLOBAL-PI-OVERLAY's per-trigger control over loading the
134
- // operator-staged pi packages, and INT-TRIGGERS-FILE-CONTRACT's per-trigger container image. Both sit at
135
- // JOB level, NOT inside `trigger`: `trigger` is the descriptive context object carried verbatim into
136
- // /job/event.json (INT-CONTAINER-JOB-INPUTS) and must stay descriptive, so an execution switch has no
137
- // business there. Both are omitted entirely when the matched rule did not set them, so an unflagged
138
- // trigger's job literal is byte-identical to today's.
139
- const job = {
140
- repo: subset.repository?.full_name,
141
- target: resolved.target,
142
- // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
143
- // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
144
- // resolving a flow, and its job must carry NO flow key at all -- a present-and-undefined flow would
145
- // change the enqueued bytes for every consumer that serializes the job. The spread keeps a flow
146
- // rule's literal byte-identical to what it always was.
147
- ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
148
- ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
149
- ...(resolved.image !== undefined ? { image: resolved.image } : {}),
150
- // #227. A SEPARATE spread, and this is the whole of the bug it replaces: folded into the `image`
151
- // conditional above, a trigger that named a venue but no image had its venue silently dropped here --
152
- // it loaded, validated, reached this function on the rule, and then ran on the default. That is
153
- // exactly the destructive absence `validateBackend`'s near-miss sweep refuses a misspelling for,
154
- // arriving through the plumbing instead of the spelling, one file downstream of the guard.
155
- ...(resolved.backend !== undefined ? { backend: resolved.backend } : {}),
156
- // #291. A separate spread, the backend line's rule one field over: folded into a neighbour's
157
- // conditional, a trigger that named only exclusions would have them silently dropped here -- and a
158
- // dropped exclusion runs the job WITH the tool, the destructive absence the loader's own sweep
159
- // refuses a misspelling for, arriving through the plumbing instead of the spelling.
160
- ...(resolved.excludeTools !== undefined ? { excludeTools: resolved.excludeTools } : {}),
161
- // REQ-MCP-OPERATOR-SERVERS. Its own spread, the excludeTools rule above: a dropped list runs
162
- // the job without its servers.
163
- ...(resolved.mcp !== undefined ? { mcp: resolved.mcp } : {}),
164
- // #502. One spread PER FIELD, the excludeTools rule above: folded into one conditional, a trigger
165
- // that named only a model would have its provider's absence decide whether the model survives,
166
- // and a dropped model runs the job on the deployment default while the file reads as chosen.
167
- ...(resolved.provider !== undefined ? { provider: resolved.provider } : {}),
168
- ...(resolved.model !== undefined ? { model: resolved.model } : {}),
169
- ...(resolved.maxTurns !== undefined ? { maxTurns: resolved.maxTurns } : {}),
170
- ...(resolved.models !== undefined ? { models: resolved.models } : {}),
171
- // #501. Its own spread, the rule above: a dropped cap runs the job under the deployment's cap, or none,
172
- // while the file reads as narrowed.
173
- ...(resolved.maxCostUsd !== undefined ? { maxCostUsd: resolved.maxCostUsd } : {}),
174
- // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
175
- // NEVER inside `trigger`. That placement is sharpest here of all: `trigger` is carried into
176
- // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
177
- // basename(folder) restraint already exists to prevent.
178
- ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
179
- // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
180
- // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
181
- // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
182
- ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
183
- ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
184
- ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
185
- ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
186
- // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
187
- // byte-identical to today's, so the key is absent rather than present-and-undefined.
188
- ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
189
- // REQ-AWAIT-INPUT (issue #625): whether the job may end awaiting a person's answer. Present only when armed,
190
- // resume's rule, so an unflagged job's data stays byte-identical.
191
- ...(resolved.awaitInput === true ? { awaitInput: true } : {}),
192
- // A phrase-free reply to a parked issue (issue #625): the worker gates it on the `parked` sidecar before any spend.
193
- ...(resolved.parkedReply === true ? { parkedReply: true } : {}),
194
- // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level like the rest, never
195
- // inside `trigger`, and for the sharpest version of that reason: this is the caller's fanout count --
196
- // receiver.mjs reads it to decide how many times to enqueue -- and a descriptive context object copied
197
- // into /job/event.json is no place for the number of jobs to create.
198
- ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
199
- trigger: {
200
- event: eventName,
201
- action,
202
- deliveryId,
203
- sender: { id: subset.sender.id },
204
- matched: resolved.matched,
205
- ...(resolved.comment ? { comment: resolved.comment } : {}),
206
- // The invoking review, present only on the review route (issue #66), sibling of `comment` and
207
- // carried for the same reason: without it a "Request changes: rename the helper" review starts a
208
- // job that cannot know what was asked. All four fields are named by INT-WEBHOOK-PAYLOAD-SUBSET and
209
- // the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
210
- //
211
- // NOTE the pair above: `event` is `pull_request_review` and `action` is the raw `submitted`,
212
- // byte-for-byte what GitHub sent. `matched.action` is the canonical `review_submitted`, because
213
- // `matched` names the triggers.json entry that fired rather than the payload. This is the first
214
- // GitHub case where the two differ, and INT-CONTAINER-JOB-INPUTS says so.
215
- ...(resolved.review ? { review: resolved.review } : {}),
216
- },
217
- };
218
- return { enqueue: true, job };
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 parkedReply = !phraseHit && typeof phrase === "string" && typeof body === "string" && triggers.comment?.resume === true && triggers.comment?.command === undefined && subset.issue?.pull_request !== true && hasParkedLabel(subset.issue?.labels);
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 (so: redeliver, retry, loop) traffic the filter would only ever drop as
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) {
@@ -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 analogue of
8
- * `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the property
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, so Forgejo redelivers and the stable `X-GitHub-Delivery` GUID dedups the retry. Collapsing
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
- * Build the resolver. `token` is the same operator-supplied access token the worker uses; `fetchFn` is
40
- * injected so the whole module is testable offline.
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.
@@ -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, so GitHub redelivers and the stable `X-GitHub-Delivery` GUID dedups the retry.
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
  *
@@ -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 so GitLab redelivers.
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
  *