machine-bridge-mcp 0.12.0 → 0.12.2

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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.2 - 2026-07-12
4
+
5
+ ### Cross-platform persistence and browser reliability
6
+
7
+ - Extend transient Windows atomic replacement from eight short linear retries to sixteen bounded exponential retries with jitter. The implementation continues to use one same-directory atomic rename and never falls back to deleting the destination, while tolerating longer antivirus/indexer/reader sharing windows.
8
+ - Eliminate a broker-test race that could miss the runtime `hello` message between WebSocket `open` and listener registration. Pairing HTTP requests and WebSocket open/message/close waits are now bounded, and failed proxy candidates are terminated so a handshake cannot retain the test process indefinitely.
9
+
10
+ ### CI, release, and supply-chain integrity
11
+
12
+ - Require a completed successful push-triggered GitHub Actions run for the exact `origin/main` commit before creating or verifying a version tag, GitHub Release, or release asset. A local test pass is necessary but no longer sufficient for publication.
13
+ - Pin all third-party GitHub Actions to immutable commit SHAs, retain Dependabot updates, and enforce the pinning plus the release-CI wiring through architecture and dedicated release-gate tests.
14
+ - Run a reachable-history privacy audit in the package-audit job. The scanner covers historical UTF-8 blob contents, historical paths, and commit messages without printing matched values, while narrowly ignoring the standard public Dependabot signing trailer.
15
+
16
+ ### Regression coverage and audit follow-up
17
+
18
+ - Add deterministic tests for twelve consecutive transient Windows replacement failures, exponential delay selection, lost browser handshakes, bounded socket waits, portable simulated-Linux launcher paths on Windows, POSIX-versus-Windows daemon-stop semantics, deleted historical credential blobs, public automation trailers, and successful/failed/pending release CI states.
19
+ - Update the architecture, engineering, privacy, testing, release, security, and audit documentation. Record remaining immutable Git-history identity metadata separately from current-tree and active-credential findings.
20
+
21
+ ## 0.12.1 - 2026-07-12
22
+
23
+ ### Real local automation corrections
24
+
25
+ - Make the fixed macOS JXA helper return JSON as the top-level `osascript` result instead of using `console.log`, which real `osascript` sends to stderr. Empty helper stdout now fails explicitly rather than being interpreted as a successful empty object.
26
+ - Skip recursive macOS menu-bar/menu traversal by default so bounded inspection reaches main-window controls promptly. Add `include_menus` to inspection and actions for explicit menu automation.
27
+ - Normalize hyphens, underscores, dots, and spaces during capability ranking, and strongly boost explicitly named skills and commands. Natural-language references such as `agents progressive disclosure` now select `agents-progressive-disclosure` instead of a generic competing skill.
28
+
29
+ ### Live verification and regression coverage
30
+
31
+ - Add regression coverage for missing JXA output, menu-recursion forwarding, and punctuation-normalized Skill selection. Add an opt-in real macOS Calculator smoke path and document isolated-profile MV3 browser validation.
32
+ - Validate the source runtime with a temporary real global instruction file, an existing user Skill, a registered direct-argv command, shell execution, Calculator discovery/open/activation/inspection/click, and a real unpacked browser extension controlling a local form through inspect/fill/click/source/screenshot operations. Test files, browser profiles, temporary global configuration, tabs, and application state are removed afterward.
33
+
3
34
  ## 0.12.0 - 2026-07-12
4
35
 
5
36
  ### Locking, persistence, and process lifecycle
package/SECURITY.md CHANGED
@@ -19,7 +19,7 @@ Do not include live MCP passwords, daemon secrets, OAuth tokens, Cloudflare cred
19
19
 
20
20
  ## Repository and documentation privacy
21
21
 
22
- Tests, examples, documentation, release notes, and package metadata are publication surfaces. Use only synthetic hostnames, resource aliases, usernames, paths, and project names. Run `npm run privacy:check` before committing or publishing, and maintain private local identifiers in the ignored `.privacy-denylist`; the scanner never prints matched values. See [Repository privacy hygiene](docs/PRIVACY.md).
22
+ Tests, examples, documentation, release notes, and package metadata are publication surfaces. Use only synthetic hostnames, resource aliases, usernames, paths, and project names. Run `npm run privacy:check` before committing, review `npm run privacy:history` before publishing, and maintain private local identifiers in the ignored `.privacy-denylist`; neither scanner mode prints matched values. See [Repository privacy hygiene](docs/PRIVACY.md).
23
23
 
24
24
  Removing a value from the current branch does not remove it from Git history, caches, forks, or an already published npm package. Rotate any exposed credential immediately and coordinate destructive history rewriting separately when its risk is justified.
25
25
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "0.12.0",
4
+ "version": "0.12.2",
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",
@@ -29,5 +29,5 @@
29
29
  "action": {
30
30
  "default_title": "Machine Bridge Browser"
31
31
  },
32
- "version_name": "0.12.0"
32
+ "version_name": "0.12.2"
33
33
  }
@@ -189,7 +189,7 @@ No persistent skill or project-context index is trusted as authoritative. `sessi
189
189
 
190
190
  ## Progressive disclosure and task selection
191
191
 
192
- `agent_context` returns bounded skill metadata. `load_local_skill` returns full instructions only for one selected bundle. `resolve_task_capabilities` tokenizes the current task, ranks skill names/descriptions and command names/descriptions/argv, returns matches with scores, and loads the leading skill only when its relevance threshold is met.
192
+ `agent_context` returns bounded skill metadata. `load_local_skill` returns full instructions only for one selected bundle. `resolve_task_capabilities` tokenizes the current task, ranks skill names/descriptions and command names/descriptions/argv, returns matches with scores, and loads the leading skill only when its relevance threshold is met. Hyphens, underscores, dots, and whitespace are normalized for matching, while an explicitly named skill or registered command receives a strong deterministic boost. Thus a task that says “agents progressive disclosure” can match `agents-progressive-disclosure` without relying on generic description words.
193
193
 
194
194
  This ranking is deterministic local assistance, not semantic certainty. The model must still evaluate whether the selected skill applies. Machine Bridge does not execute skill scripts implicitly and does not fabricate a dynamically named MCP tool per skill.
195
195
 
@@ -40,7 +40,7 @@ A canonical workspace receives an independent profile, Worker name, secret set,
40
40
 
41
41
  `RelayConnection` owns remote WebSocket transport, authenticated `hello_ack` readiness, heartbeat liveness, reconnect backoff, and outage logging. Stdio mode invokes `LocalRuntime` directly without that adapter.
42
42
 
43
- `daemon-process.mjs` owns workspace-daemon inspection and takeover. It distinguishes platform service state from the lock-owning Node process, validates PID and process-start identity, canonicalizes workspace/state paths before comparison, parses bounded process command lines without executing them, and sends `SIGTERM` only to a verified same-workspace `--daemon-only` process. CLI orchestration never treats a missing launchd/systemd job as proof that the process exited.
43
+ `daemon-process.mjs` owns workspace-daemon inspection and takeover. It distinguishes platform service state from the lock-owning Node process, validates PID and process-start identity, canonicalizes workspace/state paths before comparison, parses bounded process command lines without executing them, and sends `SIGTERM` only to a verified same-workspace `--daemon-only` process. POSIX daemons may ignore that signal and reach the bounded non-escalating timeout; Node's Windows signal mapping terminates the verified process directly. CLI orchestration never treats a missing launchd/systemd job as proof that the process exited.
44
44
 
45
45
  ### Agent context and capability resolver
46
46
 
@@ -216,7 +216,7 @@ The Worker also treats a new connection as a bounded candidate until it authenti
216
216
 
217
217
  ## Persistence
218
218
 
219
- Local state and global config are owner-only, versioned, and size-bounded. Shared persistence primitives write a complete private temporary file, `fsync` it, and either hard-link it for an exclusive claim or atomically replace the destination. Machine-level browser pairing state is owner-only and shared across workspace runtimes through the local broker; its bearer token is not part of workspace state responses. State, managed-job manager, detached runner, browser pairing, and service definitions share the same flushed atomic-replacement primitive plus bounded retry for transient Windows sharing failures.
219
+ Local state and global config are owner-only, versioned, and size-bounded. Shared persistence primitives write a complete private temporary file, `fsync` it, and either hard-link it for an exclusive claim or atomically replace the destination. Machine-level browser pairing state is owner-only and shared across workspace runtimes through the local broker; its bearer token is not part of workspace state responses. State, managed-job manager, detached runner, browser pairing, and service definitions share the same flushed atomic-replacement primitive. Only classified transient Windows sharing failures are retried, using a bounded sixteen-attempt exponential schedule with jitter; the implementation never deletes the destination as a retry fallback, so readers are not intentionally exposed to a missing-file interval.
220
220
 
221
221
  Process locks contain purpose, workspace, ownership token, lock time, and process start time. Stale removal rechecks device/inode/size/mtime and token so an old observer cannot delete a replacement lock. Recent malformed claims receive a grace period. Startup/state operations wait a bounded interval; daemon and runner identity remain process-lifetime locks. Managed-job transition and recovery locks use the same ownership/snapshot principles and support an atomic runner handoff.
222
222
 
@@ -236,6 +236,12 @@ Foreground logging defaults to `info`; autostart uses `warn`. Authenticated read
236
236
 
237
237
  Cloudflare sampling is size control rather than an audit log. The project intentionally does not claim complete forensic logging. See [LOGGING.md](LOGGING.md).
238
238
 
239
+ ## Release integrity
240
+
241
+ Repository-local checks are necessary but cannot prove behavior on every supported operating system. `scripts/github-release.mjs` therefore queries `.github/workflows/ci.yml` for the exact `origin/main` commit and requires the newest push-triggered run to be completed with `success` before it creates or verifies a version tag, GitHub Release, or package asset. Pull-request runs, older successful runs, pending runs, and successful runs for another SHA do not satisfy the gate. The selection policy is isolated in `scripts/release-ci.mjs` and tested independently.
242
+
243
+ Third-party workflow actions are pinned to immutable commit SHAs. Dependabot proposes reviewed SHA updates, and `architecture:test` rejects a return to movable action tags or removal of the reachable-history package-audit step.
244
+
239
245
  ## Explicit non-goals
240
246
 
241
247
  - operating-system sandboxing of arbitrary executables;
package/docs/AUDIT.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Engineering and security audit
2
2
 
3
- This document records the cross-cutting audit performed for version 0.12.0. It complements, but does not replace, the continuously enforced contracts in `SECURITY.md`, `docs/ENGINEERING.md`, and the test suite.
3
+ This document records the cross-cutting audit initiated for version 0.12.0 and the 0.12.2 follow-up triggered by real cross-platform CI failures. It complements, but does not replace, the continuously enforced contracts in `SECURITY.md`, `docs/ENGINEERING.md`, and the test suite.
4
+
5
+ ## 0.12.2 follow-up findings
6
+
7
+ The failed `0.12.1` workflow had two independent causes. Windows exhausted the old approximately half-second atomic-replacement retry window while a stress reader repeatedly held the destination open. Linux and macOS lost an immediately emitted browser runtime `hello` between awaiting `open` and registering the message listener, then waited without a deadline until the 20-minute job timeout. The fixes preserve atomic replacement, extend only classified transient retries, pre-register the browser handshake listener, bound all relevant HTTP/WebSocket waits, and terminate failed proxy candidates.
8
+
9
+ The review also found three governance gaps not represented by the original failure message:
10
+
11
+ - GitHub Releases could be created from a local pass while the exact cross-platform push CI was failing. Publication now requires successful exact-commit push CI before tagging or release verification.
12
+ - Workflow actions used movable major-version tags. They are now pinned to immutable official commit SHAs, with an executable invariant.
13
+ - Repository privacy checks covered only the current tree. Package audit now scans reachable historical paths, bounded UTF-8 blobs, and commit messages; deleted credential fixtures fail without echoing their values.
14
+
15
+ No generic active credential pattern was found in the current tree or reachable history after excluding standard public automation trailers. The developer-local denylist does match legacy historical identifiers, and the Git metadata audit found a legacy non-noreply author/committer identity. These are identity/privacy metadata rather than active credentials. Removing them would require a coordinated history rewrite and force-update of affected refs, which is intentionally not performed as an incidental code fix.
4
16
 
5
17
  ## Scope
6
18
 
@@ -9,7 +9,7 @@ This document records project-wide decisions that must survive individual fixes,
9
9
  3. **Machine Bridge authority and host authority are separate.** `full` removes Machine Bridge's own policy, path, shell, and environment restrictions. It cannot override an MCP host, connector gateway, operating system, endpoint-security product, cloud IAM, remote authentication, or `sudo`.
10
10
  4. **Publication surfaces contain no real environment metadata.** Source, tests, fixtures, examples, release notes, filenames, package contents, tags, and release assets use synthetic identifiers and reserved example domains.
11
11
  5. **Secrets are never operational log data.** Tool arguments, command text, stdin, stdout, stderr, file content, OAuth bodies, credentials, and local resource values are not logged.
12
- 6. **A release is one version across all surfaces.** Package metadata, Worker version, browser-extension version/name, Git tag, GitHub Release, npm version, documentation, and deployed health version must agree before a release is considered complete.
12
+ 6. **A release is one version with successful cross-platform evidence.** Package metadata, Worker version, browser-extension version/name, Git tag, GitHub Release, npm version, documentation, and deployed health version must agree, and the exact `origin/main` commit must have a completed successful push-triggered GitHub Actions run before a tag or release is created.
13
13
  7. **Generic local automation is structured, not arbitrary evaluation.** Browser/application features may expose broad user authority under canonical `full`, but must not accept caller-provided JavaScript, AppleScript, JXA, or extension code.
14
14
  8. **Daily-browser integration uses the existing profile.** The supported primary browser path is the packaged authenticated extension and machine-level loopback broker, preserving current tabs/login state; a separate automation profile is not an equivalent replacement.
15
15
  9. **Pairing and resource secrets are not conversation or log data.** Tokens and injected local-resource values must not be returned, embedded in URLs, or written to operational logs.
@@ -123,7 +123,7 @@ Every defect fix includes a regression test that fails for the original reason.
123
123
  The required matrix includes:
124
124
 
125
125
  - Linux, macOS, and Windows on the pinned Node/npm baseline;
126
- - privacy and release-impact gates;
126
+ - current-tree and reachable-history privacy gates, release-impact enforcement, and exact-commit release-CI gating;
127
127
  - package-manifest and sensitive-artifact inspection;
128
128
  - generated type checks and recursively discovered JavaScript/shell syntax checks;
129
129
  - concurrent exclusive-lock/atomic-replacement tests, PID-reuse/age tests, and fail-closed service-lifecycle tests;
@@ -134,7 +134,7 @@ The required matrix includes:
134
134
  - managed-job integrity, recovery, cancellation, cleanup, and redaction;
135
135
  - dependency audit, registry signatures/attestations, SBOM, and Worker dry run.
136
136
 
137
- Cross-platform tests must not depend on shell syntax, case-sensitive Windows paths, Unix-only executable shims, or timing races when a deterministic scheduler can be injected.
137
+ Cross-platform tests must not depend on shell syntax, case-sensitive Windows paths, Unix-only executable shims, or timing races when a deterministic scheduler can be injected. Local success cannot substitute for the required Linux/macOS/Windows push CI result used by the release gate.
138
138
 
139
139
  ## Documentation rules
140
140
 
@@ -177,7 +177,7 @@ A thorough review asks:
177
177
  - Are persistent files atomic, owner-only, size-bounded, and symlink-aware?
178
178
  - Can browser/app automation be expressed without arbitrary evaluation, and are pairing/resource values absent from results and logs?
179
179
  - Can a stale PID, stale socket, duplicate request, partial write, or ambiguous remote response violate integrity?
180
- - Are package, CI, Worker, service, and release behavior tested on every supported platform?
180
+ - Are package, CI, Worker, service, and release behavior tested on every supported platform, and does publication verify the exact successful push CI run?
181
181
  - Does the complete diff contain any real identifier, path, host, account, or credential-shaped value?
182
182
  - Does the change require a new npm version and deployment?
183
183
 
@@ -83,7 +83,9 @@ Then use `value_resource` for a text field or `resources` for `browser_upload_fi
83
83
  - `inspect_local_application` returns a bounded macOS Accessibility tree.
84
84
  - `operate_local_application` performs a structured Accessibility action.
85
85
 
86
- macOS UI inspection and actions require Accessibility permission for the Node/Machine Bridge process. Application discovery and opening are available on supported desktop platforms; structured UI inspection currently targets macOS. The application backend uses fixed JXA implementation code. The caller selects only an application, structured selector, action, and optional bounded value; NUL-containing action text is rejected. `include_values` never returns values for secure-text roles or controls whose metadata indicates passwords, tokens, one-time codes, or payment-card secrets.
86
+ macOS UI inspection and actions require Accessibility permission for the Node/Machine Bridge process. Application discovery and opening are available on supported desktop platforms; structured UI inspection currently targets macOS. The application backend uses fixed JXA implementation code and requires a non-empty JSON result from `osascript`; missing or malformed helper output fails explicitly. The caller selects only an application, structured selector, action, and optional bounded value; NUL-containing action text is rejected. `include_values` never returns values for secure-text roles or controls whose metadata indicates passwords, tokens, one-time codes, or payment-card secrets.
87
+
88
+ Menu-bar and menu subtrees are not recursively expanded by default. This keeps main-window controls within the element/time budget for applications with large localized menu hierarchies. Pass `include_menus: true` to `inspect_local_application` or `operate_local_application` only when the target is a menu item.
87
89
 
88
90
  ## Capability discovery and automatic selection
89
91
 
@@ -61,7 +61,7 @@ Application UI inspection/actions require Accessibility permission for the Node/
61
61
 
62
62
  `machine-mcp` is a foreground command. It remains attached to the terminal, defaults to `info` logging, and stops on `Ctrl+C`. `machine-mcp service start` launches the installed platform service in the background and returns to the shell; that service uses `warn` logging.
63
63
 
64
- A global npm install changes the CLI files on disk but does not replace an already running Node process. Startup and other state-changing CLI operations use a token/process-identity lock and wait up to 30 seconds for a normal concurrent startup to finish; a short launchd/systemd overlap is therefore serialized rather than reported immediately as an error. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd/systemd/task scheduling no longer tracks, including legacy locks without mode/version metadata. Before sending `SIGTERM`, Machine Bridge verifies PID and process start time plus the live command line, entrypoint, canonical workspace, canonical state root, and daemon-only flag. It waits up to 15 seconds for both the PID and lock to disappear and never escalates a daemon takeover to a forced kill. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
64
+ A global npm install changes the CLI files on disk but does not replace an already running Node process. Startup and other state-changing CLI operations use a token/process-identity lock and wait up to 30 seconds for a normal concurrent startup to finish; a short launchd/systemd overlap is therefore serialized rather than reported immediately as an error. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd/systemd/task scheduling no longer tracks, including legacy locks without mode/version metadata. Before sending `SIGTERM`, Machine Bridge verifies PID and process start time plus the live command line, entrypoint, canonical workspace, canonical state root, and daemon-only flag. It waits up to 15 seconds for both the PID and lock to disappear and does not add a later forced-kill escalation. On POSIX, a daemon that handles and ignores `SIGTERM` therefore reaches the bounded timeout and remains alive. Node maps the supported termination signals to immediate process termination on Windows, so the same verified-daemon stop normally completes rather than exercising the POSIX timeout branch. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
65
65
 
66
66
  `machine-mcp service status [WORKSPACE]` reports two independent layers: the platform service (`active`) and `workspace_daemon`, plus `effective_active` and `orphaned_workspace_daemon` summary flags. On macOS it is possible for launchd to report inactive while a prior Node process remains alive with parent PID 1; that is an orphan-daemon condition, not proof that the daemon stopped. `service stop` unloads the provider when present and then terminates only a verified service-style workspace daemon. `service uninstall` and full uninstall are ordered fail-closed operations: provider stop → verified daemon stop(s) → definition removal. A failed or ambiguous stop leaves definitions and state intact. If takeover reaches its deadline, run:
67
67
 
package/docs/PRIVACY.md CHANGED
@@ -8,9 +8,14 @@ Run:
8
8
 
9
9
  ```sh
10
10
  npm run privacy:check
11
+ npm run privacy:history
11
12
  ```
12
13
 
13
- The check scans tracked and unignored new UTF-8 files and relative names for generic/encrypted/algorithm-specific private-key headers, AWS/GitHub/GitLab/npm/Slack/Google/live-payment/API token forms, JWT-shaped bearer values, embedded-credential URLs, absolute user-home paths, non-example email/`user@host` identifiers, credential-shaped filenames, and locally configured private identifiers. A tracked `.npmrc` is parsed: non-secret repository settings such as `engine-strict=true` are allowed, while authentication/identity keys, environment interpolation, and embedded credentials fail closed. Publication-surface symbolic links are rejected rather than followed. Binary, invalid UTF-8, and files above the bounded scanner limit require explicit manual review instead of being silently skipped. Findings report only file, line, and rule; the matched value is never printed.
14
+ `privacy:check` scans tracked and unignored new UTF-8 files and relative names for generic/encrypted/algorithm-specific private-key headers, AWS/GitHub/GitLab/npm/Slack/Google/live-payment/API token forms, JWT-shaped bearer values, embedded-credential URLs, absolute user-home paths, non-example email/`user@host` identifiers, credential-shaped filenames, and locally configured private identifiers. A tracked `.npmrc` is parsed: non-secret repository settings such as `engine-strict=true` are allowed, while authentication/identity keys, environment interpolation, and embedded credentials fail closed. Publication-surface symbolic links are rejected rather than followed. Binary, invalid UTF-8, and files above the bounded scanner limit require explicit manual review instead of being silently skipped. Findings report only file, line, and rule; the matched value is never printed.
15
+
16
+ `privacy:history` first runs the same current-tree checks, then scans every reachable historical blob path and bounded UTF-8 blob plus every reachable commit message. It catches values that were committed and later deleted. Standard public Dependabot signing trailers are ignored only in that exact commit-message context; the same non-example address remains disallowed in ordinary files. The local `.privacy-denylist` is also applied to history, so a developer may discover legacy identifiers that CI cannot know. Such a finding is real publication history, but ordinary commits cannot erase it.
17
+
18
+ Git author and committer identity headers are canonical Git metadata rather than blob or commit-message content and are not automatically rejected. Audit them separately with `git log`; changing historical identity metadata requires a coordinated history rewrite and force-update of affected refs.
14
19
 
15
20
  Maintain machine-specific names in an ignored owner-only file:
16
21
 
@@ -41,13 +46,13 @@ Before committing or publishing:
41
46
 
42
47
  - inspect the complete staged diff, including tests, snapshots, examples, release notes, and generated metadata;
43
48
  - use reserved example domains and neutral aliases;
44
- - run `npm run privacy:check`, `npm run check`, and `npm pack --dry-run`;
49
+ - run `npm run privacy:check`, review `npm run privacy:history`, run `npm run check`, and inspect `npm pack --dry-run`;
45
50
  - treat paths, host aliases, usernames, codenames, real browser URLs/page captures, application names tied to a user, and form data as private metadata even when they are not authentication secrets;
46
51
  - keep browser pairing-state files and captured source/screenshots out of fixtures, documentation, support logs, and release assets;
47
52
  - review any tracked `.npmrc` as configuration code and never commit authentication, registry identity, environment interpolation, cert/key paths, or credential-bearing URLs;
48
53
  - inspect package modes and filenames as well as file contents—an empty `.env`, private-key filename, database, or log is still an inappropriate publication artifact.
49
54
 
50
- The scanner is heuristic. It cannot identify every personal or organizational name, split/transformed/encrypted value, image, archive, binary fixture, custom credential format, or data already present in Git history. Passing it is a gate, not proof that a publication contains no private data.
55
+ The scanner is heuristic. It cannot identify every personal or organizational name, split/transformed/encrypted value, image, archive, binary fixture, custom credential format, unreachable/pruned object, reflog-only object, fork/cache copy, or Git identity header. Passing current-tree and reachable-history checks is a gate, not proof that every publication copy contains no private data.
51
56
 
52
57
  ## Incident response
53
58
 
package/docs/RELEASING.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The release invariant is:
4
4
 
5
- - `main` points to the release commit.
5
+ - `main` points to the release commit, and the exact commit has a completed successful push-triggered `.github/workflows/ci.yml` run.
6
6
  - `v<package version>` points to that same commit locally and on GitHub.
7
7
  - A final GitHub Release exists for the tag.
8
8
  - The GitHub Release contains the npm tarball generated from that commit.
@@ -21,8 +21,8 @@ The release invariant is:
21
21
  ```
22
22
 
23
23
  2. Add the matching dated `CHANGELOG.md` section.
24
- 3. Run `npm run release-impact:check`, `npm run privacy:check`, `npm run check`, both dependency audits, `npm audit signatures`, and generate a CycloneDX `npm sbom`.
25
- 4. Inspect the complete diff and `npm pack --dry-run`, including packaged file modes and every helper referenced by package scripts, then commit and push all release changes to `main`.
24
+ 3. Run `npm run release-impact:check`, `npm run privacy:check`, review `npm run privacy:history`, run `npm run check`, both dependency audits, `npm audit signatures`, and generate a CycloneDX `npm sbom`.
25
+ 4. Inspect the complete diff and `npm pack --dry-run`, including packaged file modes and every helper referenced by package scripts, then commit and push all release changes to `main`. Wait for the push-triggered Linux/macOS/Windows and package-audit checks to complete successfully.
26
26
 
27
27
  A privacy/security documentation correction is not “docs only” for release purposes. It requires a replacement npm version and, when appropriate, deprecation or unpublication of the affected version.
28
28
 
@@ -34,7 +34,7 @@ From a clean `main` worktree:
34
34
  npm run release:publish
35
35
  ```
36
36
 
37
- The command validates the project, including repository privacy checks, fast-forwards `origin/main`, creates or verifies the annotated version tag, pushes it, builds the npm tarball, creates or updates the GitHub Release, uploads the tarball, and verifies the resulting state. Read-only GitHub operations, Git pushes, and idempotent release updates use bounded retries only for classified transient network failures; ambiguous Release creation responses are resolved by querying server state before continuing.
37
+ The command validates the project, including repository privacy checks, fast-forwards `origin/main` when needed, then requires the latest push-triggered CI run for that exact commit to be completed successfully before it creates or verifies the annotated version tag. It then pushes the tag, builds the npm tarball, creates or updates the GitHub Release, uploads the tarball, and verifies the resulting state. Read-only GitHub operations, Git pushes, and idempotent release updates use bounded retries only for classified transient network failures; ambiguous Release creation responses are resolved by querying server state before continuing.
38
38
 
39
39
  To verify without changing anything:
40
40
 
@@ -64,4 +64,4 @@ The npm lifecycle repeats the full project checks and the GitHub synchronization
64
64
  - An authenticated GitHub CLI session with repository release permission.
65
65
  - An npm account that owns the package or has maintainer permission.
66
66
 
67
- GitHub Actions is intentionally not required by this release path. This avoids coupling releases to an OAuth token with the separate `workflow` scope while retaining fail-closed synchronization.
67
+ GitHub Actions is a required publication boundary. The authenticated GitHub CLI must be able to read Actions runs, and a missing, pending, cancelled, skipped, or failed exact-commit push run blocks both `release:publish` and `release:check`. This prevents a machine-specific local pass from publishing a version that fails another supported platform.
package/docs/TESTING.md CHANGED
@@ -13,15 +13,15 @@ The repository requires Node.js 26 and npm 12. `.node-version`, `.nvmrc`, `packa
13
13
  The suite includes:
14
14
 
15
15
  - release-impact enforcement requiring a new package version and CHANGELOG section for release-relevant changes;
16
- - release-state diagnostics distinguishing missing local/remote version tags from tags that point to the wrong commit, including the required publication order;
16
+ - 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;
17
17
  - generated Cloudflare Worker types and strict TypeScript checking, including unused-local and unused-parameter rejection;
18
18
  - recursive syntax validation for every JavaScript file under the shipped/runtime/test roots plus the shell wrapper;
19
19
  - shared tool-catalog schema, annotation, and profile-inventory checks;
20
20
  - default working-agreement injection without user files, bounded automatic project metadata, script-body non-disclosure, user-global opt-out, repository opt-out rejection, global `model_instructions_file` injection in stdio/remote initialization, hierarchical precedence, live project/skill rescanning and fingerprints, automatic task ranking/loading, command override/removal, direct argv handling, timeout ceilings, and execution-profile denial;
21
21
  - concurrent complete-before-visible lock claims, atomic replacement under active readers, malformed-lock grace, snapshot/token-safe reclamation, absolute-age expiry, PID-reuse detection, and bounded startup-lock waiting;
22
- - foreground takeover of active and orphaned background daemons, legacy lock identification from canonicalized process arguments, foreground-process protection, actual-PID exit waiting, non-forcing timeout guidance, daemon lock mode/version/process-start metadata, launchd service-target semantics, and silent idempotent duplicate service starts;
22
+ - foreground takeover of active and orphaned background daemons, legacy lock identification from canonicalized process arguments, foreground-process protection, actual-PID exit waiting, POSIX non-escalating timeout behavior, Windows verified-daemon stop semantics, daemon lock mode/version/process-start metadata, launchd service-target semantics, and silent idempotent duplicate service starts;
23
23
  - fail-closed service lifecycle ordering for provider-stop, all-workspace daemon-stop, and definition removal, including platform/daemon/removal failure injection and normalized macOS/systemd/Windows results;
24
- - machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, and broker result redaction;
24
+ - machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, broker result redaction, pre-registered runtime-handshake listeners, and bounded socket/HTTP waits;
25
25
  - canonical path and symbolic-link escape tests;
26
26
  - relative-path privacy and error-path redaction tests;
27
27
  - atomic create/update, optimistic hash, exact edit, and patch transaction tests;
@@ -36,7 +36,7 @@ The suite includes:
36
36
  - local resource CLI registration, permission checks, dynamic reload, state-path redaction, and content non-disclosure;
37
37
  - real Ed25519 and RSA generation, idempotent reuse, public/private correspondence, mode enforcement, incomplete/mismatched/symlink rejection, and private-content non-disclosure;
38
38
  - real-machine canonical-full sandbox acceptance for outside-workspace I/O, direct/shell execution, full environment inheritance, SSH prerequisites, temporary authorized-key writing, and detached cleanup without external state changes;
39
- - deterministic injected atomic-replace failures and repeated Windows full-sandbox runs to catch transient file-sharing races;
39
+ - deterministic injected atomic-replace failures, sustained transient Windows sharing contention beyond the old retry budget, bounded exponential delay selection, and repeated Windows full-sandbox runs;
40
40
  - canonical named-profile repair and full-only tool exposure parity between local and Worker policy filters;
41
41
  - managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, descendant-tree escalation, token/snapshot-safe transition locks, runner process identity, plan scrubbing, PID-reuse-safe dead-runner recovery, and legacy PID-file compatibility;
42
42
  - daemon/startup locking, successfully-read corrupt-JSON recovery, and explicit propagation/preservation of oversized, symbolic-link, permission, and I/O failures;
@@ -49,9 +49,22 @@ The suite includes:
49
49
  - live stdio MCP initialization with session instructions, capability resolution, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
50
50
  - live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, daemon-backed session bootstrap, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure.
51
51
 
52
+ ## Opt-in live desktop and browser validation
53
+
54
+ The normal suite uses deterministic mocks for macOS Accessibility and an authenticated in-process extension peer for browser routing. Before a release that changes local UI automation, run the macOS live smoke test on a machine where the invoking Node/terminal process has Accessibility permission:
55
+
56
+ ```sh
57
+ npm run app-automation:live-test
58
+ ```
59
+
60
+ It opens Calculator, activates it through the fixed JXA helper, verifies structured JSON output, inspects main-window Accessibility controls with menu recursion disabled, clicks the `One` control, and quits the application. It is intentionally excluded from CI because macOS TCC permission and a graphical login session are host state.
61
+
62
+ For browser changes, perform an isolated-profile smoke test with the packaged unpacked extension: pair it to a temporary source runtime, navigate a local no-store page, inspect controls, fill multiple fields, click a button, verify the live DOM, and capture a screenshot. A Playwright persistent Chromium context is an acceptable harness because it loads the real MV3 service worker while keeping the user's daily browser profile untouched.
63
+
52
64
  ## Additional release checks
53
65
 
54
66
  ```sh
67
+ npm run privacy:history
55
68
  npm run worker:dry-run
56
69
  npm audit --audit-level=high
57
70
  npm audit --omit=dev --audit-level=high
@@ -62,7 +75,7 @@ npm run version:check
62
75
  npm run release-impact:check
63
76
  ```
64
77
 
65
- GitHub Actions executes the main suite on Linux, macOS, and Windows using the pinned Node 26/npm 12 baseline. Because Node 26 currently bundles npm 11, CI explicitly disables setup-node's automatic package-manager cache and upgrades npm from the runner temporary directory before any project-local npm command can trigger strict `devEngines`. Checkout fetches version tags so the release-impact gate can compare the branch with the latest release. A separate package-audit job audits both the complete dependency graph and the production-only graph, verifies registry signatures and attestations, validates a CycloneDX SBOM written under the runner temporary directory, exercises the documented isolated global installation, then performs a dry-run package build. The macOS matrix job also runs the installation smoke test because Wrangler's optional `fsevents` resolution is platform-specific. Dependency and GitHub Actions updates are monitored by Dependabot.
78
+ GitHub Actions executes the main suite on Linux, macOS, and Windows using the pinned Node 26/npm 12 baseline. Because Node 26 currently bundles npm 11, CI explicitly disables setup-node's automatic package-manager cache and upgrades npm from the runner temporary directory before any project-local npm command can trigger strict `devEngines`. Checkout fetches version tags so the release-impact gate can compare the branch with the latest release. A separate package-audit job scans reachable Git history, audits both the complete dependency graph and the production-only graph, verifies registry signatures and attestations, validates a CycloneDX SBOM written under the runner temporary directory, exercises the documented isolated global installation, then performs a dry-run package build. The macOS matrix job also runs the installation smoke test because Wrangler's optional `fsevents` resolution is platform-specific. Third-party Actions are pinned to immutable 40-character commit SHAs; architecture tests reject movable tags, and Dependabot monitors both action and dependency updates.
66
79
 
67
80
  ## Test design rules
68
81
 
@@ -79,7 +92,7 @@ GitHub Actions executes the main suite on Linux, macOS, and Windows using the pi
79
92
 
80
93
  ## Privacy hygiene
81
94
 
82
- Run `npm run privacy:check` before committing and before packaging. Developers should maintain an ignored owner-only `.privacy-denylist` for machine aliases, usernames, internal codenames, and other private identifiers that a generic scanner cannot know. See [Repository privacy hygiene](PRIVACY.md).
95
+ Run `npm run privacy:check` before committing and before packaging. Run and review `npm run privacy:history` before release; local denylist findings may require an explicit owner decision because ordinary commits cannot remove public history. Developers should maintain an ignored owner-only `.privacy-denylist` for machine aliases, usernames, internal codenames, and other private identifiers that a generic scanner cannot know. See [Repository privacy hygiene](PRIVACY.md).
83
96
 
84
97
  ## Package manifest
85
98
 
@@ -89,4 +102,4 @@ The stdio integration test also sends an oversized line, verifies bounded reject
89
102
 
90
103
  ## Architecture and documentation regression checks
91
104
 
92
- `npm run architecture:test` rejects local-module dependency cycles, missing static or dynamic relative imports, package scripts that reference missing files, drift from the recursive syntax scanner, incomplete executable package directories, inconsistent installation guidance, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
105
+ `npm run architecture:test` rejects movable GitHub Action references and loss of the CI history scan, release-gate script drift, local-module dependency cycles, missing static or dynamic relative imports, package scripts that reference missing files, drift from the recursive syntax scanner, incomplete executable package directories, inconsistent installation guidance, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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",
@@ -42,7 +42,7 @@
42
42
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
43
43
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
44
44
  "syntax": "node scripts/syntax-check.mjs",
45
- "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
45
+ "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
46
46
  "worker:dry-run": "wrangler deploy --dry-run",
47
47
  "worker:integration-test": "node tests/worker-integration-test.mjs",
48
48
  "release:check": "node scripts/github-release.mjs --check",
@@ -55,6 +55,7 @@
55
55
  "full-access:test": "node tests/full-access-test.mjs",
56
56
  "atomic-fs:test": "node tests/atomic-fs-test.mjs",
57
57
  "privacy:check": "node scripts/privacy-check.mjs",
58
+ "privacy:history": "node scripts/privacy-check.mjs --history",
58
59
  "package:test": "node tests/package-test.mjs",
59
60
  "release-impact:check": "node scripts/release-impact-check.mjs",
60
61
  "release-impact:test": "node tests/release-impact-test.mjs",
@@ -65,11 +66,13 @@
65
66
  "secure-file:test": "node tests/secure-file-test.mjs",
66
67
  "architecture:test": "node tests/architecture-test.mjs",
67
68
  "release-state:test": "node tests/release-state-test.mjs",
69
+ "release-ci:test": "node tests/release-ci-test.mjs",
68
70
  "agent-context:test": "node tests/agent-context-test.mjs",
69
71
  "browser-bridge:test": "node tests/browser-bridge-test.mjs",
70
72
  "app-automation:test": "node tests/app-automation-test.mjs",
71
73
  "process-lock:test": "node tests/process-lock-test.mjs",
72
- "service-lifecycle:test": "node tests/service-lifecycle-test.mjs"
74
+ "service-lifecycle:test": "node tests/service-lifecycle-test.mjs",
75
+ "app-automation:live-test": "node tests/app-automation-test.mjs --live-macos"
73
76
  },
74
77
  "dependencies": {
75
78
  "wrangler": "4.110.0",
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { runNetworkCommand } from "./network-retry.mjs";
13
+ import { requireSuccessfulCiRun } from "./release-ci.mjs";
13
14
  import { tagSyncError } from "./release-state.mjs";
14
15
  import { fileURLToPath } from "node:url";
15
16
 
@@ -124,6 +125,31 @@ function remoteTagCommit(tag) {
124
125
  return (peeled ?? direct)?.[0] ?? null;
125
126
  }
126
127
 
128
+ function assertSuccessfulCi(head) {
129
+ const text = outputNetwork("gh", [
130
+ "run",
131
+ "list",
132
+ "--workflow",
133
+ ".github/workflows/ci.yml",
134
+ "--commit",
135
+ head,
136
+ "--event",
137
+ "push",
138
+ "--limit",
139
+ "20",
140
+ "--json",
141
+ "databaseId,status,conclusion,headSha,event,createdAt,url",
142
+ ]);
143
+ let runs;
144
+ try { runs = JSON.parse(text); }
145
+ catch { fail("GitHub Actions did not return valid JSON"); }
146
+ let run;
147
+ try { run = requireSuccessfulCiRun(runs, head); }
148
+ catch (error) { fail(String(error?.message || error)); }
149
+ console.log(`GitHub Actions CI succeeded for ${head} (run ${run.databaseId}).`);
150
+ return run;
151
+ }
152
+
127
153
  function releaseInfo(tag) {
128
154
  const result = runNetwork(
129
155
  "gh",
@@ -148,6 +174,7 @@ function assertCoreSync({ requireReleaseAsset }) {
148
174
  if (head !== originMain) {
149
175
  fail(`HEAD ${head} does not match origin/main ${originMain}`);
150
176
  }
177
+ assertSuccessfulCi(head);
151
178
 
152
179
  const localCommit = localTagCommit(tag);
153
180
  const localTagError = tagSyncError({ scope: "local", tag, head, commit: localCommit });
@@ -275,6 +302,7 @@ function publishCurrent() {
275
302
  }
276
303
  runNetwork("git", ["push", "origin", "HEAD:main"]);
277
304
  }
305
+ assertSuccessfulCi(head);
278
306
 
279
307
  const existingLocal = localTagCommit(tag);
280
308
  if (existingLocal && existingLocal !== head) {
@@ -5,6 +5,11 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
7
  const selfPath = "scripts/privacy-check.mjs";
8
+ const requestedOptions = new Set(process.argv.slice(2));
9
+ for (const option of requestedOptions) {
10
+ if (option !== "--history") throw new Error(`unknown privacy-check option: ${option}`);
11
+ }
12
+ const scanHistory = requestedOptions.has("--history");
8
13
  const candidates = collectCandidateFiles(root);
9
14
  const denylist = loadDenylist(path.join(root, ".privacy-denylist"));
10
15
  const findings = [];
@@ -47,6 +52,8 @@ for (const relativePath of candidates) {
47
52
  scanDenylist(relativePath, text, denylist, findings);
48
53
  }
49
54
 
55
+ const historySummaryCounts = scanHistory ? scanReachableHistory(root, denylist, findings) : { blobs: 0, commits: 0 };
56
+
50
57
  if (findings.length) {
51
58
  for (const finding of findings.slice(0, 100)) {
52
59
  process.stderr.write(`${redactReportPath(finding.path, denylist)}:${finding.line}: ${finding.rule}\n`);
@@ -56,7 +63,8 @@ if (findings.length) {
56
63
  process.exit(1);
57
64
  }
58
65
 
59
- process.stderr.write(`privacy check ok (${candidates.length} tracked/unignored files; ${denylist.length} local denylist entries)\n`);
66
+ const historySummary = scanHistory ? `; ${historySummaryCounts.blobs} reachable history blobs; ${historySummaryCounts.commits} commit messages` : "";
67
+ process.stderr.write(`privacy check ok (${candidates.length} tracked/unignored files; ${denylist.length} local denylist entries${historySummary})\n`);
60
68
 
61
69
  function collectCandidateFiles(directory) {
62
70
  try {
@@ -87,6 +95,136 @@ function collectCandidateFiles(directory) {
87
95
  }
88
96
  }
89
97
 
98
+ function scanReachableHistory(directory, entries, out) {
99
+ let listing;
100
+ try {
101
+ listing = execFileSync("git", ["-C", directory, "rev-list", "--objects", "--all", "-z"], {
102
+ encoding: "buffer",
103
+ maxBuffer: 64 * 1024 * 1024,
104
+ stdio: ["ignore", "pipe", "pipe"],
105
+ });
106
+ } catch {
107
+ throw new Error("privacy history scan could not enumerate reachable Git objects");
108
+ }
109
+ let records;
110
+ try { records = new TextDecoder("utf-8", { fatal: true }).decode(listing).split("\0").filter(Boolean); }
111
+ catch { throw new Error("privacy history scan encountered a non-UTF-8 Git object path"); }
112
+ const objectPaths = new Map();
113
+ let objectHash = "";
114
+ for (const record of records) {
115
+ if (/^[0-9a-f]{40,64}$/.test(record)) {
116
+ objectHash = record;
117
+ continue;
118
+ }
119
+ if (!record.startsWith("path=") || !objectHash) {
120
+ objectHash = "";
121
+ continue;
122
+ }
123
+ const relativePath = record.slice(5);
124
+ if (relativePath && !relativePath.includes("\0")) {
125
+ if (!objectPaths.has(objectHash)) objectPaths.set(objectHash, new Set());
126
+ objectPaths.get(objectHash).add(relativePath);
127
+ }
128
+ objectHash = "";
129
+ }
130
+ const hashes = [...objectPaths.keys()];
131
+ let metadata;
132
+ try {
133
+ metadata = execFileSync("git", ["-C", directory, "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], {
134
+ input: `${hashes.join("\n")}\n`,
135
+ encoding: "utf8",
136
+ maxBuffer: 64 * 1024 * 1024,
137
+ stdio: ["pipe", "pipe", "pipe"],
138
+ }).trim().split(/\r?\n/);
139
+ } catch {
140
+ throw new Error("privacy history scan could not classify reachable Git objects");
141
+ }
142
+ const blobSizes = new Map();
143
+ for (const line of metadata) {
144
+ const match = /^([0-9a-f]{40,64}) blob ([0-9]+)$/.exec(line);
145
+ if (match) blobSizes.set(match[1], Number(match[2]));
146
+ }
147
+ let scanned = 0;
148
+ for (const [hash, paths] of objectPaths) {
149
+ if (!blobSizes.has(hash)) continue;
150
+ const relativePaths = [...paths].sort();
151
+ const reportPath = relativePaths[0] || "unknown";
152
+ const contentPath = relativePaths.find((relativePath) => relativePath !== selfPath) || reportPath;
153
+ for (const relativePath of relativePaths) {
154
+ if (relativePath === ".privacy-denylist") continue;
155
+ const historicalPath = `history/${hash.slice(0, 12)}/${relativePath}`;
156
+ scanDenylistPath(historicalPath, entries, out);
157
+ scanSensitivePath(historicalPath, out);
158
+ }
159
+ if (blobSizes.get(hash) > 5 * 1024 * 1024) {
160
+ out.push({ path: `history/${hash.slice(0, 12)}/${reportPath}`, line: 1, rule: "historical file exceeds privacy scanner size limit and requires manual review" });
161
+ continue;
162
+ }
163
+ let buffer;
164
+ try {
165
+ buffer = execFileSync("git", ["-C", directory, "cat-file", "blob", hash], {
166
+ encoding: "buffer",
167
+ maxBuffer: 6 * 1024 * 1024,
168
+ stdio: ["ignore", "pipe", "pipe"],
169
+ });
170
+ } catch {
171
+ out.push({ path: `history/${hash.slice(0, 12)}/${reportPath}`, line: 1, rule: "historical file content could not be read" });
172
+ continue;
173
+ }
174
+ scanned += 1;
175
+ const historicalPath = `history/${hash.slice(0, 12)}/${contentPath}`;
176
+ if (buffer.includes(0)) {
177
+ out.push({ path: historicalPath, line: 1, rule: "binary file in reachable Git history requires manual review" });
178
+ continue;
179
+ }
180
+ let historicalText;
181
+ try { historicalText = new TextDecoder("utf-8", { fatal: true }).decode(buffer); }
182
+ catch {
183
+ out.push({ path: historicalPath, line: 1, rule: "non-UTF-8 file in reachable Git history requires manual review" });
184
+ continue;
185
+ }
186
+ if (contentPath !== selfPath) scanBuiltIn(historicalPath, historicalText, out);
187
+ if (relativePaths.some((relativePath) => path.basename(relativePath).toLowerCase() === ".npmrc")) scanNpmrc(historicalPath, historicalText, out);
188
+ scanDenylist(historicalPath, historicalText, entries, out);
189
+ }
190
+ const commitCount = scanReachableCommitMessages(directory, entries, out);
191
+ return { blobs: scanned, commits: commitCount };
192
+ }
193
+
194
+ function scanReachableCommitMessages(directory, entries, out) {
195
+ let output;
196
+ try {
197
+ output = execFileSync("git", ["-C", directory, "log", "--all", "--format=%H%x00%B%x00"], {
198
+ encoding: "buffer",
199
+ maxBuffer: 64 * 1024 * 1024,
200
+ stdio: ["ignore", "pipe", "pipe"],
201
+ });
202
+ } catch {
203
+ throw new Error("privacy history scan could not read reachable commit messages");
204
+ }
205
+ let records;
206
+ try { records = new TextDecoder("utf-8", { fatal: true }).decode(output).split("\0"); }
207
+ catch { throw new Error("privacy history scan encountered a non-UTF-8 commit message"); }
208
+ let count = 0;
209
+ for (let index = 0; index + 1 < records.length; index += 2) {
210
+ const hash = records[index].trim();
211
+ const message = records[index + 1];
212
+ if (!/^[0-9a-f]{40,64}$/.test(hash)) continue;
213
+ const reportPath = `history/commit/${hash.slice(0, 12)}/message`;
214
+ const scannedMessage = removeKnownPublicAutomationTrailers(message);
215
+ scanBuiltIn(reportPath, scannedMessage, out);
216
+ scanDenylist(reportPath, scannedMessage, entries, out);
217
+ count += 1;
218
+ }
219
+ return count;
220
+ }
221
+
222
+ function removeKnownPublicAutomationTrailers(message) {
223
+ return String(message).split(/\r?\n/).filter((line) => {
224
+ return !/^(?:Signed-off-by|Co-authored-by):\s+dependabot\[bot\]\s+<[^>\s]+@github\.com>\s*$/i.test(line);
225
+ }).join("\n");
226
+ }
227
+
90
228
  function redactReportPath(relativePath, entries) {
91
229
  let shown = String(relativePath);
92
230
  for (const entry of entries) {
@@ -112,7 +250,6 @@ function scanBuiltIn(relativePath, text, out) {
112
250
  ["live payment API key", /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{16,}\b/g],
113
251
  ["API secret token", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g],
114
252
  ["JWT-like bearer token", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g],
115
- ["URL with embedded credentials", /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@[^\s/"'<>]+/gi],
116
253
  ["absolute macOS/Linux home path", /(?:^|[\s"'=(:,])\/(?:Users|home)\/([^/\s"'<>]+)\//gm],
117
254
  ["absolute Windows home path", /(?:^|[\s"'=(:,])\b[A-Za-z]:\\Users\\([^\\\s"'<>]+)\\/gm],
118
255
  ];
@@ -122,6 +259,11 @@ function scanBuiltIn(relativePath, text, out) {
122
259
  out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule });
123
260
  }
124
261
  }
262
+ const credentialUrl = /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@([^\s/"'<>]+)/gi;
263
+ for (const match of text.matchAll(credentialUrl)) {
264
+ if (isReservedExampleHost(stripHostPort(match[1]))) continue;
265
+ out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule: "URL with embedded credentials" });
266
+ }
125
267
  const emailLike = /\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b/gi;
126
268
  for (const match of text.matchAll(emailLike)) {
127
269
  if (isReservedExampleHost(match[1]) || String(match[1]).toLowerCase().endsWith(".noreply.github.com")) continue;
@@ -215,6 +357,12 @@ function hasNearbySshContext(text, offset) {
215
357
  return /\b(?:ssh|scp|sftp)\b/i.test(text.slice(start, end));
216
358
  }
217
359
 
360
+ function stripHostPort(host) {
361
+ const value = String(host || "");
362
+ if (value.startsWith("[")) return value.slice(1, value.indexOf("]") > 0 ? value.indexOf("]") : undefined);
363
+ return value.replace(/:[0-9]+$/, "");
364
+ }
365
+
218
366
  function isReservedExampleHost(host) {
219
367
  const value = String(host || "").toLowerCase().replace(/\.$/, "");
220
368
  return value === "localhost"
@@ -0,0 +1,18 @@
1
+ export function requireSuccessfulCiRun(runs, head) {
2
+ if (!Array.isArray(runs)) throw new Error("GitHub Actions response is not an array");
3
+ if (!/^[0-9a-f]{40,64}$/i.test(String(head || ""))) throw new Error("release commit SHA is invalid");
4
+ const matching = runs
5
+ .filter((run) => run && run.headSha === head && run.event === "push")
6
+ .sort((left, right) => String(right.createdAt || "").localeCompare(String(left.createdAt || "")));
7
+ const run = matching[0];
8
+ if (!run) {
9
+ throw new Error(`no push-triggered CI run exists for release commit ${head}; wait for GitHub Actions to register the run and retry`);
10
+ }
11
+ if (run.status !== "completed") {
12
+ throw new Error(`CI run ${run.databaseId || "unknown"} for release commit ${head} is ${run.status || "unknown"}; wait for completion and retry`);
13
+ }
14
+ if (run.conclusion !== "success") {
15
+ throw new Error(`CI run ${run.databaseId || "unknown"} for release commit ${head} concluded ${run.conclusion || "unknown"}; fix or rerun CI before release`);
16
+ }
17
+ return run;
18
+ }
@@ -112,12 +112,12 @@ export class AgentContextManager {
112
112
  const discovered = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
113
113
  const maxSkills = clampInt(args.max_skills, 10, 1, 50);
114
114
  const skillMatches = discovered.skills
115
- .map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}`) }))
115
+ .map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}`, skill.name) }))
116
116
  .filter((item) => item.score > 0)
117
117
  .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
118
118
  .slice(0, maxSkills);
119
119
  const commandMatches = [...state.commands.values()]
120
- .map((command) => ({ command, score: relevanceScore(task, `${command.name} ${command.description} ${command.argv.join(" ")}`) }))
120
+ .map((command) => ({ command, score: relevanceScore(task, `${command.name} ${command.description} ${command.argv.join(" ")}`, command.name) }))
121
121
  .filter((item) => item.score > 0)
122
122
  .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name))
123
123
  .slice(0, 20);
@@ -718,7 +718,7 @@ function capabilityFingerprint(state, skills) {
718
718
  }));
719
719
  }
720
720
 
721
- function relevanceScore(task, candidate) {
721
+ function relevanceScore(task, candidate, identity = "") {
722
722
  const taskTokens = tokenize(task);
723
723
  const candidateTokens = tokenize(candidate);
724
724
  if (!taskTokens.size || !candidateTokens.size) return 0;
@@ -726,18 +726,31 @@ function relevanceScore(task, candidate) {
726
726
  for (const token of taskTokens) {
727
727
  if (candidateTokens.has(token)) score += token.length >= 6 ? 2 : 1;
728
728
  }
729
- const taskLower = task.toLowerCase();
730
- const candidateLower = candidate.toLowerCase();
731
- if (candidateLower.includes(taskLower) || taskLower.includes(candidateLower)) score += 4;
729
+ const taskComparable = comparableText(task);
730
+ const candidateComparable = comparableText(candidate);
731
+ const identityComparable = comparableText(identity);
732
+ if (candidateComparable.includes(taskComparable) || taskComparable.includes(candidateComparable)) score += 4;
733
+ if (identityComparable.length >= 3 && ` ${taskComparable} `.includes(` ${identityComparable} `)) score += 12;
732
734
  return score;
733
735
  }
734
736
 
737
+ function comparableText(value) {
738
+ return String(value || "")
739
+ .toLowerCase()
740
+ .replace(/[^a-z0-9\p{Script=Han}]+/gu, " ")
741
+ .trim()
742
+ .replace(/\s+/g, " ");
743
+ }
744
+
735
745
  function tokenize(value) {
736
746
  const text = String(value || "").toLowerCase();
737
747
  const tokens = new Set();
738
748
  for (const raw of text.match(/[a-z0-9_][a-z0-9_.-]{1,}/g) || []) {
739
749
  const token = raw.replace(/^[.-]+|[.-]+$/g, "");
740
750
  if (token.length >= 2 && !TOKEN_STOP_WORDS.has(token)) tokens.add(token);
751
+ for (const part of token.split(/[._-]+/)) {
752
+ if (part.length >= 2 && !TOKEN_STOP_WORDS.has(part)) tokens.add(part);
753
+ }
741
754
  }
742
755
  for (const sequence of text.match(/[\p{Script=Han}]{1,}/gu) || []) {
743
756
  if (!TOKEN_STOP_WORDS.has(sequence)) tokens.add(sequence);
@@ -109,6 +109,7 @@ export class AppAutomationManager {
109
109
  maxDepth: clampInt(args.max_depth, 6, 1, MAX_UI_DEPTH),
110
110
  maxElements: clampInt(args.max_elements, 200, 1, MAX_UI_ELEMENTS),
111
111
  includeValues: args.include_values === true,
112
+ includeMenus: args.include_menus === true,
112
113
  };
113
114
  const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
114
115
  return {
@@ -143,6 +144,7 @@ export class AppAutomationManager {
143
144
  value,
144
145
  maxDepth: clampInt(args.max_depth, 8, 1, MAX_UI_DEPTH),
145
146
  maxElements: MAX_UI_ELEMENTS,
147
+ includeMenus: args.include_menus === true,
146
148
  };
147
149
  const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
148
150
  return {
@@ -168,9 +170,11 @@ export class AppAutomationManager {
168
170
  undefined,
169
171
  input,
170
172
  );
173
+ const output = result.stdout.trim();
174
+ if (!output) throw new Error("macOS accessibility helper returned no JSON output");
171
175
  let parsed;
172
176
  try {
173
- parsed = JSON.parse(result.stdout.trim() || "{}");
177
+ parsed = JSON.parse(output);
174
178
  } catch {
175
179
  throw new Error("macOS accessibility helper returned invalid JSON");
176
180
  }
@@ -337,7 +341,7 @@ function describe(element, index, includeValues) {
337
341
  return item;
338
342
  }
339
343
  function childrenOf(element) { return safe(() => element.uiElements(), []); }
340
- function flatten(root, maxDepth, maxElements, includeValues) {
344
+ function flatten(root, maxDepth, maxElements, includeValues, includeMenus) {
341
345
  const output = [];
342
346
  const elements = [];
343
347
  const stack = [{ element: root, depth: 0 }];
@@ -346,10 +350,12 @@ function flatten(root, maxDepth, maxElements, includeValues) {
346
350
  const children = childrenOf(current.element);
347
351
  for (let i = children.length - 1; i >= 0; i--) {
348
352
  const child = children[i];
353
+ const item = describe(child, output.length, includeValues);
349
354
  elements.push(child);
350
- output.push(describe(child, output.length, includeValues));
355
+ output.push(item);
351
356
  if (output.length >= maxElements) break;
352
- if (current.depth + 1 < maxDepth) stack.push({ element: child, depth: current.depth + 1 });
357
+ const isMenuTree = String(item.role || '').startsWith('AXMenu');
358
+ if (current.depth + 1 < maxDepth && (includeMenus || !isMenuTree)) stack.push({ element: child, depth: current.depth + 1 });
353
359
  }
354
360
  }
355
361
  return { output, elements, truncated: stack.length > 0 || output.length >= maxElements };
@@ -366,14 +372,14 @@ function main() {
366
372
  const process = se.applicationProcesses.byName(payload.application);
367
373
  if (!safe(() => process.exists(), false)) throw new Error('application process not found or Accessibility access denied');
368
374
  if (payload.operation === 'inspect') {
369
- const flattened = flatten(process, payload.maxDepth, payload.maxElements, payload.includeValues === true);
370
- return { frontmost: safe(() => process.frontmost(), false), elements: flattened.output, truncated: flattened.truncated };
375
+ const flattened = flatten(process, payload.maxDepth, payload.maxElements, payload.includeValues === true, payload.includeMenus === true);
376
+ return { frontmost: safe(() => process.frontmost(), false), elements: flattened.output, truncated: flattened.truncated, menus_included: payload.includeMenus === true };
371
377
  }
372
378
  if (payload.action === 'activate') {
373
379
  process.frontmost = true;
374
380
  return { ok: true, matched: 1 };
375
381
  }
376
- const flattened = flatten(process, payload.maxDepth, payload.maxElements, true);
382
+ const flattened = flatten(process, payload.maxDepth, payload.maxElements, true, payload.includeMenus === true);
377
383
  const matchesList = [];
378
384
  for (let i = 0; i < flattened.output.length; i++) if (matches(flattened.output[i], payload.selector)) matchesList.push(i);
379
385
  const chosen = payload.selector.index !== undefined ? matchesList[payload.selector.index] : matchesList[0];
@@ -398,5 +404,5 @@ function main() {
398
404
  }
399
405
  return { ok: true, matched: matchesList.length, selected_index: chosen, element: describe(element, chosen, false) };
400
406
  }
401
- try { console.log(JSON.stringify(main())); } catch (error) { console.log(JSON.stringify({ error: String(error.message || error) })); }
407
+ (() => { try { return JSON.stringify(main()); } catch (error) { return JSON.stringify({ error: String(error.message || error) }); } })()
402
408
  `;
@@ -5,8 +5,11 @@ const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
5
5
 
6
6
  export function replaceFileSync(source, target, options = {}) {
7
7
  const rename = typeof options.rename === "function" ? options.rename : renameSync;
8
- const attempts = clampInteger(options.attempts, 8, 1, 32);
8
+ const sleep = typeof options.sleep === "function" ? options.sleep : sleepSync;
9
+ const random = typeof options.random === "function" ? options.random : Math.random;
10
+ const attempts = clampInteger(options.attempts, 16, 1, 64);
9
11
  const baseDelayMs = clampInteger(options.baseDelayMs, 15, 0, 1000);
12
+ const maxDelayMs = Math.max(baseDelayMs, clampInteger(options.maxDelayMs, 250, 0, 2000));
10
13
  let lastError;
11
14
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
12
15
  try {
@@ -15,7 +18,7 @@ export function replaceFileSync(source, target, options = {}) {
15
18
  } catch (error) {
16
19
  lastError = error;
17
20
  if (!isTransientReplaceError(error) || attempt === attempts) throw error;
18
- sleepSync(Math.min(baseDelayMs * attempt, 250));
21
+ sleep(retryDelayMs(attempt, baseDelayMs, maxDelayMs, random));
19
22
  }
20
23
  }
21
24
  throw lastError || new Error("atomic file replacement failed");
@@ -25,6 +28,15 @@ export function isTransientReplaceError(error) {
25
28
  return TRANSIENT_REPLACE_ERRORS.has(String(error?.code || ""));
26
29
  }
27
30
 
31
+ function retryDelayMs(attempt, baseDelayMs, maxDelayMs, random) {
32
+ if (baseDelayMs <= 0 || maxDelayMs <= 0) return 0;
33
+ const exponential = Math.min(baseDelayMs * (2 ** Math.min(attempt - 1, 5)), maxDelayMs);
34
+ const randomValue = Number(random());
35
+ const normalizedRandom = Number.isFinite(randomValue) ? Math.min(Math.max(randomValue, 0), 1) : 0;
36
+ const jitter = Math.floor(exponential * 0.5 * normalizedRandom);
37
+ return Math.min(exponential + jitter, maxDelayMs);
38
+ }
39
+
28
40
  function sleepSync(milliseconds) {
29
41
  if (milliseconds <= 0) return;
30
42
  Atomics.wait(WAIT_BUFFER, 0, 0, milliseconds);
@@ -355,7 +355,9 @@ export class BrowserBridgeManager {
355
355
  if (settled) return;
356
356
  settled = true;
357
357
  clearTimeout(timer);
358
- if (!ok) try { ws.close(); } catch {}
358
+ if (!ok) {
359
+ try { ws.terminate(); } catch { try { ws.close(); } catch {} }
360
+ }
359
361
  resolvePromise(ok);
360
362
  };
361
363
  ws.on("message", (data) => {
@@ -200,6 +200,11 @@
200
200
  "type": "boolean",
201
201
  "default": false
202
202
  },
203
+ "include_menus": {
204
+ "type": "boolean",
205
+ "default": false,
206
+ "description": "Include and recursively inspect the application menu hierarchy. Disabled by default so main-window controls are reached quickly."
207
+ },
203
208
  "timeout_seconds": {
204
209
  "type": "integer",
205
210
  "minimum": 1,
@@ -286,6 +291,11 @@
286
291
  "type": "string",
287
292
  "pattern": "^[a-z][a-z0-9._-]{0,63}$"
288
293
  },
294
+ "include_menus": {
295
+ "type": "boolean",
296
+ "default": false,
297
+ "description": "Include menu-bar and menu subtrees when matching the action selector."
298
+ },
289
299
  "max_depth": {
290
300
  "type": "integer",
291
301
  "minimum": 1,
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
3
3
  import serverMetadata from "../shared/server-metadata.json";
4
4
 
5
5
  const SERVER_NAME = String(serverMetadata.name);
6
- const SERVER_VERSION = "0.12.0";
6
+ const SERVER_VERSION = "0.12.2";
7
7
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
8
8
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
9
9
  const JSONRPC_VERSION = "2.0";