forgedock 1.48.1 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,20 +31,49 @@ const C = PREFLIGHT_CODES;
31
31
  /** A remote or hybrid execution is configured (the only case the pool checks apply to). */
32
32
  export const usesPool = (exec) => Boolean(exec) && (exec.mode === "remote" || exec.mode === "hybrid") && ["pool", "remote"].includes(exec.provider ?? "pool");
33
33
 
34
- /** The pool token resolves (env/keychain `token_env`, else the router's `forgedock_token_file`) and the router does not answer 401/403 to an authenticated ping. */
35
- export async function checkPoolAuth({ exec, router = null, credential = () => null, ping = null }) {
34
+ /** #5131: `a` and `b` name the same `scheme://host:port` (default ports filled, path and trailing slash ignored). */
35
+ const sameEndpoint = (a, b) => {
36
+ const key = (u) => { try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname}:${x.port || (x.protocol === "https:" ? "443" : "80")}`; } catch { return null; } };
37
+ return key(a) !== null && key(a) === key(b);
38
+ };
39
+
40
+ /**
41
+ * The injectable stand-in for execution.mjs `poolCredential` when no `poolToken` is given (tests, older callers): `credential`
42
+ * on `token_env` (default COMPUTESHARE_TOKEN), then the router token, for `remote` only when its endpoint is unset or the router's.
43
+ */
44
+ export const legacyPoolToken = (credential = () => null) => (exec, router) => {
45
+ const name = exec?.tokenEnv ?? "COMPUTESHARE_TOKEN";
46
+ const v = credential(name);
47
+ if (v) return { token: v, source: name };
48
+ const ok = Boolean(router?.token) && ((exec?.provider ?? "pool") === "pool" || !exec?.endpoint || sameEndpoint(exec.endpoint, router.endpoint));
49
+ return ok ? { token: router.token, source: "forgedock_token_file" } : { token: null, source: null };
50
+ };
51
+
52
+ /** The sources a resolver tries for `exec` (names only, never values), for a FAIL detail. */
53
+ const sourcesTried = (exec, router) => [
54
+ `env/keychain ${exec.tokenEnv ?? (exec.provider === "remote" ? "(no token_env)" : "COMPUTESHARE_TOKEN")}`,
55
+ ...(exec.tokenFile ? [`token_file ${exec.tokenFile}`] : []),
56
+ !router ? "no router config (COMPUTESHARE_CONFIG / ~/.config/computeshare/router.json)"
57
+ : (exec.provider ?? "pool") === "remote" && exec.endpoint && !sameEndpoint(exec.endpoint, router.endpoint) ? `the router's forgedock_token_file (not used: endpoint ${exec.endpoint} is not the local router ${router.endpoint})`
58
+ : "the router's forgedock_token_file",
59
+ ].join(", ");
60
+
61
+ /**
62
+ * The pool token resolves through `poolToken(exec, router) -> {token, source}` (#5131: hostLaneIo wires execution.mjs
63
+ * poolCredential with the dispatch env, so preflight and lane dispatch resolve the same token), else `credential`/`router`
64
+ * (legacyPoolToken), and the router does not answer 401/403 to an authenticated ping. Details name sources, never values.
65
+ */
66
+ export async function checkPoolAuth({ exec, router = null, credential = () => null, ping = null, poolToken = null }) {
36
67
  const code = C.PREFLIGHT_POOL_AUTH;
37
68
  if (!usesPool(exec)) return pass(code, "skipped: no remote or hybrid execution", "remote");
38
- const name = exec.tokenEnv ?? "COMPUTESHARE_TOKEN";
39
- const fromEnv = credential(name);
40
- const token = fromEnv || router?.token || null;
41
- if (!token) return fail(code, `pool token does not resolve: tried env/keychain ${name}, ${router ? "the router's forgedock_token_file" : "no router config (COMPUTESHARE_CONFIG / ~/.config/computeshare/router.json)"}`, "remote");
69
+ const { token, source } = (poolToken ?? legacyPoolToken(credential))(exec, router) ?? {};
70
+ if (!token) return fail(code, `pool token does not resolve: tried ${sourcesTried(exec, router)}`, "remote");
42
71
  const endpoint = exec.endpoint ?? router?.endpoint ?? null;
43
- if (!endpoint || typeof ping !== "function") return pass(code, `token resolved from ${fromEnv ? name : "forgedock_token_file"}`, "remote");
72
+ if (!endpoint || typeof ping !== "function") return pass(code, `token resolved from ${source}`, "remote");
44
73
  let r = null;
45
74
  try { r = await ping(endpoint, token); } catch { r = null; }
46
- if ([401, 403].includes(r?.status)) return fail(code, `router ${endpoint} answered HTTP ${r.status} to the token from ${fromEnv ? name : "forgedock_token_file"}`, "remote");
47
- return pass(code, `token resolved from ${fromEnv ? name : "forgedock_token_file"}${r ? "" : "; router unreachable (not an auth failure)"}`, "remote");
75
+ if ([401, 403].includes(r?.status)) return fail(code, `router ${endpoint} answered HTTP ${r.status} to the token from ${source}`, "remote");
76
+ return pass(code, `token resolved from ${source}${r ? "" : "; router unreachable (not an auth failure)"}`, "remote");
48
77
  }
49
78
 
50
79
  /** Every online pool agent runs the CLI's version. `agents` is the router's `GET /agents` list (each spreads versionOf: version, image). */
@@ -99,9 +128,11 @@ export async function runLaneChecks({ exec = null, env = process.env, cliVersion
99
128
  let router = null;
100
129
  try { router = io.router?.() ?? null; } catch { router = null; }
101
130
  const ping = io.ping ?? null;
102
- const agentsOf = async () => { try { return (await io.agents?.(exec?.endpoint ?? router?.endpoint, exec?.tokenEnv ? (io.credential?.(exec.tokenEnv) ?? router?.token) : router?.token)) ?? null; } catch { return null; } };
131
+ // #5131: one resolver for the auth check and the agents read: io.poolToken (poolCredential), else the injected credential/router.
132
+ const poolToken = io.poolToken ?? legacyPoolToken(io.credential ?? (() => null));
133
+ const agentsOf = async () => { try { return (await io.agents?.(exec?.endpoint ?? router?.endpoint, poolToken(exec, router)?.token ?? null)) ?? null; } catch { return null; } };
103
134
  return Promise.all([
104
- safe(C.PREFLIGHT_POOL_AUTH, "remote", () => checkPoolAuth({ exec, router, credential: io.credential, ping })),
135
+ safe(C.PREFLIGHT_POOL_AUTH, "remote", () => checkPoolAuth({ exec, router, poolToken, ping })),
105
136
  safe(C.PREFLIGHT_POOL_VERSION, "remote", async () => checkPoolVersion({ exec, agents: usesPool(exec) ? await agentsOf() : null, cliVersion: cliV })),
106
137
  safe(C.PREFLIGHT_PATH_LOOP, "all", () => checkPathLoop({ env: { ...env, PATH: env.PATH ?? "" }, ...(io.loops ? { loops: io.loops } : {}) })),
107
138
  safe(C.PREFLIGHT_GH_FIELDS, "all", () => (io.ghExec ? checkGhFields({ exec: io.ghExec }) : pass(C.PREFLIGHT_GH_FIELDS, "skipped: no gh", "all"))),
@@ -113,7 +144,7 @@ export const formatLaneChecks = (checks) => checks.map((c) => `${c.ok ? "PASS" :
113
144
 
114
145
  /** Real-host io for the daemon, the batch and `doctor --lanes`. gh output is cached per process (it cannot change under a running engine). */
115
146
  export async function hostLaneIo({ env = process.env } = {}) {
116
- const { poolRouterConfig } = await import("./execution.mjs");
147
+ const { poolCredential, poolRouterConfig } = await import("./execution.mjs");
117
148
  const { resolveCredential } = await import("./credentials.mjs");
118
149
  const { execFile } = await import("node:child_process");
119
150
  const call = (url, token, timeoutMs = 5000) => fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
@@ -122,6 +153,7 @@ export async function hostLaneIo({ env = process.env } = {}) {
122
153
  return {
123
154
  router: () => poolRouterConfig({ env }),
124
155
  credential: (n) => resolveCredential(n, { env }),
156
+ poolToken: (exec, router) => poolCredential(exec, { env, router }), // #5131: the dispatch resolver on the dispatch env
125
157
  ping: async (endpoint, token) => ({ status: (await call(`${endpoint}/status`, token)).status }),
126
158
  agents: async (endpoint, token) => { const r = await call(`${endpoint}/agents`, token); return r.ok ? (await r.json())?.agents ?? null : null; },
127
159
  ghExec: (args) => { const k = args.join(" "); if (!memo.has(k)) memo.set(k, run(args)); return memo.get(k); },
@@ -31,6 +31,29 @@ describe("pool_auth", () => {
31
31
  });
32
32
  });
33
33
 
34
+ describe("pool_auth shared resolver (#5131)", () => {
35
+ const remote = { mode: "remote", provider: "remote", endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN" };
36
+ const router = { endpoint: "http://127.0.0.1:7710", token: "router-tok" };
37
+ it("5131: FAILs remote on a non-matching endpoint although a router token exists, naming sources never the value", async () => {
38
+ const a = await checkPoolAuth({ exec: remote, router, credential: () => null });
39
+ assert.equal(a.ok, false); assert.equal(a.code, "preflight.pool_auth"); assert.equal(a.scope, "remote");
40
+ assert.match(a.detail, /COMPUTESHARE_TOKEN/); assert.match(a.detail, /not the local router/); assert.doesNotMatch(a.detail, /router-tok/);
41
+ const viaIo = await checkPoolAuth({ exec: remote, router, poolToken: () => ({ token: null, source: null }) });
42
+ assert.equal(viaIo.ok, false);
43
+ assert.equal((await checkPoolAuth({ exec: { ...remote, endpoint: "http://127.0.0.1:7710/" }, router, credential: () => null })).ok, true, "matching endpoint: router token applies");
44
+ });
45
+ it("5131: runLaneChecks uses io.poolToken for the auth check and the agents read, and FAILs a rejected token", async () => {
46
+ const calls = [];
47
+ const io = { router: () => router, poolToken: (exec, r) => { calls.push([exec.provider, r?.endpoint]); return { token: "tok", source: "token_file" }; },
48
+ ping: async (_e, t) => ({ status: t === "tok" ? 401 : 200 }), agents: async (_e, t) => { calls.push(["agents", t]); return []; } };
49
+ const checks = await runLaneChecks({ exec: remote, env: { PATH: "" }, io: { ...io, loops: () => [] } });
50
+ const auth = checks.find((c) => c.code === "preflight.pool_auth");
51
+ assert.equal(auth.ok, false); assert.match(auth.detail, /HTTP 401/); assert.match(auth.detail, /token_file/); assert.doesNotMatch(auth.detail, /\btok\b/);
52
+ assert.ok(calls.some((c) => c[0] === "agents" && c[1] === "tok"));
53
+ assert.ok(calls.some((c) => c[0] === "remote" && c[1] === router.endpoint));
54
+ });
55
+ });
56
+
34
57
  describe("pool_version", () => {
35
58
  it("reports the agent, image version, CLI version and upgrade command", () => {
36
59
  const c = checkPoolVersion({ exec: pool, cliVersion: "1.39.0", agents: [{ name: "a1", ok: true, version: "1.37.0" }, { name: "a2", ok: true, version: "1.39.0" }] });
package/bin/lean/lean.mjs CHANGED
@@ -32,7 +32,7 @@ import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, self
32
32
  import { retrying, ghCacheRetry, refreshAuthOnce, runRefreshScript, GH_AUTH_RE, GH_AUTH_RETRY_SECONDS, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
33
33
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, parseAmendmentsComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
34
34
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
35
- import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
35
+ import { computeTrigger, riskPathHits, TRIGGER_MODES, pathDomains, parseEnforceDomains } from "./review-trigger.mjs";
36
36
  import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
37
37
 
38
38
  const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
@@ -205,7 +205,8 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
205
205
  * @param {"off"|"report"} [o.conformanceMutation] forge.yaml engine.conformance_mutation (#4913): rerun each evidence test with the change reverted; a survivor is conformance.mutation_survived (shadow unless conformance_kinds enforces it)
206
206
  * @param {Record<string,"enforce"|"shadow">} [o.conformanceKinds] forge.yaml engine.conformance_kinds: a failure code's own mode (enforce blocks, shadow is recorded only); others follow `conformance`
207
207
  * @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
208
- * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
208
+ * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs,
209
+ * enforceDomains: review.enforce_domains (#5099), a touched listed domain makes the lane enforce}
209
210
  * @param {object} [o.verifier] forge.yaml review.verifier/verifier_model (#4397): {mode: off (default)|shadow|enforce, model}
210
211
  * @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
211
212
  * @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
@@ -239,6 +240,7 @@ export async function runCore(o) {
239
240
  const remoteVerify = vcfg.mode !== "local";
240
241
  const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
241
242
  const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
243
+ const enforceDomains = parseEnforceDomains(o.reviewTrigger?.enforceDomains ?? []); // #5099
242
244
  const verifierCfg = parseVerifierConfig(o.verifier ?? {});
243
245
  const routingCfg = parseBuilderRouting(o.builderRouting ?? {});
244
246
  // #4394: the build's model tier, routed from the frozen contract; a failed validate round on the mid tier escalates once
@@ -336,6 +338,7 @@ export async function runCore(o) {
336
338
  let guardAddedMs = 0; // #4705 (R12): wall time the guard gate added, summed over the lane, carried on RUN_TERMINAL
337
339
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
338
340
  const kinds = normalizeKindModes(conformanceKinds); // engine.conformance_kinds: a failure code's own mode
341
+ let domains = [], domainsLogged = null; // #5099: review domains the diff touches (review-roster.sh path triggers)
339
342
  let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
340
343
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
341
344
  let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (the same structure/hotspot gates as the full lane, #4502); summed session time
@@ -2211,7 +2214,7 @@ export async function runCore(o) {
2211
2214
  await renew();
2212
2215
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
2213
2216
  prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
2214
- : reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
2217
+ : reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, domains, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
2215
2218
  await renew();
2216
2219
  await attempt("review", ++reviewN, rr, null, "review");
2217
2220
  const parsed = parseReview(rr?.text);
@@ -2342,6 +2345,20 @@ export async function runCore(o) {
2342
2345
  ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
2343
2346
  T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
2344
2347
  }
2348
+ // #5099: the domains the diff touches get domain checks in the review; a review.enforce_domains hit enforces (sticky)
2349
+ const pd = pathDomains(files);
2350
+ const dErr = "Review domains unavailable: scripts/review-roster.sh failed; reviewed without domain checks";
2351
+ if (pd === null && !T.anomalies.includes(dErr)) T.anomalies.push(dErr);
2352
+ domains = pd ?? [];
2353
+ const dHits = shadow ? domains.filter((d) => enforceDomains.includes(d)) : [];
2354
+ if (dHits.length) {
2355
+ shadow = false; riskEnforced = dHits.map((d) => `domain:${d}`);
2356
+ T.decisions.push(`Conformance enforced (review.enforce_domains): ${dHits.join(", ")}`);
2357
+ }
2358
+ if (domainsLogged !== domains.join(",")) {
2359
+ domainsLogged = domains.join(",");
2360
+ ev({ event: "REVIEW_DOMAINS", domains, enforced: dHits.length ? dHits : undefined, ...(pd === null ? { error: "roster.failed" } : {}) });
2361
+ }
2345
2362
  if (!amendFixed && sessionId && amendmentKindProblems(result?.amendments).length) { // #5089: one in-lane correction, outside the conformance resume budget
2346
2363
  amendFixed = true;
2347
2364
  const bad = amendmentKindProblems(result.amendments);
@@ -1659,6 +1659,24 @@ describe("conformance shadow mode (the default)", () => {
1659
1659
  assert.ok(traj(w)[0].includes("| Close | issue closed (partial) |"));
1660
1660
  });
1661
1661
 
1662
+ it("#5099 a web/ diff gets frontend domain checks in the reviewer prompt; review.enforce_domains makes the lane enforce", async () => {
1663
+ const k = { ...CONTRACT, preserve: [] }, changed = ["web/app/page.tsx", "test/foo.test.js"];
1664
+ const seenPrompts = (w) => { const out = [], r0 = w.io.runner; w.io.runner = async (c) => { if (c.role === "reviewer") out.push(c.prompt); return r0(c); }; return out; };
1665
+ const dir = tmpDir("dom-"), s = world({ contract: k, changed, reviews: [omission()] }), ps = seenPrompts(s);
1666
+ const rs = await runShadow(s, { dir });
1667
+ assert.equal(rs.terminalReason, "merged", "without enforce_domains the lane stays shadow");
1668
+ assert.match(ps[0], /<domain_checks>[\s\S]*regressions row[\s\S]*- frontend: [\s\S]*not-found\/error boundary/);
1669
+ assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "REVIEW_DOMAINS").map((e) => e.domains), [["frontend"]]);
1670
+ const plain = world({ contract: k, reviews: [ok({}, k)] }), pp = seenPrompts(plain);
1671
+ await runShadow(plain);
1672
+ assert.ok(!pp[0].includes("<domain_checks>"), "no domain touched: no domain block");
1673
+ const e = world({ contract: k, changed, reviews: [omission(), omission()] });
1674
+ const re = await runShadow(e, { reviewTrigger: { mode: "shadow", riskPaths: [], enforceDomains: ["frontend"] } });
1675
+ assert.equal(re.terminalReason, "needs-human"); assert.equal(e.merges.length, 0);
1676
+ assert.deepEqual(re.conformance, { mode: "enforce", would: null, risk: true });
1677
+ assert.ok(traj(e)[0].includes("Conformance enforced (review.enforce_domains): frontend"));
1678
+ });
1679
+
1662
1680
  it("#4398 an investigation whose answer the review marks deferred is tracked before the PR and closes partial, even though shadow merges it", async () => {
1663
1681
  const k = { ...CONTRACT, preserve: [] };
1664
1682
  const w = world({ title: "investigate: foo", contract: k, reviews: [conf({ requirements: [{ id: "R1", status: "deferred", evidence: "", validated_by: "", note: "question open" }] }, k)] });
@@ -5930,6 +5948,8 @@ describe("review trigger (#4273): shadow logs and always reviews, enforce skips
5930
5948
  assert.deepEqual(at('review:\n trigger: enforce\n risk_paths: "db/**, *.sql"\n'), { mode: "enforce", riskPaths: ["db/**", "*.sql"] });
5931
5949
  assert.deepEqual(at("review:\n tech_stack: x\n risk_paths:\n - \"infra/**\"\n - lib/acct/*.ts # c\n trigger: bogus\n"), { mode: "shadow", riskPaths: ["infra/**", "lib/acct/*.ts"] });
5932
5950
  assert.equal(at("engine:\n path: core\n"), undefined);
5951
+ assert.deepEqual(at('review:\n enforce_domains: "Frontend, bogus"\n'), { mode: "shadow", riskPaths: [], enforceDomains: ["frontend"] }, "#5099");
5952
+ assert.deepEqual(at("review:\n enforce_domains:\n - auth\n risk_paths:\n - db/**\n"), { mode: "shadow", riskPaths: ["db/**"], enforceDomains: ["auth"] }, "#5099 block lists");
5933
5953
  });
5934
5954
  });
5935
5955
 
@@ -4,7 +4,8 @@
4
4
  * Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring `Depends on` / `Blocked by`
5
5
  * among the given issues (wait for the dependency to merge; skip when it ends anywhere but merged/invalid).
6
6
  * A `dependency-open` run is requeued behind its dependencies, pulling an out-of-set one into the batch; a
7
- * `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124). A dependency that is parked
7
+ * `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124); an issue read later (`--add`,
8
+ * `--requeue`, a resume, a body re-read) resolves a decomposed dependency to its sub-issues too (#5112). A dependency that is parked
8
9
  * (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
9
10
  * the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
10
11
  * A skipped dependent whose blocker later closes or merges outside its lane is re-admitted (#4595, needs `dispatchCheck`).
@@ -21,6 +22,8 @@
21
22
  * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
22
23
  * limit holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
23
24
  * `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
25
+ * #5112: with `remote.offlineFallback` (`execution.fallback_local`), an issue deferred `remote.capacityFallbackAfter` times (default
26
+ * `CAPACITY_FALLBACK_AFTER`) for `sandbox.capacity` runs on the host when `remote.headroom()` (host-busy guard) allows it.
24
27
  * Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
25
28
  * host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
26
29
  * batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset.
@@ -136,6 +139,8 @@ export const HOST_BUSY_RETRY_S = 60;
136
139
  // #4788: an unreachable pool is a wait too (the lane already retried for unreachable_window_min); a stall is reported by poolStall.
137
140
  const UNCAPPED_DEFERRALS = new Set(["host.busy", "sandbox.capacity", "sandbox.unreachable", "lane.unresolved"]);
138
141
  export const POOL_OFFLINE_ALERT_MIN = 10;
142
+ // #5112: `sandbox.capacity` deferrals of one issue after which `execution.fallback_local` runs it on the host (headroom permitting).
143
+ export const CAPACITY_FALLBACK_AFTER = 3;
139
144
  export const RATE_LIMIT_FALLBACK_S = 15 * 60;
140
145
  export const DEFER_BACKOFF_S = 120;
141
146
  export const DEFER_BACKOFF_MAX_S = 30 * 60;
@@ -274,6 +279,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
274
279
  const notBefore = toMap(saved?.notBefore);
275
280
  const requeues = toMap(saved?.requeues);
276
281
  const deferrals = toMap(saved?.deferrals);
282
+ const capDefers = toMap(saved?.capDeferrals); // #5112: issue -> `sandbox.capacity` deferrals (uncapped, so not in `deferrals`)
283
+ const capFellBack = new Set(); // #5112: issues already reported as a capacity fallback (one decision each)
277
284
  const engineErrors = toMap(saved?.engineErrors);
278
285
  const checked = toMap(saved?.prefilter); // issue -> pre-filter result
279
286
  const estimates = toMap(saved?.estimates); // #4409: issue -> estimated lane minutes; only with `estimate`
@@ -358,7 +365,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
358
365
  const save = () => {
359
366
  if (!store || halted) return;
360
367
  store.save({ v: 1, ...(setup ? { phase: "setup", setup: { ...setup } } : {}), order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
361
- pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}),
368
+ pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}), ...(capDefers.size ? { capDeferrals: toObj(capDefers) } : {}),
362
369
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
363
370
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
364
371
  ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering), recoveryClasses: toObj(classUsed) } : {}),
@@ -391,6 +398,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
391
398
  if (typeof trusted === "function") { let ok = false; try { ok = (await trusted(n)) === true; } catch { ok = false; } if (!ok) return []; }
392
399
  return parseDeps(body, n);
393
400
  };
401
+ // #5112: a dependency that ended `decomposed` (sub-issues joined, mode not off) is its sub-issues, to a fixpoint; used on every edge write.
402
+ const throughDecomposed = (ds) => {
403
+ if (!joinsDecomposed(autoChildren?.mode ?? "decomposed")) return [...new Set(ds)];
404
+ const out = [], seen = new Set(), stack = [...ds].reverse();
405
+ while (stack.length) {
406
+ const d = stack.pop();
407
+ if (seen.has(d)) continue;
408
+ seen.add(d);
409
+ const r = results.get(d), subs = r?.outcome === "decomposed" && Array.isArray(r.subissues) ? r.subissues.filter((x) => Number.isSafeInteger(x) && x > 0) : [];
410
+ if (subs.length) stack.push(...[...subs].reverse()); else out.push(d);
411
+ }
412
+ return [...new Set(out)];
413
+ };
414
+ // #5112: the issue's declared edges re-read from its body (null when unreadable: the caller keeps what it has).
415
+ const reread = async (n) => {
416
+ let body;
417
+ try { body = await readBody(n); } catch { return null; }
418
+ if (drift?.holds) bodies.set(n, body);
419
+ return declared(n, body);
420
+ };
394
421
  const readDeps = async (n) => {
395
422
  let body = "";
396
423
  try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
@@ -401,7 +428,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
401
428
  await readPriority(n);
402
429
  if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; } lanes.set(n, l); }
403
430
  // deps marks the issue read (a resume skips it): set it last, so a concurrent setup save never persists a half-read issue (#4963)
404
- deps.set(n, d);
431
+ deps.set(n, throughDecomposed(d));
405
432
  };
406
433
  const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
407
434
  // A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
@@ -516,6 +543,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
516
543
  }
517
544
  return "busy";
518
545
  };
546
+ // #5112: opt-in (`execution.fallback_local`): an issue the pool refused `capacityFallbackAfter` times (default CAPACITY_FALLBACK_AFTER)
547
+ // for `sandbox.capacity` runs on the host when the host-busy guard (`remote.headroom()`, absent = allowed) passes; not ok or a throw waits.
548
+ const capacityFallback = (n) => {
549
+ if (!remote?.offlineFallback) return false;
550
+ const after = Number.isInteger(remote.capacityFallbackAfter) && remote.capacityFallbackAfter > 0 ? remote.capacityFallbackAfter : CAPACITY_FALLBACK_AFTER;
551
+ if ((capDefers.get(n) ?? 0) < after) return false;
552
+ if (typeof remote.headroom !== "function") return true;
553
+ try { return remote.headroom()?.ok !== false; } catch { return false; }
554
+ };
519
555
  // A host slot for `n` (a lane-refused issue runs nothing and needs none); refused: `n` stays queued in the registry.
520
556
  // true: start it; false: no capacity now; "busy" (#4481): another batch runs `n`, try the next issue.
521
557
  const grant = (n, wanted) => {
@@ -555,6 +591,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
555
591
  // #4547: opt-in (`execution.fallback_local`): every pool agent offline or asleep (sampled online 0) runs the lane on the host.
556
592
  if (remote.offlineFallback && cap && cap.online === 0) {
557
593
  p = { where: "local", reason: `fallback to local: no pool agent online (${p.reason})` };
594
+ } else if (capacityFallback(n)) {
595
+ p = { where: "local", reason: `fallback to local: pool at capacity after ${capDefers.get(n)} deferrals (${p.reason})` };
596
+ if (!capFellBack.has(n)) {
597
+ capFellBack.add(n);
598
+ decided({ issue: n, what: `run #${n} on the host: the pool refused it ${capDefers.get(n)} times for capacity (execution.fallback_local)`, why: "sustained sandbox.capacity and the host has headroom",
599
+ alternative: "keep deferring until a pool slot frees" });
600
+ }
558
601
  } else if (cap && Number.isInteger(cap.free)) {
559
602
  if (cap.at !== capAt) { capAt = cap.at; capAdmits = 0; }
560
603
  if (capWait.blocked(n, cap)) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
@@ -664,7 +707,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
664
707
  if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
665
708
  // A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
666
709
  requeues.set(n, (requeues.get(n) ?? 0) + 1);
667
- deps.set(n, [...new Set([...(deps.get(n) ?? []), ...depList])]);
710
+ deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...depList]));
668
711
  await addIssues(depList);
669
712
  // Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
670
713
  const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
@@ -686,6 +729,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
686
729
  if (r?.code === "sandbox.unreachable") notePool(true, r.detail ?? "sandbox unreachable");
687
730
  if (r?.code === "sandbox.capacity") {
688
731
  capWait.refused(n);
732
+ capDefers.set(n, (capDefers.get(n) ?? 0) + 1); // #5112: counted for the fallback_local capacity fallback
689
733
  try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
690
734
  }
691
735
  if (!pol.capped) {
@@ -929,10 +973,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
929
973
  if (!fresh.length) return kids;
930
974
  // An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
931
975
  for (const n of pending) {
932
- let body;
933
- try { body = await readBody(n); } catch { continue; }
934
- if (drift?.holds) bodies.set(n, body);
935
- deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
976
+ const d = await reread(n);
977
+ if (d) deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...d]));
936
978
  }
937
979
  await addIssues(fresh);
938
980
  save();
@@ -964,10 +1006,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
964
1006
  .filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
965
1007
  if (!fresh.length) return 0;
966
1008
  for (const n of pending) {
967
- let body;
968
- try { body = await readBody(n); } catch { continue; }
969
- if (drift?.holds) bodies.set(n, body);
970
- deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
1009
+ const d = await reread(n);
1010
+ if (d) deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...d]));
971
1011
  }
972
1012
  await addIssues(fresh);
973
1013
  save();
@@ -980,7 +1020,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
980
1020
  return fresh.length;
981
1021
  };
982
1022
  // #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
983
- // return to pending, the prior rows kept in history; edges stay, so a dependent still waits for the issue to merge.
1023
+ // return to pending, the prior rows kept in history; their `Depends on` edges are re-read from the body and replaced (#5112).
984
1024
  const drainRequeues = async () => {
985
1025
  if (!getRequeued) return 0;
986
1026
  let got = [];
@@ -1000,14 +1040,25 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1000
1040
  }
1001
1041
  }
1002
1042
  for (const m of moved) {
1003
- for (const x of [requeues, deferrals, engineErrors, recoveries, recovering]) x.delete(m);
1043
+ for (const x of [requeues, deferrals, capDefers, engineErrors, recoveries, recovering]) x.delete(m);
1004
1044
  relabelled.delete(m);
1005
1045
  if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
1006
1046
  if (!pending.includes(m)) requeue(m, 0);
1007
1047
  }
1048
+ // #5112: the requeued issues' `Depends on` edges are re-read and replaced, so a body edit drops or repoints an edge
1049
+ // (a decomposed dependency resolves to its sub-issues); an unreadable body keeps the edges it had.
1050
+ const edited = [];
1051
+ for (const m of moved) {
1052
+ const d = await reread(m);
1053
+ if (!d) continue;
1054
+ const before = deps.get(m) ?? [], after = throughDecomposed(d);
1055
+ deps.set(m, after);
1056
+ if (before.length !== after.length || before.some((x) => !after.includes(x))) edited.push(`#${m} -> ${after.length ? after.map((x) => `#${x}`).join(", ") : "none"}`);
1057
+ }
1058
+ noteCrossLane();
1008
1059
  save();
1009
1060
  const list = moved.filter((m) => m !== e.issue).map((m) => `#${m}`);
1010
- decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}`,
1061
+ decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}${edited.length ? `; edges re-read: ${edited.join("; ")}` : ""}`,
1011
1062
  why: e.reason || "operator decision", alternative: "start a second batch" });
1012
1063
  update({ issue: e.issue, outcome: "requeued", detail: `${e.reason || "operator decision"} (was ${row.outcome})` });
1013
1064
  for (const m of moved) if (m !== e.issue) update({ issue: m, outcome: "requeued", detail: `dependency #${e.issue} requeued`, trigger: e.issue }); // #4687
@@ -463,12 +463,25 @@ export function decisionNote(comments = []) {
463
463
  return kept.length ? `\n\nBinding decisions posted after the contract froze (trusted; they override the issue text where they conflict, and the frozen contract still applies):\n${kept.join("\n---\n")}` : "";
464
464
  }
465
465
 
466
- export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null }) {
466
+ /** #5099: one check line per review domain (domains from review-trigger.mjs pathDomains); mirrors the /review-pr personas. */
467
+ export const DOMAIN_CHECKS = Object.freeze({
468
+ frontend: "Dynamic API in a static/ISR tree: a headers(), cookies(), connection() or searchParams read in a layout, a not-found/error boundary or a module they import, when the route tree is static or ISR (revalidate, generateStaticParams, dynamic = 'force-static'), opts the route out of static rendering or throws at render and turns pages into HTTP 500.",
469
+ });
470
+
471
+ /** #5099: the <domain_checks> block for the domains the diff touches; "" when none. */
472
+ export function domainChecksBlock(domains = []) {
473
+ const ds = [...new Set((domains ?? []).map(String).filter(Boolean))].sort();
474
+ if (!ds.length) return "";
475
+ const lines = ds.map((d) => `- ${d}: check the diff for breakage in this domain.${DOMAIN_CHECKS[d] ? ` ${DOMAIN_CHECKS[d]}` : ""}`);
476
+ return `\n<domain_checks>\nThis diff touches these review domains. Report every finding below as a regressions row with path:line (it blocks the merge).\n${lines.join("\n")}\n</domain_checks>`;
477
+ }
478
+
479
+ export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null, domains = [] }) {
467
480
  const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
468
481
  const decisions = comments.filter((c) => isDecision(c?.body) && isTrustedIssueComment(c)).map((c) => String(c.body)); // #4901: operator decisions bind the review; other FORGE: records stay out
469
482
  notes.push(...decisions);
470
483
  const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
471
- return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
484
+ return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}${domainChecksBlock(domains)}`;
472
485
  }
473
486
 
474
487
  /**
@@ -7,6 +7,7 @@
7
7
  * log REVIEW_TRIGGER, always review), enforce (skip the reviewer when not triggered), off (today's behaviour).
8
8
  * Run directly, it reports per repo how many would-skip lanes the reviewer flagged as nonconforming.
9
9
  */
10
+ import { execFileSync } from "node:child_process";
10
11
  import { existsSync, readdirSync, readFileSync } from "node:fs";
11
12
  import { homedir } from "node:os";
12
13
  import { join } from "node:path";
@@ -43,6 +44,30 @@ export function parseRiskPaths(v) {
43
44
  return xs.map((x) => String(x ?? "").trim()).filter(Boolean);
44
45
  }
45
46
 
47
+ /** #5099: the review domains a lane can touch, from scripts/review-roster.sh path triggers. */
48
+ export const DOMAINS = Object.freeze(["api", "auth", "database", "frontend", "infra"]);
49
+ const DOMAIN_OF_TRIGGER = Object.freeze({ "frontend-path": "frontend", "auth-path": "auth", "infra-path": "infra", "route-handler": "infra", "migration-path": "database", "sdk-path": "api" });
50
+ const ROSTER = fileURLToPath(new URL("../../scripts/review-roster.sh", import.meta.url));
51
+
52
+ /** forge.yaml review.enforce_domains (#5099): a comma-separated string or a list -> known domains, lowercased, unique. */
53
+ export function parseEnforceDomains(v) {
54
+ return [...new Set(parseRiskPaths(v).map((x) => x.toLowerCase()).filter((x) => DOMAINS.includes(x)))].sort();
55
+ }
56
+
57
+ /**
58
+ * #5099: the domains the changed files touch, by the path triggers of the bundled scripts/review-roster.sh (the one
59
+ * source of the domain path rules). Sorted, unique; [] without files; null when the script fails (the caller logs it).
60
+ */
61
+ export function pathDomains(files = [], { script = ROSTER } = {}) {
62
+ const list = [...new Set((files ?? []).map(String).filter(Boolean))];
63
+ if (!list.length) return [];
64
+ let out;
65
+ try { out = execFileSync("bash", [script, "--files-file", "-", "--changed-lines", "1"], { input: `${list.join("\n")}\n`, encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "ignore"] }); } catch { return null; }
66
+ const m = /^REASON=.*?triggers:([^\n]*)$/m.exec(String(out));
67
+ if (!/^SELECTED_AGENTS=/m.test(String(out))) return null;
68
+ return [...new Set((m ? m[1].trim().split(/\s+/) : []).map((t) => DOMAIN_OF_TRIGGER[t]).filter(Boolean))].sort();
69
+ }
70
+
46
71
  /**
47
72
  * #4398: the changed files matching a declared forge.yaml review.risk_paths glob (sorted, unique). Only declared globs
48
73
  * count, never the built-in risk words: a hit makes the conformance gate enforce for that lane. [] without globs.
@@ -7,7 +7,8 @@ import { join } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
- import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
10
+ import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport, pathDomains, parseEnforceDomains } from "./review-trigger.mjs";
11
+ import { reviewerPrompt, domainChecksBlock } from "./prompts.mjs";
11
12
 
12
13
  const HOT = { enabled: true, file_lines: 2000, growth_lines: 150, files: [] };
13
14
  const clean = (patch = {}) => computeTrigger({ files: ["src/foo.js", "test/foo.test.js"], hotspots: HOT, ...patch });
@@ -118,3 +119,29 @@ describe("triggerReport (#4273)", () => {
118
119
  assert.deepEqual(JSON.parse(cli.stdout)["o/a"].would_skip_flagged, 1);
119
120
  });
120
121
  });
122
+
123
+ describe("pathDomains / domain checks (#5099)", () => {
124
+ it("maps review-roster.sh path triggers to domains: web/ is frontend", () => {
125
+ assert.deepEqual(pathDomains(["web/src/app/(site)/not-found.tsx"]), ["frontend"]);
126
+ assert.deepEqual(pathDomains(["src/auth/x.js", "app/api/route.ts", "db/migrations/0001.sql", "sdk/client.py"]), ["api", "auth", "database", "frontend", "infra"]);
127
+ assert.deepEqual(pathDomains(["src/foo.js"]), []);
128
+ assert.deepEqual(pathDomains([]), []);
129
+ });
130
+ it("a failing roster script is null (never throws)", () => {
131
+ assert.equal(pathDomains(["web/a.tsx"], { script: "/nonexistent/review-roster.sh" }), null);
132
+ });
133
+ it("parseEnforceDomains keeps known domains only", () => {
134
+ assert.deepEqual(parseEnforceDomains("Frontend, bogus , auth"), ["auth", "frontend"]);
135
+ assert.deepEqual(parseEnforceDomains(["infra", "infra"]), ["infra"]);
136
+ assert.deepEqual(parseEnforceDomains(undefined), []);
137
+ });
138
+ it("the reviewer prompt gains a frontend check only when frontend is touched, and is unchanged without domains", () => {
139
+ const args = { issue: { number: 7, title: "t", body: "b" }, contract: {}, diff: "" };
140
+ assert.equal(reviewerPrompt(args), reviewerPrompt({ ...args, domains: [] }));
141
+ assert.equal(domainChecksBlock([]), "");
142
+ const p = reviewerPrompt({ ...args, domains: ["frontend"] });
143
+ assert.ok(p.endsWith("</domain_checks>"));
144
+ assert.match(p, /- frontend: .*headers\(\), cookies\(\).*ISR/);
145
+ assert.match(p, /regressions row with path:line/);
146
+ });
147
+ });
@@ -87,10 +87,11 @@ export function sanitizePath(path, { exclude = [], exists = existsSync, tmpRoots
87
87
  /**
88
88
  * The allowlisted part of `env`, with PATH sanitised. `passthrough` names extra keys to keep (`orchestration.env_passthrough`,
89
89
  * the configured `execution.token_env`); `pathExclude` is `orchestration.path_exclude`. Secret-looking names are dropped unless named.
90
+ * An empty value is never pinned, passthrough keys included (#5131: an empty pin would mask the daemon's secret).
90
91
  */
91
92
  export function batchEnv(env = process.env, { passthrough = [], pathExclude = [], exists = existsSync, tmpRoots } = {}) {
92
93
  const extra = new Set(passthrough);
93
- const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
94
+ const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && v !== "" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
94
95
  if (typeof out.PATH === "string") out.PATH = sanitizePath(out.PATH, { exclude: pathExclude, exists, ...(tmpRoots ? { tmpRoots } : {}) });
95
96
  return out;
96
97
  }
@@ -105,10 +106,17 @@ export function redactEnv(env) {
105
106
  return Object.fromEntries(Object.entries(env ?? {}).map(([k, v]) => [k, SECRET_ENV.test(k) ? "<redacted>" : v]));
106
107
  }
107
108
 
108
- /** `base` without its allowlisted non-secret keys (secret-named ones, e.g. GH_TOKEN, stay from the daemon: never pinned), plus `kept`: an account selector the client did not set never leaks in from the daemon. */
109
+ /**
110
+ * `base` without its allowlisted non-secret keys, plus `kept`: an account selector the client did not set never leaks in from
111
+ * the daemon, and a pinned key wins. A secret-named key is only pinned when named (`env_passthrough`, `token_env`): a non-empty
112
+ * pin wins, as before #5131 (the operator chose that account); an empty secret pin never overrides the daemon's value (#5131).
113
+ */
109
114
  export function mergeEnv(base, kept) {
110
115
  const out = Object.fromEntries(Object.entries(base).filter(([k]) => k in kept || !ENV_KEEP.test(k) || SECRET_ENV.test(k)));
111
- return { ...out, ...kept };
116
+ for (const [k, v] of Object.entries(kept)) {
117
+ if (!SECRET_ENV.test(k) || (typeof v === "string" && v !== "")) out[k] = v;
118
+ }
119
+ return out;
112
120
  }
113
121
 
114
122
  /** Take the daemon lock: `{took}` (the dead owner's pid it replaced, or null); null when another live daemon holds it. */