@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 +10 -1
- package/package.json +1 -1
- package/src/config.mjs +9 -1
- package/src/doctor.mjs +24 -2
- package/src/env-allowlist.mjs +7 -3
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +88 -29
- package/src/forges.mjs +40 -12
- package/src/index.mjs +6 -0
- package/src/processor.mjs +10 -0
- package/src/queue.mjs +32 -0
- package/src/triggers.mjs +2 -2
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.
|
|
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.
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
}
|
package/src/forgejo-auth.mjs
CHANGED
|
@@ -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
|
*
|
package/src/forgejo-host.mjs
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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/${
|
|
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
|
-
*
|
|
92
|
-
*
|
|
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
|
|
100
|
-
if (
|
|
101
|
-
throw new InfraRetry(`forgejo-host: ${owner}/${name}
|
|
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
|
-
|
|
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).
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
* - `
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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.
|
|
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() !== "";
|