forgedock 1.48.0 → 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.
@@ -24,6 +24,7 @@ import { readExperiment } from "./experiment.mjs";
24
24
  import { readBranches, resolveLane, isDefaultLane, makeLaneCache } from "./lean/lane.mjs";
25
25
  import { parseVerify } from "./lean/verify.mjs";
26
26
  import { normalizeKindModes } from "./lean/conformance.mjs";
27
+ import { parseEnforceDomains } from "./lean/review-trigger.mjs";
27
28
  import { viewIssue } from "./lean/gh-assoc.mjs";
28
29
  import { otherLaneOwner } from "./lean/lanelock.mjs";
29
30
  import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
@@ -192,7 +193,7 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
192
193
  }
193
194
  /**
194
195
  * forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
195
- * list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
196
+ * list (` risk_paths:` then ` - glob`); `review.enforce_domains` (#5099) the same way. Returns {mode, riskPaths, enforceDomains?}; undefined when none is set (the core defaults to
196
197
  * shadow with the built-in risk patterns). An invalid mode reads as unset.
197
198
  */
198
199
  export function readReviewTrigger(cwd = process.cwd()) {
@@ -202,19 +203,24 @@ export function readReviewTrigger(cwd = process.cwd()) {
202
203
  try { sec = parseForgeYaml(raw)?.review ?? {}; } catch { /* unreadable: no trigger config */ }
203
204
  const m = String(sec?.trigger ?? "").trim();
204
205
  const mode = ["shadow", "enforce", "off"].includes(m) ? m : undefined;
205
- const riskPaths = typeof sec?.risk_paths === "string" ? sec.risk_paths.split(",").map((x) => x.trim()).filter(Boolean) : [];
206
- let inReview = false, listIndent = null;
206
+ const scalar = (v) => (typeof v === "string" ? v.split(",").map((x) => x.trim()).filter(Boolean) : []);
207
+ const lists = { risk_paths: scalar(sec?.risk_paths), enforce_domains: scalar(sec?.enforce_domains) }; // #5099: enforce_domains
208
+ const keyOf = (line) => /^\s+(risk_paths|enforce_domains):\s*(#.*)?$/.exec(line)?.[1] ?? null;
209
+ let inReview = false, listIndent = null, listKey = null;
207
210
  for (const line of raw.split(/\r?\n/)) {
208
211
  if (/^\s*(#.*)?$/.test(line)) continue;
209
212
  if (/^\S/.test(line)) { inReview = /^review:\s*(#.*)?$/.test(line); listIndent = null; continue; }
210
213
  if (!inReview) continue;
211
214
  const indent = line.length - line.trimStart().length;
212
- if (listIndent === null) { if (/^\s+risk_paths:\s*(#.*)?$/.test(line)) listIndent = indent; continue; }
213
- if (indent <= listIndent) { listIndent = /^\s+risk_paths:\s*(#.*)?$/.test(line) ? indent : null; continue; }
215
+ if (listIndent === null) { listKey = keyOf(line); if (listKey) listIndent = indent; continue; }
216
+ if (indent <= listIndent) { listKey = keyOf(line); listIndent = listKey ? indent : null; continue; }
214
217
  const li = /^\s+-\s+["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line);
215
- if (li) riskPaths.push(li[1].trim());
218
+ if (li) lists[listKey].push(li[1].trim());
216
219
  }
217
- return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
220
+ const riskPaths = lists.risk_paths;
221
+ const enforceDomains = parseEnforceDomains(lists.enforce_domains);
222
+ if (!mode && !riskPaths.length && !enforceDomains.length) return undefined;
223
+ return { mode: mode ?? "shadow", riskPaths, ...(enforceDomains.length ? { enforceDomains } : {}) };
218
224
  }
219
225
  /**
220
226
  * forge.yaml `review.verifier` (off|shadow|enforce) and `review.verifier_model`, else `engine.lean_verifier_model` (#4397).
@@ -1741,7 +1747,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1741
1747
  // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
1742
1748
  let gitDir = null;
1743
1749
  try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
1744
- const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env, prefetch: (n) => prefetchOf.get(n) ?? [] });
1750
+ const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env, prefetch: (n) => prefetchOf.get(n) ?? [], ...(deps.sandboxFetch ? { fetchFn: deps.sandboxFetch } : {}) });
1745
1751
  const { makeLedger } = await import("./lean/ledger.mjs");
1746
1752
  const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
1747
1753
  const bud = exec.budget ?? {};
@@ -1766,11 +1772,13 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1766
1772
  const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1767
1773
  // #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
1768
1774
  const relays = Array.isArray(provider.relays) ? provider.relays : [];
1775
+ // #5112: the host-busy guard, shared by hybrid placement and the fallback_local capacity fallback.
1776
+ const hostHeadroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1769
1777
  const place = !hybrid ? (n) => execMod.placeLane({ exec, needs: needsOf.get(n) ?? [], relays }) : await (async () => {
1770
1778
  const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1771
1779
  let weight = deps.validationWeight ?? null;
1772
1780
  if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
1773
- const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1781
+ const headroom = hostHeadroom;
1774
1782
  return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
1775
1783
  })();
1776
1784
  // #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
@@ -1785,7 +1793,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1785
1793
  return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
1786
1794
  };
1787
1795
  remote = {
1788
- capacity, max: exec.maxRemote, isRemote: () => true, place, identity: () => holdId, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1796
+ capacity, max: exec.maxRemote, isRemote: () => true, place, identity: () => holdId, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true, headroom: hostHeadroom, ...(exec.capacityFallbackAfter ? { capacityFallbackAfter: exec.capacityFallbackAfter } : {}) } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1789
1797
  admit: (u) => execBudget.admit(u),
1790
1798
  onUsage: (n, sb, rec) => {
1791
1799
  const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { resolveLane, makeLaneCache } from "./lane.mjs";
10
- import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
10
+ import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists, CAPACITY_FALLBACK_AFTER } from "./orchestrate.mjs";
11
11
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
12
12
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
13
13
 
@@ -47,7 +47,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
47
47
  const pids = () => Object.values(batch.store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean);
48
48
  let lanePids = [];
49
49
  try {
50
- const child = spawn(process.execPath, [dispatcher], { stdio: "ignore" });
50
+ // The two-lane wait must not depend on host memory: the live local lane cap (#4777) reads forge.yaml from the dispatcher's cwd.
51
+ writeFileSync(join(work, "forge.yaml"), "engine:\n admission_enabled: false\n");
52
+ const child = spawn(process.execPath, [dispatcher], { stdio: "ignore", cwd: work });
51
53
  const exited = new Promise((r) => child.on("exit", r));
52
54
  await until(() => readFileSync(launches, "utf8").trim().split("\n").filter(Boolean).length === 2 && pids().length === 2);
53
55
  lanePids = pids();
@@ -1065,6 +1067,108 @@ describe("#4547: opt-in local fallback in mode remote when no pool agent is onli
1065
1067
  });
1066
1068
  });
1067
1069
 
1070
+ describe("#5112: late dependents of a decomposed issue, edge re-read on --requeue, capacity fallback_local", () => {
1071
+ const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
1072
+ it("#5112 an issue added after its dependency decomposed waits on the sub-issues, not skipped", async () => {
1073
+ const bodies = { 1: "", 3: "Depends on #1", 5: "Depends on #1", 6: "", 11: "", 12: "" };
1074
+ let added = [];
1075
+ const started = [], finished = [], log = [], g = gate();
1076
+ const done = orchestrate({ issues: [1, 3], parallel: 4, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getAdded: () => added, onUpdate: (r) => log.push(r),
1077
+ runOne: async (n) => {
1078
+ started.push(n);
1079
+ if (n === 1) return { terminalReason: "decomposed", subissues: [11, 12] };
1080
+ if (n === 11 || n === 12) await g.p;
1081
+ finished.push(n);
1082
+ return { terminalReason: "merged", pr: 100 + n };
1083
+ } });
1084
+ await until(() => started.includes(11) && started.includes(12));
1085
+ added = [5];
1086
+ await until(() => log.some((r) => r.outcome === "added" && /#5/.test(r.detail ?? "")));
1087
+ added = [5, 6]; // an unrelated later --add re-reads the pending bodies: #3's edge to #1 must stay resolved
1088
+ await until(() => log.some((r) => r.outcome === "added" && /#6/.test(r.detail ?? "")));
1089
+ g.open();
1090
+ const rows = await done;
1091
+ const out = Object.fromEntries(rows.map((r) => [r.issue, r.outcome]));
1092
+ assert.equal(out[5], "merged", JSON.stringify(rows.find((r) => r.issue === 5)));
1093
+ assert.equal(out[3], "merged", JSON.stringify(rows.find((r) => r.issue === 3)));
1094
+ for (const n of [3, 5]) assert.ok(finished.indexOf(n) > finished.indexOf(11) && finished.indexOf(n) > finished.indexOf(12), `#${n} ran after the sub-issues`);
1095
+ });
1096
+
1097
+ it("#5112 --requeue re-reads the edited Depends on edges", async () => {
1098
+ const bodies = { 1: "", 2: "Depends on #1", 3: "" };
1099
+ let entries = [];
1100
+ const started = [], log = [], decisions = [], g3 = gate();
1101
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getRequeued: () => entries,
1102
+ decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
1103
+ runOne: async (n) => {
1104
+ started.push(n);
1105
+ if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
1106
+ return n === 1 ? { terminalReason: "needs-human", detail: "builder.blocked: product-decision" } : { terminalReason: "merged" };
1107
+ } });
1108
+ await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
1109
+ bodies[2] = ""; // the operator dropped the wrong edge
1110
+ entries = [{ issue: 2, reason: "edge dropped", by: "operator", at: "2026-11-05T00:00:00Z" }];
1111
+ await until(() => started.includes(2), 3000);
1112
+ g3.open();
1113
+ const rows = await done;
1114
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "needs-human"], [2, "merged"], [3, "merged"]]);
1115
+ assert.ok(decisions.some((d) => d.issue === 2 && /edges re-read: #2 -> none/.test(d.what)));
1116
+ });
1117
+
1118
+ it("#5112 --requeue of a dependent skipped behind a decomposed issue waits on its sub-issues", async () => {
1119
+ const saved = { v: 1, order: [1, 2, 11], deps: { 1: [], 2: [1], 11: [] }, pending: [11],
1120
+ results: { 1: { issue: 1, outcome: "decomposed", subissues: [11] }, 2: { issue: 2, outcome: "skipped", detail: "dependency #1 ended decomposed" } } };
1121
+ let entries = [];
1122
+ const finished = [], g = gate();
1123
+ const done = orchestrate({ issues: [], parallel: 2, pollMs: 5, readBody: async (n) => (n === 2 ? "Depends on #1" : ""), getRequeued: () => entries,
1124
+ store: { load: () => saved, save: () => {} },
1125
+ runOne: async (n) => { if (n === 11) await g.p; finished.push(n); return { terminalReason: "merged" }; } });
1126
+ entries = [{ issue: 2, reason: "parent decomposed", by: "operator", at: "2026-11-05T00:00:00Z" }];
1127
+ await new Promise((r) => setTimeout(r, 50));
1128
+ g.open();
1129
+ const rows = await done;
1130
+ assert.equal(rows.find((r) => r.issue === 2)?.outcome, "merged");
1131
+ assert.deepEqual(finished, [11, 2]);
1132
+ });
1133
+
1134
+ const runCap = async (remoteExtra) => {
1135
+ const c = { t: 1_000_000 };
1136
+ const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
1137
+ const where = [], decisions = [];
1138
+ let remoteTries = 0;
1139
+ const rows = await orchestrate({ issues: [1], parallel: 1, now: () => c.t, sleep, pollMs: 1, readBody: async () => "", decide: (d) => decisions.push(d),
1140
+ remote: { max: 1, isRemote: () => true, capacity: () => ({ free: null, online: 1, at: 1 }), ...remoteExtra },
1141
+ runOne: async (n, _ln, ctx = {}) => {
1142
+ where.push(ctx.placement ?? { where: "remote" });
1143
+ if (ctx.placement?.where === "local") return { terminalReason: "merged", pr: 101 };
1144
+ remoteTries++;
1145
+ return remoteTries >= 5 ? { terminalReason: "merged", pr: 101 } : { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "HTTP 503 capacity" };
1146
+ } });
1147
+ return { rows, where, remoteTries, decisions };
1148
+ };
1149
+ it("#5112 fallback_local runs a lane locally after N sandbox.capacity deferrals when the host has headroom", async () => {
1150
+ const { rows, where, remoteTries, decisions } = await runCap({ offlineFallback: true, capacityFallbackAfter: 2, headroom: () => ({ ok: true, detail: "" }) });
1151
+ assert.equal(rows[0].outcome, "merged");
1152
+ assert.equal(remoteTries, 2);
1153
+ assert.equal(where.at(-1).where, "local");
1154
+ assert.match(where.at(-1).reason, /pool at capacity after 2 deferrals/);
1155
+ assert.equal(decisions.filter((d) => d.issue === 1 && /capacity/.test(d.what)).length, 1);
1156
+ });
1157
+ it("#5112 a busy host (headroom not ok, or a throwing probe) or fallback_local off keeps the lane remote", async () => {
1158
+ for (const extra of [{ offlineFallback: true, capacityFallbackAfter: 2, headroom: () => ({ ok: false, detail: "mem" }) },
1159
+ { offlineFallback: true, capacityFallbackAfter: 2, headroom: () => { throw new Error("probe"); } }, { capacityFallbackAfter: 2 }]) {
1160
+ const { where, remoteTries } = await runCap(extra);
1161
+ assert.equal(remoteTries, 5);
1162
+ assert.ok(where.every((w) => w.where !== "local"));
1163
+ }
1164
+ });
1165
+ it("#5112 without capacityFallbackAfter the default CAPACITY_FALLBACK_AFTER applies", async () => {
1166
+ const { remoteTries, where } = await runCap({ offlineFallback: true });
1167
+ assert.equal(remoteTries, CAPACITY_FALLBACK_AFTER);
1168
+ assert.equal(where.at(-1).where, "local");
1169
+ });
1170
+ });
1171
+
1068
1172
  describe("#4788: a pool offline for longer than N minutes is reported once", () => {
1069
1173
  const run = async (onlineAt) => {
1070
1174
  const c = { t: 1_000_000 };
@@ -266,9 +266,20 @@ export function tmpdirInputs(command) {
266
266
 
267
267
  const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does not create; the run TMPDIR is per run, so the engine's rerun and red-green check will not find it. Create the file inside the command (\`… > ${p} && …\`) or use a path the repo tracks`;
268
268
 
269
- const E2E_WORD_RE = /\b(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)\b/i;
270
- const WEAKEN_RE = /\b(?:no|not|without|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|instead of|rather than|in lieu of|swap\w*)\b/i;
271
- const MOCKED_RE = /\b(?:mock\w*|stub\w*|fake\w*|unit|vitest|jest|component tests?|route tests?)\b/i;
269
+ const E2E_TERM = String.raw`(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)`;
270
+ const E2E_WORD_RE = new RegExp(String.raw`\b${E2E_TERM}\b`, "i");
271
+ const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit(?:[- ]tests?)?|vitest|jest|component tests?|route tests?)`;
272
+ const MOCKED_RE = new RegExp(String.raw`\b${MOCKED_TERM}\b`, "i");
273
+ // #5116: a negated mock ("do not mock", "no stubs") or a fake clock is not a substitute for the e2e check.
274
+ const NEG_MOCK_RE = /\b(?:do(?:es)? not|don't|never|no|not|without)\s+(?:use\s+|any\s+)?(?:mock\w*|stub\w*|fake\w*)/gi;
275
+ // #5130: a negated weakener ("do not skip the e2e suite", "e2e tests are not replaced") keeps the e2e check.
276
+ const NEG_DROP_RE = /\b(?:do(?:es)? not|don't|never|not)\s+(?:be\s+|been\s+|being\s+)?(?:skip|drop|replac|swap|downgrad)\w*/gi;
277
+ const FAKE_CLOCK_RE = /\bfake[- ](?:timers?|clocks?)\b/gi;
278
+ // A weakener governs the e2e term only within a few words and the same clause (no . or ; between them).
279
+ const GAP = String.raw`(?:[\s,]+\w+){0,5}?[\s,]+`;
280
+ const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:no|not|without|never|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP}${E2E_TERM}\b`, "i");
281
+ const DROP_AFTER_RE = new RegExp(String.raw`\b${E2E_TERM}${GAP}(?:replaced|dropped|skipped|swapped|downgraded|unnecessary|(?:not|no longer)\s+(?:needed|required|necessary|run\w*|used|executed))\b`, "i");
282
+ const INSTEAD_OF_RE = new RegExp(String.raw`\b${MOCKED_TERM}[^.;]{0,60}?\b(?:instead of|rather than|in lieu of)\b${GAP}${E2E_TERM}\b`, "i");
272
283
  const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
273
284
  const NEG_PROBE_RE = /\b(?:no|not|without|never)\b[^.;]{0,30}(?:forge-probe|acceptance[- ]probe)/i;
274
285
  /**
@@ -282,7 +293,9 @@ export function weakenedE2eDecisions(c) {
282
293
  const elsewhere = (id) => JSON.stringify({ r: c?.requirements ?? [], d: c?.deliverables ?? [], k: decisions.filter((k) => k?.id !== id) });
283
294
  return decisions.filter((k) => {
284
295
  const choice = String(k?.choice ?? "");
285
- if (!E2E_WORD_RE.test(choice) || !WEAKEN_RE.test(choice) || !MOCKED_RE.test(choice)) return false;
296
+ if (!E2E_WORD_RE.test(choice)) return false;
297
+ const mocked = choice.replace(NEG_MOCK_RE, " ").replace(FAKE_CLOCK_RE, " ").replace(NEG_DROP_RE, " ");
298
+ if (!MOCKED_RE.test(mocked) || !(DROP_BEFORE_RE.test(mocked) || DROP_AFTER_RE.test(mocked) || INSTEAD_OF_RE.test(mocked))) return false;
286
299
  const own = `${choice} ${k?.justification ?? ""}`;
287
300
  return !((PROBE_RE.test(own) && !NEG_PROBE_RE.test(own)) || /forge-probe/i.test(elsewhere(k?.id)));
288
301
  }).map((k) => k.id).filter(Boolean);
@@ -1228,7 +1241,7 @@ export function bucketProblems(r) {
1228
1241
  const POST_PROMOTION_RE = /(?:after|post)[- ]?(?:promotion|release|deploy)|in production after|production[- ]verif|promot(?:e|ion)|rollout|purge|deploy[- ]time/i;
1229
1242
  export const READONLY_PROD_RE = /\bssh\s+[\w.-]*-monitor\b|\bobs-psql\b|\bprod(?:uction)?[ _-]monitor\b|\bread[- ]only\b[^.]{0,40}\bprod(?:uction)?\b|\bprod(?:uction)?\b[^.]{0,30}\b(?:query|queries|capture|telemetry|logs?)\b/i;
1230
1243
  /** An item that truly needs the outside world (or a write to production) stays external even when it also reads production. */
1231
- export const EXTERNAL_NEED_RE = /credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?\b|private[- ]key|\blogin\b|\bsudo\b|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|\b(?:delete|drop|truncate|wipe|destroy|modify|migrate|write)\b/i;
1244
+ export const EXTERNAL_NEED_RE = /credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?\b|private[- ]key|\blogin\b|\bsudo\b|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|\b(?:delete|drop|truncate|wipe|destroy|modify|migrate|write|update|restart|rotate|remove|grant|alter)\b/i;
1232
1245
  /**
1233
1246
  * #5078: where an owner deferral goes. `owner:orchestrator`, `owner:promote:*` or post-promotion text -> post-deploy (the
1234
1247
  * rollup); read-only production data or capture (with no credential/spend/account/third-party/customer need) -> host; else external (credentials, spend, an account, a third party or a customer).
@@ -1325,6 +1325,24 @@ describe("#5087 a plan decision may not weaken an e2e/integration criterion to m
1325
1325
  assert.deepEqual(weakenedE2eDecisions({ ...C, deliverables: [...C.deliverables, { id: "D9", path: "x.md", change: "declare a forge-probe block" }], decisions: [K2] }), []);
1326
1326
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ ...K2, justification: "no acceptance probe" }] }), ["K2"]);
1327
1327
  });
1328
+ it("#5116 negated or strengthening wording is not a weakening; real weakenings still are", () => {
1329
+ for (const choice of ["Run the Playwright e2e suite against the live stack; do not mock the API", "Integration tests use fake timers, no sleeps", "E2E check hits the real server rather than a stubbed one"]) {
1330
+ const c = { ...C, decisions: [{ id: "K1", choice, source: "doc" }] };
1331
+ assert.deepEqual(weakenedE2eDecisions(c), [], choice);
1332
+ assert.ok(!validateContract(c, ISSUE).problems.some((p) => p.startsWith("K1:")), choice);
1333
+ }
1334
+ for (const choice of ["No Playwright e2e; integration coverage via route and component tests", "Replace the e2e test with mocked unit tests", "Skip e2e, use vitest instead", "Use mocked unit tests instead of the e2e check"]) {
1335
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1336
+ }
1337
+ });
1338
+ it("#5130 double negatives and word prefixes are not weakenings; trailing negations and distant weakeners are", () => {
1339
+ for (const choice of ["Do not skip the e2e suite; mock the mailer", "E2E tests are not replaced by mocks", "Skip the e2e run for billing units", "No integration with United Airlines API", "Skip e2e; ship the Unity build"]) {
1340
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
1341
+ }
1342
+ for (const choice of ["Mock everything; the e2e suite is not needed", "e2e is not run, jest mocks cover it", "e2e tests are not required, rely on mocked unit tests", "e2e suite no longer runs; mocks used", "Skip the slow flaky nightly e2e and use mocks", "Use stubbed unit tests, e2e not needed"]) {
1343
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1344
+ }
1345
+ });
1328
1346
  it("leaves unrelated decisions and contracts without decisions alone", () => {
1329
1347
  assert.deepEqual(weakenedE2eDecisions(C), []);
1330
1348
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);
@@ -1383,4 +1401,12 @@ describe("release review #5103", () => {
1383
1401
  }
1384
1402
  assert.equal(route5103({ bucket: "owner:decide", why: "Should we delete production data older than 90 days?" }), "external");
1385
1403
  });
1404
+ it("#5115: production-mutating verbs naming a prod monitor stay external, never host", () => {
1405
+ for (const why of ["ssh ovh-monitor and restart the worker", "rotate the prod monitor webhook", "update the production telemetry config",
1406
+ "remove the stale prod monitor alert", "grant read-only prod access to the analyst", "alter the production query timeout"]) {
1407
+ assert.equal(RO5103.test(why), true, `read-only prod phrase: ${why}`);
1408
+ assert.equal(host(why), false, why);
1409
+ assert.equal(route5103({ bucket: "owner:x", why }), "external", why);
1410
+ }
1411
+ });
1386
1412
  });
@@ -94,11 +94,22 @@ export const ownedModelEnv = (subagentModel = null) => {
94
94
  const m = sanitizeCliModel(subagentModel);
95
95
  return { ...(m ? { CLAUDE_CODE_SUBAGENT_MODEL: m } : {}), CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "0" };
96
96
  };
97
- /** The one `--settings` of a lean session: the owned model env, plus the re-read hooks for builder sessions (#4456). */
98
- export const leanSettingsFlags = ({ subagentModel = null, reread = false } = {}) =>
99
- ["--settings", JSON.stringify({ env: ownedModelEnv(subagentModel), ...(reread ? { hooks: rereadHooks() } : {}) })];
97
+ /** #5118: ForgeDock's pre-tool-use guard (bin/hooks/pre-tool-use.mjs, rule 6 blocks Claude attribution trailers). */
98
+ export const PRE_TOOL_USE_HOOK = fileURLToPath(new URL("../hooks/pre-tool-use.mjs", import.meta.url));
99
+ /** #5118: a lane container has no operator settings.json, so it carries neither the guard nor the attribution pins. */
100
+ export const inSandbox = (env = process.env) => env.IS_SANDBOX === "1" || Boolean(env.CS_SANDBOX_ID);
101
+ /**
102
+ * The one `--settings` of a lean session: the owned model env, the attribution pins (#5118: Claude Code's default
103
+ * Co-Authored-By trailer off, whatever the config dir holds), the re-read hooks for builder sessions (#4456), and in a
104
+ * sandbox the pre-tool-use guard on Bash (#5118: second line of defence; local lanes get it from operator settings).
105
+ */
106
+ export const leanSettingsFlags = ({ subagentModel = null, reread = false, guard = false } = {}) => {
107
+ const hooks = reread ? rereadHooks() : {};
108
+ if (guard) hooks.PreToolUse = [...(hooks.PreToolUse ?? []), { matcher: "Bash", hooks: [{ type: "command", command: `${JSON.stringify(process.execPath)} ${JSON.stringify(PRE_TOOL_USE_HOOK)}` }] }];
109
+ return ["--settings", JSON.stringify({ env: ownedModelEnv(subagentModel), includeCoAuthoredBy: false, attribution: { commit: "", pr: "" }, ...(Object.keys(hooks).length ? { hooks } : {}) })];
110
+ };
100
111
 
101
- export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null } = {}) {
112
+ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null, sandboxed = inSandbox() } = {}) {
102
113
  return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
103
114
  const bin = resolveClaudeCliBinary(cwd) || "claude";
104
115
  const caps = getClaudeCliCapabilities(cwd);
@@ -106,7 +117,7 @@ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRun
106
117
  const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
107
118
  // #4843 owned models, in extraArgs so FORGEDOCK_FULL_SESSION=1 (which drops sessionFlags) keeps them (#4848 BUG-1);
108
119
  // #4456 re-read hooks: builder sessions only, and like the other session flags not in a full session
109
- const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1" });
120
+ const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1", guard: sandboxed });
110
121
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
111
122
  args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: [...(resume ? ["--resume", resume] : []), ...settings],
112
123
  logger: { log() {} },
@@ -32,6 +32,9 @@
32
32
  * `openExecutionBudget` enforces the hard caps (`budget_minutes`/`budget_usd` per batch, `day_budget_*` per host UTC day,
33
33
  * from the host-day store `execution-budget.json` in the sched dir): pure arithmetic, no LLM.
34
34
  *
35
+ * #5112: `execution.capacity_fallback_after` (positive integer, `capacityFallbackAfter`) is how many `sandbox.capacity` deferrals of one
36
+ * issue make `fallback_local` run it on the host (orchestrate `CAPACITY_FALLBACK_AFTER` when unset), gated by `hostHeadroom`.
37
+ *
35
38
  * Hybrid (#4256): `placeLane` places each lane from facts (lane kind, validation weight, host headroom, verify.mode);
36
39
  * orchestrate runs a remote-placed lane locally when `max_remote` or the execution budget is exhausted.
37
40
  *
@@ -113,9 +116,12 @@ export function parseExecution(sec = {}) {
113
116
  let endpoint = null;
114
117
  try { const u = new URL(str(s.endpoint)); if (u.protocol === "https:" || u.protocol === "http:") endpoint = str(s.endpoint); } catch { endpoint = null; }
115
118
  const tokenEnv = ENV_RE.test(str(s.token_env)) ? str(s.token_env) : null;
119
+ const tokenFile = str(s.token_file) || null; // #5131: explicit pool/remote token file
116
120
  const cacheStore = str(s.cache_store) || null;
117
121
  const allowActions = str(s.allow_actions).toLowerCase() === "true";
118
122
  const fallbackLocal = str(s.fallback_local).toLowerCase() === "true";
123
+ const cfa = Number(str(s.capacity_fallback_after));
124
+ const capacityFallbackAfter = Number.isInteger(cfa) && cfa > 0 ? cfa : null; // #5112: unset: orchestrate's CAPACITY_FALLBACK_AFTER
119
125
  const heavyRaw = str(s.heavy).toLowerCase();
120
126
  const heavy = heavyRaw === "true" ? true : heavyRaw === "false" ? false : null;
121
127
  const cap = (v) => { const t = str(v), x = Number(t); return t !== "" && Number.isFinite(x) && x >= 0 ? x : null; };
@@ -133,7 +139,7 @@ export function parseExecution(sec = {}) {
133
139
  const uw = Number(str(s.unreachable_window_min));
134
140
  const accountRotator = str(s.account_rotator) === "tokenbaton" ? "tokenbaton" : null; // #5094: rotator.mjs reads its per-account quota state
135
141
  const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
136
- return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}), ...(accountRotator ? { accountRotator } : {}) };
142
+ return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(tokenFile ? { tokenFile } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(capacityFallbackAfter !== null ? { capacityFallbackAfter } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}), ...(accountRotator ? { accountRotator } : {}) };
137
143
  }
138
144
 
139
145
  const round1 = (x) => Math.round(x * 10) / 10;
@@ -480,18 +486,47 @@ export function containerProvider({ batch, repo, image, gitDir, cacheDir = `${ho
480
486
 
481
487
  const typedError = (code, message) => Object.assign(new Error(message), { code });
482
488
 
489
+ /** #5131: an endpoint as `scheme://host:port` (default ports filled, no path or trailing slash); null when unparsable. */
490
+ const endpointKey = (u) => { try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname}:${x.port || (x.protocol === "https:" ? "443" : "80")}`; } catch { return null; } };
491
+
492
+ /** #5131: the trimmed contents of a token file (`~/` is the home dir); null when unset, unreadable or empty. */
493
+ const readTokenFile = (file) => {
494
+ if (!file) return null;
495
+ try { return readFileSync(String(file).startsWith("~/") ? join(homedir(), String(file).slice(2)) : String(file), "utf8").trim() || null; } catch { return null; }
496
+ };
497
+
498
+ /**
499
+ * #5131: the one pool/remote credential resolver, shared by both providers, `remoteTransportFor` and `preflight.pool_auth`:
500
+ * `token_env` from the env, then the keychain; then `execution.token_file`; then the local router's `forgedock_token_file`
501
+ * (`router`, poolRouterConfig output): always for `pool`, for `remote` only when its endpoint is unset or the router's.
502
+ * Returns `{token, source}`; `source` names where it came from (env name, `token_file`, `forgedock_token_file`), never the value.
503
+ */
504
+ export function poolCredential(exec = {}, { env = process.env, router = null, keychain } = {}) {
505
+ if (exec?.tokenEnv) {
506
+ const v = resolveCredential(exec.tokenEnv, { env, ...(keychain ? { keychain } : {}) });
507
+ if (v) return { token: v, source: exec.tokenEnv };
508
+ }
509
+ const f = readTokenFile(exec?.tokenFile);
510
+ if (f) return { token: f, source: "token_file" };
511
+ const routerOk = Boolean(router?.token) && ((exec?.provider ?? "pool") === "pool" || !exec?.endpoint || endpointKey(exec.endpoint) === endpointKey(router.endpoint));
512
+ return routerOk ? { token: router.token, source: "forgedock_token_file" } : { token: null, source: null };
513
+ }
514
+
483
515
  /**
484
516
  * The remote transport for forge.yaml `execution` settings: `opts.transport` when injected, else `httpTransport` on
485
- * `exec.endpoint` with the credential `exec.tokenEnv` (env, then keychain). Throws `execution.provider.misconfigured`
517
+ * `exec.endpoint` with the credential `exec.tokenEnv` from `credentials`, or by default poolCredential (#5131: env,
518
+ * keychain, `token_file`, the matching local router's token). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
486
519
  * without an endpoint, `execution.provider.actions_refused` for a GitHub Actions endpoint without `allow_actions`.
487
520
  */
488
- export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = (n) => resolveCredential(n, { env }) } = {}) {
521
+ export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn } = {}) {
489
522
  if (transport) return transport;
490
523
  if (!exec?.endpoint) throw typedError("execution.provider.misconfigured", "execution.provider remote needs execution.endpoint");
491
524
  if (isActionsEndpoint(exec.endpoint) && exec.allowActions !== true) {
492
525
  throw typedError("execution.provider.actions_refused", "execution.endpoint is a GitHub Actions API; set execution.allow_actions: true to spend Actions minutes");
493
526
  }
494
- return httpTransport({ endpoint: exec.endpoint, token: exec.tokenEnv ? credentials(exec.tokenEnv) : null });
527
+ const token = credentials ? (exec.tokenEnv ? credentials(exec.tokenEnv) : null)
528
+ : poolCredential({ provider: "remote", ...exec }, { env, router: router !== undefined ? router : poolRouterConfig({ env }) }).token;
529
+ return httpTransport({ endpoint: exec.endpoint, token, ...(fetchFn ? { fetchFn } : {}) });
495
530
  }
496
531
 
497
532
  /**
@@ -510,26 +545,28 @@ export function poolRouterConfig({ env = process.env, path = env.COMPUTESHARE_CO
510
545
  export function providerByName(name, opts) {
511
546
  if (name === "local") return localProvider(opts);
512
547
  if (name === "container") return containerProvider(opts);
513
- if (name === "remote") {
514
- const exec = opts?.exec ?? {};
515
- const env = opts?.env ?? process.env;
516
- return remoteProvider({ batch: opts.batch, repo: opts.repo, settings: { ...exec, ...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }, env, transport: remoteTransportFor(exec, { transport: opts?.transport, env }), ...(opts?.now ? { now: opts.now } : {}) });
517
- }
518
- if (name === "pool") {
519
- // #4546: `remote` with the endpoint and token defaulted to the local router, and the repo's forge.yaml sent along.
548
+ if (name === "remote" || name === "pool") {
520
549
  const env = opts?.env ?? process.env;
521
550
  const exec = opts?.exec ?? {};
522
- const router = opts?.transport || exec.endpoint ? null : poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
523
- if (!opts?.transport && !exec.endpoint && !router) throw typedError("execution.provider.misconfigured", "execution.provider pool needs a local router config (forgedock pool router) or execution.endpoint");
551
+ const pool = name === "pool";
552
+ // #4546: `pool` is `remote` with the endpoint and token defaulted to the local router, and the repo's forge.yaml sent along.
553
+ // #5131: `remote` reads the router config too, for the token fallback when its endpoint is the local router's.
554
+ const router = opts?.transport || (pool && exec.endpoint) ? null : poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
555
+ if (pool && !opts?.transport && !exec.endpoint && !router) throw typedError("execution.provider.misconfigured", "execution.provider pool needs a local router config (forgedock pool router) or execution.endpoint");
524
556
  let forgeYaml = null;
525
- try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; }
526
- // The router's token is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
527
- const tokenEnv = exec.tokenEnv ?? (router?.token ? "FORGEDOCK_POOL_TOKEN" : undefined);
528
- // #4685: an empty env/keychain lookup of a configured token_env falls back to the router's token file.
529
- const credentials = (n) => (n === "FORGEDOCK_POOL_TOKEN" && !exec.tokenEnv ? router?.token ?? null : resolveCredential(n, { env }) ?? router?.token ?? null);
530
- const settings = { ...exec, ...(exec.endpoint ? {} : router ? { endpoint: router.endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
557
+ if (pool) { try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; } }
558
+ const endpoint = exec.endpoint ?? (pool ? router?.endpoint : undefined);
559
+ const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router });
560
+ const resolved = resolve();
561
+ // A token read from a file is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
562
+ const fixed = pool ? "FORGEDOCK_POOL_TOKEN" : "FORGEDOCK_REMOTE_TOKEN";
563
+ const tokenEnv = exec.tokenEnv ?? (resolved.token ? fixed : undefined);
564
+ // #4685/#5131: an empty env/keychain lookup of `token_env` falls back to `token_file`, then the (matching) router's token file.
565
+ const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env })); // per call, like resolveCredential
566
+ const settings = { ...exec, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
531
567
  ...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }; // #4778: dispatch-time relay pre-fetch
532
- return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, name: "pool", env, credentials, transport: remoteTransportFor(settings, { transport: opts?.transport, env, credentials }), ...(opts?.now ? { now: opts.now } : {}) });
568
+ return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, ...(pool ? { name: "pool" } : {}), env, credentials,
569
+ transport: remoteTransportFor(settings, { transport: opts?.transport, env, credentials, ...(opts?.fetchFn ? { fetchFn: opts.fetchFn } : {}) }), ...(opts?.now ? { now: opts.now } : {}) });
533
570
  }
534
571
  const e = new Error(`unknown execution.provider ${JSON.stringify(name)} (known: ${SANDBOX_PROVIDERS.join(", ")})`);
535
572
  e.code = "execution.provider.unknown";
@@ -7,7 +7,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { renderContract } from "./render.mjs";
10
- import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, remoteHoldId, usageLimitInTail, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
10
+ import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, remoteHoldId, usageLimitInTail, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget, poolCredential, remoteTransportFor } from "./execution.mjs";
11
11
  import { makeLedger, countLiveLanes } from "./ledger.mjs";
12
12
  import { openBatch } from "./batch.mjs";
13
13
  import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
@@ -716,6 +716,10 @@ describe("#4547: fallback_local and agent liveness", () => {
716
716
  assert.equal(parseExecution({ mode: "remote", max_remote: "2", fallback_local: "true" }).fallbackLocal, true);
717
717
  assert.equal(parseExecution({ mode: "remote", fallback_local: "yes" }).fallbackLocal, undefined);
718
718
  });
719
+ it("#5112 parseExecution: capacity_fallback_after is a positive integer, absent otherwise", () => {
720
+ assert.equal(parseExecution({ mode: "remote", fallback_local: "true", capacity_fallback_after: "5" }).capacityFallbackAfter, 5);
721
+ for (const v of [undefined, "", "0", "-1", "2.5", "x"]) assert.equal("capacityFallbackAfter" in parseExecution({ mode: "remote", capacity_fallback_after: v }), false, String(v));
722
+ });
719
723
  it("transport capacity carries free and liveness; none valid is unknown", async () => {
720
724
  const { httpTransport } = await import("./sandbox-remote.mjs");
721
725
  const cap = async (data, ok = true) => httpTransport({ endpoint: "http://pool.test", token: "t", fetchFn: async () => ({ ok, status: ok ? 200 : 500, headers: new Headers(), text: async () => JSON.stringify(data) }) }).capacity();
@@ -901,3 +905,66 @@ describe("#5095: launch 5xx and a pool usage limit are deferred, never needs-hum
901
905
  assert.equal(plain.terminalReason, "engine-error");
902
906
  });
903
907
  });
908
+
909
+ describe("remote/pool credential resolver (#5131)", () => {
910
+ /** A router config dir: router.json listening on 127.0.0.1:7710 and the sibling forgedock.token. */
911
+ const routerDir = () => {
912
+ const d = tmpDir("router-5131-");
913
+ writeFileSync(join(d, "router.json"), JSON.stringify({ listen: { host: "127.0.0.1", port: 7710 } }));
914
+ writeFileSync(join(d, "forgedock.token"), "router-tok\n");
915
+ return d;
916
+ };
917
+ const recorder = () => {
918
+ const seen = [];
919
+ const fetchFn = async (url, init = {}) => { seen.push({ url: String(url), auth: init.headers?.authorization ?? null }); return new Response("{}", { status: 200 }); };
920
+ return { seen, fetchFn };
921
+ };
922
+ const remoteVia = (exec, { fetchFn, dir }) => providerByName("remote", { batch: { id: "b5131" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", ...exec }),
923
+ env: {}, poolConfigPath: join(dir, "router.json"), fetchFn });
924
+
925
+ it("5131: providerByName remote with no env/keychain token sends the router token file's bearer only to the router's endpoint", async () => {
926
+ const dir = routerDir();
927
+ const hit = recorder();
928
+ await remoteVia({ endpoint: "http://127.0.0.1:7710/" }, { fetchFn: hit.fetchFn, dir }).capacity();
929
+ assert.ok(hit.seen.length >= 1);
930
+ assert.equal(hit.seen[0].auth, "Bearer router-tok");
931
+ const miss = recorder();
932
+ await remoteVia({ endpoint: "https://runner.example.com" }, { fetchFn: miss.fetchFn, dir }).capacity();
933
+ assert.ok(miss.seen.length >= 1);
934
+ assert.equal(miss.seen[0].auth, null, "the local router token never goes to another endpoint");
935
+ });
936
+
937
+ it("5131: poolCredential order is token_env (env, keychain), token_file, then the matching router token; source never holds the value", () => {
938
+ const dir = routerDir();
939
+ const file = join(dir, "explicit.token");
940
+ writeFileSync(file, " file-tok \n");
941
+ const router = { endpoint: "http://127.0.0.1:7710", token: "router-tok" };
942
+ const keychain = () => null;
943
+ assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "http://127.0.0.1:7710" }, { env: { COMPUTESHARE_TOKEN: "env-tok" }, router, keychain }), { token: "env-tok", source: "COMPUTESHARE_TOKEN" });
944
+ assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "http://127.0.0.1:7710" }, { env: {}, router, keychain: () => "kc-tok" }), { token: "kc-tok", source: "COMPUTESHARE_TOKEN" });
945
+ assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "http://127.0.0.1:7710/" }, { env: {}, router, keychain }), { token: "router-tok", source: "forgedock_token_file" });
946
+ assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", tokenFile: file, endpoint: "https://x.example" }, { env: {}, router, keychain }), { token: "file-tok", source: "token_file" });
947
+ assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "https://x.example" }, { env: {}, router, keychain }), { token: null, source: null });
948
+ assert.deepEqual(poolCredential({ provider: "pool", endpoint: "https://x.example" }, { env: {}, router, keychain }), { token: "router-tok", source: "forgedock_token_file" });
949
+ assert.deepEqual(poolCredential({ provider: "remote", tokenFile: join(dir, "missing.token") }, { env: {}, router: null }), { token: null, source: null });
950
+ });
951
+
952
+ it("5131: execution.token_file is parsed and sent as the bearer to any endpoint", async () => {
953
+ const dir = routerDir();
954
+ const file = join(dir, "explicit.token");
955
+ writeFileSync(file, "file-tok\n");
956
+ assert.equal(parseExecution({ token_file: file }).tokenFile, file);
957
+ assert.equal(parseExecution({ token_file: "" }).tokenFile, undefined);
958
+ const hit = recorder();
959
+ await remoteVia({ endpoint: "https://runner.example.com", token_file: file }, { fetchFn: hit.fetchFn, dir }).capacity();
960
+ assert.equal(hit.seen[0].auth, "Bearer file-tok");
961
+ });
962
+
963
+ it("5131: remoteTransportFor's default credentials (gc) fall back to the matching router token", async () => {
964
+ const hit = recorder();
965
+ const t = remoteTransportFor({ endpoint: "http://127.0.0.1:7710" }, { env: {}, router: { endpoint: "http://127.0.0.1:7710", token: "router-tok" }, fetchFn: hit.fetchFn });
966
+ await t.capacity();
967
+ assert.equal(hit.seen[0].auth, "Bearer router-tok");
968
+ assert.throws(() => remoteTransportFor({}, { env: {}, router: null }), (e) => e.code === "execution.provider.misconfigured");
969
+ });
970
+ });