forgedock 1.36.0 → 1.37.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.
Files changed (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -0,0 +1,177 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { buildGuard, guardTests, parseHunks, renderGuard, parseGuard, guardIssue, listGuards, backfill, guardsCli, readIndex, guardAfterMerge, historyPreserve, withHistoryRows, writeIndex } from "./guards.mjs";
6
+ import { renderContract } from "./render.mjs";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+
9
+ const DIFF = `diff --git a/src/a.mjs b/src/a.mjs
10
+ --- a/src/a.mjs
11
+ +++ b/src/a.mjs
12
+ @@ -10,2 +10,3 @@ export function fixed() {
13
+ -x
14
+ +y
15
+ +z
16
+ @@ -30 +31,0 @@ function other() {
17
+ -gone
18
+ diff --git a/test/a.test.mjs b/test/a.test.mjs
19
+ --- a/test/a.test.mjs
20
+ +++ b/test/a.test.mjs
21
+ @@ -1,0 +2,3 @@
22
+ +t
23
+ `;
24
+ const nodeContract = { repro_test: { path: "test/a.test.mjs", id: "fixed keeps x", command: "node --test test/a.test.mjs" }, validate: [{ id: "V1", command: "node --test test/a.test.mjs --test-name-pattern=\"fixed keeps x\"" }] };
25
+ const pyContract = { repro_test: { path: "tests/test_a.py", command: "pytest -q tests/test_a.py::test_fixed" }, validate: [{ id: "V1", command: "pytest -q tests/test_a.py -k fixed" }, { id: "V2", command: "pytest -q tests/test_b.py" }] };
26
+
27
+ test("node --test fixture: file and id, hunks exclude test files with symbol and deletion anchor", () => {
28
+ const { guard } = buildGuard({ issue: 7, title: "Fix x\nbug", pr: 9, mergeSha: "abc", contract: nodeContract, diff: DIFF });
29
+ assert.deepEqual(guard.tests, [{ path: "test/a.test.mjs", ids: ["fixed keeps x"] }]);
30
+ assert.deepEqual(guard.hunks, [{ file: "src/a.mjs", start: 10, end: 12, symbol: "export function fixed() {" }, { file: "src/a.mjs", start: 32, end: 32, symbol: "function other() {" }]);
31
+ assert.equal(guard.why, "Fix x bug");
32
+ });
33
+
34
+ test("pytest fixture: ::ids and -k, every test file named", () => {
35
+ const t = guardTests(pyContract);
36
+ assert.deepEqual(t.find((x) => x.path === "tests/test_a.py").ids.sort(), ["fixed", "test_fixed"]);
37
+ assert.deepEqual(t.find((x) => x.path === "tests/test_b.py").ids, []); // file granularity
38
+ });
39
+
40
+ test("a contract without a test yields no guard and says why", () => {
41
+ assert.match(buildGuard({ issue: 1, contract: { repro_test: "none", repro_test_reason: "docs only", validate: [{ command: "npm run lint" }] } }).reason, /repro_test none \(docs only\)/);
42
+ assert.match(buildGuard({ issue: 1, contract: { validate: [] } }).reason, /names no test/);
43
+ });
44
+
45
+ test("render/parse round trip survives '-->', '--!>' and '<x>' in a field", () => {
46
+ const g = { v: 1, issue: 3, why: "a --> b --!> c <x>", tests: [{ path: "t --!> <y>", ids: ["--!>"] }], hunks: [] };
47
+ const body = renderGuard(g);
48
+ const inner = body.slice(0, -" -->".length);
49
+ assert.ok(body.endsWith(" -->"));
50
+ assert.equal(inner.includes(">"), false); // no terminator variant can appear before the closing -->
51
+ assert.deepEqual(parseGuard(body), g);
52
+ });
53
+
54
+ function fakeGh({ contract, comments = [], title = "Fix the thing" }) {
55
+ const posted = [];
56
+ const body = `<!-- FORGE:CONTRACT -->\n${renderContract({ requirements: [], deliverables: [], preserve: [], validate: [], ...contract }, "abcdef123456")}`;
57
+ const gh = async (a) => {
58
+ if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ number: 7, title, comments: [{ body }, ...comments, ...posted.map((b) => ({ body: b }))] });
59
+ if (a[0] === "pr" && a[1] === "view") return JSON.stringify({ number: 9, mergeCommit: { oid: "deadbeefcafe" } });
60
+ if (a[0] === "issue" && a[1] === "comment") { posted.push(a[a.indexOf("--body") + 1]); return ""; }
61
+ throw new Error(`unexpected gh ${a.join(" ")}`);
62
+ };
63
+ return { gh, posted };
64
+ }
65
+ const git = async (a) => { assert.deepEqual(a.slice(0, 2), ["diff", "-U0"]); assert.ok(a.includes("deadbeefcafe^1")); return DIFF; };
66
+
67
+ test("guardIssue posts exactly one FORGE:GUARD and is idempotent on rerun", async () => {
68
+ const f = fakeGh({ contract: nodeContract });
69
+ const r1 = await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 });
70
+ assert.ok(r1.posted);
71
+ const r2 = await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 });
72
+ assert.ok(r2.exists);
73
+ assert.equal(f.posted.length, 1);
74
+ assert.equal(parseGuard(f.posted[0]).merge_sha, "deadbeefcafe");
75
+ });
76
+
77
+ test("guardIssue: no test posts nothing; unavailable merge commit is a logged skip", async () => {
78
+ const f = fakeGh({ contract: { repro_test: "none", repro_test_reason: "n/a" } });
79
+ assert.ok((await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 })).skipped);
80
+ assert.equal(f.posted.length, 0);
81
+ const g = fakeGh({ contract: nodeContract });
82
+ const r = await guardIssue({ gh: g.gh, git: async () => { throw new Error("bad object"); }, repo: "o/r", issue: 7, pr: 9 });
83
+ assert.match(r.reason, /not available locally/);
84
+ });
85
+
86
+ test("listGuards filters by hunk file, test file or directory", () => {
87
+ const gs = [buildGuard({ issue: 7, contract: nodeContract, diff: DIFF }).guard, buildGuard({ issue: 8, contract: pyContract, diff: "" }).guard];
88
+ assert.deepEqual(listGuards(gs, { path: "src/a.mjs" }).map((g) => g.issue), [7]);
89
+ assert.deepEqual(listGuards(gs, { path: "tests" }).map((g) => g.issue), [8]);
90
+ assert.deepEqual(listGuards(gs, { path: "nope.js" }), []);
91
+ assert.equal(listGuards(gs).length, 2);
92
+ });
93
+
94
+ test("backfill guards merged lanes and logs why a lane without a test yields none", async () => {
95
+ const withTest = fakeGh({ contract: nodeContract }), noTest = fakeGh({ contract: { validate: [] } });
96
+ const by = { 1: withTest, 2: noTest };
97
+ const gh = async (a) => {
98
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify([{ number: 1 }, { number: 2 }]);
99
+ if (a[0] === "pr" && a[1] === "list") return JSON.stringify([{ number: 9, body: `Closes #${a.join(" ").match(/#(\d+) in:body/)[1]}`, mergeCommit: { oid: "deadbeefcafe" } }]);
100
+ return by[a[2]].gh(a);
101
+ };
102
+ const logs = [];
103
+ const r = await backfill({ gh, git, repo: "o/r", since: "2026-01-01", log: (m) => logs.push(m) });
104
+ assert.deepEqual(r.posted, [1]);
105
+ assert.equal(r.skipped[0].issue, 2);
106
+ assert.match(logs[0], /names no test/);
107
+ assert.equal(withTest.posted.length, 1);
108
+ });
109
+
110
+ test("guards list prints guards for a path and caches the index; falls back to it offline", async () => {
111
+ const root = tmpDir("guards-");
112
+ {
113
+ const g = buildGuard({ issue: 7, title: "t", contract: nodeContract, diff: DIFF }).guard;
114
+ const gh = async () => JSON.stringify([{ number: 7, comments: [{ body: renderGuard(g) }] }]);
115
+ const out = [];
116
+ assert.equal(await guardsCli(["list", "--path", "src/a.mjs"], { gh, repo: "o/r", print: (m) => out.push(m), root }), 0);
117
+ assert.match(out[0], /#7/);
118
+ assert.equal(readIndex("o/r", { root }).length, 1);
119
+ const off = [];
120
+ await guardsCli(["list", "--path", "test/a.test.mjs"], { gh: async () => { throw new Error("offline"); }, repo: "o/r", print: (m) => off.push(m), root });
121
+ assert.match(off[0], /#7/);
122
+ }
123
+ });
124
+ test("parseHunks ignores deleted files", () => {
125
+ assert.deepEqual(parseHunks("--- a/x.js\n+++ /dev/null\n@@ -1,2 +0,0 @@\n"), []);
126
+ });
127
+ test("guardAfterMerge fetches the bare branch (not origin/<lane>) and the merge sha when absent locally", async () => {
128
+ const calls = [];
129
+ let have = false;
130
+ const body = `<!-- FORGE:CONTRACT -->\n${renderContract({ requirements: [], deliverables: [], preserve: [], ...nodeContract }, "abcdef123456")}`;
131
+ const io = {
132
+ gh: async (a) => a[0] === "issue" ? JSON.stringify({ number: 9, title: "t", comments: [{ body }] }) : a[0] === "pr" ? JSON.stringify({ number: 3, mergeCommit: { oid: "abc123" } }) : "",
133
+ git: async (a) => { calls.push(a.join(" ")); if (a[0] === "diff") { if (!have) throw new Error("bad object"); return DIFF; } if (a.join(" ") === "fetch origin abc123") have = true; return ""; },
134
+ };
135
+ const r = await guardAfterMerge({ io, repo: "o/r", issue: 9, pr: 3, cwd: "/x", base: "origin/staging" });
136
+ assert.ok(calls.includes("fetch origin staging"));
137
+ assert.ok(!calls.some((c) => c.includes("origin/staging")));
138
+ assert.ok(r.posted, JSON.stringify(r));
139
+ });
140
+
141
+ const G = (issue, file, over = {}) => ({ v: 1, issue, pr: issue + 100, merge_sha: "s", tests: [{ path: "test/x.test.mjs", ids: [] }], hunks: [{ file, start: 1, end: 2, symbol: null }], why: `fix ${issue}`, ...over });
142
+ test("historyPreserve: guarded deliverable yields rows, unguarded none, own issue skipped", () => {
143
+ const guards = [G(5, "src/a.mjs"), G(6, "lib/other.mjs"), G(7, "src/a.mjs")];
144
+ const rows = historyPreserve({ guards, deliverables: [{ path: "src/a.mjs" }], issue: 7 });
145
+ assert.deepEqual(rows, [{ id: "H1", what: "fix 5 (#5, PR #105)", source: "guard" }]);
146
+ assert.deepEqual(historyPreserve({ guards, deliverables: [{ path: "src/zzz/q.mjs" }], issue: 1 }), []);
147
+ });
148
+ test("historyPreserve: deterministic under input order, capped at 5, proximity then recency", () => {
149
+ const guards = [G(1, "lib/z.mjs"), G(2, "src/a.mjs"), G(3, "src/b.mjs"), G(4, "src/c.mjs"), G(5, "src/d.mjs"), G(6, "src/e.mjs"), G(8, "x/y.mjs", { tests: [{ path: "src/a.mjs", ids: [] }] }), G(9, "src/a.mjs")];
150
+ const d = [{ path: "src/a.mjs" }];
151
+ const a = historyPreserve({ guards, deliverables: d, issue: 99 });
152
+ const b = historyPreserve({ guards: [...guards].reverse(), deliverables: d, issue: 99 });
153
+ assert.deepEqual(a, b);
154
+ assert.equal(a.length, 5);
155
+ assert.deepEqual(a.map((r) => r.what.match(/#(\d+),/)[1]), ["9", "2", "8", "6", "5"], "hunk match first, then test match, then same directory by recency");
156
+ assert.deepEqual(a.map((r) => r.id), ["H1", "H2", "H3", "H4", "H5"]);
157
+ });
158
+ test("withHistoryRows reads the local index; no index or no match returns the same contract", () => {
159
+ const root = tmpDir("guards-hist-");
160
+ const c = { requirements: [], deliverables: [{ id: "D1", path: "src/a.mjs" }], preserve: [{ id: "P1", what: "keep" }, { id: "H9", what: "old", source: "guard" }], validate: [] };
161
+ assert.equal(withHistoryRows(c, { repo: "o/r", issue: 1, root }), c, "no index");
162
+ writeIndex("o/r", [G(5, "src/a.mjs")], { root });
163
+ const w = withHistoryRows(c, { repo: "o/r", issue: 1, root });
164
+ assert.deepEqual(w.preserve.map((p) => p.id), ["P1", "H1"]);
165
+ const none = { ...c, deliverables: [{ id: "D1", path: "docs/q.md" }] };
166
+ assert.equal(withHistoryRows(none, { repo: "o/r", issue: 1, root }), none);
167
+ });
168
+ test("guardAfterMerge adds the posted guard to an existing local index only", async () => {
169
+ const root = tmpDir("guards-idx-");
170
+ const body = `<!-- FORGE:CONTRACT -->\n${renderContract({ requirements: [], deliverables: [], preserve: [], ...nodeContract }, "abcdef123456")}`;
171
+ const io = { gh: async (a) => a[0] === "issue" ? JSON.stringify({ number: 9, title: "t", comments: [{ body }] }) : a[0] === "pr" ? JSON.stringify({ number: 3, mergeCommit: { oid: "abc123" } }) : "", git: async (a) => (a[0] === "diff" ? DIFF : "") };
172
+ assert.ok((await guardAfterMerge({ io, repo: "o/r", issue: 9, pr: 3, cwd: "/x", root })).posted);
173
+ assert.equal(readIndex("o/r", { root }), null, "no index is not created from one guard");
174
+ writeIndex("o/r", [G(5, "src/a.mjs")], { root });
175
+ await guardAfterMerge({ io, repo: "o/r", issue: 9, pr: 3, cwd: "/x", root });
176
+ assert.deepEqual(readIndex("o/r", { root }).map((g) => g.issue), [5, 9]);
177
+ });
@@ -14,12 +14,13 @@
14
14
  * host whose memory is used by something else. Fail-open: an unreadable registry or a lock timeout grants.
15
15
  *
16
16
  * Builder-run commands are gated by per-run PATH shims (`writeHeavyShims`) that call this file as a CLI:
17
- * `heavy.mjs run --name <n> -- <abs-real-bin> args...` (nested calls pass through via FORGE_HEAVY_HELD).
17
+ * `heavy.mjs run --name <n> -- <abs-real-bin> args...` (nested calls pass through via FORGE_HEAVY_HELD). A wrapper that
18
+ * re-resolves the name back to the shim is cut off by FORGE_HEAVY_CHAIN (`name:cmd|...`, see `runWrapped`, #4680).
18
19
  */
19
20
  import { spawn } from "node:child_process";
20
21
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, appendFileSync, writeFileSync, chmodSync } from "node:fs";
21
22
  import { cpus, freemem } from "node:os";
22
- import { join } from "node:path";
23
+ import { dirname, join } from "node:path";
23
24
  import { fileURLToPath, pathToFileURL } from "node:url";
24
25
  import { writeAtomic } from "./batch.mjs";
25
26
  import { schedDir, pidAlive, DEFAULT_PRIORITY } from "./sched.mjs";
@@ -249,15 +250,34 @@ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
249
250
 
250
251
  /** Shim script for one command: exec node heavy.mjs run --name <n> -- <real> "$@". */
251
252
  export function heavyShimScript(name, real, { node = process.execPath, script = fileURLToPath(import.meta.url) } = {}) {
252
- return `${SHIM_HEADER}exec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
253
+ return `${SHIM_HEADER}export FORGE_HEAVY_SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
253
254
  }
254
255
 
256
+ /** True when `text` is a shell-style script that looks `name` up on PATH itself (a pass-through wrapper, #4680). */
257
+ export function isPassThroughWrapper(text, name) {
258
+ const t = String(text ?? "");
259
+ return t.startsWith("#!") && t.includes(name) && /\b(command\s+-v|which|type\s+-p)\b/.test(t);
260
+ }
261
+ const head = (p) => { try { return readFileSync(p, "utf8").slice(0, 4096); } catch { return ""; } };
262
+
255
263
  /**
256
264
  * Write 0755 shims into `binDir` for every HEAVY_BINS name found on `path` (binDir excluded, real path resolved now, so
257
- * a shim never calls itself). Returns the names written.
265
+ * a shim never calls itself). A target that is a pass-through wrapper is skipped for the next match past its dir, or the
266
+ * shim is skipped with a `warn` line when none is left (#4680). Returns the names written.
258
267
  */
259
- export async function writeHeavyShims(binDir, { path = process.env.PATH ?? "", find = which, node, script, mkdirp = (d) => mkdirSync(d, { recursive: true }), writeShim = (p, t) => { writeFileSync(p, t); chmodSync(p, 0o755); } } = {}) {
260
- const found = HEAVY_BINS.map((n) => [n, find(n, path, [binDir])]).filter(([, r]) => r);
268
+ export async function writeHeavyShims(binDir, { path = process.env.PATH ?? "", find = which, read = head, warn = (m) => process.stderr.write(`${m}\n`), node, script, mkdirp = (d) => mkdirSync(d, { recursive: true }), writeShim = (p, t) => { writeFileSync(p, t); chmodSync(p, 0o755); } } = {}) {
269
+ const found = [];
270
+ for (const n of HEAVY_BINS) {
271
+ const skip = [binDir];
272
+ let real = find(n, path, skip);
273
+ while (real && isPassThroughWrapper(read(real), n)) {
274
+ skip.push(dirname(real));
275
+ const next = find(n, path, skip);
276
+ if (!next) warn(`forgedock heavy: ${n} at ${real} is a PATH pass-through wrapper with no real binary behind it; shim skipped`);
277
+ real = next;
278
+ }
279
+ if (real) found.push([n, real]);
280
+ }
261
281
  if (!found.length) return [];
262
282
  await mkdirp(binDir);
263
283
  for (const [n, real] of found) await writeShim(join(binDir, n), heavyShimScript(n, real, { ...(node ? { node } : {}), ...(script ? { script } : {}) }));
@@ -277,17 +297,43 @@ export function sumWaits(text, waits = {}) {
277
297
  return { waits, queueMax };
278
298
  }
279
299
 
300
+ export const SHIM_LOOP_MAX = 4;
301
+ /**
302
+ * Re-entry guard (#4680): FORGE_HEAVY_CHAIN lists `name:cmd` per shim run. A repeat of the same name+cmd where cmd is a
303
+ * pass-through wrapper means it sent the call back to the shim; a repeat of a real binary is a legitimate nested call
304
+ * (`npm run` inside an npm script) and passes through unchanged, unrecorded. For a wrapper, resolve the next PATH match past the shim dir and every chain dir; none, or more
305
+ * than SHIM_LOOP_MAX entries, is a typed `env.shim_loop` error and nothing is spawned. Returns {cmd, env} or {error}.
306
+ */
307
+ export function guardChain(name, cmd, env, read = head) {
308
+ const chain = String(env.FORGE_HEAVY_CHAIN ?? "").split("|").filter(Boolean);
309
+ const dirs = chain.map((e) => dirname(e.slice(e.indexOf(":") + 1)));
310
+ const loop = (why) => ({ error: `env.shim_loop: ${name} ${why} (PATH dirs: ${[env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean).join(", ") || "none"})` });
311
+ const repeat = chain.includes(`${name}:${cmd}`);
312
+ if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env };
313
+ if (chain.length >= SHIM_LOOP_MAX) return loop(`re-entered the heavy shim more than ${SHIM_LOOP_MAX} times`);
314
+ if (repeat) {
315
+ const next = which(name, env.PATH ?? "", [env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean));
316
+ if (!next) return loop(`re-entered via ${cmd} and no other ${name} is on PATH`);
317
+ cmd = next;
318
+ }
319
+ return { cmd, env: { ...env, FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
320
+ }
321
+
280
322
  /**
281
323
  * CLI body: `run --name <n> -- <real> args...`. Pass-through when FORGE_HEAVY_HELD is set or the step is llm; else
282
324
  * acquire, run with FORGE_HEAVY_HELD=1 and FORGE_HOST_PARALLELISM from live holders, release, propagate the exit.
283
325
  */
284
- export async function runWrapped(argv, { env = process.env, open = openHeavy, run = spawnInherit, host } = {}) {
326
+ export async function runWrapped(argv, { env: env0 = process.env, open = openHeavy, run = spawnInherit, host } = {}) {
327
+ let env = env0;
285
328
  const sep = argv.indexOf("--");
286
329
  if (argv[0] !== "run" || sep < 0 || !argv[sep + 1]) throw new Error("usage: heavy.mjs run --name <name> -- <command> [args...]");
287
330
  const ni = argv.indexOf("--name");
288
331
  const name = ni >= 0 && ni < sep ? argv[ni + 1] : argv[sep + 1];
289
- const [cmd, ...args] = argv.slice(sep + 1);
332
+ let [cmd, ...args] = argv.slice(sep + 1);
290
333
  const c = heavyConfig({}, env);
334
+ const g0 = guardChain(name, cmd, env);
335
+ if (g0.error) { process.stderr.write(`${g0.error}\n`); return { code: 126, signal: null }; }
336
+ cmd = g0.cmd; env = g0.env;
291
337
  if (env.FORGE_HEAVY_HELD || env.FORGE_HEAVY_DISABLED === "1" || classifyStep(name, args) === "llm") return run(cmd, args, env);
292
338
  const hostP = host ?? defaultHost();
293
339
  const h = open(env.FORGEDOCK_SCHED_DIR || schedDir(env), { env, host: hostP });
@@ -2,10 +2,11 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import { tmpDir } from "../tests/_tmp.mjs";
8
- import { classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
9
+ import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
9
10
  import { checkAdmission, ADMISSION_DEFAULTS } from "./admission.mjs";
10
11
  import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
11
12
  import { readHeavy } from "../engine-cli.mjs";
@@ -235,3 +236,75 @@ describe("heavy shims and the run wrapper (#4169)", () => {
235
236
  ["ALERTS", 'deferred-to-ci: 40% (4/10 lanes, threshold 30%), e.g. "npx tsc"']);
236
237
  });
237
238
  });
239
+
240
+ describe("shim re-entry through a pass-through wrapper (#4680)", () => {
241
+ it("shim with pass-through wrapper runs once", async () => {
242
+ const d = tmpDir("heavy-wrap-"), wrap = join(d, "wrap"), real = join(d, "real"), bin = join(d, "bin"), log = join(d, "log");
243
+ mkdirSync(wrap, { recursive: true }); mkdirSync(real, { recursive: true });
244
+ writeFileSync(join(wrap, "docker"), `#!/bin/sh\necho w >> ${log}\nexec "$(PATH="\${PATH#${wrap}:}" command -v docker)" "$@"\n`);
245
+ writeFileSync(join(real, "docker"), `#!/bin/sh\necho r >> ${log}\necho "Docker version 1"\n`);
246
+ for (const dir of [wrap, real]) chmodSync(join(dir, "docker"), 0o755);
247
+ const path = `${wrap}:${bin}:${real}`;
248
+ const warnings = [];
249
+ assert.deepEqual(await writeHeavyShims(bin, { path, warn: (m) => warnings.push(m) }), ["docker"]);
250
+ assert.match(readFileSync(join(bin, "docker"), "utf8"), new RegExp(`-- '${real}/docker' "\\$@"`), "wrapper skipped at write time, real binary baked");
251
+ assert.deepEqual(warnings, []);
252
+ // Force the runtime guard: bake the wrapper itself, as a shim written before the wrapper appeared would.
253
+ writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker")));
254
+ chmodSync(join(bin, "docker"), 0o755);
255
+ const r = spawnSync(join(bin, "docker"), ["--version"], { env: { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" }, encoding: "utf8", timeout: 20000 });
256
+ assert.equal(r.status, 0, r.stderr);
257
+ assert.match(r.stdout, /Docker version 1/);
258
+ assert.equal(readFileSync(log, "utf8"), "w\nr\n", "wrapper and real binary each ran once (two shim node processes at most)");
259
+ });
260
+
261
+ it("a name+cmd seen twice resolves past the shim and chain dirs; no match or depth above the cap is env.shim_loop", () => {
262
+ const d = tmpDir("heavy-chain-"), a = join(d, "a"), b = join(d, "b"), s = join(d, "s");
263
+ for (const x of [a, b, s]) { mkdirSync(x, { recursive: true }); writeFileSync(join(x, "tsc"), '#!/bin/sh\nexec "$(command -v tsc)" "$@"\n'); chmodSync(join(x, "tsc"), 0o755); }
264
+ const env = { PATH: [s, a, b].join(":"), FORGE_HEAVY_SHIM_DIR: s };
265
+ const first = guardChain("tsc", join(a, "tsc"), env);
266
+ assert.equal(first.cmd, join(a, "tsc")); assert.equal(first.env.FORGE_HEAVY_CHAIN, `tsc:${join(a, "tsc")}`);
267
+ const again = guardChain("tsc", join(a, "tsc"), first.env);
268
+ assert.equal(again.cmd, join(b, "tsc"));
269
+ assert.equal(again.env.FORGE_HEAVY_CHAIN, `tsc:${join(a, "tsc")}|tsc:${join(b, "tsc")}`);
270
+ const none = guardChain("tsc", join(b, "tsc"), { ...again.env, PATH: [s, a, b].join(":") });
271
+ assert.match(none.error, /^env\.shim_loop: tsc .*no other tsc/);
272
+ const deep = guardChain("tsc", "/x/tsc", { ...env, FORGE_HEAVY_CHAIN: Array.from({ length: SHIM_LOOP_MAX }, (_, i) => `tsc:/d${i}/tsc`).join("|") });
273
+ assert.match(deep.error, /^env\.shim_loop: tsc re-entered .*more than 4 times.*\/d0/);
274
+ });
275
+
276
+ it("a nested call to the same real binary (not a wrapper) passes through at any depth, unrecorded", async () => {
277
+ const real = "/home/u/.nvm/bin/npm", spawned = [];
278
+ const run = async (cmd, a, e) => { spawned.push([cmd, e.FORGE_HEAVY_CHAIN]); return { code: 0, signal: null }; };
279
+ const chain = Array.from({ length: SHIM_LOOP_MAX - 1 }, (_, i) => `npm:/d${i}/npm`).concat(`npm:${real}`).join("|");
280
+ for (const FORGE_HEAVY_CHAIN of [`npm:${real}`, chain]) {
281
+ const env = { PATH: "/shim:/home/u/.nvm/bin", FORGE_HEAVY_CHAIN, FORGE_HEAVY_HELD: "1" };
282
+ assert.equal((await runWrapped(["run", "--name", "npm", "--", real, "run", "build"], { env, run })).code, 0);
283
+ }
284
+ assert.equal(chain.split("|").length, SHIM_LOOP_MAX);
285
+ assert.deepEqual(spawned, [[real, `npm:${real}`], [real, chain]]);
286
+ });
287
+
288
+ it("runWrapped refuses to spawn on a loop and records the chain otherwise", async () => {
289
+ const spawned = [];
290
+ const run = async (cmd, a, e) => { spawned.push([cmd, e.FORGE_HEAVY_CHAIN]); return { code: 0, signal: null }; };
291
+ const env = { FORGE_HEAVY_HELD: "1", FORGE_HEAVY_CHAIN: Array.from({ length: SHIM_LOOP_MAX }, (_, i) => `x:/d${i}/x`).join("|") };
292
+ assert.equal((await runWrapped(["run", "--name", "x", "--", "/r/x"], { env, run })).code, 126);
293
+ assert.deepEqual(spawned, []);
294
+ await runWrapped(["run", "--name", "x", "--", "/r/x"], { env: { FORGE_HEAVY_HELD: "1" }, run });
295
+ assert.deepEqual(spawned, [["/r/x", "x:/r/x"]]);
296
+ });
297
+
298
+ it("wrapper detection: PATH-lookup shell scripts only; a wrapper with nothing behind it is skipped with a warning", async () => {
299
+ assert.ok(isPassThroughWrapper('#!/bin/sh\nexec "$(command -v docker)" "$@"', "docker"));
300
+ assert.ok(!isPassThroughWrapper("#!/bin/sh\necho hi\n", "docker"));
301
+ assert.ok(!isPassThroughWrapper("\x7fELF command -v docker", "docker"));
302
+ const d = tmpDir("heavy-skip-"), wrap = join(d, "wrap"), bin = join(d, "bin");
303
+ mkdirSync(wrap, { recursive: true });
304
+ writeFileSync(join(wrap, "docker"), '#!/bin/sh\nexec "$(command -v docker)" "$@"\n'); chmodSync(join(wrap, "docker"), 0o755);
305
+ const warnings = [];
306
+ assert.deepEqual(await writeHeavyShims(bin, { path: `${bin}:${wrap}`, warn: (m) => warnings.push(m) }), []);
307
+ assert.equal(warnings.length, 1); assert.match(warnings[0], /docker .*wrapper/);
308
+ assert.ok(!existsSync(join(bin, "docker")));
309
+ });
310
+ });
@@ -3,6 +3,6 @@
3
3
  * or status 143/137 and no timer of ours fired (our own kills throw CLI_TIMEOUT / CLI_IDLE / CLI_CONTEXT / CLI_TURNS).
4
4
  */
5
5
  export function isExternalKill(e) {
6
- if (e?.code !== "CLI_BACKEND_FAILED") return false;
6
+ if (e?.code !== "CLI_BACKEND_FAILED") return false; // #4690: a scope-limit stop is CLI_LANE_LIMIT, never a host kill
7
7
  return e.signal === "SIGTERM" || e.signal === "SIGKILL" || e.status === 143 || e.status === 137;
8
8
  }