forgedock 1.44.0 → 1.46.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.
@@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process";
7
7
  import { readFileSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
- import { ensureSignedOff, needsSignoff, offendersOf } from "./signoff.mjs";
10
+ import { ensureSignedOff, needsSignoff, offendersOf, salvageBranch, dropSalvageTracking } from "./signoff.mjs";
11
11
 
12
12
  const CLEAN = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
13
13
  const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...CLEAN, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }).trim();
@@ -143,4 +143,34 @@ describe("ensureSignedOff", () => {
143
143
  await assert.rejects(ensureSignedOff({ git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.engineCode === "engine.dco_rewrite" && e.noIdentity === false && /boom/.test(e.message));
144
144
  assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "churn\n");
145
145
  });
146
+ it("#5047 unsigned commits on a salvage ref are rewritten once the lane is resumed from it, and the signed rewrite pushes as the lane branch", async () => {
147
+ const f = fixture();
148
+ const bad = f.commit("a.txt", "unsigned placeholder", "placeholder");
149
+ const ref = salvageBranch(7, bad);
150
+ assert.equal(ref, `forge-salvage/7-${bad.slice(0, 10)}`);
151
+ f.sh("push", "-q", "origin", `${bad}:refs/heads/${ref}`);
152
+ // the recovery lane fetches the salvage branch as origin/<branch>, builds the worktree from it, then drops that tracking ref
153
+ f.sh("fetch", "-q", "origin", `${ref}:refs/remotes/origin/feat/x`);
154
+ assert.equal((await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} })).rewritten, 0, "with the tracking ref the commits are excluded (the defect)");
155
+ assert.equal(await dropSalvageTracking({ git: f.git, branch: "feat/x" }), true);
156
+ const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
157
+ assert.equal(r.rewritten, 1);
158
+ assert.match(f.sh("log", "-1", "--format=%B"), /Signed-off-by: Lane Bot <lane@example.com>/);
159
+ f.sh("push", "-q", "-u", "origin", "feat/x");
160
+ assert.notEqual(f.sh("rev-parse", "origin/feat/x"), bad);
161
+ });
162
+ it("#5047 an unsigned commit pushed to a salvage ref is not on the lane branch's exclusion range", async () => {
163
+ const f = fixture();
164
+ const bad = f.commit("a.txt", "unsigned placeholder", "placeholder");
165
+ f.sh("push", "-q", "origin", `${bad}:refs/heads/${salvageBranch(7, bad)}`);
166
+ assert.equal((await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} })).rewritten, 1);
167
+ });
168
+ it("#5047 signed history already pushed on origin/<branch> is never rewritten", async () => {
169
+ const f = fixture();
170
+ f.commit("s.txt", "signed", "--signoff");
171
+ f.sh("push", "-q", "-u", "origin", "feat/x");
172
+ const head = f.sh("rev-parse", "HEAD");
173
+ assert.equal((await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} })).rewritten, 0);
174
+ assert.equal(f.sh("rev-parse", "HEAD"), head);
175
+ });
146
176
  });
@@ -240,7 +240,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
240
240
  async function sandboxCred(id, kind, req) {
241
241
  const r = own(id);
242
242
  const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
243
- if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
243
+ if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized", hop: "agent", reason: "agent auth: bad sandbox credential" } };
244
244
  if (!CRED_TTL[kind]) return { status: 404, body: { error: "unknown credential" } };
245
245
  const c = credCache[kind];
246
246
  if (!c || now() - c.at > CRED_TTL[kind]) {
@@ -256,7 +256,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
256
256
  async function sandboxRelay(id, rid, req) {
257
257
  const r = own(id);
258
258
  const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
259
- if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
259
+ if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized", hop: "agent", reason: "agent auth: bad sandbox credential" } };
260
260
  const lane = r.alias ?? r.id; // #4935: a claimed warm sandbox posts under its warm id, but the router queue and the dispatcher use the lane id
261
261
  const path = `/agent/relay/${encodeURIComponent(lane)}${rid ? `/${encodeURIComponent(rid)}` : ""}`;
262
262
  const x = rid ? await call(cfg.router_url, "GET", path, { token: cfg.token, timeoutMs: 20000 })
@@ -400,7 +400,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
400
400
  if (cred) { const r = await sandboxCred(decodeURIComponent(cred[1]), cred[2], req); return send(res, r.status, r.body); }
401
401
  const relay = (req.method === "POST" && /^\/sandboxes\/([^/]+)\/relay$/.exec(url.pathname)) || (req.method === "GET" && /^\/sandboxes\/([^/]+)\/relay\/([^/]+)$/.exec(url.pathname));
402
402
  if (relay) { const r = await sandboxRelay(decodeURIComponent(relay[1]), relay[2] ? decodeURIComponent(relay[2]) : null, req); return send(res, r.status, r.body ?? {}); }
403
- if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized" });
403
+ if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized", hop: "agent", reason: "agent auth: router token rejected by agent" });
404
404
  for (const [match, fn] of routes) {
405
405
  const p = match(req, url);
406
406
  if (!p) continue;
@@ -147,13 +147,15 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
147
147
  catch (e) { log.warn?.(`forge.yaml for ${spec.repo.slug} unreadable: ${e.message}`); return spec; }
148
148
  }
149
149
 
150
+ /** #5053: an agent's 401 reaches the dispatcher through this router; tag it so it is not mistaken for a router auth failure. */
151
+ const agentBody = (r, name) => (r.status === 401 ? { ...(r.data && typeof r.data === "object" ? r.data : {}), error: "unauthorized", hop: "agent", agent: name, reason: typeof r.data?.reason === "string" ? r.data.reason : "agent auth: router token rejected by agent" } : r.data ?? {});
150
152
  async function launch(spec) {
151
153
  if (typeof spec?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(spec.id)) return { status: 400, body: { error: "spec.id required ([A-Za-z0-9._-], max 128)" } };
152
154
  const known = own(sbx(), spec.id);
153
155
  if (known && own(agents(), known.agent)) {
154
156
  known.touched = now(); store.save();
155
157
  const r = await agentCall(own(agents(), known.agent), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...sizeFor(spec) });
156
- return { status: r.status, body: r.data, retryAfter: r.retryAfter };
158
+ return { status: r.status, body: agentBody(r, known.agent), retryAfter: r.retryAfter };
157
159
  }
158
160
  const size = sizeFor(spec);
159
161
  const fits = (await capacities()).filter((c) => c.ok && !c.paused && c.cpus_free >= size.cpus && c.mem_free_gb >= size.mem_gb)
@@ -162,7 +164,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
162
164
  const r = await agentCall(own(agents(), c.name), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...size }, 120000);
163
165
  if (r.status === 503) continue; // raced with another launch; try the next host
164
166
  if (r.ok) { sbx()[spec.id] = { agent: c.name, issue: spec.issue ?? null, batch: spec.batch ?? null, createdAt: now(), touched: now() }; store.save(); }
165
- return { status: r.status, body: { ...(r.data ?? {}), agent: c.name } };
167
+ return { status: r.status, body: { ...agentBody(r, c.name), agent: c.name } };
166
168
  }
167
169
  return { status: 503, body: { error: "no host has a free reservation", lane: size }, retryAfter: cfg.retry_after_s ?? 60 };
168
170
  }
@@ -190,7 +192,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
190
192
  try {
191
193
  const r = await agentCall(a, method, path);
192
194
  if (method === "DELETE" && (r.ok || r.status === 404)) { delete sbx()[id]; relayQ.delete(id); store.save(); }
193
- return { status: r.status, body: r.data };
195
+ return { status: r.status, body: agentBody(r, s.agent) };
194
196
  } catch (e) { return { status: 502, body: { error: `agent ${s.agent} unreachable: ${e.message}` } }; }
195
197
  }
196
198
 
@@ -214,7 +216,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
214
216
  }
215
217
 
216
218
  function enroll(body) {
217
- if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token" } };
219
+ if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token", hop: "router", reason: "router auth: bad enrollment token" } };
218
220
  const name = String(body.name ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "");
219
221
  let url;
220
222
  try { url = new URL(String(body.url)); } catch { return { status: 400, body: { error: "url required" } }; }
@@ -321,14 +323,23 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
321
323
  async function handle(req, res) {
322
324
  const url = new URL(req.url, "http://router");
323
325
  if (!url.pathname.startsWith("/health")) log.info?.(`${req.socket.remoteAddress} ${req.method} ${url.pathname}`); // query (tokens) never logged
326
+ // #5053: every rejected request is logged with its status; the sandbox id comes from the path, the repo slug from a launch body (never tokens or the query).
327
+ const meta = { id: /^\/sandboxes\/([^/]+)/.exec(url.pathname)?.[1] };
328
+ const writeHead = res.writeHead.bind(res);
329
+ res.writeHead = (status, ...a) => {
330
+ if (status >= 400 && url.pathname !== "/health") log.warn?.(`rejected ${req.method} ${url.pathname} -> ${status}${meta.id && meta.id !== "claim" ? ` sandbox=${meta.id.slice(0, 128)}` : ""}${meta.repo ? ` repo=${meta.repo}` : ""}`);
331
+ return writeHead(status, ...a);
332
+ };
324
333
  try {
325
334
  for (const [match, r] of routes) {
326
335
  const p = match(req, url);
327
336
  if (!p) continue;
328
- if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized" });
329
- if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized" });
337
+ if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized", hop: "router", reason: "router auth: bad or missing token" });
338
+ if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized", hop: "router", reason: "router auth: bad or missing token" });
330
339
  if (r.auth === "enroll" && !enrollOk(url.searchParams.get("token"))) return send(res, 401, "unauthorized\n");
331
340
  const body = req.method === "POST" ? await readJson(req) : null;
341
+ if (typeof body?.repo?.slug === "string") meta.repo = body.repo.slug.slice(0, 200);
342
+ if (typeof body?.id === "string") meta.id = body.id;
332
343
  const out = await r.fn(p, body, url, req);
333
344
  if (out.bundle) return sendBundle(res);
334
345
  if (out.image) return sendImage(res, out.image);
package/docs/CONFIG.md CHANGED
@@ -472,7 +472,7 @@ orchestration:
472
472
 
473
473
  ## `decisions` (OPTIONAL)
474
474
 
475
- Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)). **Not yet wired:** nothing calls `settle` in production, so `mode` (including `live`), `veto_hours` and `reversal_max` have no effect today; `policy` and `human_only` are read by prodwatch and issue-watch (`loadPolicy`) only. The parser has one nesting level and no arrays, so lists are comma-separated.
475
+ Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)). The issue-watch stage (#4793) calls `settle` for the open `needs-human` issues a watch selects: it runs when `mode: live` (a repo setting: every watch of the repo runs the stage), or when the watch sets `sweep_needs_human: true`; `policy` and `human_only` are also read by prodwatch (`loadPolicy`). The parser has one nesting level and no arrays, so lists are comma-separated.
476
476
 
477
477
  | Key | Type | Description |
478
478
  |---|---|---|
@@ -718,6 +718,8 @@ issue_watch:
718
718
  | `parallel` | integer | No | K of the batch in `batch` mode. Default 4. |
719
719
  | `include_followups` | string | No | `none` (default) or `defer-only`. |
720
720
  | `postdeploy_rollup` | issue number | No | Release issue that collects post-deploy-only follow-ups as checklist lines. |
721
+ | `sweep_needs_human` | boolean | No | Also settle `needs-human` escalations that predate the watch (the backlog). Default false; `forgedock issue-watch run --dry-run` classifies them first and writes nothing. |
722
+ | `max_launches` | integer | No | Total lane launches (claims plus requeues by the decision engine); at N the watch drains. Default: none. Decision-stage issues also count against `max_issues`. |
721
723
  | `threshold` | map | release | `{ min_issues, min_score, max_age_days }`; any one fires a release. |
722
724
  | `immediate` | list | release | `label:<name>` or `priority:P0..P3` entries that cut a release now with only their theme. |
723
725
  | `max_release_issues` | integer | release | Cap on a release set, closure included. Default 40. |
@@ -1358,6 +1360,7 @@ verification:
1358
1360
  | `consumer_sweep.mode` | string | No | `advisory` (default): the validate consumer sweep (`scripts/consumer-sweep.sh`, V4.6) records unaddressed referencing sites and never fails. `blocking`: unaddressed sites fail validate with `consumer_unaddressed`, build runs one repair round, and sites still unaddressed are recorded as `CONSUMERS: unresolved` and passed to review as known risk (never `needs-human`). Flip to `blocking` only after `bash scripts/consumer-sweep.sh --eval-history 20` shows a mean of at most 2 flagged sites per merge and none above 10 on your repo |
1359
1361
  | `consumer_sweep.budget_seconds` | integer | No | Wall-clock budget for the sweep (default `60`). When it runs out the sweep stops with `BUDGET_EXCEEDED`, reports the partial result, and never fails, in either mode |
1360
1362
  | `test_runners` | map (path prefix → runner) | No | Default unset (no runner enforced). For repos whose validate commands need a managed runner (uv, poetry, pnpm workspace). The lean engine checks it at contract freeze (`testRunnerProblems`, `bin/lean/preflight.mjs`): a validate command that touches a configured path prefix without containing that prefix's runner string is reported as a contract problem, instead of failing later as `contract.invalid_command` / `env.tool_missing`. Paths in the command are relative to the runner's cwd. Write it as a block-style map: `parseForgeYaml` does not read inline `{ ... }` maps. |
1363
+ | `validate.recipes` | map (regex → replacement) | No | Default unset. A top-level `validate:` block with a block-style `recipes:` map, e.g. `"poetry run pytest": "uv run --with-requirements r.txt pytest"`. Each key is a regex source; the first recipe that matches a frozen validate or `repro_test` command rewrites it at contract freeze (`applyValidateRecipes`), recorded as a decision. Use it for commands whose environment is not installed in lane sandboxes. |
1361
1364
 
1362
1365
  **Commands that use this section**: `quality-gate`, `validate` (V2 runs `verification.commands`; V4.8 runs the local CI gate; health checks and `consumer_sweep`), `work-on`
1363
1366
 
package/docs/DECISIONS.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Decision engine (#4766)
2
2
 
3
- > **Status: not yet wired.** No production code calls `settle` yet: the issue-watch stage does not run it, so `decisions.mode`
4
- > (including `live`), `veto_hours` and `reversal_max` have no effect and no `decision_engine` telemetry is emitted. Only the policy
5
- > and authority-matrix reads (`loadPolicy`) are used today, by prodwatch and issue-watch. The flow below describes the intended behaviour.
3
+ > **Status: wired (#4793).** The issue-watch stage runs `settle` each round for the open `needs-human` issues that match the watch's selectors.
6
4
 
7
5
  Settles a lane's decision-only `needs-human` escalation against the target repo's own north star instead of parking it.
8
6
  Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. Config: [`decisions:`](CONFIG.md).
@@ -30,7 +28,20 @@ Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. C
30
28
  `decision_engine` telemetry ([TELEMETRY.md](TELEMETRY.md)): decided, escalated, vetoed, reversed, needs-human before/after. A reversal rate above
31
29
  `reversal_max` turns the engine back to shadow. The batch report lists every decision under `decided`.
32
30
 
33
- ## Not in this module
34
-
35
- The `issue-watch` stage that lists `needs-human` issues and calls `settle` (`sweep_needs_human`, `--dry-run` via `classify`, caps, status counts)
36
- did not land with #4765 (merged without the sweep); a follow-up issue tracks it. Running a declared monitor query for `evidence` and folding `post-deploy` items into the release checklist are follow-ups.
31
+ ## The issue-watch stage
32
+
33
+ `bin/lean/issuewatch-decisions.mjs` runs inside `forgedock issue-watch` (every mode), not as a separate entry point. It is active when `decisions.mode: live`
34
+ or the watch sets `sweep_needs_human: true`. `decisions.mode: live` is a repo setting, so it activates the stage in every watch of the repo (also watches that never opted in); set it only when each watch's selectors are meant to settle `needs-human` issues. Each round it lists open `needs-human` issues of trusted authors that match the watch selectors, takes the newest
35
+ `FORGE:NEEDS_HUMAN` comment (its comment id is the escalation id `settle` is idempotent on) and the typed `terminalCode` from `FORGE:STATE`, and calls `settle`.
36
+
37
+ - **Backlog.** An escalation older than the watch start is only touched by a watch with `sweep_needs_human: true`. `forgedock issue-watch run --dry-run` prints the `classify` result for every escalation (backlog marked) and writes nothing.
38
+ - **Caps.** Each handled issue counts against `max_issues`; each requeue (a live decision or an evidence hand-off) against `max_launches`, which also stops the watch claiming past it.
39
+ - **Status.** `issue-watch status` gains `needs-human decisions: decided N evidence-gathered N folded N external-kept N`.
40
+ - **Evidence.** A block of "missing read-only production data" on a repo whose forge.yaml declares `prod_watch.sources` clears `needs-human` and goes back to a lane with a comment naming the prod monitor capability (placement `prod_monitor`); without a declared source it keeps the label.
41
+ - **Post-deploy only.** Folded into the watch's `postdeploy_rollup` checklist, `needs-human` cleared and the issue closed; without a rollup it is kept.
42
+ - **Shadow.** Only live mode acts: in shadow (the default) a post-deploy-only or evidence escalation posts a `FORGE:DECISION (shadow)` comment and changes nothing (no fold, close, label change or launch).
43
+ - **Relaunch.** A live decision or evidence hand-off clears `needs-human` and launches the issue through the watcher's own lane launch (per-issue watches); batch and release watches re-select it on the next round.
44
+ - **Trust.** Only `FORGE:NEEDS_HUMAN` and `FORGE:STATE` comments from OWNER/MEMBER/COLLABORATOR or the engine's own logins (`ownLogins`; the ForgeDock App posts as CONTRIBUTOR) count, never other CONTRIBUTORs.
45
+ - **Failures.** A judge or gh failure is not recorded as handled: the escalation is retried for up to three rounds. The judge call has a 2 minute timeout, and a round makes at most 20 judge calls even when `max_issues` is unset; the rest wait for the next round.
46
+ - **Veto.** A `forge:decision-veto` label or a `veto` reply inside `veto_hours` reverts a live decision (`revertVeto`) and counts toward the reversal rate that forces shadow.
47
+ - The judge is the only LLM call (`claude --print` in the real io).
@@ -4,6 +4,7 @@ Operator-facing notes for behavior changes that need action or awareness. Newest
4
4
 
5
5
  ## Unreleased: `forgedock selftest`, opt-in release gate and nightly run; gate-marker check ignored
6
6
 
7
+ - **Pre-deploy check, needs-human decision stage (#4793):** `decisions.mode: live` in forge.yaml now turns on the issue-watch decision stage in every watch of the repo, not only watches that set `sweep_needs_human`. Before upgrading, confirm each watch's selectors should settle `needs-human` issues, or leave `decisions.mode` at `shadow` (shadow only comments). Escalations are read only from OWNER/MEMBER/COLLABORATOR or the engine's own logins, with at most 20 judge calls per round. See [DECISIONS.md](DECISIONS.md).
7
8
  - **New command** `forgedock selftest [--static] | --live [--repo <owner/sandbox>] [--pr N]`. It runs a live contract harness against a disposable sandbox repo, plus a static check of every `gh --json` field list. See [SELFTEST.md](SELFTEST.md).
8
9
  - **Opt-in nightly run:** `selftest.nightly_interval_hours` makes the schedd daemon start a detached live selftest on that schedule and file one issue on failure. Restart schedd after upgrading to pick it up.
9
10
  - **Gate-marker check ignored:** the `Check for FORGE gate markers` check is no longer read as a CI failure by `review-staging`, `/review-pr-staging` or `/deploy-pr`. It only echoes the latest gate marker, so after a GATE_FAILURE it blocked its own GATE_PASS (#4941).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"