machine-bridge-mcp 1.2.10 → 1.2.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.11 - 2026-07-20
4
+
5
+ ### Bounded output continuation and repository backlog gate
6
+
7
+ - Stop large one-shot process responses from overwhelming MCP hosts. `run_process`, `run_local_command`, and `exec_command` now inline only a bounded stdout/stderr preview, retain up to 1 MiB per stream in a temporary completed process session, and return an `output_session_id` for paged `read_process` continuation. Nonzero exits keep their human error message bounded while preserving structured continuation details.
8
+ - Avoid protocol-level payload duplication by replacing the text mirror of large object results with a compact field summary while retaining the authoritative object in `structuredContent`. The fast/platform/full check runner suppresses successful child noise, preserves bounded head/tail diagnostics on failure, and supports explicit `MBM_CHECK_VERBOSE=1` streaming. Coverage cleanup now retries concurrent late V8 coverage-file writes instead of failing after the thresholds already passed.
9
+ - Resolve the release-acceptance file race by opening no-follow descriptors first, checking descriptor/path identity, and reading acceptance records and tarballs through bounded descriptors. Remove the temporary CodeQL exception and add deterministic path-replacement and symbolic-link regressions.
10
+ - Add a guarded GitHub backlog pre-push check. `npm run github:push` now blocks unrelated open pull requests and open issues not covered by a standard closing keyword in the current branch commits; the current branch PR remains updateable. Add unit and integration coverage for output paging, compact MCP projection, bounded check diagnostics, and backlog enforcement.
11
+
3
12
  ## 1.2.10 - 2026-07-20
4
13
 
5
14
  ### Relay interruption recovery
package/CONTRIBUTING.md CHANGED
@@ -44,7 +44,7 @@ Repository-only infrastructure changes, such as a `.github/` workflow update, do
44
44
  6. give the repository owner `npm run release:candidate:start -- --allow-worker-deploy`; the owner explicitly authorizes the in-place candidate Worker deployment, starts the exact candidate locally, and leaves it running;
45
45
  7. verify the live candidate through Machine Bridge, including Worker version/hash, remote health, relay readiness, exact local version, and representative functionality relevant to the change;
46
46
  8. after observed verification succeeds, have the coding agent run the exact `release:accept` command printed by the candidate tool, creating `release-acceptance/v<version>.json`;
47
- 9. commit the acceptance record and push the clean non-`main` branch only with `npm run github:push`.
47
+ 9. resolve every open issue and pull request before pushing. The current branch may cover an issue only with a standard closing keyword such as `Closes #47`; unrelated open PRs block the push. Commit the acceptance record and push the clean non-`main` branch only with `npm run github:push`, which paginates and enforces that backlog contract.
48
48
 
49
49
  Automated checks do not authorize step 8. The coding agent may record acceptance only after it has observed the owner-started candidate operating successfully. Any packaged-file change after acceptance changes the npm tarball hash and requires a regenerated candidate and another observed live verification.
50
50
 
package/README.md CHANGED
@@ -217,7 +217,7 @@ Major groups include:
217
217
 
218
218
  ```sh
219
219
  npm ci
220
- npm run check:fast # local feedback: static, unit, architecture, policy, lifecycle
220
+ npm run check:fast # quiet-success local feedback; set MBM_CHECK_VERBOSE=1 only for live child logs
221
221
  npm run check # complete suite, equivalent to check:full
222
222
  npm run worker:dry-run
223
223
  npm audit --audit-level=high
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "1.2.10",
4
+ "version": "1.2.11",
5
5
  "description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -30,5 +30,5 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "1.2.10"
33
+ "version_name": "1.2.11"
34
34
  }
@@ -213,7 +213,11 @@ This is a process-level transaction, not a filesystem-wide atomic transaction ac
213
213
 
214
214
  The default `full` profile passes the complete parent environment. Isolated environment mode, used by the narrower named profiles unless overridden, creates private runtime HOME, temporary, and cache directories and passes only a small set of path/locale/platform variables. It reduces accidental credential inheritance but cannot prevent explicit access to known filesystem paths, credential stores, network services, or other user resources.
215
215
 
216
- `execution-limits.mjs` is the shared source for local tool-call concurrency, one-shot process timeout/stdin/output limits, and process-session count/stdin/output/retention limits. `server_info.runtime.execution_guardrails` reports those enforced limits together with explicit `not-enforced` values for CPU quota, memory quota, and network isolation. One-shot calls have bounded output and timeouts. Startup-lock waits, daemon takeover, process-session reads, managed-job recovery handoff, browser/page waits, application-cache freshness, and in-memory duration metrics use monotonic elapsed time, so wall-clock correction cannot extend or prematurely terminate their configured duration. Persisted timestamps and retention/credential expiry continue to use wall time. Process sessions retain bounded byte buffers with monotonic offsets, accept bounded stdin, support short output/exit waits, and are capped per runtime. Valid UTF-8 is returned as text; byte slices that are not valid UTF-8 also include lossless base64 data. Session IDs are random. Sessions are memory-only and are killed on runtime stop, remote disconnect, or daemon replacement.
216
+ `execution-limits.mjs` is the shared source for local tool-call concurrency, one-shot process timeout/stdin/output limits, and process-session count/stdin/output/retention limits. `server_info.runtime.execution_guardrails` reports those enforced limits together with explicit `not-enforced` values for CPU quota, memory quota, and network isolation. Public one-shot commands inline at most 32 KiB per stream. When either stream exceeds that preview, the runtime keeps up to 1 MiB per stream in a closed in-memory process session for thirty minutes and returns an `output_session_id`; `read_process` then reads monotonic byte-offset pages. The oldest exited session is evicted before an active session is refused, so continuation retention is explicitly best effort rather than durable. The continuation stores command basename and cwd metadata but not argv or shell text. It is memory-only and disappears on runtime stop or daemon replacement.
217
+
218
+ Large object results use `structuredContent` as the authoritative representation. The human text mirror is complete only below the shared 16 KiB projection threshold; above it, both local stdio and the Worker return the same compact byte-count/field summary instead of serializing the object a second time. `process-result-projection.mjs`, `process-output-stream.mjs`, and the shared result projector keep lifecycle, byte retention, and MCP presentation as separate boundaries.
219
+
220
+ Startup-lock waits, daemon takeover, process-session reads, managed-job recovery handoff, browser/page waits, application-cache freshness, and in-memory duration metrics use monotonic elapsed time, so wall-clock correction cannot extend or prematurely terminate their configured duration. Persisted timestamps and retention/credential expiry continue to use wall time. Process sessions retain bounded byte buffers with monotonic offsets, accept bounded stdin, support short output/exit waits, and are capped per runtime. Valid UTF-8 is returned as text; byte slices that are not valid UTF-8 also include lossless base64 data. Head/tail previews trim incomplete UTF-8 boundary code points instead of introducing replacement characters. Session IDs are random. Running sessions are killed on runtime stop, remote disconnect, or daemon replacement.
217
221
 
218
222
  Child processes run in a separate process group where supported. Timeout, cancellation, disconnect, and replacement send termination to process trees, with a referenced forced-escalation timer that remains alive even when the direct child exits before a resistant descendant. Windows uses tree-aware task termination.
219
223
 
package/docs/AUDIT.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Security and privacy audit notes
2
2
 
3
+ ## 2026-07-20 version 1.2.11 bounded-result and release-file audit
4
+
5
+ The reported web truncation was not one defect. One-shot command tools retained up to 512 KiB independently for stdout and stderr, returned no continuation handle after dropping their middle, and generic MCP framing serialized object results once as text and again as `structuredContent`. The layered verification runner then inherited every child stream, so a long successful gate could accumulate output even though only its final status mattered. Nonzero process exits also promoted the retained stderr preview into the human error message. These independent amplification points made host-side response truncation frequent and made locally truncated command output unrecoverable.
6
+
7
+ Version 1.2.11 separates preview, retention, and presentation. Public one-shot commands inline 32 KiB per stream and retain up to 1 MiB per stream in an exited in-memory process session. The response supplies a random `output_session_id` and bounded `read_process` parameters; reads use monotonic offsets, disclose aged-out data, and preserve invalid byte slices with base64. Failed commands keep a bounded message and carry the same continuation only in exposed structured details. Large object results have one authoritative `structuredContent` value and a short shared text projection rather than a second full JSON serialization. UTF-8 head/tail previews no longer split Chinese or emoji code points. Green verification tasks suppress child noise; failures retain bounded head and tail, while explicit verbose mode remains available.
8
+
9
+ The only open security issue concerned `lstat` followed by path-based reads of release acceptance JSON and candidate tarballs. Both now open with no-follow semantics, validate the descriptor as a regular file, compare descriptor and path identity, enforce a pre-allocation size ceiling, and read through the descriptor. Deterministic tests replace the path after open and prove the descriptor remains authoritative; symlink records and tarballs fail closed. The temporary CodeQL file-race exception is removed. The guarded push path now also paginates the complete GitHub issue/PR backlog and refuses unrelated open PRs or issues not closed by the current branch, making repository backlog completion an executable release condition rather than a conversational promise.
10
+
11
+ Repeated full-gate execution also exposed a separate test-infrastructure race: V8 could finish writing a coverage file just after the last fixture process exited, causing recursive cleanup to fail with `ENOTEMPTY` even though every threshold passed. Coverage cleanup now uses bounded retry/backoff, the contract is architecture-tested, and three consecutive coverage runs pass.
12
+
13
+ Privacy review found no new durable output store. Continuation bytes live only in the daemon heap, share the existing eight-session/30-minute bounds, are omitted from logs and acceptance records, and disappear on runtime stop. Complete and production dependency audits remain at zero vulnerabilities; registry signatures, attestations, package file modes, generated references, and reachable Git history privacy checks are independently verified.
14
+
3
15
  ## 2026-07-20 version 1.2.10 transient-relay recovery audit
4
16
 
5
17
  Repeated ChatGPT web executions were terminating even though the local daemon and Worker were healthy immediately afterward. The causal defect was not the tool implementation or its configured timeout. A brief WebSocket loss—made more likely when the daemon relay traverses an environment-selected local proxy—was promoted into a terminal request failure at both ends: the Worker rejected every pending call assigned to the closed socket, while the local runtime cancelled all relay-origin calls and killed their ordinary child processes. Automatic WebSocket reconnection restored future traffic but could not recover the request already abandoned by both state machines.
package/docs/LOGGING.md CHANGED
@@ -77,7 +77,9 @@ With `--verbose`, the same incident additionally includes bounded structured fie
77
77
 
78
78
  ## Tool and result-delivery events
79
79
 
80
- All per-tool starts, successes, failures, cancellations, timing, and expected late-result disposal are debug-only. The MCP response already reports the outcome to the caller; duplicating routine tool traffic at default levels creates noise and can reveal activity patterns.
80
+ All per-tool starts, successes, failures, cancellations, timing, and expected late-result disposal are debug-only. The MCP response already reports the outcome to the caller; duplicating routine tool traffic at default levels creates noise and can reveal activity patterns. Completed one-shot output continuations are not written to daemon logs or disk; their bounded stdout/stderr remains only in the daemon process-session memory and expires with normal session retention.
81
+
82
+ The layered repository check runner follows the same noise rule. Green child-task output is discarded after the child exits; only task name and elapsed time are printed. Failed tasks expose bounded head/tail stdout and stderr diagnostics. `MBM_CHECK_VERBOSE=1` is an explicit operator choice to stream raw child output and is not used by default or CI.
81
83
 
82
84
  A completed local result is normally sent on the ready relay connection. If that socket disappears, the runtime queues the bounded result envelope during the thirty-second same-daemon reconnect window rather than logging a terminal delivery failure. Debug output records only a shortened call ID and queue/reconnect counts. After the same daemon process completes readiness, replay emits one recovery event; a different process cannot inherit the result. Explicit caller cancellation suppresses eventual delivery. If the relay does not recover before the grace deadline, ordinary calls are cancelled, queued results are discarded, and the existing outage state machine determines whether the persistent failure warrants a warning. Tool arguments, commands, and result content are never logged.
83
85
 
@@ -248,8 +248,9 @@ Defense-in-depth limits include:
248
248
  - text writes and patch envelopes: 5 MiB;
249
249
  - images: 4 MiB before base64 encoding;
250
250
  - shell/argv envelope: 64 KiB;
251
- - captured one-shot output: 512 KiB per stream by default;
252
- - process-session retained output: 1 MiB per stream, with lossless base64 fallback for non-UTF-8 slices;
251
+ - public one-shot output preview: 32 KiB per stream; larger output returns an `output_session_id` and remains readable through `read_process`;
252
+ - internal bounded subprocess capture: 512 KiB per stream by default;
253
+ - running or completed process-session retained output: 1 MiB per stream for up to 30 minutes, best effort subject to the eight-session capacity, with monotonic offsets and lossless base64 fallback for non-UTF-8 slices;
253
254
  - process sessions: 8 retained per runtime;
254
255
  - process stdin write: 64 KiB per call;
255
256
  - local simultaneous tool calls: 16;
package/docs/RELEASING.md CHANGED
@@ -73,7 +73,7 @@ Commit the candidate changes and acceptance record, then push the branch only th
73
73
  npm run github:push
74
74
  ```
75
75
 
76
- The command requires a clean non-`main` branch, verifies that the acceptance record is tracked, rebuilds the npm package, compares both hashes, and only then executes a non-force push of the current branch. Direct pushes to `main` remain prohibited. Any packaged-file change after acceptance invalidates the hash and blocks the next push until the owner retests a regenerated candidate.
76
+ The command requires a clean non-`main` branch, fetches `origin/main`, paginates all open GitHub issues and pull requests, and refuses to push while an unrelated PR remains open or an issue is not covered by a standard closing keyword in `origin/main..HEAD`. An already-open PR for the current branch is allowed so it can be updated. The command then verifies that the acceptance record is tracked, rebuilds the npm package, compares both hashes, and only then executes a non-force push of the current branch. Direct pushes to `main` remain prohibited. Any packaged-file change after acceptance invalidates the hash and blocks the next push until the owner retests a regenerated candidate.
77
77
 
78
78
  Open a pull request, satisfy the required checks, and squash-merge. Pull-request CI repeats the acceptance verification. A content-preserving squash changes the Git commit but not the package bytes, so the accepted hash remains valid.
79
79
 
package/docs/TESTING.md CHANGED
@@ -12,12 +12,16 @@ npm run check # complete suite; equivalent to check:full
12
12
 
13
13
  The explicit task lists live in `scripts/check-plan.mjs`. The fast plan retains static analysis, policy, architecture, contracts, and focused unit behavior. The platform plan adds process, service, browser/application, state, managed-job, cryptographic, and real-machine behavior tests needed on macOS and Windows. The full-only plan adds coverage, browser-broker integration, package/install, stdio, Worker/OAuth, and real-browser navigation suites. `npm run check` remains the authoritative complete gate.
14
14
 
15
+ Successful child-task stdout/stderr is intentionally suppressed so a long green plan does not overwhelm an MCP response or hide the final status behind host truncation. Progress and timing remain visible. A failed task returns bounded head/tail diagnostics for both streams. Set `MBM_CHECK_VERBOSE=1` only when an operator explicitly needs live child output; verbose mode can be large and should be run through a process session when used remotely.
16
+
15
17
  The repository requires Node.js 26 and npm 12. `.node-version`, `.nvmrc`, `packageManager`, `devEngines`, and strict engine checks keep local and CI execution on the same baseline.
16
18
 
17
19
  The suite includes:
18
20
 
19
21
  - package-impact classification derived from the npm package manifest, allowing repository-only GitHub workflow maintenance without weakening version requirements for shipped content;
20
- - interactive candidate acceptance hashing, including owner-authorized in-place Worker deployment plus owner-started candidate verification, exact tarball SHA-1/SHA-512 matching, acceptance-record exclusion from the package, legacy 1.2.8 marker compatibility, and invalidation after any packaged-byte change;
22
+ - interactive candidate acceptance hashing, including owner-authorized in-place Worker deployment plus owner-started candidate verification, exact tarball SHA-1/SHA-512 matching, descriptor-first no-follow reads, path-identity/symlink regressions, acceptance-record exclusion from the package, legacy 1.2.8 marker compatibility, and invalidation after any packaged-byte change;
23
+ - one-shot process output preview/continuation, completed-session paging, bounded nonzero-exit details, UTF-8-safe head/tail diagnostics, compact local/Worker MCP text mirrors, and quiet-success/bounded-failure verification-runner behavior;
24
+ - GitHub backlog enforcement that paginates all open issues and pull requests, permits only the current branch PR, and requires standard closing keywords for every open issue before a guarded push;
21
25
  - release-impact enforcement requiring a new package version and CHANGELOG section for release-relevant changes;
22
26
  - release-state diagnostics distinguishing missing local/remote version tags from tags that point to the wrong commit, plus a release-CI gate that rejects missing, pending, failed, pull-request-only, stale, or wrong-commit runs;
23
27
  - generated Cloudflare Worker types under ignored `.wrangler/` state and strict TypeScript checking, including unused-local and unused-parameter rejection; packaging rejects generated declarations;
@@ -1455,7 +1455,7 @@ List effective direct-argv commands from project manifests and safe automatic pa
1455
1455
 
1456
1456
  **Run registered local command**
1457
1457
 
1458
- Run an effective manifest or automatic package-script command through its fixed argv, cwd, timeout ceiling, and extra-argument policy.
1458
+ Run an effective manifest or automatic package-script command through its fixed argv, cwd, timeout ceiling, and extra-argument policy. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.
1459
1459
 
1460
1460
  | Contract field | Value |
1461
1461
  |---|---|
@@ -1988,7 +1988,7 @@ Return bounded metadata and patch output for one revision without running reposi
1988
1988
 
1989
1989
  **Run process directly**
1990
1990
 
1991
- Execute an argv array without a command shell. This avoids shell parsing but does not sandbox the executable or code it launches.
1991
+ Execute an argv array without a command shell. This avoids shell parsing but does not sandbox the executable or code it launches. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.
1992
1992
 
1993
1993
  | Contract field | Value |
1994
1994
  |---|---|
@@ -2074,7 +2074,7 @@ Start a direct argv process without a shell and retain bounded stdout, stderr, a
2074
2074
 
2075
2075
  **Read process session**
2076
2076
 
2077
- Read bounded stdout and stderr deltas from a server-managed process session, optionally waiting briefly for new output.
2077
+ Read bounded stdout and stderr deltas from a running process session or a recently completed one-shot command continuation, optionally waiting briefly for new output.
2078
2078
 
2079
2079
  | Contract field | Value |
2080
2080
  |---|---|
@@ -2796,7 +2796,7 @@ Request cancellation of a detached managed job. The runner terminates the active
2796
2796
 
2797
2797
  **Execute shell command**
2798
2798
 
2799
- Execute a shell command with workspace cwd. This is not a sandbox and has the operating-system authority of the local user.
2799
+ Execute a shell command with workspace cwd. This is not a sandbox and has the operating-system authority of the local user. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.
2800
2800
 
2801
2801
  | Contract field | Value |
2802
2802
  |---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "1.2.10",
3
+ "version": "1.2.11",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -129,7 +129,11 @@
129
129
  "check-plan:test": "node tests/check-plan-test.mjs",
130
130
  "check:platform": "node scripts/run-checks.mjs platform",
131
131
  "release": "node scripts/github-release.mjs --publish",
132
- "release:candidate:start": "node scripts/start-release-candidate.mjs"
132
+ "release:candidate:start": "node scripts/start-release-candidate.mjs",
133
+ "check-runner:test": "node tests/check-runner-test.mjs",
134
+ "process-output:test": "node tests/process-output-continuation-test.mjs",
135
+ "github-backlog:test": "node tests/github-backlog-test.mjs",
136
+ "github:backlog": "node scripts/github-backlog.mjs"
133
137
  },
134
138
  "dependencies": {
135
139
  "https-proxy-agent": "9.1.0",
@@ -5,6 +5,9 @@ export const FAST_CHECK_TASKS = Object.freeze([
5
5
  "release-state:test",
6
6
  "release-ci:test",
7
7
  "network-retry:test",
8
+ "check-runner:test",
9
+ "process-output:test",
10
+ "github-backlog:test",
8
11
  "secure-file:test",
9
12
  "worker-secret-file:test",
10
13
  "sarif-security:test",
@@ -0,0 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+ import { BoundedOutput } from "../src/local/bounded-output.mjs";
4
+
5
+ const FAILURE_OUTPUT_BYTES_PER_STREAM = 64 * 1024;
6
+
7
+ export async function runVerificationPlan(options) {
8
+ const {
9
+ mode,
10
+ tasks,
11
+ npmCli,
12
+ cwd = process.cwd(),
13
+ env = process.env,
14
+ verbose = false,
15
+ stdout = process.stdout,
16
+ stderr = process.stderr,
17
+ spawnProcess = spawn,
18
+ } = options;
19
+ if (!npmCli) throw new Error("check runner must run through npm so npm_execpath is available");
20
+ const planStartedAt = performance.now();
21
+ stdout.write(`running ${mode} verification plan (${tasks.length} tasks)\n`);
22
+ for (const [index, task] of tasks.entries()) {
23
+ const taskStartedAt = performance.now();
24
+ stdout.write(`\n[${index + 1}/${tasks.length}] npm run ${task}\n`);
25
+ const result = await runTask({ task, npmCli, cwd, env, verbose, spawnProcess });
26
+ const elapsedSeconds = ((performance.now() - taskStartedAt) / 1000).toFixed(1);
27
+ if (result.error) throw result.error;
28
+ if (result.code !== 0) {
29
+ stderr.write(`verification task failed after ${elapsedSeconds}s: ${task}\n`);
30
+ emitFailureDiagnostics(result, stderr);
31
+ const error = new Error(`verification task failed: ${task}`);
32
+ error.exitCode = result.code || 1;
33
+ throw error;
34
+ }
35
+ stdout.write(`completed ${task} in ${elapsedSeconds}s\n`);
36
+ }
37
+ const totalSeconds = ((performance.now() - planStartedAt) / 1000).toFixed(1);
38
+ stdout.write(`\n${mode} verification plan passed in ${totalSeconds}s\n`);
39
+ }
40
+
41
+ function runTask({ task, npmCli, cwd, env, verbose, spawnProcess }) {
42
+ return new Promise((resolvePromise) => {
43
+ const stdout = verbose ? null : new BoundedOutput(FAILURE_OUTPUT_BYTES_PER_STREAM);
44
+ const stderr = verbose ? null : new BoundedOutput(FAILURE_OUTPUT_BYTES_PER_STREAM);
45
+ let child;
46
+ try {
47
+ child = spawnProcess(process.execPath, [npmCli, "run", "--silent", task], {
48
+ cwd,
49
+ env: { ...env, NO_COLOR: env.NO_COLOR || "1" },
50
+ stdio: verbose ? "inherit" : ["ignore", "pipe", "pipe"],
51
+ windowsHide: true,
52
+ shell: false,
53
+ });
54
+ } catch (error) {
55
+ resolvePromise({ code: 1, error, stdout, stderr });
56
+ return;
57
+ }
58
+ child.stdout?.on?.("data", (chunk) => stdout?.append(chunk));
59
+ child.stderr?.on?.("data", (chunk) => stderr?.append(chunk));
60
+ child.once("error", (error) => resolvePromise({ code: 1, error, stdout, stderr }));
61
+ child.once("close", (code) => resolvePromise({ code: Number.isInteger(code) ? code : 1, stdout, stderr }));
62
+ });
63
+ }
64
+
65
+ function emitFailureDiagnostics(result, output) {
66
+ const stdoutText = result.stdout?.text?.() || "";
67
+ const stderrText = result.stderr?.text?.() || "";
68
+ if (stdoutText) output.write(`\n--- task stdout ---\n${ensureNewline(stdoutText)}`);
69
+ if (stderrText) output.write(`\n--- task stderr ---\n${ensureNewline(stderrText)}`);
70
+ if (!stdoutText && !stderrText) output.write("task produced no captured output\n");
71
+ }
72
+
73
+ function ensureNewline(value) {
74
+ return value.endsWith("\n") ? value : `${value}\n`;
75
+ }
@@ -115,7 +115,7 @@ try {
115
115
  if (failures.length) throw new Error(`coverage thresholds failed:\n- ${failures.join("\n- ")}`);
116
116
  console.log("critical-module coverage thresholds passed");
117
117
  } finally {
118
- rmSync(coverageDir, { recursive: true, force: true });
118
+ rmSync(coverageDir, { recursive: true, force: true, maxRetries: 8, retryDelay: 50 });
119
119
  }
120
120
 
121
121
  function collectCoverage(directory) {
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ export function closingIssueNumbers(messages) {
10
+ const numbers = new Set();
11
+ const pattern = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?[ \t]+#(\d+)\b/gi;
12
+ for (const match of String(messages || "").matchAll(pattern)) numbers.add(Number(match[1]));
13
+ return numbers;
14
+ }
15
+
16
+ export function backlogBlockers({ issues = [], pullRequests = [], branch = "", commitMessages = "" }) {
17
+ const closing = closingIssueNumbers(commitMessages);
18
+ const issueBlockers = issues.filter((issue) => !closing.has(Number(issue.number)));
19
+ const pullRequestBlockers = pullRequests.filter((pull) => !(
20
+ String(pull.headRefName || "") === branch
21
+ && pull.headRepository
22
+ && pull.headRepository === pull.baseRepository
23
+ ));
24
+ return { issueBlockers, pullRequestBlockers, closing: [...closing].sort((left, right) => left - right) };
25
+ }
26
+
27
+ export function assertGitHubBacklogReady(options = {}) {
28
+ const run = options.run || runCommand;
29
+ const cwd = options.cwd || root;
30
+ const branch = options.branch || output(run, "git", ["branch", "--show-current"], cwd);
31
+ if (!branch) throw new Error("cannot inspect GitHub backlog from a detached HEAD");
32
+ const commitMessages = output(run, "git", ["log", "--format=%B%x00", "origin/main..HEAD"], cwd);
33
+ const issueRows = pagedJson(output(run, "gh", [
34
+ "api", "--paginate", "--slurp", "repos/{owner}/{repo}/issues?state=open&per_page=100",
35
+ ], cwd), "open GitHub issues");
36
+ const pullRows = pagedJson(output(run, "gh", [
37
+ "api", "--paginate", "--slurp", "repos/{owner}/{repo}/pulls?state=open&per_page=100",
38
+ ], cwd), "open GitHub pull requests");
39
+ const issues = issueRows
40
+ .filter((item) => !item.pull_request)
41
+ .map((item) => ({ number: item.number, title: item.title, url: item.html_url }));
42
+ const pullRequests = pullRows.map((item) => ({
43
+ number: item.number,
44
+ title: item.title,
45
+ headRefName: item.head?.ref,
46
+ headRepository: item.head?.repo?.full_name,
47
+ baseRepository: item.base?.repo?.full_name,
48
+ url: item.html_url,
49
+ }));
50
+ const blockers = backlogBlockers({ issues, pullRequests, branch, commitMessages });
51
+ if (!blockers.issueBlockers.length && !blockers.pullRequestBlockers.length) {
52
+ const covered = blockers.closing.length ? `; closing issue(s): ${blockers.closing.map((number) => `#${number}`).join(", ")}` : "";
53
+ return { branch, issues, pullRequests, ...blockers, message: `GitHub backlog gate passed${covered}.` };
54
+ }
55
+ throw new Error(formatBlockers(blockers, branch));
56
+ }
57
+
58
+ function formatBlockers(blockers, branch) {
59
+ const lines = ["GitHub backlog is not ready for another push."];
60
+ if (blockers.issueBlockers.length) {
61
+ lines.push("Open issues not closed by a commit in origin/main..HEAD:");
62
+ for (const issue of blockers.issueBlockers) lines.push(`- #${issue.number} ${boundedTitle(issue.title)}${issue.url ? ` (${issue.url})` : ""}`);
63
+ }
64
+ if (blockers.pullRequestBlockers.length) {
65
+ lines.push(`Open pull requests other than the current branch (${branch}):`);
66
+ for (const pull of blockers.pullRequestBlockers) lines.push(`- #${pull.number} ${boundedTitle(pull.title)} [${pull.headRefName || "unknown branch"}]${pull.url ? ` (${pull.url})` : ""}`);
67
+ }
68
+ lines.push("Resolve or close every blocker before pushing. The current branch may cover an issue with a standard closing keyword such as 'Closes #47'.");
69
+ return lines.join("\n");
70
+ }
71
+
72
+ function boundedTitle(value) {
73
+ return String(value || "untitled").replace(/[\r\n]+/g, " ").slice(0, 200);
74
+ }
75
+
76
+ function pagedJson(text, label) {
77
+ let pages;
78
+ try { pages = JSON.parse(text || "[]"); }
79
+ catch (error) { throw new Error(`${label} response was not valid JSON: ${error.message}`); }
80
+ if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) {
81
+ throw new Error(`${label} response was not a slurped page array`);
82
+ }
83
+ return pages.flat();
84
+ }
85
+
86
+ function output(run, command, args, cwd) {
87
+ const result = run(command, args, cwd);
88
+ return String(result.stdout || "").trim();
89
+ }
90
+
91
+ function runCommand(command, args, cwd) {
92
+ const result = spawnSync(command, args, {
93
+ cwd,
94
+ encoding: "utf8",
95
+ env: process.env,
96
+ stdio: "pipe",
97
+ windowsHide: true,
98
+ shell: false,
99
+ });
100
+ if (result.error) throw result.error;
101
+ if (result.status !== 0) {
102
+ const detail = String(result.stderr || result.stdout || "").trim();
103
+ throw new Error(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
104
+ }
105
+ return result;
106
+ }
107
+
108
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
109
+ try {
110
+ const result = assertGitHubBacklogReady();
111
+ console.log(result.message);
112
+ } catch (error) {
113
+ console.error(`GitHub backlog gate failed: ${error?.message || error}`);
114
+ process.exit(1);
115
+ }
116
+ }
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { verifyCurrentReleaseAcceptance } from "./release-acceptance.mjs";
7
+ import { assertGitHubBacklogReady } from "./github-backlog.mjs";
7
8
 
8
9
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
10
 
@@ -13,6 +14,9 @@ try {
13
14
  const branch = output("git", ["branch", "--show-current"]);
14
15
  if (!branch) throw new Error("cannot push from a detached HEAD");
15
16
  if (branch === "main") throw new Error("direct pushes to main are prohibited; push a reviewed branch and merge through a pull request");
17
+ run("git", ["fetch", "origin", "main", "--prune"]);
18
+ const backlog = assertGitHubBacklogReady({ cwd: root, branch });
19
+ console.log(backlog.message);
16
20
 
17
21
  const acceptance = verifyCurrentReleaseAcceptance(root);
18
22
  if (acceptance.required) {
@@ -1,6 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import {
3
- lstatSync,
4
3
  mkdtempSync,
5
4
  readFileSync,
6
5
  rmSync,
@@ -8,6 +7,7 @@ import {
8
7
  import { tmpdir } from "node:os";
9
8
  import { join } from "node:path";
10
9
  import { spawnSync } from "node:child_process";
10
+ import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
11
11
 
12
12
  export const ACCEPTANCE_SCHEMA_VERSION = 1;
13
13
  export const ACCEPTANCE_POLICY_VERSION = "1.2.8";
@@ -15,6 +15,7 @@ export const AGENT_VERIFIED_ACCEPTANCE_VERSION = "1.2.9";
15
15
  export const ACCEPTANCE_CONFIRMATION = "owner-started-agent-verified-local-candidate";
16
16
  export const LEGACY_ACCEPTANCE_CONFIRMATION = "repository-owner-local-test";
17
17
  const MAX_ACCEPTANCE_BYTES = 64 * 1024;
18
+ const MAX_RELEASE_TARBALL_BYTES = 64 * 1024 * 1024;
18
19
 
19
20
  export function requiresLocalAcceptance(version) {
20
21
  return compareVersions(parseVersion(version), parseVersion(ACCEPTANCE_POLICY_VERSION)) >= 0;
@@ -76,20 +77,25 @@ export function packProject(root, destination) {
76
77
 
77
78
  export function readAcceptance(root, version) {
78
79
  const path = acceptancePath(root, version);
79
- const stat = lstatSync(path);
80
- if (stat.isSymbolicLink() || !stat.isFile()) {
81
- throw new Error(`release acceptance record must be a regular file: ${path}`);
82
- }
83
- if (stat.size > MAX_ACCEPTANCE_BYTES) {
84
- throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
80
+ let bytes;
81
+ try {
82
+ bytes = readBoundedRegularFileSync(
83
+ path, MAX_ACCEPTANCE_BYTES, "release acceptance record",
84
+ { verifyPathIdentity: true },
85
+ );
86
+ } catch (error) {
87
+ const message = String(error?.message || error);
88
+ if (message.includes("exceeds")) throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
89
+ if (message.includes("regular file") || message.includes("symbolic link") || message.includes("identity changed")) {
90
+ throw new Error(`release acceptance record must be a regular file: ${path}`);
91
+ }
92
+ throw error;
85
93
  }
86
- let value;
87
94
  try {
88
- value = JSON.parse(readFileSync(path, "utf8"));
95
+ return JSON.parse(bytes.toString("utf8"));
89
96
  } catch (error) {
90
97
  throw new Error(`release acceptance record is not valid JSON: ${error.message}`);
91
98
  }
92
- return value;
93
99
  }
94
100
 
95
101
  export function verifyAcceptanceRecord(record, metadata) {
@@ -134,9 +140,19 @@ export function verifyCurrentReleaseAcceptance(root) {
134
140
  }
135
141
 
136
142
  export function verifyTarball(path, metadata) {
137
- const stat = lstatSync(path);
138
- if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("release candidate tarball is not a regular file");
139
- const bytes = readFileSync(path);
143
+ let bytes;
144
+ try {
145
+ bytes = readBoundedRegularFileSync(
146
+ path, MAX_RELEASE_TARBALL_BYTES, "release candidate tarball",
147
+ { verifyPathIdentity: true },
148
+ );
149
+ } catch (error) {
150
+ const message = String(error?.message || error);
151
+ if (message.includes("regular file") || message.includes("symbolic link") || message.includes("identity changed")) {
152
+ throw new Error("release candidate tarball is not a regular file");
153
+ }
154
+ throw error;
155
+ }
140
156
  const shasum = createHash("sha1").update(bytes).digest("hex");
141
157
  const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
142
158
  if (shasum !== metadata.shasum || integrity !== metadata.integrity) {
@@ -1,29 +1,19 @@
1
- import { spawnSync } from "node:child_process";
2
- import { performance } from "node:perf_hooks";
3
1
  import { checkTasks } from "./check-plan.mjs";
2
+ import { runVerificationPlan } from "./check-runner.mjs";
4
3
 
5
4
  const mode = process.argv[2] || "full";
6
5
  const tasks = checkTasks(mode);
7
- const npmCli = process.env.npm_execpath;
8
- if (!npmCli) throw new Error("check runner must run through npm so npm_execpath is available");
9
- const planStartedAt = performance.now();
10
6
 
11
- console.log(`running ${mode} verification plan (${tasks.length} tasks)`);
12
- for (const [index, task] of tasks.entries()) {
13
- const taskStartedAt = performance.now();
14
- console.log(`\n[${index + 1}/${tasks.length}] npm run ${task}`);
15
- const result = spawnSync(process.execPath, [npmCli, "run", task], {
16
- cwd: process.cwd(),
17
- env: process.env,
18
- stdio: "inherit",
7
+ try {
8
+ await runVerificationPlan({
9
+ mode,
10
+ tasks,
11
+ npmCli: process.env.npm_execpath,
12
+ verbose: process.env.MBM_CHECK_VERBOSE === "1",
19
13
  });
20
- const elapsedSeconds = ((performance.now() - taskStartedAt) / 1000).toFixed(1);
21
- if (result.error) throw result.error;
22
- if (result.status !== 0) {
23
- console.error(`verification task failed after ${elapsedSeconds}s: ${task}`);
24
- process.exit(result.status || 1);
14
+ } catch (error) {
15
+ if (error?.message && !String(error.message).startsWith("verification task failed:")) {
16
+ console.error(error.message);
25
17
  }
26
- console.log(`completed ${task} in ${elapsedSeconds}s`);
18
+ process.exit(Number(error?.exitCode) || 1);
27
19
  }
28
- const totalSeconds = ((performance.now() - planStartedAt) / 1000).toFixed(1);
29
- console.log(`\n${mode} verification plan passed in ${totalSeconds}s`);
@@ -39,12 +39,29 @@ export class BoundedOutput {
39
39
  }
40
40
 
41
41
  get truncatedBytes() {
42
- return Math.max(0, this.totalBytes - this.maximum);
42
+ if (this.full) return 0;
43
+ const head = decodeUtf8Boundary(this.head, "head");
44
+ const tail = decodeUtf8Boundary(this.tail, "tail");
45
+ return Math.max(0, this.totalBytes - head.bytes - tail.bytes);
43
46
  }
44
47
 
45
48
  text() {
46
49
  if (this.full) return this.full.toString("utf8");
47
- const marker = `\n\n[truncated ${this.truncatedBytes} bytes; preserved beginning and end]\n\n`;
48
- return `${this.head.toString("utf8")}${marker}${this.tail.toString("utf8")}`;
50
+ const head = decodeUtf8Boundary(this.head, "head");
51
+ const tail = decodeUtf8Boundary(this.tail, "tail");
52
+ const omitted = Math.max(0, this.totalBytes - head.bytes - tail.bytes);
53
+ const marker = `\n\n[truncated ${omitted} bytes; preserved beginning and end]\n\n`;
54
+ return `${head.text}${marker}${tail.text}`;
49
55
  }
50
56
  }
57
+
58
+ function decodeUtf8Boundary(buffer, side) {
59
+ const decoder = new TextDecoder("utf-8", { fatal: true });
60
+ for (let trim = 0; trim <= Math.min(3, buffer.length); trim += 1) {
61
+ const slice = side === "head"
62
+ ? buffer.subarray(0, buffer.length - trim)
63
+ : buffer.subarray(trim);
64
+ try { return { text: decoder.decode(slice), bytes: slice.length }; } catch { /* try the next code-point boundary */ }
65
+ }
66
+ return { text: new TextDecoder("utf-8").decode(buffer), bytes: buffer.length };
67
+ }
@@ -54,6 +54,7 @@ export function publicError(error, options = {}) {
54
54
  code: normalized.code,
55
55
  message: normalized.expose ? normalized.message : defaultMessage(normalized.code),
56
56
  retryable: normalized.retryable,
57
+ ...(normalized.expose && normalized.details ? { details: normalized.details } : {}),
57
58
  };
58
59
  }
59
60
 
@@ -61,7 +62,10 @@ export function remoteBridgeError(value, fallbackMessage = "remote operation fai
61
62
  if (isRecord(value)) {
62
63
  const code = normalizeErrorCode(value.code);
63
64
  const message = typeof value.message === "string" && value.message ? value.message : fallbackMessage;
64
- return new BridgeError(code, message, { retryable: value.retryable === true });
65
+ return new BridgeError(code, message, {
66
+ retryable: value.retryable === true,
67
+ details: isRecord(value.details) ? value.details : undefined,
68
+ });
65
69
  }
66
70
  return new BridgeError("execution_failed", fallbackMessage);
67
71
  }