@edgehero/pi-dispatch 4.3.0 → 4.3.1

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 CHANGED
@@ -466,8 +466,17 @@ FORGEJO_URL=
466
466
  # A REPOSITORY-scoped token ("Specific repositories") carrying only write:repository and write:issue.
467
467
  # Narrower than GitLab's equivalent -- Forgejo has no all-or-nothing `api` scope. What it cannot do is
468
468
  # expire: there is no App or installation token, so rotation is the whole mitigation
469
- # (CONST-TOKEN-SCOPED-PER-JOB).
469
+ # (CONST-TOKEN-SCOPED-PER-JOB). Every forgejo job carries this token, so its account should be a WRITE
470
+ # collaborator of each repository, never an admin (an admin's write:repository token can change settings
471
+ # and branch protection: OQ-043).
470
472
  FORGEJO_TOKEN=
473
+ # The author gate's own lookup token, read ONLY by the receiver and never handed to a job (PI_FORWARD_ENV and
474
+ # run.secrets refuse it). Forgejo answers a lookup of another user's repository permission only to a site
475
+ # admin or a repository admin (a write collaborator gets 403, and then no trigger ever runs), so mint this
476
+ # from an account that IS an admin (or the owner) of each repository, scoped read:repository and nothing
477
+ # else. Unset, the lookup uses FORGEJO_TOKEN, which then has to belong to an admin: doctor warns.
478
+ # docs/forgejo.md walks it.
479
+ FORGEJO_PERMISSION_TOKEN=
471
480
  # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
472
481
  # FORGEJO_AUTH_SOURCE=
473
482
  # The harness account's NUMERIC id. Required when the token above is repository-scoped, because such a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch worker and CLI: runs the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
6
6
  "keywords": [
package/src/config.mjs CHANGED
@@ -10,7 +10,7 @@ import { homedir, hostname, tmpdir } from "node:os";
10
10
  import { delimiter, isAbsolute, posix } from "node:path";
11
11
  import { DEFAULT_BACKEND, backendRefusals, parseBackendFloor, parseBackendList } from "./backends.mjs";
12
12
  import { egressArmed, egressProxyName } from "./egress.mjs";
13
- import { MINTED_TOKEN_VARS } from "./forges.mjs";
13
+ import { MINTED_TOKEN_VARS, RECEIVER_ONLY_VARS } from "./forges.mjs";
14
14
  import { SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS } from "./retention-sweep.mjs";
15
15
  import { parseSecretProfiles } from "./secret-profiles.mjs";
16
16
  import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, parseWaitProfiles } from "./wait-for.mjs";
@@ -178,6 +178,14 @@ function forwardEnvList(raw, egressArmed = false) {
178
178
  `PI_FORWARD_ENV must not forward ${minted.join(", ")} -- the worker mints a per-job token (CONST-TOKEN-SCOPED-PER-JOB) and a forwarded operator token would silently override it`,
179
179
  );
180
180
  }
181
+ // OQ-043: a receiver-only credential (FORGEJO_PERMISSION_TOKEN) is an ADMIN account's token, kept apart from the job's
182
+ // precisely so no job holds it. Forwarding it would undo that with one config line.
183
+ const receiverOnly = names.filter((n) => RECEIVER_ONLY_VARS.has(n));
184
+ if (receiverOnly.length > 0) {
185
+ throw configError(
186
+ `PI_FORWARD_ENV must not forward ${receiverOnly.join(", ")} -- only the receiver reads it, for the author gate's permission lookup, and it belongs to an account that is an admin of the repositories, which is exactly what a job must not hold (OQ-043)`,
187
+ );
188
+ }
181
189
  const workerOnly = names.filter((n) => WORKER_ONLY_SECRET_VARS.has(n));
182
190
  if (workerOnly.length > 0) {
183
191
  throw configError(
package/src/doctor.mjs CHANGED
@@ -604,7 +604,7 @@ export const GITHUB_SERVICE_KEYS = Object.freeze(["GITHUB_AUTH_SOURCE", "GITHUB_
604
604
  * `.env`. TEMP is TMPDIR's twin in the worker's temp root; PI_CODING_AGENT_DIR is where the worker reads auth.json. */
605
605
  export const WORKER_SERVICE_KEYS = Object.freeze(["PI_JOB_IMAGE", "PI_JOB_MEMORY", "PI_JOB_CPUS", "PI_CONCURRENCY", ...Object.values(HOST_BUDGET_KEYS), "PI_TRIGGERS_FILE", "PI_LOGS_DIR", "PI_LOG_RETENTION_DAYS", "PI_SETTINGS_FILE", "PI_SESSIONS_DIR", "PI_SESSIONS_TTL_DAYS", "PI_SESSION_MAX_AGE_DAYS", "PI_SESSION_MAX_CONTEXT_PCT", "PI_SESSION_MAX_RESUME_CHAIN", "PI_GLOBAL_PI_DIR", "PI_GLOBAL_ALLOW_EXTENSIONS", "PI_FORWARD_ENV", "PI_AUTH_FROM_PI", "PI_CODING_AGENT_DIR", "PI_BACKEND_FLOOR", "PI_SECRET_PROFILES", "PI_SECRET_RESOLVER_ROOTS", "PI_WAIT_PROFILES", "PI_WAIT_AFTER_MAX_MS", "PI_SANDBOX_RETENTION_HOURS", "PI_ALLOWED_MODELS", "PI_DISPATCH_RUN_ROOTS", "GITHUB_PAT_VAR", "TEMP", ...Object.values(DOLLAR_ENV_NAMES)]);
606
606
  /** Issue #471: the receiver's keys doctor judges its boot by (the receiver's unit reads the same `.env`). */
607
- export const RECEIVER_SERVICE_KEYS = Object.freeze(["WEBHOOK_SECRET", "RECEIVER_PORT", "GITLAB_TOKEN", "GITLAB_URL", "GITLAB_WEBHOOK_MODE", "GITLAB_WEBHOOK_SECRET", "FORGEJO_URL", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_ORG_URL", "AZURE_TOKEN", "AZURE_WEBHOOK_MODE", "AZURE_WEBHOOK_SECRET", "AZURE_WEBHOOK_HEADER"]);
607
+ export const RECEIVER_SERVICE_KEYS = Object.freeze(["WEBHOOK_SECRET", "RECEIVER_PORT", "GITLAB_TOKEN", "GITLAB_URL", "GITLAB_WEBHOOK_MODE", "GITLAB_WEBHOOK_SECRET", "FORGEJO_URL", "FORGEJO_TOKEN", "FORGEJO_PERMISSION_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_ORG_URL", "AZURE_TOKEN", "AZURE_WEBHOOK_MODE", "AZURE_WEBHOOK_SECRET", "AZURE_WEBHOOK_HEADER"]);
608
608
  /**
609
609
  * Issue #471 (follow-up): the variables the CLIs doctor spawns read from their environment, by the CLI that reads them.
610
610
  * The worker starts podman, docker and gh with its OWN environment (no `env` option: `image-preflight.mjs`,
@@ -727,7 +727,7 @@ export const DOCTOR_SHELL_KEYS = Object.freeze({
727
727
 
728
728
  /** Issue #471: the service keys whose values a disagreement line never prints (set or unset only); URLs print as
729
729
  * `urlShown` does. Every other key's two values are shown, quoted. */
730
- const SECRET_SERVICE_KEYS = new Set([VALKEY_PASSWORD_KEY, "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "WEBHOOK_SECRET", "GITLAB_TOKEN", "GITLAB_WEBHOOK_SECRET", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_TOKEN", "AZURE_WEBHOOK_SECRET"]);
730
+ const SECRET_SERVICE_KEYS = new Set([VALKEY_PASSWORD_KEY, "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "WEBHOOK_SECRET", "GITLAB_TOKEN", "GITLAB_WEBHOOK_SECRET", "FORGEJO_TOKEN", "FORGEJO_PERMISSION_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_TOKEN", "AZURE_WEBHOOK_SECRET"]);
731
731
  const URL_SERVICE_KEYS = new Set(["VALKEY_URL", "GITLAB_URL", "FORGEJO_URL", "AZURE_ORG_URL"]);
732
732
 
733
733
  /** `keys` narrowed to `SERVICE_ENV_KEYS` plus the provider's own key variables. */
@@ -830,6 +830,7 @@ export const EMPTY_READ_AS_UNSET = Object.freeze({
830
830
  GITLAB_WEBHOOK_SECRET: "empty",
831
831
  FORGEJO_URL: "empty",
832
832
  FORGEJO_TOKEN: "empty",
833
+ FORGEJO_PERMISSION_TOKEN: "blank",
833
834
  FORGEJO_WEBHOOK_SECRET: "empty",
834
835
  AZURE_ORG_URL: "empty",
835
836
  AZURE_TOKEN: "empty",
@@ -2005,6 +2006,27 @@ export async function collectChecks(shellVars, seams) {
2005
2006
  } else {
2006
2007
  checks.push({ ok: true, label: `forgejo triggers configured (${urlShown(env.FORGEJO_URL)})` });
2007
2008
  }
2009
+ // Measured on Forgejo 14.0.5: the author gate's permission lookup answers 403 unless the token's account is a site
2010
+ // admin, a repository admin, or the actor themselves. Which token asks is the receiver's choice (OQ-043):
2011
+ // FORGEJO_PERMISSION_TOKEN when set, a read:repository token of an admin account that no job ever holds; else
2012
+ // FORGEJO_TOKEN, every job's token, whose account must then be an admin. Doctor cannot ask Forgejo either way
2013
+ // (forgejo triggers name no repository, each delivery does), so it says which token is in use: a FACT LINE when the
2014
+ // dedicated one is set, with the requirement in the label (issue #462's form), and a WARNING on the fallback,
2015
+ // because the fallback is the configuration that puts an admin's token into every job.
2016
+ const permissionToken = typeof env.FORGEJO_PERMISSION_TOKEN === "string" && env.FORGEJO_PERMISSION_TOKEN.trim() !== "";
2017
+ if (permissionToken) {
2018
+ checks.push({
2019
+ ok: true,
2020
+ label: "forgejo author-gate lookups use FORGEJO_PERMISSION_TOKEN: its account must be an admin (or the owner) of every repository the triggers serve and the token needs read:repository only, while FORGEJO_TOKEN, which every job carries, stays a write collaborator's (docs/forgejo.md)",
2021
+ });
2022
+ } else {
2023
+ checks.push({
2024
+ ok: false,
2025
+ warn: true,
2026
+ label: "FORGEJO_PERMISSION_TOKEN is unset, so forgejo author-gate lookups use FORGEJO_TOKEN: Forgejo answers them only to a repository admin, so that account must be one, and then every forgejo job carries an admin's token that can change repository settings and branch protection (OQ-043)",
2027
+ fix: "keep FORGEJO_TOKEN's account a write collaborator, and set FORGEJO_PERMISSION_TOKEN to a read:repository token of an account that is an admin (or the owner) of the repositories; only the receiver reads it (docs/forgejo.md)",
2028
+ });
2029
+ }
2008
2030
  }
2009
2031
 
2010
2032
  // Issue #508: with the egress policy on, a job reaches a forge only over https on port 443.
@@ -30,7 +30,7 @@ import { join } from "node:path";
30
30
  import { findEnvKeys } from "@earendil-works/pi-ai/compat";
31
31
  import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
32
32
  import { egressEnv } from "./egress.mjs";
33
- import { forgeSpec } from "./forges.mjs";
33
+ import { RECEIVER_ONLY_VARS, forgeSpec } from "./forges.mjs";
34
34
  import { apiKeyVariable } from "./provider-key.mjs";
35
35
  import { isDeterminateFsCode } from "./transient.mjs";
36
36
  import { KEYLESS_HOW, keylessVerdict, providerRenameHint, readOverlayModels, RENAMED_PROVIDERS } from "./model-endpoints.mjs";
@@ -558,7 +558,7 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
558
558
  delete env.PI_DISPATCH_PRICE_TABLE;
559
559
 
560
560
  // Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
561
- // no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into BOTH of its forge's variables because
561
+ // no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into EVERY one of its forge's variables because
562
562
  // each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
563
563
  // -- and mirroring forecloses any precedence surprise inside the container.
564
564
  //
@@ -583,8 +583,12 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
583
583
  }
584
584
  for (const name of spec.tokenVars) env[name] = githubToken;
585
585
  const host = forgeHosts?.[forgeKind];
586
- if (spec.hostVar && host) env[spec.hostVar] = host;
586
+ if (host) for (const name of spec.hostVars) env[name] = host;
587
587
  }
588
588
 
589
+ // OQ-043: the receiver's own lookup credential (FORGEJO_PERMISSION_TOKEN) is an admin account's token and never a
590
+ // job's. PI_FORWARD_ENV and run.secrets both refuse it upstream; this is the line that holds if either is bypassed.
591
+ for (const name of RECEIVER_ONLY_VARS) delete env[name];
592
+
589
593
  return env;
590
594
  }
@@ -16,6 +16,10 @@
16
16
  * - host-held YES; it lives in the worker's env and reaches a container only as an env value.
17
17
  * - env-injected YES; never written to /workspace, .git/config, argv, or a log.
18
18
  * - not merge-capable YES in practice; branch protection is the barrier and no merge API is called.
19
+ * Provided this account stays a WRITE collaborator: the author gate's lookup needs a
20
+ * repository admin, and asks with the receiver's own FORGEJO_PERMISSION_TOKEN for that.
21
+ * Without it the lookup falls back to this token, whose account must then be an admin,
22
+ * and an admin's write:repository token can lift that protection (OQ-043).
19
23
  * - short-lived NO. The operator mints it by hand. Forgejo offers no installation-token
20
24
  * equivalent, so there is no path to a bounded expiry at all.
21
25
  *
@@ -4,17 +4,25 @@
4
4
  *
5
5
  * BRANCH PROTECTION IS THE REASON TO READ THIS FILE. `github-host.mjs` treats a 404 from
6
6
  * `/repos/{o}/{r}/branches/{b}/protection` as "not protected", which is correct on GitHub, where that
7
- * endpoint exists and 404 means "no protection on this branch". Forgejo has no such endpoint at all -- it
8
- * uses `/branch_protections`, a different path with a different shape -- so carrying GitHub's assumption
9
- * across would make EVERY branch report unprotected and silently disarm the backstop that stops the agent
10
- * pushing to a protected branch. Issue #61 records this, and `REQ-BRANCH-PROTECTION-PRECONDITION` already
11
- * cites it by number as the failure that ordering exists to avoid.
7
+ * endpoint exists and 404 means "no protection on this branch". Forgejo has no such endpoint at all, so
8
+ * carrying GitHub's assumption across would make EVERY branch report unprotected. Issue #61 records this, and
9
+ * `REQ-BRANCH-PROTECTION-PRECONDITION` already cites it by number as the failure that ordering exists to avoid.
12
10
  *
13
- * And the fix is not simply "call the other endpoint". Forgejo's rules are GLOB patterns: a rule named
14
- * `release/*` protects `release/1.0` while `GET /branch_protections/release/1.0` returns 404. So this
15
- * LISTS the rules and matches each pattern against the branch, exactly as the GitLab host does -- and it
16
- * reuses that host's `matchesBranch` rather than writing a second globber, because two implementations of
17
- * "which branches does this rule cover" is two chances to widen it.
11
+ * The answer is the branch object's own `protected` field, from `GET /repos/{o}/{r}/branches/{branch}`. Forgejo
12
+ * computes it with the GLOB match itself: at v14.0.5 `GetBranch` (routers/api/v1/repo/branch.go) calls
13
+ * `GetFirstMatchProtectedBranchRule`, which loads every rule of the repository and returns the first whose
14
+ * `Match` covers the branch (models/git/protected_branch_list.go, protected_branch.go: a plain name compared
15
+ * case-insensitively, anything else as a glob), and `convert.ToBranch` sets `protected: true` exactly when a rule
16
+ * matched. So a `release/*` rule protects `release/1.0` here as it does in the forge. The route needs only read
17
+ * access to the code (`reqRepoReader(unit.TypeCode)`), which the job's `write` collaborator token has.
18
+ *
19
+ * This used to LIST `/branch_protections` and glob-match the rules here (reusing the GitLab host's
20
+ * `matchesBranch`). That listing is an admin route (`reqAdmin()`), so a job token of a `write` collaborator got
21
+ * 403 `{"message":"user should be an owner or a collaborator with admin write of a repository"}` (measured on
22
+ * Forgejo 14.0.5) and every job failed before spending: the check had quietly required an admin's token, which
23
+ * OQ-043 exists to keep out of jobs. The `protected` field is older than everything the listing supported
24
+ * (Gitea 1.11, and Forgejo is a fork of 1.18 and later), so no fallback to the listing is kept: it could only
25
+ * ever answer for a token that should not be in the job.
18
26
  *
19
27
  * A non-2xx is retryable, never `false`. Collapsing an error into "unprotected" is the same fail-open in a
20
28
  * different costume.
@@ -24,11 +32,21 @@ import { configError } from "./config.mjs";
24
32
  import { PARKED_LABEL } from "./parked.mjs";
25
33
  import { InfraRetry } from "./processor.mjs";
26
34
  import { fetchFailureReason } from "./gitlab-identity.mjs";
27
- import { matchesBranch } from "./gitlab-host.mjs";
28
35
  import { isDeterminateFetchFailure } from "./transient.mjs";
29
36
 
30
37
  const API_PREFIX = "/api/v1";
31
38
 
39
+ /**
40
+ * A branch name as the tail of `/branches/*`. Forgejo routes that path with a wildcard (`m.Get("/*", repo.GetBranch)`),
41
+ * so a branch with a slash is sent with its slashes and each SEGMENT escaped, never as one `%2F`-joined token.
42
+ */
43
+ function branchPath(branch) {
44
+ return String(branch).split("/").map(encodeURIComponent).join("/");
45
+ }
46
+
47
+ /** How many pages of repository labels the parked marker's lookup reads before it gives up (50 a page at most). */
48
+ const MAX_LABEL_PAGES = 20;
49
+
32
50
  /** Build the host surface. Returns the same four methods every forge host exposes. */
33
51
  export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
34
52
  const root = `${String(apiUrl ?? "").replace(/\/+$/, "")}${API_PREFIX}`;
@@ -74,7 +92,7 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
74
92
  if (typeof branch !== "string" || branch === "") {
75
93
  throw new InfraRetry(`forgejo-host: ${owner}/${name} reported no default_branch`);
76
94
  }
77
- const info = await get(`/repos/${owner}/${name}/branches/${encodeURIComponent(branch)}`, token);
95
+ const info = await get(`/repos/${owner}/${name}/branches/${branchPath(branch)}`, token);
78
96
  // Gitea's Branch carries its tip as `commit.id`; some versions and forks also expose `commit.sha`.
79
97
  // Both are read, and neither being a string is a hard failure rather than an undefined SHA -- a job
80
98
  // that cannot name the commit it is standing on must not proceed to clone something else.
@@ -86,24 +104,22 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
86
104
  }
87
105
 
88
106
  /**
89
- * Whether the default branch is covered by any protection rule.
107
+ * Whether the default branch is covered by any protection rule, as Forgejo itself decides it: the branch
108
+ * object's `protected` (see the module header for why this endpoint, and not the rule listing).
90
109
  *
91
- * See the module header: this is the endpoint and the glob matching that GitHub's 404 rule would have
92
- * got wrong in two independent ways.
110
+ * A non-2xx (a missing branch's 404 included) is retryable through `get`, never `false`, and so is a body whose
111
+ * `protected` is not a boolean: an instance that stops sending the field must not read as a determinate answer.
93
112
  */
94
113
  async function isDefaultBranchProtected(ref, token) {
95
114
  const [owner, name] = splitRepo(ref);
96
115
  const repo = await get(`/repos/${owner}/${name}`, token);
97
116
  const branch = repo?.default_branch;
98
117
  if (typeof branch !== "string" || branch === "") return false;
99
- const rules = await get(`/repos/${owner}/${name}/branch_protections`, token);
100
- if (!Array.isArray(rules)) {
101
- throw new InfraRetry(`forgejo-host: ${owner}/${name} branch_protections returned a non-array`);
118
+ const info = await get(`/repos/${owner}/${name}/branches/${branchPath(branch)}`, token);
119
+ if (typeof info?.protected !== "boolean") {
120
+ throw new InfraRetry(`forgejo-host: ${owner}/${name} branch ${branch} reported no boolean protected`);
102
121
  }
103
- // `rule_name` is the current field; `branch_name` is its deprecated predecessor and is still what
104
- // older instances send. Reading only the new one would report every branch on an older Forgejo as
105
- // unprotected -- the same class of silent fail-open this file exists to avoid.
106
- return rules.some((rule) => matchesBranch(rule?.rule_name ?? rule?.branch_name, branch));
122
+ return info.protected;
107
123
  }
108
124
 
109
125
  /**
@@ -155,10 +171,22 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
155
171
  }
156
172
 
157
173
  /**
158
- * Add or remove the parked marker on an issue (issue #625, parked.mjs). Forgejo adds and removes a
159
- * label by NAME (its IssueLabelsOption and the `{identifier}` path take a name or an id), but only one the repository
160
- * already has, so adding first creates the repository label; a label that already exists answers 409 or 422 there. The caller treats any throw as fail-open:
161
- * the session store's sidecar, not this label, decides whether a reply resumes anything.
174
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). The caller treats any throw as fail-open: the
175
+ * session store's sidecar, not this label, decides whether a reply resumes anything.
176
+ *
177
+ * BY ID, AFTER A LOOKUP, because Forgejo does not refuse a duplicate label name. Measured on 14.0.5: a second
178
+ * `POST /repos/{o}/{r}/labels {"name":"pi-awaiting-input",...}` answers 201 and creates ANOTHER repository label of
179
+ * that name (the repository's labels became ai #1, pi-awaiting-input #2, pi-awaiting-input #3 after the second park);
180
+ * adding by name then attaches EVERY label of that name (the issue carried #2 and #3, two adds in its timeline); and
181
+ * removing by name took off only ONE (204, #3 stayed). So the marker outlived the resumed run for good, each park added
182
+ * a repository label, and every later plain reply was routed, to end as `not-parked`. The 409/422 this used to accept
183
+ * on the create never happens on Forgejo 14.
184
+ *
185
+ * Adding: list the repository's labels, create one only when none has the exact name, and attach the LOWEST id, so a
186
+ * repository that already holds duplicates stops growing them and the issue gets one marker. Removing: read the
187
+ * labels the issue actually carries and remove every one of that name by id, so an issue that holds two from before
188
+ * this fix is cleared too. The duplicate repository labels themselves are left alone: deleting a repository's labels
189
+ * is the operator's call, and a spare one carried by no issue routes nothing (docs/forgejo.md says how to tidy them).
162
190
  */
163
191
  async function setParkedLabel(ref, target, on, token) {
164
192
  const [owner, name] = splitRepo(ref);
@@ -175,14 +203,45 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
175
203
  throw new InfraRetry(`forgejo-host: ${method} ${path} failed (${fetchFailureReason(err)})`);
176
204
  }
177
205
  if (!res.ok && !accept.includes(res.status)) throw new InfraRetry(`forgejo-host: ${method} ${path} returned ${res.status}`);
206
+ return res;
178
207
  };
179
208
  const issue = `/repos/${owner}/${name}/issues/${encodeURIComponent(target?.number)}/labels`;
209
+ const markers = (labels) => (Array.isArray(labels) ? labels : []).filter((l) => l?.name === PARKED_LABEL && Number.isSafeInteger(l?.id)).map((l) => l.id);
180
210
  if (on) {
181
- await send("POST", `/repos/${owner}/${name}/labels`, { name: PARKED_LABEL, color: "#fbca04", description: "pi-dispatch: the last run is waiting for an answer" }, [409, 422]);
182
- await send("POST", issue, { labels: [PARKED_LABEL] });
211
+ let ids = await repoLabelIds(owner, name, token, markers);
212
+ if (ids.length === 0) {
213
+ const res = await send("POST", `/repos/${owner}/${name}/labels`, { name: PARKED_LABEL, color: "#fbca04", description: "pi-dispatch: the last run is waiting for an answer" }, [409, 422]);
214
+ // A forge that DOES refuse the duplicate (409/422, a Gitea or a later Forgejo) raced another park to it: list
215
+ // again. A 201 hands back the label, so no second read is needed.
216
+ const created = res.ok ? await res.json().catch(() => null) : null;
217
+ ids = Number.isSafeInteger(created?.id) ? [created.id] : await repoLabelIds(owner, name, token, markers);
218
+ }
219
+ if (ids.length === 0) throw new InfraRetry(`forgejo-host: ${owner}/${name} has no ${PARKED_LABEL} label after creating it`);
220
+ await send("POST", issue, { labels: [Math.min(...ids)] });
183
221
  return;
184
222
  }
185
- await send("DELETE", `${issue}/${encodeURIComponent(PARKED_LABEL)}`, undefined, [404]);
223
+ // Already gone is success: 404 (no such issue) and, on the removal, 422 -- Forgejo's answer when no such label
224
+ // exists (measured on 14.0.5: {"message":"label does not exist [label_id: 0, repo_id: 1]"}). Matched on the status
225
+ // alone: in 14.0.5's DeleteIssueLabel the only 422 is "label does not exist", the binding's own 422 needs a request
226
+ // body and none is sent, and the message is free to change between versions. A stray 422 read as gone would leave
227
+ // a stale marker, which decides nothing.
228
+ const carried = markers(await get(issue, token, { notFound: [] }));
229
+ for (const id of carried) await send("DELETE", `${issue}/${id}`, undefined, [404, 422]);
230
+ }
231
+
232
+ /**
233
+ * The ids of every repository label named `PARKED_LABEL`, in id order. Paged until an EMPTY page rather than until a
234
+ * short one, because the instance caps the page size (`MAX_RESPONSE_ITEMS`) below what was asked for, and a short page
235
+ * is then every page. Bounded, so a server that never returns an empty page cannot hold a job's teardown.
236
+ */
237
+ async function repoLabelIds(owner, name, token, markers) {
238
+ const ids = [];
239
+ for (let page = 1; page <= MAX_LABEL_PAGES; page++) {
240
+ const labels = await get(`/repos/${owner}/${name}/labels?page=${page}&limit=50`, token);
241
+ if (!Array.isArray(labels) || labels.length === 0) return ids.sort((a, b) => a - b);
242
+ ids.push(...markers(labels));
243
+ }
244
+ throw new InfraRetry(`forgejo-host: ${owner}/${name} lists more than ${MAX_LABEL_PAGES} pages of labels`);
186
245
  }
187
246
 
188
247
  return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
package/src/forges.mjs CHANGED
@@ -37,8 +37,13 @@
37
37
  * names that must be refused in `PI_FORWARD_ENV`, and those two lists being derived from one entry is
38
38
  * the point: a forge added to the mint but not to the refusal set is a long-lived host token forwarded
39
39
  * into every container, and nothing would have said so.
40
- * - `hostVar` is where a self-hosted instance URL lands in the container, or `null` for a forge that has
41
- * no instance concept in this codebase yet.
40
+ * - `hostVars` are where a self-hosted instance URL lands in the container (the configured URL verbatim,
41
+ * scheme included), or an empty list for a forge that has no instance concept in this codebase yet. A
42
+ * LIST for the reason `tokenVars` is one: the CLI's own name is the one that matters, and a second,
43
+ * forge-named copy is what scripts and skills reach for.
44
+ * - `receiverOnlyVars` are credentials of this forge that only the RECEIVER reads, and that must never reach a
45
+ * job: refused in `PI_FORWARD_ENV`, reserved from `run.secrets`, and deleted from the container env as a
46
+ * backstop (`RECEIVER_ONLY_VARS`). Empty for every forge whose author gate the job's own token can answer.
42
47
  * - `prLabelAction` is this forge's `pull_request` action meaning "a label changed", or `null` where the
43
48
  * forge has no distinguishable one. It decides whether a rule naming that action must carry a positive
44
49
  * selector, and the reason is independent of who is gating: a rule keyed on labels that names no labels
@@ -51,7 +56,8 @@ export const FORGES = {
51
56
  deliveryIdName: "X-GitHub-Delivery GUID",
52
57
  pullRequestSep: "#",
53
58
  tokenVars: ["GITHUB_TOKEN", "GH_TOKEN"],
54
- hostVar: null,
59
+ hostVars: [],
60
+ receiverOnlyVars: [],
55
61
  prLabelAction: "labeled",
56
62
  },
57
63
  gitlab: {
@@ -59,7 +65,8 @@ export const FORGES = {
59
65
  deliveryIdName: "webhook-id / Idempotency-Key",
60
66
  pullRequestSep: "!",
61
67
  tokenVars: ["GITLAB_TOKEN", "GL_TOKEN"],
62
- hostVar: "GITLAB_HOST",
68
+ hostVars: ["GITLAB_HOST"],
69
+ receiverOnlyVars: [],
63
70
  // A label added to a merge request arrives as a plain `update`, indistinguishable from any other
64
71
  // edit, so there is no action for a positive-selector rule to attach to. (Separately, a GitLab label
65
72
  // is not an approval at all -- a Guest can set one at issue creation -- which is why every gitlab
@@ -76,9 +83,23 @@ export const FORGES = {
76
83
  // Forgejo numbers issues and pull requests from ONE per-repository sequence, exactly as GitHub does
77
84
  // -- an issue and a PR cannot share an index. So `#` names one thing and no discriminator is needed.
78
85
  pullRequestSep: "#",
79
- // `tea` reads GITEA_SERVER_TOKEN; the unprefixed name is what the API examples and most scripts use.
80
- tokenVars: ["FORGEJO_TOKEN", "GITEA_SERVER_TOKEN"],
81
- hostVar: "FORGEJO_HOST",
86
+ // `tea` logs in from the environment ONLY through GITEA_TOKEN plus GITEA_INSTANCE_URL. Measured on the
87
+ // pinned tea 0.15.0 in the 4.3.0 job image against a live Forgejo 14.0.5: that pair lists issues, while
88
+ // GITEA_SERVER_URL + GITEA_SERVER_TOKEN (with or without GITEA_LOGIN_VIA_ENV=1), FORGEJO_HOST +
89
+ // GITEA_SERVER_TOKEN, and GITEA_TOKEN alone inside a clone all fail "Error: no available login". So
90
+ // from be1cc42e until this row changed, every `tea` command the forgejo envelope tells the agent to run
91
+ // failed. GITEA_SERVER_TOKEN stays: it is the default of `tea login add --token` (its --help says
92
+ // `[$GITEA_SERVER_TOKEN]`), so an agent that adds a login explicitly still gets the job's token, and it
93
+ // stays reserved. FORGEJO_TOKEN is what the API examples and most scripts use.
94
+ // worker/test/forges.test.mjs pins these names to the tea version image/Dockerfile installs.
95
+ tokenVars: ["FORGEJO_TOKEN", "GITEA_TOKEN", "GITEA_SERVER_TOKEN"],
96
+ // GITEA_INSTANCE_URL is tea's, and must be the full URL with its scheme (FORGEJO_URL is). FORGEJO_HOST
97
+ // is read by no CLI; it is kept because it has been in every forgejo job since #61 and a skill may use it.
98
+ hostVars: ["GITEA_INSTANCE_URL", "FORGEJO_HOST"],
99
+ // The author gate's own lookup token (OQ-043): Forgejo answers a lookup of another user's permission only to a
100
+ // repository admin, so this is a read:repository token of an account that is one, and the job's FORGEJO_TOKEN
101
+ // can stay a write collaborator's. A job holding it would hold an admin's token, which is the whole point lost.
102
+ receiverOnlyVars: ["FORGEJO_PERMISSION_TOKEN"],
82
103
  // Forgejo names the action, so the positive-selector rule applies exactly as it does on GitHub.
83
104
  // Note this is HYGIENE, not the gate: unlike GitHub, every forgejo trigger is additionally gated on
84
105
  // the actor's resolved repository permission -- see filter-forgejo.mjs for why that is not
@@ -99,7 +120,8 @@ export const FORGES = {
99
120
  pullRequestSep: "!",
100
121
  // `az repos` reads AZURE_DEVOPS_EXT_PAT; SYSTEM_ACCESSTOKEN is what a pipeline-shaped script expects.
101
122
  tokenVars: ["AZURE_DEVOPS_EXT_PAT", "SYSTEM_ACCESSTOKEN"],
102
- hostVar: "AZURE_DEVOPS_ORG_URL",
123
+ hostVars: ["AZURE_DEVOPS_ORG_URL"],
124
+ receiverOnlyVars: [],
103
125
  // Azure attaches no labels to a pull request at all, so there is no action to attach the rule to and
104
126
  // a predicated PR rule could never match. The loader refuses one rather than letting it load dead.
105
127
  prLabelAction: null,
@@ -140,14 +162,20 @@ export const MINTED_TOKEN_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[ki
140
162
  * `MINTED_TOKEN_VARS` and derived the same way, from the table rather than by hand, so a forge added
141
163
  * later cannot be missed here either.
142
164
  *
143
- * Separate from `MINTED_TOKEN_VARS` because `hostVar` is a separate column: the token set is
165
+ * Separate from `MINTED_TOKEN_VARS` because `hostVars` is a separate column: the token set is
144
166
  * `tokenVars` only, so a name like `GITLAB_HOST` is in NEITHER set until this one exists. That gap is
145
167
  * not theoretical -- `buildContainerEnv` writes this variable after the mint, so a trigger field able
146
168
  * to name it would be silently overwritten, and the job would run without the value it asked for on a
147
- * clean exit 0. github's row is `null` and contributes nothing, which is why the filter is here and
148
- * not at the call site.
169
+ * clean exit 0. github's row is empty and contributes nothing.
149
170
  */
150
- export const FORGE_HOST_VARS = new Set(FORGE_KINDS.map((kind) => FORGES[kind].hostVar).filter((name) => typeof name === "string"));
171
+ export const FORGE_HOST_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].hostVars));
172
+
173
+ /**
174
+ * Every credential name only the receiver reads (`receiverOnlyVars`), derived from the table like the two sets above.
175
+ * The worker refuses each in `PI_FORWARD_ENV`, the triggers loader refuses each in `run.secrets`, and
176
+ * `buildContainerEnv` deletes each last, so no path puts one in a container.
177
+ */
178
+ export const RECEIVER_ONLY_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].receiverOnlyVars));
151
179
 
152
180
  /**
153
181
  * The separator between a repo label and a target number, for this forge and this target type: the
package/src/index.mjs CHANGED
@@ -22,6 +22,7 @@ import { BUDGET_RECHECK_MS, HOST_BUDGET_TICK_MS, NEVER_FITS_RECHECK_MS, makeHost
22
22
  import { governedDollars } from "./allocation.mjs";
23
23
  import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, afterMs, unreadableConditions, waitArmed, waitBackoffMs, waitLabel, waitProfileNames } from "./wait-for.mjs";
24
24
  import { makeWaitState } from "./wait-state.mjs";
25
+ import { releaseSemanticWindow } from "./queue.mjs";
25
26
 
26
27
 
27
28
  export const QUEUE = "pi-jobs";
@@ -1344,6 +1345,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1344
1345
  // mirroring the name/signal injection above. Omitted when unwired so a bare processor falls back
1345
1346
  // to runJob's no-op default (a chain fault can never flip a completed outcome either way).
1346
1347
  ...(deps.collectChain ? { collectChain: (ctx) => deps.collectChain({ ...ctx, job }) } : {}),
1348
+ // DES-AWAIT-INPUT-ENDS-THE-JOB: a parked run's semantic window is closed on the REAL BullMQ job, which holds the
1349
+ // dedup id the add stored and the compare-and-delete that frees only a key it still owns (queue.mjs). Bound
1350
+ // unconditionally, AFTER the `...deps` spread like the injections around it: whether the key may outlive a park
1351
+ // is not a choice a wiring gets to make by omission.
1352
+ releaseParkedWindow: () => releaseSemanticWindow(job, deps?.log),
1347
1353
  // The plan collector (issue #505) the same way and for the same reason: the writer it records is the REAL job's
1348
1354
  // `.id`, and the authority it checks is `.data` as queued. The pickup's `portfolio` decision rides in `ctx`.
1349
1355
  ...(deps.collectPlan ? { collectPlan: (ctx) => deps.collectPlan({ ...ctx, job }) } : {}),
package/src/processor.mjs CHANGED
@@ -302,6 +302,10 @@ export async function runJob(job, deps) {
302
302
  // (job, on) => Promise<void> (issue #625): add or remove the forge's parked marker. Never throws; the marker is a
303
303
  // hint for the receiver, and the sidecar decides. The default does nothing.
304
304
  parkedLabel = async () => {},
305
+ // () => Promise<unknown> (DES-AWAIT-INPUT-ENDS-THE-JOB): close this job's semantic dedup window once it has parked,
306
+ // so the answer is not swallowed against it. Takes no job: the key belongs to the REAL BullMQ job, which only the
307
+ // wiring holds (index.mjs binds queue.mjs's `releaseSemanticWindow` to it). Never throws. The default does nothing.
308
+ releaseParkedWindow = async () => {},
305
309
  // The session store's root, or null when the feature is unavailable (REQ-RESUMABLE-SESSION). Read
306
310
  // ONLY to answer the fail-closed gate below -- nothing here opens it, and it never reaches the
307
311
  // container env (docker-run.mjs mounts a per-job COPY, never the store).
@@ -1520,6 +1524,12 @@ export async function runJob(job, deps) {
1520
1524
  // job is ignored, since that runner was never offered the tool.
1521
1525
  const parked = exitReason === "awaiting-input" && job.awaitInput === true;
1522
1526
  const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context, parked }) : null;
1527
+ // The window closes AFTER the promotion (an answer that gets in must find the `parked` mark and the transcript)
1528
+ // and BEFORE the comment and the marker: the marker is what routes a phrase-free reply on an issue, and the
1529
+ // comment is what tells a pull request's author to reply, so no answer they invite can meet the old key. Every
1530
+ // park takes this path, a resumed run that parks again included. Guarded although the seam never throws: a
1531
+ // fault here must not cost the person the comment that says how to continue.
1532
+ if (parked) await Promise.resolve().then(releaseParkedWindow).catch(() => {});
1523
1533
  if (parked) await comment(job, parkedComment(job.target));
1524
1534
  // The forge marker the receiver routes a phrase-free reply on: set on a park, cleared when a run on a key that
1525
1535
  // could park ends without parking. Never throws, and decides nothing: the sidecar promotion just wrote does.
package/src/queue.mjs CHANGED
@@ -247,6 +247,38 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
247
247
  // window only needs to absorb burst re-labels, not the full redelivery window.
248
248
  const SEMANTIC_WINDOW_MS = 10 * 60 * 1000;
249
249
 
250
+ /**
251
+ * Close the semantic window of a run that PARKED (REQ-AWAIT-INPUT, DES-AWAIT-INPUT-ENDS-THE-JOB). Never throws.
252
+ *
253
+ * The window exists to coalesce rapid duplicate deliveries into one ACTIVE job. A ttl-mode key outlives the job it
254
+ * was set for (bullmq 5.80.4, `removeDeduplicationKeyIfNeededOnFinalization.lua`: a finished job deletes its key only
255
+ * when it has no ttl), so without this a person's answer that lands within ten minutes of the delivery that STARTED
256
+ * the parked run is swallowed against that finished run: the receiver answers `deduplicated`, nothing runs, and the
257
+ * marker stays. Once a run has parked, the next delivery on its key is a deliberate answer, never a duplicate.
258
+ *
259
+ * It asks the BullMQ job itself, rather than rebuilding the key from the job data: `job.deduplicationId` is the id
260
+ * the add stored with the job (`deid` in its hash), so there is no second spelling of the key format to drift from
261
+ * `enqueueForgeJob`'s, and it covers every suffix (the `closed:`/`cmd:` slot, a replica's `:r<n>`) by construction.
262
+ * And `job.removeDeduplicationKey()` is a compare-and-delete (`removeDeduplicationKey-1.lua`: DEL only when the key
263
+ * still holds THIS job's id), where `queue.removeDeduplicationKey(id)` is a bare DEL that could free a key a later
264
+ * job now holds.
265
+ *
266
+ * Fails open with a named line: a failure only leaves today's behaviour (the answer waits out the window).
267
+ * `released` is false, logged as a release and not as a failure, when the key is already gone (the window lapsed
268
+ * during a long run) or now names another job.
269
+ */
270
+ export async function releaseSemanticWindow(job, log = () => {}) {
271
+ if (!job?.deduplicationId || typeof job.removeDeduplicationKey !== "function") return false;
272
+ try {
273
+ const released = (await job.removeDeduplicationKey()) === true;
274
+ log("parked_window_released", { jobId: job.id ?? null, released });
275
+ return released;
276
+ } catch (error) {
277
+ log("parked_window_release_failed", { jobId: job.id ?? null, code: typeof error?.code === "string" ? error.code : "error" });
278
+ return false;
279
+ }
280
+ }
281
+
250
282
  /**
251
283
  * Enqueue a GitHub-triggered job. Returns `{ jobId, deduplicated, survivingJobId? }` -- the computed
252
284
  * per-delivery id, plus whether the SEMANTIC window swallowed this delivery (issue #289; the GUID
package/src/triggers.mjs CHANGED
@@ -30,7 +30,7 @@ import { imageRefProblem } from "./image-ref.mjs";
30
30
  // materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
31
31
  // traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
32
32
  import { SKILL_NAME_RE } from "./flow-gate.mjs";
33
- import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
33
+ import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RECEIVER_ONLY_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
34
34
  import { findDuplicateKey } from "./json-duplicates.mjs";
35
35
  // The server-name rule, from the one module that parses the overlay's list (its body is import-inert).
36
36
  import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
@@ -261,7 +261,7 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
261
261
  * Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
262
262
  * forge added to the table later must not need a second edit here to stay covered.
263
263
  */
264
- export const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...RUNNER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
264
+ export const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...RECEIVER_ONLY_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...RUNNER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
265
265
 
266
266
  function isNonEmptyString(value) {
267
267
  return typeof value === "string" && value.trim() !== "";