forgedock 1.27.0 → 1.28.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 (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +542 -38
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,154 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { writeFileSync, mkdirSync, chmodSync, existsSync, readFileSync, statSync } from "node:fs";
6
+ import { join, delimiter } from "node:path";
7
+ import { execFileSync } from "node:child_process";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { detectPins, parsePackageManager, nodeSatisfies, nodeWarning, shimScripts, prepareToolchain, toolchainEnv, writeShims, summary } from "./toolchain.mjs";
10
+
11
+ const tmp = (p) => tmpDir(p);
12
+ const repo = (files) => {
13
+ const d = tmp("tc-repo-");
14
+ for (const [f, t] of Object.entries(files)) writeFileSync(join(d, f), typeof t === "string" ? t : JSON.stringify(t));
15
+ return d;
16
+ };
17
+ const exe = (dir, name, body) => { mkdirSync(dir, { recursive: true }); const p = join(dir, name); writeFileSync(p, `#!/bin/sh\n${body}\n`); chmodSync(p, 0o755); return p; };
18
+ /**
19
+ * A host whose PATH has pnpm 11.0.0 and a fake corepack/npx that print the requested version for `--version`
20
+ * (`corepack pnpm@X --version`, `npx -y -p pnpm@X pnpm --version`) without any network.
21
+ */
22
+ const fakeHost = ({ corepack = true } = {}) => {
23
+ const d = tmp("tc-host-");
24
+ exe(d, "pnpm", 'echo 11.0.0');
25
+ const pick = 'for a in "$@"; do case "$a" in pnpm@*) v="${a#pnpm@}";; esac; done; for a in "$@"; do [ "$a" = "--version" ] && { echo "$v"; exit 0; }; done; exit 0';
26
+ if (corepack) exe(d, "corepack", pick);
27
+ exe(d, "npx", pick);
28
+ return d;
29
+ };
30
+ const sysPath = process.env.PATH ?? "";
31
+
32
+ describe("toolchain pins (#4163)", () => {
33
+ it("parsePackageManager accepts pm@x.y.z (with a +sha suffix) and nothing else", () => {
34
+ assert.deepEqual(parsePackageManager("pnpm@10.4.1+sha512.abc"), { name: "pnpm", version: "10.4.1" });
35
+ assert.deepEqual(parsePackageManager("yarn@4.1.0"), { name: "yarn", version: "4.1.0" });
36
+ for (const bad of ["pnpm@10", "bun@1.0.0", "pnpm@latest", "pnpm@1.0.0;rm -rf /", "", null]) assert.equal(parsePackageManager(bad), null, String(bad));
37
+ });
38
+
39
+ it("detects packageManager and node pins in priority order", async () => {
40
+ const d = repo({ "package.json": { packageManager: "pnpm@10.4.1", engines: { node: ">=18" } }, ".nvmrc": "20.11.0\n", ".node-version": "22\n" });
41
+ const p = await detectPins(d);
42
+ assert.deepEqual(p.pm, { name: "pnpm", version: "10.4.1", source: "package.json packageManager" });
43
+ assert.deepEqual(p.node, { spec: "20.11.0", source: ".nvmrc" });
44
+ const e = await detectPins(repo({ "package.json": { engines: { node: "^20" } } }));
45
+ assert.deepEqual(e.node, { spec: "^20", source: "package.json engines.node" });
46
+ assert.equal(e.pm, null);
47
+ const tv = await detectPins(repo({ ".tool-versions": "# pins\nnodejs 20.1.0\npnpm 9.1.0\n" }));
48
+ assert.deepEqual(tv.pm, { name: "pnpm", version: "9.1.0", source: ".tool-versions" });
49
+ assert.deepEqual(tv.node, { spec: "20.1.0", source: ".tool-versions" });
50
+ });
51
+
52
+ it("forge.yaml env.toolchain overrides the pm pin; off disables detection; junk is a note", async () => {
53
+ const d = repo({ "package.json": { packageManager: "pnpm@10.4.1" }, ".nvmrc": "20" });
54
+ assert.deepEqual((await detectPins(d, { override: "pnpm@9.0.0" })).pm, { name: "pnpm", version: "9.0.0", source: "forge.yaml env.toolchain" });
55
+ const off = await detectPins(d, { override: "off" });
56
+ assert.equal(off.pm, null); assert.equal(off.node, null);
57
+ const junk = await detectPins(d, { override: "pnpm" });
58
+ assert.equal(junk.pm.version, "10.4.1");
59
+ assert.match(junk.notes[0], /env\.toolchain "pnpm" is not/);
60
+ });
61
+
62
+ it("nodeSatisfies covers exact, partial, x, caret, tilde, comparators and ||", () => {
63
+ const t = [["20", "v20.11.0", true], ["20", "v22.1.0", false], ["20.11", "v20.11.4", true], ["20.x", "v20.0.0", true], ["^20.5", "v20.4.0", false],
64
+ ["^20.5", "v20.9.0", true], ["~20.5.1", "v20.6.0", false], [">=18", "v26.7.0", true], [">=18 <21", "v22.0.0", false], ["18 || 20", "v20.1.0", true],
65
+ ["< 20", "v20.0.0", false], ["<=20", "v20.9.9", true], [">20", "v20.9.9", false], [">20", "v21.0.0", true], ["v20.11.0", "v20.11.0", true]];
66
+ for (const [spec, v, want] of t) assert.equal(nodeSatisfies(spec, v), want, `${spec} vs ${v}`);
67
+ assert.equal(nodeSatisfies("18 - 20", "v19.0.0"), true);
68
+ assert.equal(nodeSatisfies("18 - 20", "v21.0.0"), false);
69
+ assert.equal(nodeSatisfies("hydrogen", "v19.0.0"), null);
70
+ });
71
+
72
+ it("an unsatisfied node pin yields a clear warning; a satisfied or alias pin yields none", () => {
73
+ assert.match(nodeWarning({ spec: "18.19.0", source: ".nvmrc" }, "v26.7.0"), /host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
74
+ assert.equal(nodeWarning({ spec: ">=18", source: "package.json engines.node" }, "v26.7.0"), null);
75
+ assert.equal(nodeWarning({ spec: "lts/iron", source: ".nvmrc" }, "v26.7.0"), null);
76
+ assert.match(nodeWarning({ spec: "iron-ish", source: ".nvmrc" }, "v26.7.0"), /cannot evaluate/);
77
+ });
78
+
79
+ it("shims: corepack first, npx fallback, yarn berry via @yarnpkg/cli-dist, values quoted", () => {
80
+ const pm = { name: "pnpm", version: "10.4.1" };
81
+ assert.match(shimScripts(pm, { corepack: "/h/corepack", npx: "/h/npx" }).pnpm, /exec '\/h\/corepack' 'pnpm@10\.4\.1' "\$@"/);
82
+ assert.match(shimScripts(pm, { corepack: "/h/corepack" }).pnpx, /'pnpm@10\.4\.1' dlx "\$@"/);
83
+ assert.match(shimScripts(pm, { npx: "/h/npx" }).pnpm, /exec '\/h\/npx' -y -p 'pnpm@10\.4\.1' pnpm "\$@"/);
84
+ assert.match(shimScripts({ name: "yarn", version: "4.1.0" }, { npx: "/h/npx" }).yarn, /-p '@yarnpkg\/cli-dist@4\.1\.0' yarn/);
85
+ assert.match(shimScripts({ name: "yarn", version: "1.22.19" }, { npx: "/h/npx" }).yarn, /-p 'yarn@1\.22\.19' yarn/);
86
+ const npm = shimScripts({ name: "npm", version: "10.2.0" }, { corepack: "/h/c", npx: "/h/npx" });
87
+ assert.deepEqual(Object.keys(npm), ["npm", "npx"]);
88
+ assert.match(npm.npx, /exec '\/h\/npx' -y -p 'npm@10\.2\.0' npx "\$@"/);
89
+ assert.deepEqual(shimScripts(pm, {}), {});
90
+ });
91
+
92
+ for (const corepack of [true, false]) {
93
+ it(`R1: with packageManager pnpm@10.4.1 and pnpm 11 on PATH, runner/validate/hook shells resolve pnpm --version to 10.4.1 (${corepack ? "corepack" : "npx"})`, async () => {
94
+ const host = fakeHost({ corepack });
95
+ const basePath = `${host}${delimiter}${sysPath}`;
96
+ assert.equal(execFileSync("sh", ["-c", "pnpm --version"], { env: { ...process.env, PATH: basePath } }).toString().trim(), "11.0.0", "the host pnpm is the wrong one");
97
+ const d = repo({ "package.json": { name: "x", packageManager: "pnpm@10.4.1+sha512.ff" } });
98
+ const binDir = join(tmp("tc-run-"), "toolchain", "bin");
99
+ const tc = await prepareToolchain({ dir: d, binDir, basePath, hostNode: () => "v20.0.0" });
100
+ assert.ok(tc.env && tc.env.PATH.startsWith(binDir + delimiter), "shim dir first on PATH");
101
+ assert.equal(tc.summary, "pnpm@10.4.1 (package.json packageManager)");
102
+ const env = { ...process.env, ...tc.env };
103
+ // runner: the claude CLI child gets this env (bin/runner.mjs applies `env` last); its Bash tool shells inherit it.
104
+ assert.equal(execFileSync("bash", ["-lc", "pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "runner shell");
105
+ // validate: a validate command is a shell command in the worktree.
106
+ assert.equal(execFileSync("sh", ["-c", "cd . && pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "validate command");
107
+ // hooks: git runs pre-commit / commit-msg hooks with the committing shell's PATH.
108
+ const git = (a) => execFileSync("git", a, { env: { ...env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, cwd: d }).toString();
109
+ git(["init", "-q"]);
110
+ exe(join(d, ".git", "hooks"), "pre-commit", `pnpm --version > "${join(d, "pre.out")}"`);
111
+ exe(join(d, ".git", "hooks"), "commit-msg", `pnpm --version > "${join(d, "msg.out")}"`);
112
+ git(["add", "package.json"]);
113
+ git(["commit", "-q", "-m", "x"]);
114
+ assert.equal(readFileSync(join(d, "pre.out"), "utf8").trim(), "10.4.1", "pre-commit hook");
115
+ assert.equal(readFileSync(join(d, "msg.out"), "utf8").trim(), "10.4.1", "commit-msg hook");
116
+ });
117
+ }
118
+
119
+ it("R2: a node pin the host does not satisfy produces a warning (and no shim without a pm pin)", async () => {
120
+ const binDir = join(tmp("tc-run-"), "bin");
121
+ const tc = await prepareToolchain({ dir: repo({ ".nvmrc": "18.19.0" }), binDir, hostNode: () => "v26.7.0" });
122
+ assert.match(tc.warning, /^host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
123
+ assert.equal(tc.summary, "node 18.19.0 (.nvmrc)");
124
+ assert.equal(tc.env, null); assert.equal(existsSync(binDir), false);
125
+ });
126
+
127
+ it("R4: a repo without pins gets nothing: no env, no shim dir, no warning, no notes", async () => {
128
+ const binDir = join(tmp("tc-run-"), "bin");
129
+ for (const files of [{}, { "package.json": { name: "x", scripts: { test: "node --test" } } }]) {
130
+ const tc = await prepareToolchain({ dir: repo(files), binDir, hostNode: () => { throw new Error("must not probe node"); } });
131
+ assert.equal(tc.env, null); assert.equal(tc.summary, null); assert.equal(tc.warning, null);
132
+ assert.deepEqual(tc.shims, []); assert.deepEqual(tc.notes, []);
133
+ assert.equal(existsSync(binDir), false);
134
+ }
135
+ });
136
+
137
+ it("toolchainEnv prepends the shim dir", () => {
138
+ assert.deepEqual(toolchainEnv("/s", "/a:/b"), { PATH: `/s${delimiter}/a:/b`, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", COREPACK_ENABLE_STRICT: "0" });
139
+ });
140
+
141
+ it("writeShims writes 0755 shims that never resolve the launcher from the shim dir; summary names the pins", async () => {
142
+ const host = fakeHost();
143
+ const binDir = join(tmp("tc-ws-"), "bin");
144
+ exe(binDir, "corepack", "echo self");
145
+ const names = await writeShims(binDir, { name: "pnpm", version: "10.4.1" }, { path: `${binDir}${delimiter}${host}` });
146
+ assert.deepEqual(names, ["pnpm", "pnpx"]);
147
+ for (const n of names) assert.equal(statSync(join(binDir, n)).mode & 0o777, 0o755);
148
+ assert.match(readFileSync(join(binDir, "pnpm"), "utf8"), new RegExp(`exec '${join(host, "corepack")}' 'pnpm@10\\.4\\.1'`));
149
+ assert.deepEqual(await writeShims(join(tmp("tc-ws-"), "bin"), { name: "yarn", version: "4.1.0" }, { path: "/nonexistent" }), []);
150
+ assert.equal(summary({ pm: { name: "pnpm", version: "10.4.1", source: "package.json packageManager" }, node: { spec: "20", source: ".nvmrc" } }), "pnpm@10.4.1 (package.json packageManager), node 20 (.nvmrc)");
151
+ assert.equal(summary({ pm: null, node: null }), null);
152
+ assert.equal(nodeWarning({ spec: "1", source: ".nvmrc" }) !== null, true, "defaults to process.version");
153
+ });
154
+ });
@@ -11,6 +11,8 @@ export const VERIFY_MODES = Object.freeze(["local", "ci", "hybrid"]);
11
11
  /** Changed files that never need the CI matrix (docs-only lanes skip CI verification). */
12
12
  export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.txt", "docs/**", "devdocs/**"]);
13
13
  export const DEFAULT_MAX_ROUNDS = 2;
14
+ /** Per-command timeout of the pre-PR declared check commands (#4240, `verify.precheck_timeout_seconds`). */
15
+ export const DEFAULT_PRECHECK_TIMEOUT_SECONDS = 300;
14
16
  /** Total characters of failed-job log handed to the builder. */
15
17
  export const LOG_CAP = 12000;
16
18
  /** No run or check for the head after this long means CI is unavailable (local fallback). */
@@ -20,7 +22,7 @@ const OK = ["success", "neutral", "skipped"];
20
22
 
21
23
  /**
22
24
  * forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
23
- * maxRounds}. Invalid values fall back to the defaults; mode defaults to local (today's behaviour).
25
+ * maxRounds, precheckTimeoutSeconds}. Invalid values fall back to the defaults; mode defaults to local (today's behaviour).
24
26
  */
25
27
  export function parseVerify(sec = {}) {
26
28
  const s = sec && typeof sec === "object" ? sec : {};
@@ -32,7 +34,9 @@ export function parseVerify(sec = {}) {
32
34
  const skipPaths = rawSkip ? rawSkip.map((x) => String(x).trim()).filter(Boolean) : [...DEFAULT_SKIP_PATHS];
33
35
  const n = Number(str(s.max_rounds));
34
36
  const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
35
- return { mode, workflow, pathsInput, skipPaths, maxRounds };
37
+ const t = Number(str(s.precheck_timeout_seconds));
38
+ const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
39
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds };
36
40
  }
37
41
 
38
42
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
@@ -55,6 +59,19 @@ export function capLogs(failures, cap = LOG_CAP) {
55
59
  return failures.map((f) => { const l = String(f.log ?? ""); return { ...f, log: l.length > per ? `…${l.slice(-per)}` : l }; });
56
60
  }
57
61
  const runIdOf = (url) => /\/actions\/runs\/(\d+)/.exec(String(url ?? ""))?.[1] ?? null;
62
+ /**
63
+ * Failed checks [{name, url}] → capped [{job, url, log}]: one `failedLogs` fetch per Actions run id, the log attached to
64
+ * the first check of that run only (#4240: shared by runCiVerify and the merge-time fix rounds).
65
+ */
66
+ export async function collectFailedLogs(io, failedChecks) {
67
+ const logs = {}, out = [];
68
+ for (const c of failedChecks ?? []) {
69
+ const id = runIdOf(c.url);
70
+ if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
71
+ out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
72
+ }
73
+ return capLogs(out);
74
+ }
58
75
  const sameSha = (a, b) => Boolean(a && b) && (String(a).startsWith(b) || String(b).startsWith(a));
59
76
 
60
77
  /**
@@ -97,13 +114,7 @@ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMa
97
114
  if (checks.length && checks.every((c) => c.state !== "pending")) {
98
115
  const failed = checks.filter((c) => c.state === "failure");
99
116
  if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${checks.length} check(s) green` };
100
- const logs = {}, out = [];
101
- for (const c of failed) {
102
- const id = runIdOf(c.url);
103
- if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
104
- out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
105
- }
106
- return { result: "fail", failures: capLogs(out), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
117
+ return { result: "fail", failures: await collectFailedLogs(io, failed), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
107
118
  }
108
119
  }
109
120
  if (now() - startedAt >= ciMaxMinutes * 60000) return { result: "timeout", failures: [], runUrl: null, detail: `CI still pending after ${ciMaxMinutes} min` };
@@ -2,7 +2,7 @@
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 { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
5
+ import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
6
6
  import { precheck } from "./conformance.mjs";
7
7
 
8
8
  const HEAD = "abc123abc123abc123";
@@ -22,9 +22,9 @@ function fake(o = {}) {
22
22
 
23
23
  describe("verify-by-CI config (#4167)", () => {
24
24
  it("parseVerify defaults to local and rejects invalid values", () => {
25
- assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2 });
25
+ assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300 });
26
26
  const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
27
- assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3 });
27
+ assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300 });
28
28
  assert.equal(parseVerify({ mode: "remote" }).mode, "local");
29
29
  assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
30
30
  assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
@@ -93,3 +93,26 @@ describe("runCiVerify (#4167)", () => {
93
93
  assert.equal((await runCiVerify(g.args({ verify: parseVerify({ mode: "ci" }) }))).result, "unavailable");
94
94
  });
95
95
  });
96
+
97
+ describe("merge-time fix rounds and declared check commands (#4240)", () => {
98
+ it("collectFailedLogs fetches one log per Actions run and caps the total", async () => {
99
+ const fetched = [];
100
+ const io = { failedLogs: async (id) => { fetched.push(id); return "x".repeat(LOG_CAP * 2); } };
101
+ const out = await collectFailedLogs(io, [
102
+ { name: "Unit tests", url: "https://github.com/o/r/actions/runs/9/job/1" },
103
+ { name: "Shell tests", url: "https://github.com/o/r/actions/runs/9/job/2" },
104
+ { name: "status", url: null },
105
+ ]);
106
+ assert.deepEqual(fetched, ["9"]);
107
+ assert.deepEqual(out.map((f) => f.job), ["Unit tests", "Shell tests", "status"]);
108
+ assert.ok(out[0].log.length > 0 && out[1].log === "" && out[2].log === "");
109
+ assert.ok(out.reduce((n, f) => n + f.log.length, 0) <= LOG_CAP + 3);
110
+ assert.deepEqual(await collectFailedLogs({ failedLogs: async () => { throw new Error("gone"); } }, [{ name: "a", url: "https://x/actions/runs/1" }]), [{ job: "a", url: "https://x/actions/runs/1", log: "" }]);
111
+ });
112
+
113
+ it("parseVerify precheck_timeout_seconds: default 300, integer 1-3600 accepted, others fall back", () => {
114
+ assert.equal(parseVerify({}).precheckTimeoutSeconds, 300);
115
+ assert.equal(parseVerify({ precheck_timeout_seconds: "60" }).precheckTimeoutSeconds, 60);
116
+ for (const bad of ["0", "-5", "1.5", "abc", "3601"]) assert.equal(parseVerify({ precheck_timeout_seconds: bad }).precheckTimeoutSeconds, 300, bad);
117
+ });
118
+ });
@@ -0,0 +1,69 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Sparse lane worktrees (#4221). A lane checks out every tracked file except regular blobs at or above
5
+ * `maxBlobBytes` (forge.yaml `env.sparse_max_kb`, default 1024; 0 = off). Lockfiles, manifests, toolchain pins and
6
+ * git/forge config are always checked out (the #4220 dep cache and #4163 toolchain pins read them). The patterns are
7
+ * non-cone (`/*` then `!/<path>`), kept in the worktree's own `info/sparse-checkout`; `core.sparseCheckout` is per-worktree
8
+ * config (git sets `extensions.worktreeConfig` in the shared config), so the main checkout is untouched. A repo with no
9
+ * such blob gets today's plain `git worktree add`. A failed sparse step falls back to a full checkout; never fails a lane.
10
+ */
11
+ import { join } from "node:path";
12
+
13
+ export const DEFAULT_SPARSE_MAX_KB = 1024;
14
+
15
+ const KEEP_RE = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|\.yarnrc\.yml|uv\.lock|poetry\.lock|pyproject\.toml|requirements.*\.txt|setup\.py|setup\.cfg|\.nvmrc|\.node-version|\.tool-versions|tsconfig.*\.json|forge\.yaml|\.gitattributes|\.gitignore)$/;
16
+
17
+ /** Blobs to leave out, from `git ls-tree -r -l -z <ref>` output: {excludes, bytes}. Symlinks, gitlinks and kept files never. */
18
+ export function sparsePlan(lsTree, { maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 } = {}) {
19
+ const excludes = [];
20
+ let bytes = 0;
21
+ if (!(maxBlobBytes > 0)) return { excludes, bytes };
22
+ for (const rec of String(lsTree ?? "").split("\0")) {
23
+ const m = /^(\d{6}) blob [0-9a-f]+ +(\d+)\t([\s\S]+)$/.exec(rec.replace(/^\n+/, ""));
24
+ if (!m || !["100644", "100755"].includes(m[1])) continue;
25
+ const size = Number(m[2]), path = m[3];
26
+ if (size < maxBlobBytes || /[\n\r]/.test(path) || KEEP_RE.test(path.split("/").at(-1))) continue;
27
+ excludes.push(path);
28
+ bytes += size;
29
+ }
30
+ return { excludes, bytes };
31
+ }
32
+
33
+ /** gitignore-syntax escape for a literal path: \ * ? [ anywhere, trailing spaces. */
34
+ const esc = (p) => p.replace(/[\\*?[]| (?= *$)/g, (c) => `\\${c}`);
35
+
36
+ /** Non-cone sparse patterns that include everything except `excludes`. */
37
+ export function sparsePatterns(excludes) {
38
+ return ["/*", ...excludes.map((p) => `!/${esc(p)}`)];
39
+ }
40
+
41
+ /**
42
+ * Add a lane worktree, sparse when the plan is non-empty. `git(args, {cwd})` is the injected io.git; `write(path, text)`
43
+ * writes the pattern file (argv-safe for any number of patterns). `addArgs` is the plain `["worktree","add",...]` argv.
44
+ * A failing `worktree add` is rethrown unchanged (the caller retries lock races). Returns {sparse, excluded, bytes, fallback?}.
45
+ */
46
+ export async function addLaneWorktree(git, write, { cwd, wt, addArgs, ref, maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 }) {
47
+ let plan = { excludes: [], bytes: 0 };
48
+ if (maxBlobBytes > 0 && typeof write === "function") {
49
+ try { plan = sparsePlan(await git(["ls-tree", "-r", "-l", "-z", ref], { cwd }), { maxBlobBytes }); } catch { /* no plan: full checkout */ }
50
+ }
51
+ if (!plan.excludes.length) {
52
+ await git(addArgs, { cwd });
53
+ return { sparse: false, excluded: 0, bytes: 0 };
54
+ }
55
+ await git([...addArgs.slice(0, 2), "--no-checkout", ...addArgs.slice(2)], { cwd });
56
+ const g = (a) => git(a, { cwd: wt });
57
+ try {
58
+ await g(["sparse-checkout", "set", "--no-cone", "/*"]);
59
+ const file = String(await g(["rev-parse", "--git-path", "info/sparse-checkout"])).trim();
60
+ await write(file.startsWith("/") ? file : join(wt, file), `${sparsePatterns(plan.excludes).join("\n")}\n`);
61
+ await g(["read-tree", "-mu", "HEAD"]);
62
+ return { sparse: true, excluded: plan.excludes.length, bytes: plan.bytes };
63
+ } catch (e) {
64
+ const reason = String(e?.stderr || e?.message || e).trim().split("\n").slice(-2).join(" ").slice(0, 300);
65
+ try { await g(["sparse-checkout", "disable"]); } catch { /* full read-tree below */ }
66
+ await g(["read-tree", "-mu", "HEAD"]);
67
+ return { sparse: false, excluded: 0, bytes: 0, fallback: reason || "sparse checkout failed" };
68
+ }
69
+ }
@@ -0,0 +1,141 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdirSync, writeFileSync, readdirSync, statSync, existsSync, rmSync } from "node:fs";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { join, dirname } from "node:path";
9
+ import { sparsePlan, sparsePatterns, addLaneWorktree } from "./worktree.mjs";
10
+
11
+ const MiB = 1024 * 1024;
12
+ const gitSync = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * MiB, stdio: ["ignore", "pipe", "pipe"] });
13
+ const git = async (args, { cwd } = {}) => gitSync(args, cwd);
14
+ const write = async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); };
15
+ const blob = (n, ch = "x") => ch.repeat(n);
16
+
17
+ /** A fixture repo: {path: content}. Returns its dir. */
18
+ function repo(files) {
19
+ const dir = tmpDir("wt4221-");
20
+ const r = join(dir, "r");
21
+ mkdirSync(r);
22
+ gitSync(["init", "-q", "-b", "main"], r);
23
+ for (const [p, c] of Object.entries(files)) { mkdirSync(dirname(join(r, p)), { recursive: true }); writeFileSync(join(r, p), c); }
24
+ gitSync(["add", "-A"], r);
25
+ gitSync(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init"], r);
26
+ return { dir, r, wt: join(dir, "wt") };
27
+ }
28
+ /** Bytes of regular files on disk under `d`, skipping .git. */
29
+ function diskBytes(d) {
30
+ let n = 0;
31
+ for (const e of readdirSync(d, { withFileTypes: true })) {
32
+ if (e.name === ".git") continue;
33
+ const p = join(d, e.name);
34
+ if (e.isDirectory()) n += diskBytes(p); else if (e.isFile()) n += statSync(p).size;
35
+ }
36
+ return n;
37
+ }
38
+
39
+ const FIXTURES = {
40
+ "npm JS/TS": {
41
+ files: { "package.json": '{"name":"a","version":"1.0.0"}\n', "package-lock.json": `{"lockfileVersion":3,"pad":"${blob(MiB + 10)}"}\n`,
42
+ "tsconfig.json": "{}\n", "src/index.ts": "export const x = 1;\n", "public/asset.json": blob(2 * MiB) },
43
+ big: "public/asset.json", kept: ["package.json", "package-lock.json", "src/index.ts", "tsconfig.json"],
44
+ },
45
+ "uv Python": {
46
+ files: { "pyproject.toml": '[project]\nname = "a"\nversion = "0.1.0"\n', "uv.lock": `version = 1\n# ${blob(MiB + 10)}\n`,
47
+ "app/main.py": "print(1)\n", "data/blob.bin": blob(2 * MiB, "\u0001") },
48
+ big: "data/blob.bin", kept: ["pyproject.toml", "uv.lock", "app/main.py"],
49
+ },
50
+ };
51
+
52
+ describe("sparse lane worktrees (#4221)", () => {
53
+ for (const [name, fx] of Object.entries(FIXTURES)) {
54
+ it(`${name}: large blobs stay out, lockfiles and manifests stay in, git sees no change`, async () => {
55
+ const { dir, r, wt } = repo(fx.files);
56
+ try {
57
+ const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"], ref: "main", maxBlobBytes: MiB });
58
+ assert.deepEqual(res, { sparse: true, excluded: 1, bytes: 2 * MiB });
59
+ assert.equal(existsSync(join(wt, fx.big)), false);
60
+ assert.match(gitSync(["ls-files", "-t", fx.big], wt), /^S /);
61
+ for (const k of fx.kept) assert.ok(existsSync(join(wt, k)), `${k} checked out`);
62
+ assert.equal(gitSync(["status", "--porcelain"], wt), "");
63
+ gitSync(["add", "-A"], wt);
64
+ assert.equal(gitSync(["diff", "--cached", "--name-only"], wt), "", "git add -A stages no deletion");
65
+ // R5: on-disk checkout is no more than the included blobs (+1 MiB slack): the left-out blob costs nothing.
66
+ const included = Object.entries(fx.files).filter(([p]) => p !== fx.big).reduce((n, [, c]) => n + Buffer.byteLength(c), 0);
67
+ assert.ok(diskBytes(wt) <= included + MiB, `disk ${diskBytes(wt)} <= ${included + MiB}`);
68
+ assert.throws(() => gitSync(["config", "--get", "core.sparseCheckout"], r), "main checkout has no core.sparseCheckout");
69
+ assert.equal(Object.keys(fx.files).every((p) => existsSync(join(r, p))), true);
70
+ mkdirSync(dirname(join(wt, fx.big)), { recursive: true });
71
+ writeFileSync(join(wt, fx.big), "regenerated\n");
72
+ assert.match(gitSync(["status", "--porcelain"], wt), new RegExp(` M ${fx.big}`));
73
+ gitSync(["checkout", "--", fx.big], wt);
74
+ gitSync(["sparse-checkout", "add", `/${fx.big}`], wt);
75
+ assert.equal(statSync(join(wt, fx.big)).size, 2 * MiB);
76
+ } finally { rmSync(dir, { recursive: true, force: true }); }
77
+ });
78
+ }
79
+
80
+ it("a repo with no large blob gets today's plain worktree add", async () => {
81
+ const { dir, r, wt } = repo({ "package.json": "{}\n", "src/a.js": "1\n" });
82
+ try {
83
+ const calls = [];
84
+ const spy = async (a, o) => { calls.push(a); return git(a, o); };
85
+ const addArgs = ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"];
86
+ assert.deepEqual(await addLaneWorktree(spy, write, { cwd: r, wt, addArgs, ref: "main" }), { sparse: false, excluded: 0, bytes: 0 });
87
+ assert.deepEqual(calls.filter((a) => a[0] === "worktree"), [addArgs]);
88
+ assert.equal(calls.some((a) => a[0] === "sparse-checkout"), false);
89
+ } finally { rmSync(dir, { recursive: true, force: true }); }
90
+ });
91
+
92
+ it("maxBlobBytes 0 disables it; a failing sparse step falls back to a full checkout", async () => {
93
+ const { dir, r, wt } = repo({ "a.bin": blob(2 * MiB), "b.txt": "1\n" });
94
+ try {
95
+ const calls = [];
96
+ const spy = async (a, o) => { calls.push(a); return git(a, o); };
97
+ await addLaneWorktree(spy, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: 0 });
98
+ assert.equal(calls.some((a) => a[0] === "ls-tree" || a.includes("--no-checkout")), false);
99
+ assert.ok(existsSync(join(wt, "a.bin")));
100
+ gitSync(["worktree", "remove", "--force", wt], r);
101
+ const failing = async (a, o) => { if (a[0] === "read-tree" && !failing.done) { failing.done = true; throw Object.assign(new Error("boom"), { stderr: "fatal: boom" }); } return git(a, o); };
102
+ const res = await addLaneWorktree(failing, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
103
+ assert.equal(res.sparse, false);
104
+ assert.match(res.fallback, /boom/);
105
+ assert.ok(existsSync(join(wt, "a.bin")) && existsSync(join(wt, "b.txt")));
106
+ assert.equal(gitSync(["status", "--porcelain"], wt), "");
107
+ } finally { rmSync(dir, { recursive: true, force: true }); }
108
+ });
109
+
110
+ it("a failing worktree add is rethrown unchanged (the caller retries lock races)", async () => {
111
+ const e = Object.assign(new Error("lock"), { stderr: "could not lock config file" });
112
+ const g = async (a) => { if (a[0] === "worktree") throw e; return "100644 blob " + "a".repeat(40) + " 2000000\tbig.bin\0"; };
113
+ await assert.rejects(addLaneWorktree(g, write, { cwd: "/x", wt: "/x/wt", addArgs: ["worktree", "add", "/x/wt", "main"], ref: "main" }), (x) => x === e);
114
+ });
115
+
116
+ it("sparsePlan: threshold, keep-list, symlinks and gitlinks; patterns escape glob characters", () => {
117
+ const h = "a".repeat(40);
118
+ const out = [
119
+ `100644 blob ${h} 2000000\tweb/openapi.json`, `100755 blob ${h} 1048576\tbin/tool`, `100644 blob ${h} 1048575\tsmall.txt`,
120
+ `120000 blob ${h} 40\tlink`, `160000 commit ${h} -\tsub`, `100644 blob ${h} 3000000\tpnpm-lock.yaml`,
121
+ `100644 blob ${h} 3000000\tsvc/requirements-dev.txt`, `100644 blob ${h} 3000000\tpkg/tsconfig.base.json`, `100644 blob ${h} 5000000\ta [x]*?.bin `,
122
+ ].join("\0") + "\0";
123
+ const p = sparsePlan(out, { maxBlobBytes: MiB });
124
+ assert.deepEqual(p.excludes, ["web/openapi.json", "bin/tool", "a [x]*?.bin "]);
125
+ assert.equal(p.bytes, 2000000 + 1048576 + 5000000);
126
+ assert.deepEqual(sparsePlan(out, { maxBlobBytes: 0 }).excludes, []);
127
+ assert.deepEqual(sparsePatterns(p.excludes), ["/*", "!/web/openapi.json", "!/bin/tool", "!/a \\[x]\\*\\?.bin\\ "]);
128
+ });
129
+
130
+ it("an escaped pattern really matches its odd path", async () => {
131
+ const odd = "d/a [x]*?.bin";
132
+ const { dir, r, wt } = repo({ [odd]: blob(2 * MiB), "d/a x.bin": "keep\n", "f.txt": "1\n" });
133
+ try {
134
+ const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
135
+ assert.equal(res.sparse, true);
136
+ assert.equal(existsSync(join(wt, odd)), false);
137
+ assert.ok(existsSync(join(wt, "d/a x.bin")));
138
+ assert.equal(gitSync(["status", "--porcelain"], wt), "");
139
+ } finally { rmSync(dir, { recursive: true, force: true }); }
140
+ });
141
+ });
package/bin/runner.mjs CHANGED
@@ -359,6 +359,37 @@ export function resolveConfiguredDefaultModel(cwd) {
359
359
  }
360
360
  }
361
361
 
362
+ /**
363
+ * Whether the owner approved the paid API backend as the `auto` fallback (#4257).
364
+ * Needs both forge.yaml `execution.agent_backend: api` in `cwd` (repo owner) and
365
+ * env `FORGEDOCK_ALLOW_API_BACKEND=1` (host owner), so a committed config edit
366
+ * alone cannot start billing an API key. Never throws.
367
+ *
368
+ * @param {{cwd?: string, env?: object}} [opts]
369
+ * @returns {boolean}
370
+ */
371
+ export function isApiBackendApproved({ cwd = process.cwd(), env = process.env } = {}) {
372
+ if (String(env?.FORGEDOCK_ALLOW_API_BACKEND ?? "").trim() !== "1") return false;
373
+ try {
374
+ const forgeYamlPath = join(cwd, "forge.yaml");
375
+ if (!existsSync(forgeYamlPath)) return false;
376
+ return parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.execution?.agent_backend === "api";
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+
382
+ /** #4257: typed error when `auto` finds no usable CLI and the API backend is not approved. */
383
+ function cliUnavailableError(detail = "The claude CLI is not available") {
384
+ const err = new Error(
385
+ `${detail}, and the API backend is never used implicitly. Install and authenticate the \`claude\` CLI, ` +
386
+ "or opt in to the API backend (forge.yaml `execution.agent_backend: api` plus FORGEDOCK_ALLOW_API_BACKEND=1), " +
387
+ "or pass --backend api.",
388
+ );
389
+ err.code = "CLI_BACKEND_UNAVAILABLE";
390
+ return err;
391
+ }
392
+
362
393
  /**
363
394
  * Resolve the CLI backend timeout from forge.yaml's
364
395
  * `pipeline.cli_timeout_minutes` field, if it is a positive integer.
@@ -854,6 +885,7 @@ export function checkExecutionBackend({
854
885
  apiKey = env.ANTHROPIC_API_KEY,
855
886
  resolveCliFn = resolveClaudeCliBinary,
856
887
  spawnImpl = spawnSync,
888
+ apiApproved = isApiBackendApproved({ cwd, env }),
857
889
  sdkAvailableFn = () => {
858
890
  try {
859
891
  createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
@@ -884,7 +916,8 @@ export function checkExecutionBackend({
884
916
  return { ready: true, backend: "cli", reason: "resolved-cli" };
885
917
  }
886
918
  }
887
- if (requested === "cli") {
919
+ // #4257: auto never crosses to the paid API backend without the owner's opt-in.
920
+ if (requested === "cli" || !apiApproved) {
888
921
  return { ready: false, backend: "cli", reason: "cli-unavailable" };
889
922
  }
890
923
  }
@@ -905,7 +938,8 @@ export function checkExecutionBackend({
905
938
  * CLI and prefer it when present — but diverge on (a) which override
906
939
  * values are valid and whether an invalid one throws or is silently
907
940
  * ignored, and (b) what to return when the CLI probe fails (`resolveBackend`
908
- * always falls back to `"api"`; `resolveEnrichBackend` falls back to `"api"`
941
+ * falls back to `"api"` only with the owner's opt-in, else `"cli"` (#4257);
942
+ * `resolveEnrichBackend` falls back to `"api"`
909
943
  * only if `ANTHROPIC_API_KEY` is set, else `"none"`). Rather than force
910
944
  * those into one shape, this shared helper implements exactly the common
911
945
  * two steps and defers to a `cliFallback` callback for the diverging tail,
@@ -954,24 +988,27 @@ export function resolveBackendLadder({
954
988
  * 2. `"auto"` (the default) probes `isClaudeCliAvailable()`: prefers the
955
989
  * CLI backend when a working `claude` install is detected (reuses
956
990
  * whatever credentials the CLI already has — Pro/Max OAuth or a
957
- * CLI-managed key — with no ANTHROPIC_API_KEY required), otherwise
958
- * falls back to the API backend unchanged (existing behavior for every
959
- * caller that never opts in to this feature).
991
+ * CLI-managed key — with no ANTHROPIC_API_KEY required). Without a CLI it
992
+ * falls back to the API backend ONLY when the owner opted in
993
+ * (`isApiBackendApproved()`, #4257); otherwise it returns `"cli"` and
994
+ * `runCommand()` fails with a typed `CLI_BACKEND_UNAVAILABLE`. The API
995
+ * backend is never the implicit default.
960
996
  *
961
997
  * Delegates its shared "override-or-probe-CLI" shape to
962
998
  * `resolveBackendLadder()` (issue #2026) — this function retains its own
963
999
  * `VALID_BACKENDS` throw-on-invalid check (NOT shared; see that helper's
964
- * doc comment) and its own no-API-key-check fallback (always `"api"`,
965
- * unconditionally — the API key itself is validated lazily downstream, only
1000
+ * doc comment) and its own opt-in-gated fallback (`"api"` only when
1001
+ * approved — the API key itself is validated lazily downstream, only
966
1002
  * if/when the api backend is actually selected).
967
1003
  *
968
1004
  * @param {object} [opts]
969
1005
  * @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
970
1006
  * @param {string} [opts.cwd] - Working directory for the CLI probe.
971
1007
  * @param {object} [opts.env] - Runtime environment used for capability checks.
1008
+ * @param {Function} [opts.isCliAvailableFn] - Injectable CLI-presence probe (test seam).
972
1009
  * @returns {"cli"|"api"}
973
1010
  */
974
- export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env } = {}) {
1011
+ export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env, isCliAvailableFn = isClaudeCliAvailable } = {}) {
975
1012
  if (!VALID_BACKENDS.has(requested)) {
976
1013
  throw new Error(
977
1014
  `Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
@@ -982,7 +1019,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd(), env =
982
1019
  override: requested === "auto" ? undefined : requested,
983
1020
  validOverrides: new Set(["cli", "api"]),
984
1021
  cwd,
985
- cliFallback: () => "api",
1022
+ isCliAvailableFn,
1023
+ cliFallback: () => (isApiBackendApproved({ cwd, env }) ? "api" : "cli"),
986
1024
  });
987
1025
  }
988
1026
 
@@ -2789,7 +2827,9 @@ export function renderSummaryCard(ctx) {
2789
2827
  * @param {string} [opts.backend] - "cli" | "api" | "auto" (default
2790
2828
  * "auto"). Resolution order when omitted: $FORGEDOCK_BACKEND env > "auto".
2791
2829
  * "auto" prefers the local Claude Code CLI when detected (no
2792
- * ANTHROPIC_API_KEY needed), else falls back to the API backend. This
2830
+ * ANTHROPIC_API_KEY needed); without a CLI it uses the API backend only
2831
+ * with the owner's opt-in (`isApiBackendApproved()`, #4257), else it fails
2832
+ * with a typed CLI_BACKEND_UNAVAILABLE — api is never the default. This
2793
2833
  * precedence is intentional/shipped (issue #2003's accepted acceptance
2794
2834
  * criteria) and is NOT changed by issue #2020 — that issue only adds a
2795
2835
  * discoverability notice (below) for the case where the ladder silently
@@ -2840,6 +2880,7 @@ export async function runCommand(opts = {}) {
2840
2880
 
2841
2881
  if (resolvedBackend === "cli" && backend === "auto") {
2842
2882
  const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey, env: runtimeEnv });
2883
+ // #4257: readiness only reports api when the owner approved it.
2843
2884
  if (readiness.backend === "api") resolvedBackend = "api";
2844
2885
  }
2845
2886
 
@@ -2938,6 +2979,8 @@ export async function runCommand(opts = {}) {
2938
2979
  sessionFlags = buildMinimalSessionFlags(commandName, caps);
2939
2980
  }
2940
2981
  if (!resolvedBin && backend === "auto") {
2982
+ // #4257: never cross to the paid API backend without the owner's opt-in.
2983
+ if (!isApiBackendApproved({ cwd, env: runtimeEnv })) throw cliUnavailableError();
2941
2984
  resolvedBackend = "api";
2942
2985
  } else if (!resolvedBin) {
2943
2986
  const err = new Error("The configured claude CLI path disappeared before invocation.");
@@ -163,7 +163,7 @@ async function* listIssuePages(gh, repo, startSince) {
163
163
  }
164
164
 
165
165
  const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
166
- updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
166
+ updatedAt: it.updated_at ?? null, milestone: it.milestone?.title ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
167
167
 
168
168
  const CHECKPOINT_EVERY = 1000;
169
169
  const RATE_CHECK_EVERY = 50;