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.
- package/bin/engine-cli.mjs +18 -10
- package/bin/lean/batch.test.mjs +106 -2
- package/bin/lean/conformance.mjs +18 -5
- package/bin/lean/conformance.test.mjs +26 -0
- package/bin/lean/effects.mjs +16 -5
- package/bin/lean/execution.mjs +57 -20
- package/bin/lean/execution.test.mjs +68 -1
- package/bin/lean/lanecheck.mjs +44 -12
- package/bin/lean/lanecheck.test.mjs +23 -0
- package/bin/lean/lean.mjs +20 -3
- package/bin/lean/lean.test.mjs +20 -0
- package/bin/lean/orchestrate.mjs +66 -15
- package/bin/lean/prompts.mjs +15 -2
- package/bin/lean/review-trigger.mjs +25 -0
- package/bin/lean/review-trigger.test.mjs +28 -1
- package/bin/lean/schedd.mjs +11 -3
- package/bin/lean/schedd.test.mjs +20 -3
- package/docs/CONFIG.md +3 -1
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +1 -1
- package/scripts/owner-route-parity.test.sh +6 -0
- package/scripts/plan-validate-live.mjs +24 -7
package/bin/engine-cli.mjs
CHANGED
|
@@ -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
|
|
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
|
|
206
|
-
|
|
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) {
|
|
213
|
-
if (indent <= listIndent) {
|
|
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)
|
|
218
|
+
if (li) lists[listKey].push(li[1].trim());
|
|
216
219
|
}
|
|
217
|
-
|
|
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 =
|
|
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 ?? "?";
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -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
|
|
270
|
-
const
|
|
271
|
-
const
|
|
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)
|
|
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
|
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
98
|
-
export const
|
|
99
|
-
|
|
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() {} },
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -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`
|
|
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 =
|
|
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
|
-
|
|
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
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const
|
|
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"
|
|
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
|
+
});
|