forgedock 1.17.0 → 1.19.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/outcome-codes.mjs +37 -2
- package/bin/engine/phases.mjs +207 -106
- package/bin/engine/runlog.mjs +17 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +37 -2
- package/bin/engine.mjs +221 -15
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +125 -6
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +203 -24
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +264 -15
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +145 -522
- package/commands/review-pr.md +496 -3124
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +66 -1
- package/commands/work-on/build/implement.md +21 -1
- package/commands/work-on/build/validate.md +322 -28
- package/commands/work-on/build.md +173 -95
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +16 -12
- package/commands/work-on/remediate.md +58 -25
- package/commands/work-on/review.md +36 -21
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +132 -26
- package/docs/OUTCOME-CODES.md +12 -3
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +13 -5
- package/docs/TELEMETRY.md +7 -4
- package/docs/WORK-ON-RUNTIME.md +24 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +2 -2
- package/packages/protocol/src/phases.js +5 -5
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +3 -1
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +71 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +14 -12
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +1579 -0
- package/scripts/review-finalize.test.sh +1040 -0
- package/scripts/review-pr-delta-wiring.test.sh +32 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +53 -15
- package/scripts/review-prepare.test.sh +37 -3
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +11 -3
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +44 -6
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -116,14 +116,12 @@ async function getInvariants() {
|
|
|
116
116
|
* starting Claude Code (operator-set only — same process.env semantics as
|
|
117
117
|
* the gist guard above).
|
|
118
118
|
*
|
|
119
|
-
* 9. Main-checkout mutation guard (issue #3650) —
|
|
120
|
-
* Enforced (exit 2)
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* Enforcement becomes the default once those specs use worktrees (#3873).
|
|
126
|
-
* When enforced it denies `git checkout|switch|stash|merge`, `git reset --hard`, and
|
|
119
|
+
* 9. Main-checkout mutation guard (issue #3650) — enforced by default
|
|
120
|
+
* Enforced (exit 2) unless FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0, which downgrades it
|
|
121
|
+
* to a stderr warning (#3873). Ref-only verbs are narrowed: `git branch -d/-D/-m/-f`
|
|
122
|
+
* is denied only for the branch checked out in the target dir, and
|
|
123
|
+
* `git restore --staged` (index only) is allowed.
|
|
124
|
+
* It denies `git checkout|switch|stash|merge`, `git reset --hard`, and
|
|
127
125
|
* `gh pr checkout` when the shell's cwd is the main (non-worktree) checkout
|
|
128
126
|
* (`git rev-parse --git-dir` equals `--git-common-dir`). Pipeline workers and
|
|
129
127
|
* reviewers share that tree with the operator; mutating it races with them.
|
|
@@ -589,12 +587,12 @@ async function main() {
|
|
|
589
587
|
// forge.yaml), and `git -C <main> ...` from there is the case this rule exists for.
|
|
590
588
|
const mainCheckoutViolation = checkMainCheckoutMutation(command);
|
|
591
589
|
if (mainCheckoutViolation) {
|
|
592
|
-
if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT
|
|
590
|
+
if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT !== "0") {
|
|
593
591
|
process.stderr.write(mainCheckoutViolation);
|
|
594
592
|
process.exit(2);
|
|
595
593
|
return;
|
|
596
594
|
}
|
|
597
|
-
// Advisory
|
|
595
|
+
// Advisory only when the operator opts out with FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 (#3873).
|
|
598
596
|
process.stderr.write("WARNING (advisory, not blocked): " + mainCheckoutViolation.replace(/^BLOCKED: /, ""));
|
|
599
597
|
}
|
|
600
598
|
|
|
@@ -1304,9 +1302,11 @@ function mutatingGitCommand(tokens, depth = 0) {
|
|
|
1304
1302
|
const k = w === "eval" ? i + 1 : tokens.findIndex((t, n) => n > i && /^-[a-z]*c$/.test(t)) + 1;
|
|
1305
1303
|
if (k <= i || k >= tokens.length) return null;
|
|
1306
1304
|
const inner = w === "eval" ? tokens.slice(k).join(" ") : tokens[k];
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1305
|
+
// Every hit of the payload, not just the first (#3920): an allowed ref-only segment (`git branch -d other`)
|
|
1306
|
+
// must not mask a later mutating one. The caller decides per hit.
|
|
1307
|
+
const hits = [];
|
|
1308
|
+
for (const seg of extractCommandSegments(inner)) hits.push(...mutatingGitHits(seg, depth + 1));
|
|
1309
|
+
return hits.length ? { name: hits[0].name, dir: hits[0].dir, hits } : null;
|
|
1310
1310
|
}
|
|
1311
1311
|
break;
|
|
1312
1312
|
}
|
|
@@ -1338,12 +1338,27 @@ function mutatingGitCommand(tokens, depth = 0) {
|
|
|
1338
1338
|
return opts.some((t) => t === "-b") ? null : hit("git checkout");
|
|
1339
1339
|
case "switch":
|
|
1340
1340
|
return opts.some((t) => t === "-c" || t === "--create") ? null : hit("git switch");
|
|
1341
|
-
case "merge": case "pull": case "rebase": case "cherry-pick": case "revert": case "am":
|
|
1341
|
+
case "merge": case "pull": case "rebase": case "cherry-pick": case "revert": case "am":
|
|
1342
1342
|
return hit(`git ${sub}`);
|
|
1343
|
+
case "restore": {
|
|
1344
|
+
// `restore --staged` only rewrites the index (not the working tree); anything else (or --worktree/-W) does.
|
|
1345
|
+
const flag = (long, ch) => opts.some((t) => t === long || (/^-[a-zA-Z]+$/.test(t) && t.includes(ch)));
|
|
1346
|
+
return flag("--worktree", "W") || !flag("--staged", "S") ? hit("git restore") : null;
|
|
1347
|
+
}
|
|
1343
1348
|
case "clean":
|
|
1344
1349
|
return opts.some((t) => t === "-n" || t === "--dry-run") ? null : hit("git clean");
|
|
1345
|
-
case "branch":
|
|
1346
|
-
|
|
1350
|
+
case "branch": {
|
|
1351
|
+
// Ref-only verbs: a hit only when the delete/move/force target is the branch checked out at the
|
|
1352
|
+
// target dir (resolved by the caller). `-m <new>` with one positional renames the current branch.
|
|
1353
|
+
if (!opts.some((t) => /^(?:-[a-zA-Z]*[fFmMdD][a-zA-Z]*|--force|--move|--delete)$/.test(t))) return null;
|
|
1354
|
+
// Positionals after `--` count too (`git branch -m -- renamed` renames the checked-out branch, #3920).
|
|
1355
|
+
const pos = [...opts.filter((t) => !t.startsWith("-")), ...(dd >= 0 ? rest.slice(dd + 1) : [])];
|
|
1356
|
+
if (!pos.length) return null;
|
|
1357
|
+
const moves = opts.some((t) => t === "--move" || /^-[a-zA-Z]*[mM][a-zA-Z]*$/.test(t));
|
|
1358
|
+
const deletes = opts.some((t) => t === "--delete" || /^-[a-zA-Z]*[dD][a-zA-Z]*$/.test(t));
|
|
1359
|
+
const branches = moves && !deletes ? (pos.length === 1 ? ["@current"] : [pos[0]]) : deletes ? pos : [pos[0]];
|
|
1360
|
+
return { name: "git branch", dir, branches };
|
|
1361
|
+
}
|
|
1347
1362
|
case "reset":
|
|
1348
1363
|
return opts.some((t) => t === "--hard" || t === "--merge" || t === "--keep") ? hit("git reset") : null;
|
|
1349
1364
|
case "stash": {
|
|
@@ -1355,6 +1370,18 @@ function mutatingGitCommand(tokens, depth = 0) {
|
|
|
1355
1370
|
}
|
|
1356
1371
|
}
|
|
1357
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* All main-checkout mutation hits in one command segment, flattening `bash -c` / `eval` payloads (#3920).
|
|
1375
|
+
* @param {string[]} tokens
|
|
1376
|
+
* @param {number} [depth]
|
|
1377
|
+
* @returns {Array<{name: string, dir: string|null, branches?: string[]}>}
|
|
1378
|
+
*/
|
|
1379
|
+
function mutatingGitHits(tokens, depth = 0) {
|
|
1380
|
+
const h = mutatingGitCommand(tokens, depth);
|
|
1381
|
+
if (!h) return [];
|
|
1382
|
+
return h.hits ? h.hits : [h];
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1358
1385
|
/**
|
|
1359
1386
|
* Is `cwd` the main (non-linked-worktree) checkout of a git repo?
|
|
1360
1387
|
* Fails closed-to-allow: any error or non-git dir returns false.
|
|
@@ -1386,9 +1413,8 @@ function checkMainCheckoutMutation(command) {
|
|
|
1386
1413
|
if (process.env.FORGE_ALLOW_MAIN_CHECKOUT_GIT === "1") return null;
|
|
1387
1414
|
if (!/\b(?:git|gh)\b/.test(command)) return null;
|
|
1388
1415
|
try {
|
|
1389
|
-
let offender = null;
|
|
1390
1416
|
let baseDir = process.cwd();
|
|
1391
|
-
|
|
1417
|
+
// Decide per segment: an allowed ref-only verb (`git branch -d other`) must not mask a later mutating segment.
|
|
1392
1418
|
for (const tokens of extractCommandSegments(command)) {
|
|
1393
1419
|
const head = tokens.slice();
|
|
1394
1420
|
while (head.length && (/^[({]+$/.test(head[0]) || ["then", "do", "else", "!"].includes(head[0]))) head.shift();
|
|
@@ -1400,21 +1426,28 @@ function checkMainCheckoutMutation(command) {
|
|
|
1400
1426
|
baseDir = resolvePath(baseDir, t);
|
|
1401
1427
|
continue;
|
|
1402
1428
|
}
|
|
1403
|
-
const hit
|
|
1404
|
-
|
|
1429
|
+
for (const hit of mutatingGitHits(tokens)) {
|
|
1430
|
+
const targetDir = hit.dir === null ? baseDir : resolvePath(baseDir, hit.dir);
|
|
1431
|
+
if (!isMainCheckout(targetDir)) continue;
|
|
1432
|
+
if (hit.branches) {
|
|
1433
|
+
// Ref-only verb: only the checked-out branch matters; unresolvable (detached HEAD, error) = allow.
|
|
1434
|
+
const cur = spawnSync("git", ["-C", targetDir, "branch", "--show-current"], { encoding: "utf-8", timeout: 5000 });
|
|
1435
|
+
const current = cur.status === 0 ? String(cur.stdout).trim() : "";
|
|
1436
|
+
if (!current || !hit.branches.some((b) => b === "@current" || b === current)) continue;
|
|
1437
|
+
}
|
|
1438
|
+
// Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
|
|
1439
|
+
if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) continue;
|
|
1440
|
+
return (
|
|
1441
|
+
`BLOCKED: \`${hit.name}\` would mutate the main checkout (${targetDir}).\n` +
|
|
1442
|
+
"The main checkout is shared with the operator and other agents. Work in a git worktree " +
|
|
1443
|
+
"(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
|
|
1444
|
+
"`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
|
|
1445
|
+
"Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
|
|
1446
|
+
"(set FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 to make this advisory only).\n"
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1405
1449
|
}
|
|
1406
|
-
|
|
1407
|
-
if (!isMainCheckout(targetDir)) return null;
|
|
1408
|
-
// Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
|
|
1409
|
-
if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) return null;
|
|
1410
|
-
return (
|
|
1411
|
-
`BLOCKED: \`${offender}\` would mutate the main checkout (${targetDir}).\n` +
|
|
1412
|
-
"The main checkout is shared with the operator and other agents. Work in a git worktree " +
|
|
1413
|
-
"(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
|
|
1414
|
-
"`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
|
|
1415
|
-
"Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
|
|
1416
|
-
"(enforcement itself is opt-in: FORGE_ENFORCE_MAIN_CHECKOUT_GIT=1).\n"
|
|
1417
|
-
);
|
|
1450
|
+
return null;
|
|
1418
1451
|
} catch {
|
|
1419
1452
|
return null; // fail open
|
|
1420
1453
|
}
|
package/bin/runner.mjs
CHANGED
|
@@ -47,8 +47,11 @@ import {
|
|
|
47
47
|
mkdtempSync,
|
|
48
48
|
rmSync,
|
|
49
49
|
realpathSync,
|
|
50
|
+
lstatSync,
|
|
51
|
+
chmodSync,
|
|
50
52
|
} from "fs";
|
|
51
|
-
import {
|
|
53
|
+
import { createHash } from "crypto";
|
|
54
|
+
import { join, dirname, basename, resolve, relative, isAbsolute, extname, win32 as win32Path } from "path";
|
|
52
55
|
import os from "os";
|
|
53
56
|
import { execSync, spawnSync } from "child_process";
|
|
54
57
|
import { createRequire } from "module";
|
|
@@ -74,6 +77,89 @@ export function withForgedockHome(env) {
|
|
|
74
77
|
return env;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
/** Expand a leading `~` and require an absolute result; anything else -> null. */
|
|
81
|
+
function absoluteTmpRoot(value) {
|
|
82
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
83
|
+
if (!v) return null;
|
|
84
|
+
const expanded = v === "~" || v.startsWith("~/") ? join(os.homedir(), v.slice(1)) : v;
|
|
85
|
+
return isAbsolute(expanded) ? expanded : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Root of the per-run scratch dirs (forge#3572). Precedence: forge.yaml `engine.tmp_root`
|
|
90
|
+
* (absolute or `~`-prefixed), else null when the operator exported their own
|
|
91
|
+
* CLAUDE_CODE_TMPDIR/TMPDIR (their choice is respected and never cleaned), else `~/.forge/tmp`.
|
|
92
|
+
* A null return means "operator-managed: leave the child env untouched".
|
|
93
|
+
*
|
|
94
|
+
* @param {string} cwd
|
|
95
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
96
|
+
* @returns {string|null}
|
|
97
|
+
*/
|
|
98
|
+
export function resolveRunTmpRoot(cwd, env = process.env) {
|
|
99
|
+
try {
|
|
100
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
101
|
+
if (existsSync(forgeYamlPath)) {
|
|
102
|
+
const configured = absoluteTmpRoot(parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.engine?.tmp_root);
|
|
103
|
+
if (configured) return configured;
|
|
104
|
+
}
|
|
105
|
+
} catch { /* unreadable config: fall through to the defaults */ }
|
|
106
|
+
if (env.CLAUDE_CODE_TMPDIR || env.TMPDIR) return null;
|
|
107
|
+
return join(os.homedir(), ".forge", "tmp");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Stable, path-safe identity of the repo checkout: `<sanitized basename>-<8 hex of sha1(realpath)>`.
|
|
112
|
+
* Local only (no network), identical across a `--retry` from the same checkout, distinct per checkout.
|
|
113
|
+
* Never empty, never `.`/`..`, never contains a path separator.
|
|
114
|
+
*/
|
|
115
|
+
export function repoKey(cwd = process.cwd()) {
|
|
116
|
+
let real;
|
|
117
|
+
try { real = realpathSync(cwd); } catch { real = resolve(String(cwd)); }
|
|
118
|
+
const name = basename(real).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "") || "repo";
|
|
119
|
+
return `${name}-${createHash("sha1").update(real).digest("hex").slice(0, 8)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `<root>/<repoKey>/<issue>` for a numeric issue; null for an empty root or non-numeric issue (no path-escape, never the root). */
|
|
123
|
+
export function runTmpDir(root, issue, cwd = process.cwd()) {
|
|
124
|
+
const id = String(issue ?? "").trim();
|
|
125
|
+
return root && /^[0-9]+$/.test(id) ? join(root, repoKey(cwd), id) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when `p` is a real directory (not a symlink) owned by the current uid (uid check skipped where unavailable). */
|
|
129
|
+
function isOwnedRealDir(p) {
|
|
130
|
+
const st = lstatSync(p);
|
|
131
|
+
if (!st.isDirectory() || st.isSymbolicLink()) return false;
|
|
132
|
+
return typeof process.getuid !== "function" || st.uid === process.getuid();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Point TMPDIR and CLAUDE_CODE_TMPDIR at a per-run, disk-backed dir so venvs and Claude session data
|
|
137
|
+
* stay off a size-limited /tmp. The dir is repo-scoped and created 0700; an existing dir that is a symlink
|
|
138
|
+
* or owned by another uid is not used. No-op when the issue is unknown or the operator owns the tmp root.
|
|
139
|
+
* Best-effort: any failure leaves the env unchanged.
|
|
140
|
+
*/
|
|
141
|
+
export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
|
|
142
|
+
const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue, cwd);
|
|
143
|
+
if (!dir) return env;
|
|
144
|
+
try {
|
|
145
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
146
|
+
const parent = dirname(dir);
|
|
147
|
+
for (const d of [parent, dir]) {
|
|
148
|
+
if (!isOwnedRealDir(d)) return env;
|
|
149
|
+
chmodSync(d, 0o700);
|
|
150
|
+
}
|
|
151
|
+
env.TMPDIR = dir;
|
|
152
|
+
env.CLAUDE_CODE_TMPDIR = dir;
|
|
153
|
+
} catch { /* scratch stays on the inherited tmp */ }
|
|
154
|
+
return env;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** First positional arg when it is an issue number (the phase arg convention), else null. */
|
|
158
|
+
function issueFromArgs(args) {
|
|
159
|
+
const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
|
|
160
|
+
return /^[0-9]+$/.test(String(first ?? "")) ? String(first) : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
77
163
|
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
78
164
|
const DEFAULT_MAX_ITERATIONS = 50;
|
|
79
165
|
const DEFAULT_MAX_TOKENS = 16384;
|
|
@@ -1410,7 +1496,7 @@ export function runCliBackend({
|
|
|
1410
1496
|
// Mirrors the existing scrub in the `run_bash` tool handler below.
|
|
1411
1497
|
// GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
|
|
1412
1498
|
// intact — only the Anthropic key is removed.
|
|
1413
|
-
const childEnv = withForgedockHome({ ...process.env });
|
|
1499
|
+
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
|
|
1414
1500
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
1415
1501
|
|
|
1416
1502
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
@@ -1435,6 +1521,9 @@ export function runCliBackend({
|
|
|
1435
1521
|
// intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
|
|
1436
1522
|
// reliable for this single, non-looped invocation.
|
|
1437
1523
|
const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
|
|
1524
|
+
// forge#4037: only a spawn-stage failure is transient infra. A wall-clock kill leaves evidence that the child ran
|
|
1525
|
+
// (a kill signal, a pid, or captured output); an ETIMEDOUT with none of those never started the child.
|
|
1526
|
+
const childRan = !!result.signal || (Number.isInteger(result.pid) && result.pid > 0) || (stdout + stderr).trim().length > 0;
|
|
1438
1527
|
if (timedOut) {
|
|
1439
1528
|
const timeoutSecs = Math.round(timeoutMs / 1000);
|
|
1440
1529
|
// forge#2360: spawnSync's `timeout` option kills the child outright on
|
|
@@ -1452,15 +1541,44 @@ export function runCliBackend({
|
|
|
1452
1541
|
// review findings — #2277, #2292, #2293, #2355 — for that defect
|
|
1453
1542
|
// class).
|
|
1454
1543
|
const partial = (stdout + stderr).trim();
|
|
1455
|
-
|
|
1544
|
+
const timeoutError = new Error(
|
|
1456
1545
|
`claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
|
|
1457
1546
|
`Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
|
|
1458
1547
|
(partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
|
|
1459
1548
|
);
|
|
1549
|
+
if (!childRan) {
|
|
1550
|
+
// forge#4021: stable transient-infra class; the engine retries it on a
|
|
1551
|
+
// separate bounded counter without consuming a phase attempt.
|
|
1552
|
+
timeoutError.code = "CLI_SPAWN_TRANSIENT";
|
|
1553
|
+
timeoutError.spawnErrno = "ETIMEDOUT";
|
|
1554
|
+
throw timeoutError;
|
|
1555
|
+
}
|
|
1556
|
+
// forge#4037: the child ran and was killed at the wall-clock limit: a normal, attempt-consuming failure
|
|
1557
|
+
// (`cli.timeout`), with usage attached when the CLI had already emitted a parseable envelope.
|
|
1558
|
+
timeoutError.code = "CLI_TIMEOUT";
|
|
1559
|
+
timeoutError.usage = null;
|
|
1560
|
+
try {
|
|
1561
|
+
const u = JSON.parse(stdout.trim())?.usage;
|
|
1562
|
+
if (u && typeof u === "object") {
|
|
1563
|
+
timeoutError.usage = {
|
|
1564
|
+
input_tokens: toFiniteUsageNumber(u.input_tokens),
|
|
1565
|
+
output_tokens: toFiniteUsageNumber(u.output_tokens),
|
|
1566
|
+
cache_creation_input_tokens: toFiniteUsageNumber(u.cache_creation_input_tokens),
|
|
1567
|
+
cache_read_input_tokens: toFiniteUsageNumber(u.cache_read_input_tokens),
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
} catch {
|
|
1571
|
+
// No parseable envelope from a killed child: usage stays null (never fabricated).
|
|
1572
|
+
}
|
|
1573
|
+
throw timeoutError;
|
|
1460
1574
|
}
|
|
1461
1575
|
if (result.error) {
|
|
1462
1576
|
const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
|
|
1463
1577
|
error.code = result.error.code;
|
|
1578
|
+
if (["EAGAIN", "ENOMEM", "EMFILE"].includes(result.error.code)) {
|
|
1579
|
+
error.spawnErrno = result.error.code;
|
|
1580
|
+
error.code = "CLI_SPAWN_TRANSIENT";
|
|
1581
|
+
}
|
|
1464
1582
|
throw error;
|
|
1465
1583
|
}
|
|
1466
1584
|
|
|
@@ -2186,9 +2304,10 @@ export function resolveBashShell() {
|
|
|
2186
2304
|
* the loop catches and reports the error back to the model as an error result.
|
|
2187
2305
|
*
|
|
2188
2306
|
* @param {string} cwd
|
|
2307
|
+
* @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
|
|
2189
2308
|
* @returns {Record<string, (input: object) => string>}
|
|
2190
2309
|
*/
|
|
2191
|
-
export function getToolHandlers(cwd) {
|
|
2310
|
+
export function getToolHandlers(cwd, runIssueNumber = null) {
|
|
2192
2311
|
return {
|
|
2193
2312
|
read_file: ({ path }) => {
|
|
2194
2313
|
if (!path) throw new Error("read_file requires a 'path'");
|
|
@@ -2208,7 +2327,7 @@ export function getToolHandlers(cwd) {
|
|
|
2208
2327
|
// into the model loop, so a prompt-injection payload could otherwise issue
|
|
2209
2328
|
// a run_bash command that exfiltrates the key. gh/git auth tokens
|
|
2210
2329
|
// (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
|
|
2211
|
-
const childEnv = withForgedockHome({ ...process.env });
|
|
2330
|
+
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
|
|
2212
2331
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
2213
2332
|
// Run under bash when available so bash-style commands the model emits
|
|
2214
2333
|
// (heredocs, scripts/*.sh, single-quote semantics) behave consistently
|
|
@@ -2648,7 +2767,7 @@ export async function runCommand(opts = {}) {
|
|
|
2648
2767
|
// is a known Linux limitation at the OS level; closing it would require
|
|
2649
2768
|
// a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
|
|
2650
2769
|
// See: issue #1370 for tracking this known limitation.
|
|
2651
|
-
const handlers = getToolHandlers(cwd);
|
|
2770
|
+
const handlers = getToolHandlers(cwd, issueFromArgs(args));
|
|
2652
2771
|
const messages = [{ role: "user", content: userMessage }];
|
|
2653
2772
|
|
|
2654
2773
|
// Accumulate token usage across all messages.create() calls in this run.
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -127,8 +127,10 @@ function funnelSection(events, issues) {
|
|
|
127
127
|
for (const d of Object.values(bySeverity)) d.perReview = reviews.size ? d.total / reviews.size : null;
|
|
128
128
|
const dispositions = { ISSUE: 0, NOTE: 0, INPR_FIX: 0 };
|
|
129
129
|
for (const f of findings) if (f.disposition in dispositions) dispositions[f.disposition]++;
|
|
130
|
+
// Events written before the scope field existed carry none and are not counted.
|
|
131
|
+
const introduced = findings.filter((f) => f.scope === "introduced").length;
|
|
130
132
|
return {
|
|
131
|
-
reviews: reviews.size, findings: findings.length, perReview: ratio(findings.length, reviews.size),
|
|
133
|
+
reviews: reviews.size, findings: findings.length, introduced, perReview: ratio(findings.length, reviews.size),
|
|
132
134
|
bySeverity, dispositions,
|
|
133
135
|
validated: issues.filter((i) => i.labels.includes("validated")).length,
|
|
134
136
|
falsePositive: issues.filter((i) => i.labels.includes("false-positive")).length,
|
|
@@ -150,7 +152,7 @@ function gatesSection(events) {
|
|
|
150
152
|
};
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
function issueFlowSection(issues) {
|
|
155
|
+
function issueFlowSection(issues, introduced = 0) {
|
|
154
156
|
const real = issues.filter((i) => !i.isPr);
|
|
155
157
|
const created = {};
|
|
156
158
|
for (const i of real) { const s = issueSource(i); created[s] = (created[s] ?? 0) + 1; }
|
|
@@ -162,6 +164,7 @@ function issueFlowSection(issues) {
|
|
|
162
164
|
n: real.length, created, createdTotal: real.length, closed, closedTotal: closedIssues.length,
|
|
163
165
|
net: real.length ? real.length - closedIssues.length : null,
|
|
164
166
|
findingsPerMerge: ratio(created["review-finding"] ?? 0, merged),
|
|
167
|
+
introducedPerMerge: ratio(introduced, merged),
|
|
165
168
|
};
|
|
166
169
|
}
|
|
167
170
|
|
|
@@ -214,7 +217,8 @@ export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {
|
|
|
214
217
|
let groups = [...keys.values()].map(({ version, repo }) => {
|
|
215
218
|
const sel = (list) => list.filter((x) => x.version === version && x.repo === repo);
|
|
216
219
|
const g = { version, repo, cost: costSection(sel(counted)), time: timeSection(sel(counted)), flow: flowSection(sel(counted)),
|
|
217
|
-
funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow:
|
|
220
|
+
funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: null };
|
|
221
|
+
g.issueFlow = issueFlowSection(sel(iss), g.funnel.introduced);
|
|
218
222
|
g.n = g.flow.n;
|
|
219
223
|
g.lowN = g.n < minN;
|
|
220
224
|
g.minN = minN;
|
package/bin/stats/cache.mjs
CHANGED
|
@@ -17,13 +17,17 @@ export function cacheDir(repo, env = process.env) {
|
|
|
17
17
|
return join(root, repo.replace("/", "_"));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* { watermark: ISO string | null, coveredFrom: ISO string | null, items: { "<number>": item } }
|
|
22
|
+
* The cache covers activity in [coveredFrom, watermark]; coveredFrom null means "from the beginning". A cache written
|
|
23
|
+
* before the field existed loads with coveredFrom undefined (floor unknown), never as full coverage.
|
|
24
|
+
*/
|
|
25
|
+
export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, coveredFrom: null, items: {} });
|
|
22
26
|
|
|
23
27
|
export function loadCache(repo, env = process.env) {
|
|
24
28
|
try {
|
|
25
29
|
const c = JSON.parse(readFileSync(join(cacheDir(repo, env), "cache.json"), "utf8"));
|
|
26
|
-
if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c };
|
|
30
|
+
if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c, coveredFrom: "coveredFrom" in c ? c.coveredFrom : undefined };
|
|
27
31
|
} catch { /* missing or corrupt: refetch */ }
|
|
28
32
|
return emptyCache();
|
|
29
33
|
}
|