@edgehero/pi-dispatch 4.2.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
@@ -33,15 +33,16 @@ ANTHROPIC_API_KEY=
33
33
 
34
34
  # --- Which provider/model to run by default (override per job with --provider / --model) ---
35
35
  PI_PROVIDER=anthropic
36
- # A DATED model id is deterministic; a floating alias (e.g. claude-sonnet-4-5) can change cost.
37
- PI_MODEL=claude-sonnet-4-5-20250929
36
+ # Name an exact model version (claude-sonnet-5-5, or a dated id such as claude-sonnet-4-5-20250929); an alias
37
+ # that moves to a newer model can change cost.
38
+ PI_MODEL=claude-sonnet-5-5
38
39
  # optional: the only models a job may call, comma-separated provider/model (at most 16). A trigger's own
39
40
  # run.models replaces this list for its jobs. The default model must be on it, or every job that uses the
40
41
  # default is refused before it spends. Env only: the settings overlay cannot set or widen it.
41
42
  # Unset = any model. Needs a job image that declares the modelPolicy capability.
42
43
  # No spaces, not even after a comma: a shell that sources this file cuts the value at the first space
43
44
  # and leaves it unset, so jobs would run with no list (the worker refuses a spaced value at boot).
44
- # PI_ALLOWED_MODELS=anthropic/claude-sonnet-4-5-20250929,anthropic/claude-haiku-4-5-20251001
45
+ # PI_ALLOWED_MODELS=anthropic/claude-sonnet-5-5,anthropic/claude-haiku-5-5
45
46
 
46
47
  # --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
47
48
  # per-job turn cap -- pi has none of its own, so the harness imposes one
@@ -465,8 +466,17 @@ FORGEJO_URL=
465
466
  # A REPOSITORY-scoped token ("Specific repositories") carrying only write:repository and write:issue.
466
467
  # Narrower than GitLab's equivalent -- Forgejo has no all-or-nothing `api` scope. What it cannot do is
467
468
  # expire: there is no App or installation token, so rotation is the whole mitigation
468
- # (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).
469
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=
470
480
  # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
471
481
  # FORGEJO_AUTH_SOURCE=
472
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.2.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": [
@@ -55,6 +55,7 @@
55
55
  "./git-dirty": "./src/git-dirty.mjs",
56
56
  "./queue": "./src/queue.mjs",
57
57
  "./capabilities": "./src/capabilities.mjs",
58
+ "./parked": "./src/parked.mjs",
58
59
  "./connection": "./src/connection.mjs",
59
60
  "./valkey-auth": "./src/valkey-auth.mjs",
60
61
  "./job-id": "./src/job-id.mjs",
@@ -114,7 +115,7 @@
114
115
  "start": "node src/cli.mjs worker"
115
116
  },
116
117
  "dependencies": {
117
- "@earendil-works/pi-ai": "1.1.0",
118
+ "@earendil-works/pi-ai": "1.2.0",
118
119
  "@octokit/auth-app": "8.2.0",
119
120
  "@octokit/rest": "22.0.1",
120
121
  "bullmq": "5.80.4",
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { configError } from "./config.mjs";
25
+ import { PARKED_LABEL } from "./parked.mjs";
25
26
  import { InfraRetry } from "./processor.mjs";
26
27
  import { fetchFailureReason } from "./gitlab-identity.mjs";
27
28
  import { isDeterminateFetchFailure } from "./transient.mjs";
@@ -201,7 +202,33 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
201
202
  return { headRef: stripRefsHeads(pr?.sourceRefName), headRepo: sameRepo ? job?.repo : null };
202
203
  }
203
204
 
204
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
205
+ /**
206
+ * Add or remove the parked marker on a work item, as a TAG (issue #625, parked.mjs): Azure work items have
207
+ * tags, not labels, and the receiver reads `System.Tags` into the same `[{ name }]` shape. A tags field is written
208
+ * whole, so this reads it, edits the one tag and writes the result; Azure creates a tag the project lacks. The caller treats any throw as fail-open:
209
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
210
+ */
211
+ async function setParkedLabel(ref, target, on, token) {
212
+ const { project } = scopeOf(ref);
213
+ const path = `/${encodeURIComponent(project)}/_apis/wit/workitems/${encodeURIComponent(target?.number)}`;
214
+ const item = await get(`${path}?fields=System.Tags&api-version=${API_VERSION}`, token);
215
+ const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !== PARKED_LABEL);
216
+ const value = (on ? [...tags, PARKED_LABEL] : tags).join("; ");
217
+ let res;
218
+ try {
219
+ res = await fetchFn(`${root}${path}?api-version=${API_VERSION}`, {
220
+ method: "PATCH",
221
+ headers: { Authorization: authHeader(token), "content-type": "application/json-patch+json" },
222
+ body: JSON.stringify([{ op: "add", path: "/fields/System.Tags", value }]),
223
+ redirect: "error",
224
+ });
225
+ } catch (err) {
226
+ throw new InfraRetry(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
227
+ }
228
+ if (!res.ok) throw new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`);
229
+ }
230
+
231
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
205
232
  }
206
233
 
207
234
  /**
@@ -68,6 +68,31 @@ function prReplicaLines(replica, replicas) {
68
68
  ];
69
69
  }
70
70
 
71
+ /**
72
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
73
+ * request exist, so the words are the ones this envelope always had. On a work item neither is certain: the
74
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
75
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
76
+ * human who asked waits on the work item for a reply. So the issue form names the issue branch, checks for its pull
77
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
78
+ */
79
+ function resumedActionLines(target, n) {
80
+ if (target?.type === "pull_request") {
81
+ return [
82
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
83
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
84
+ "not. Do not open a second pull request -- your push updates the existing one.",
85
+ ];
86
+ }
87
+ const branch = issueBranch(n);
88
+ return [
89
+ "Address the activity quoted below, then reply on the work item saying what you did or why you could not.",
90
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
91
+ `branch only. Check first for an active pull request from it (\`az repos pr list --source-branch ${branch} --status active\`):`,
92
+ `if one exists your push updates it, so do not create another; only if none exists, run \`az repos pr create --source-branch ${branch}\`.`,
93
+ ];
94
+ }
95
+
71
96
  function buildResumedPrompt(flow, target, comment, instructions) {
72
97
  const n = normalizeNumber(target?.number);
73
98
  const noun = target?.type === "pull_request" ? "pull request" : "work item";
@@ -81,9 +106,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
81
106
  `\`az repos pr show --id ${n}\` (or \`az boards work-item show --id ${n}\`) before doing anything,`,
82
107
  `then follow the "${flow}" skill from the top.`,
83
108
  "",
84
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
85
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
86
- "not. Do not open a second pull request -- your push updates the existing one.",
109
+ ...resumedActionLines(target, n),
87
110
  "",
88
111
  // Above the never-merge paragraph, so the harness has the last word before the data region.
89
112
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
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";
@@ -31,7 +31,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsd
31
31
  */
32
32
  export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
33
33
  /** The model a job runs on when nothing names one (`PI_MODEL` unset): one copy, for `loadConfig` and doctor. */
34
- export const DEFAULT_MODEL = "claude-sonnet-4-5-20250929";
34
+ export const DEFAULT_MODEL = "claude-sonnet-5-5";
35
35
  /** The provider a job runs on when nothing names one (`PI_PROVIDER` unset). */
36
36
  export const DEFAULT_PROVIDER = "anthropic";
37
37
 
@@ -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(
@@ -394,7 +402,7 @@ export function globalExtensionsEnabled(env) {
394
402
  * spend caps above, the per-job token scoping, or the admin-extension recursion block.
395
403
  */
396
404
  export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
397
- const model = env.PI_MODEL ?? DEFAULT_MODEL; // dated snapshot; deterministic per CONST-PI-VERSION-PINNED
405
+ const model = env.PI_MODEL ?? DEFAULT_MODEL; // an exact model version, not a moving alias; deterministic per CONST-PI-VERSION-PINNED
398
406
  // #227. Hoisted above the object because `defaultBackend` INDEXES `backends`, and a property cannot read
399
407
  // a sibling of the literal it is in. (Calling the parser twice would be harmless -- `egressEnabled` is
400
408
  // called twice a few properties down for the same reason -- so this is about the index, not the throw.)
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";
@@ -367,7 +367,7 @@ function resolveEnvName(provider) {
367
367
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
368
368
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
369
369
  */
370
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
370
+ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
371
371
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
372
372
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
373
373
  // neither source yields one, which the processor turns into a policy refusal that refunds any reserve
@@ -433,6 +433,9 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
433
433
  // runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
434
434
  // admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
435
435
  PI_MCP_SERVERS: mcpServers.length > 0 ? mcpServers.join(",") : undefined,
436
+ // The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
437
+ // armed, never "0" or an empty string, for PI_PACKAGES' reason.
438
+ PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
436
439
  // The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
437
440
  // PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
438
441
  // (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
@@ -555,7 +558,7 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
555
558
  delete env.PI_DISPATCH_PRICE_TABLE;
556
559
 
557
560
  // Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
558
- // 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
559
562
  // each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
560
563
  // -- and mirroring forecloses any precedence surprise inside the container.
561
564
  //
@@ -580,8 +583,12 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
580
583
  }
581
584
  for (const name of spec.tokenVars) env[name] = githubToken;
582
585
  const host = forgeHosts?.[forgeKind];
583
- if (spec.hostVar && host) env[spec.hostVar] = host;
586
+ if (host) for (const name of spec.hostVars) env[name] = host;
584
587
  }
585
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
+
586
593
  return env;
587
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,30 +4,49 @@
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.
21
29
  */
22
30
 
23
31
  import { configError } from "./config.mjs";
32
+ import { PARKED_LABEL } from "./parked.mjs";
24
33
  import { InfraRetry } from "./processor.mjs";
25
34
  import { fetchFailureReason } from "./gitlab-identity.mjs";
26
- import { matchesBranch } from "./gitlab-host.mjs";
27
35
  import { isDeterminateFetchFailure } from "./transient.mjs";
28
36
 
29
37
  const API_PREFIX = "/api/v1";
30
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
+
31
50
  /** Build the host surface. Returns the same four methods every forge host exposes. */
32
51
  export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
33
52
  const root = `${String(apiUrl ?? "").replace(/\/+$/, "")}${API_PREFIX}`;
@@ -73,7 +92,7 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
73
92
  if (typeof branch !== "string" || branch === "") {
74
93
  throw new InfraRetry(`forgejo-host: ${owner}/${name} reported no default_branch`);
75
94
  }
76
- const info = await get(`/repos/${owner}/${name}/branches/${encodeURIComponent(branch)}`, token);
95
+ const info = await get(`/repos/${owner}/${name}/branches/${branchPath(branch)}`, token);
77
96
  // Gitea's Branch carries its tip as `commit.id`; some versions and forks also expose `commit.sha`.
78
97
  // Both are read, and neither being a string is a hard failure rather than an undefined SHA -- a job
79
98
  // that cannot name the commit it is standing on must not proceed to clone something else.
@@ -85,24 +104,22 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
85
104
  }
86
105
 
87
106
  /**
88
- * 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).
89
109
  *
90
- * See the module header: this is the endpoint and the glob matching that GitHub's 404 rule would have
91
- * 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.
92
112
  */
93
113
  async function isDefaultBranchProtected(ref, token) {
94
114
  const [owner, name] = splitRepo(ref);
95
115
  const repo = await get(`/repos/${owner}/${name}`, token);
96
116
  const branch = repo?.default_branch;
97
117
  if (typeof branch !== "string" || branch === "") return false;
98
- const rules = await get(`/repos/${owner}/${name}/branch_protections`, token);
99
- if (!Array.isArray(rules)) {
100
- 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`);
101
121
  }
102
- // `rule_name` is the current field; `branch_name` is its deprecated predecessor and is still what
103
- // older instances send. Reading only the new one would report every branch on an older Forgejo as
104
- // unprotected -- the same class of silent fail-open this file exists to avoid.
105
- return rules.some((rule) => matchesBranch(rule?.rule_name ?? rule?.branch_name, branch));
122
+ return info.protected;
106
123
  }
107
124
 
108
125
  /**
@@ -153,7 +170,81 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
153
170
  return { headRef: pr?.head?.ref, headRepo: pr?.head?.repo?.full_name };
154
171
  }
155
172
 
156
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
173
+ /**
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).
190
+ */
191
+ async function setParkedLabel(ref, target, on, token) {
192
+ const [owner, name] = splitRepo(ref);
193
+ const send = async (method, path, body, accept = []) => {
194
+ let res;
195
+ try {
196
+ res = await fetchFn(`${root}${path}`, {
197
+ method,
198
+ headers: { Authorization: `token ${token}`, "content-type": "application/json" },
199
+ ...(body !== undefined && { body: JSON.stringify(body) }),
200
+ redirect: "error",
201
+ });
202
+ } catch (err) {
203
+ throw new InfraRetry(`forgejo-host: ${method} ${path} failed (${fetchFailureReason(err)})`);
204
+ }
205
+ if (!res.ok && !accept.includes(res.status)) throw new InfraRetry(`forgejo-host: ${method} ${path} returned ${res.status}`);
206
+ return res;
207
+ };
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);
210
+ if (on) {
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)] });
221
+ return;
222
+ }
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`);
245
+ }
246
+
247
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
157
248
  }
158
249
 
159
250
  /**
@@ -74,6 +74,31 @@ function prReplicaLines(replica, replicas) {
74
74
  * toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no idea
75
75
  * what it was asked to do. `tea`, never `gh`: this envelope's whole reason for existing.
76
76
  */
77
+ /**
78
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
79
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
80
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
81
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
82
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
83
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
84
+ */
85
+ function resumedActionLines(target, n) {
86
+ if (target?.type === "pull_request") {
87
+ return [
88
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
89
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
90
+ "not. Do not open a second pull request -- your push updates the existing one.",
91
+ ];
92
+ }
93
+ const branch = issueBranch(n);
94
+ return [
95
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
96
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
97
+ `branch only. Check first for an open pull request from it (\`tea pr list --state open\`, look for \`${branch}\`):`,
98
+ `if one exists your push updates it, so do not open a second; only if none exists, run \`tea pr create --head ${branch}\`.`,
99
+ ];
100
+ }
101
+
77
102
  function buildResumedPrompt(flow, target, comment, instructions) {
78
103
  const n = normalizeNumber(target?.number);
79
104
  const noun = target?.type === "pull_request" ? "pull request" : "issue";
@@ -86,9 +111,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
86
111
  `If you do not recognise this ${noun}, treat this as a fresh start: read it with \`tea pr ${n}\``,
87
112
  `(or \`tea issue ${n}\`) before doing anything, then follow the "${flow}" skill from the top.`,
88
113
  "",
89
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
90
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
91
- "not. Do not open a second pull request -- your push updates the existing one.",
114
+ ...resumedActionLines(target, n),
92
115
  "",
93
116
  // Above the never-merge paragraph, so the harness has the last word before the data region.
94
117
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),