machine-bridge-mcp 1.2.0 → 1.2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/browser-extension/browser-operations.js +9 -3
  3. package/browser-extension/devtools-input.js +2 -2
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +6 -6
  6. package/docs/AUDIT.md +26 -0
  7. package/docs/ENGINEERING.md +3 -0
  8. package/docs/LOGGING.md +6 -2
  9. package/docs/OPERATIONS.md +5 -3
  10. package/docs/PROJECT_STANDARDS.md +1 -0
  11. package/docs/TESTING.md +6 -2
  12. package/docs/UPGRADING.md +6 -2
  13. package/package.json +4 -3
  14. package/scripts/coverage-check.mjs +3 -1
  15. package/src/local/account-access.mjs +7 -5
  16. package/src/local/call-registry.mjs +9 -3
  17. package/src/local/cli-local-admin.mjs +74 -38
  18. package/src/local/cli-options.mjs +18 -18
  19. package/src/local/cli-policy.mjs +1 -1
  20. package/src/local/cli-service.mjs +132 -0
  21. package/src/local/cli.mjs +24 -106
  22. package/src/local/log.mjs +10 -2
  23. package/src/local/managed-job-plan.mjs +3 -3
  24. package/src/local/policy.mjs +11 -6
  25. package/src/local/project-package.mjs +1 -1
  26. package/src/local/relay-connection.mjs +21 -0
  27. package/src/local/resource-operations.mjs +1 -1
  28. package/src/local/runtime-reporting.mjs +1 -1
  29. package/src/local/runtime.mjs +65 -27
  30. package/src/local/tool-executor.mjs +3 -3
  31. package/src/local/worker-deployment.mjs +15 -10
  32. package/src/local/worker-health.mjs +7 -3
  33. package/src/worker/access.ts +4 -3
  34. package/src/worker/http.ts +2 -2
  35. package/src/worker/index.ts +58 -17
  36. package/src/worker/oauth-controller.ts +11 -2
  37. package/src/worker/observability.ts +3 -1
  38. package/src/worker/pending-calls.ts +17 -0
  39. package/wrangler.jsonc +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.2 - 2026-07-17
4
+
5
+ ### Relay result lifecycle and human-readable diagnostics
6
+
7
+ - Bind every local `tool_result` to the authenticated relay session that delivered its `tool_call`. A result from a disconnected or replaced socket is discarded instead of being sent over a newer connection, and a caller cancellation marks the eventual local result as intentionally undeliverable. Routine late-result races are debug-only rather than repeated `relay.tool_result.delivery_failed` warnings; an actual synchronous WebSocket send failure still invalidates the ambiguous transport.
8
+ - Propagate MCP HTTP cancellation into the Worker pending-call registry. Incoming request cancellation removes both internal and session request-key indexes, sends a best-effort daemon cancellation, and records the call as cancelled. The Worker deployment now explicitly enables Cloudflare request-signal delivery and passthrough to the Durable Object.
9
+ - Add an `unmatched_results` Worker metric for results that arrive after their pending record was removed. Human log mode uses natural-language event messages and omits the redundant machine event key, while JSON mode retains stable event names and bounded structured fields.
10
+ - Add regression coverage for relay-session replacement, stale-result suppression, request-signal cleanup, human log rendering, compatibility flags, and unmatched-result observability. Local Wrangler integration continues to cover the complete OAuth/MCP/WebSocket flow and explicit MCP cancellation.
11
+
12
+ ## 1.2.1 - 2026-07-16
13
+
14
+ ### Fail-closed input contracts and bounded CLI adapters
15
+
16
+ - Eliminate prototype-chain lookup from externally controlled command, action, account-role, policy-profile, form-field, keyboard, and local-resource keys. Dispatch and enumerations now use `Map`, `Set`, `Object.hasOwn`, or null-prototype records; names such as `constructor` and `__proto__` can no longer become inherited handlers or unauthorized enum members. A malformed current-schema OAuth account role is repaired in place to a disabled reviewer account, its version is advanced, and every existing authorization code and token is revoked so an affected installation remains administrable without granting authority.
17
+ - Make Worker deployment output parsing and health verification share one canonical `workers.dev` origin validator. Wrangler output containing unrelated `/mcp`, `/healthz`, path-bearing, or wrong-name URLs is no longer accepted as deployment evidence and cannot poison the persisted URL or deployment fingerprint.
18
+ - Preserve the exact browser `maxBytes` contract for non-ASCII page source. The DOM serializer now backs off to a complete UTF-8 prefix instead of decoding a split code point into a replacement character whose encoded size exceeds the reported budget; regressions cover emoji and Chinese text at every partial-byte boundary.
19
+ - Replace ordinary browser and service CLI branch trees with named, map-driven adapters. The service adapter is independently injectable and reaches 100% function and over 90% branch coverage; architecture checks are split into module boundaries, repository hygiene, browser/security structure, and release/documentation contracts so source-shape guards remain supplemental to executable behavior tests.
20
+ - Keep local state schema 6, policy revision 5, browser pairing, resources, jobs, and Worker identity unchanged for an in-place upgrade from 1.2.0. Normal startup still converges the versioned Worker and the unpacked extension must be reloaded; no live deployment, credential rotation, daemon replacement, global installation, or npm publication is performed by this source change.
21
+
3
22
  ## 1.2.0 - 2026-07-16
4
23
 
5
24
  ### Typed evolution boundaries and project governance
@@ -181,7 +181,7 @@
181
181
  function boundedDocumentSource(limit) {
182
182
  const maxBytes = Math.max(1, Number(limit) || 1);
183
183
  const encoder = new TextEncoder();
184
- const decoder = new TextDecoder();
184
+ const decoder = new TextDecoder("utf-8", { fatal: true });
185
185
  const chunks = [];
186
186
  const VOID_ELEMENTS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
187
187
  const MAX_NODES = 100000;
@@ -207,8 +207,14 @@
207
207
  returnedBytes += bytes.byteLength;
208
208
  return true;
209
209
  }
210
- chunks.push(decoder.decode(bytes.slice(0, remaining)));
211
- returnedBytes = maxBytes;
210
+ let end = remaining;
211
+ let prefix = "";
212
+ while (end > 0) {
213
+ try { prefix = decoder.decode(bytes.slice(0, end)); break; }
214
+ catch { end -= 1; }
215
+ }
216
+ if (prefix) chunks.push(prefix);
217
+ returnedBytes += end;
212
218
  budgetExhausted = true;
213
219
  return false;
214
220
  };
@@ -100,10 +100,10 @@
100
100
  let modifiers = 0;
101
101
  for (const raw of parts) {
102
102
  const name = raw === "Ctrl" ? "Control" : raw === "Cmd" || raw === "Command" ? "Meta" : raw;
103
- if (!(name in MODIFIERS)) throw new Error(`unsupported key modifier: ${raw}`);
103
+ if (!Object.hasOwn(MODIFIERS, name)) throw new Error(`unsupported key modifier: ${raw}`);
104
104
  modifiers |= MODIFIERS[name];
105
105
  }
106
- const known = KEY_DATA[keyName];
106
+ const known = Object.hasOwn(KEY_DATA, keyName) ? KEY_DATA[keyName] : null;
107
107
  if (known) return {
108
108
  key: known.key || keyName,
109
109
  code: known.code,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "1.2.0",
4
+ "version": "1.2.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",
@@ -30,5 +30,5 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "1.2.0"
33
+ "version_name": "1.2.2"
34
34
  }
@@ -34,7 +34,7 @@ A canonical workspace receives an independent profile, Worker name, secret set,
34
34
  - `runtime-capabilities.mjs` composes agent, application, and browser capability results;
35
35
  - managed jobs, local resources, application automation, and browser automation remain separate managers.
36
36
 
37
- Architecture tests cap the orchestration module and each extracted service independently and reject a return of low-level process, patch, diagnostic, or capability-scoring logic to `LocalRuntime`. `RelayConnection` owns remote WebSocket transport, authenticated `hello_ack` readiness, heartbeat liveness, reconnect backoff, and outage logging. Stdio mode invokes `LocalRuntime` directly without that adapter.
37
+ Architecture tests cap the orchestration module and each extracted service independently and reject a return of low-level process, patch, diagnostic, or capability-scoring logic to `LocalRuntime`. `RelayConnection` owns remote WebSocket transport, authenticated `hello_ack` readiness, heartbeat liveness, reconnect backoff, outage logging, and a monotonically increasing in-memory session generation. Every relayed tool result is bound to the generation that delivered its call; a disconnected generation cannot send a late result over its replacement socket. Stdio mode invokes `LocalRuntime` directly without that adapter.
38
38
 
39
39
  `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.
40
40
 
@@ -105,7 +105,7 @@ All requests for a deployed Worker route to one named Durable Object. It owns:
105
105
 
106
106
  `BridgeRoom` owns Durable Object routing, MCP dispatch, daemon WebSocket lifecycle, and pending relay calls. `OAuthController` owns OAuth-store pruning, registration throttling, authorization pages/submission, account-admin routing, token exchange, access-token verification, and the serialization queue for OAuth mutations. Worker-internal TypeScript imports use explicit `.ts` specifiers and JSON import attributes, so the same modules are directly executable under the pinned Node runtime for focused state-machine tests as well as bundled by Wrangler.
107
107
 
108
- The Worker verifies OAuth, validates MCP envelopes and optional protocol headers, converts `tools/call` into WebSocket messages, correlates cancellation by access-token hash and JSON-RPC ID, and formats text/structured/image results. It has no local filesystem or process API.
108
+ The Worker verifies OAuth, validates MCP envelopes and optional protocol headers, converts `tools/call` into WebSocket messages, correlates cancellation by access-token hash and JSON-RPC ID, and formats text/structured/image results. Pending calls also bind the incoming request `AbortSignal`: an HTTP client disconnect removes the pending indexes and sends a best-effort daemon cancellation. The deployment contract explicitly enables Cloudflare `enable_request_signal` and `request_signal_passthrough` so the signal reaches the named Durable Object. It has no local filesystem or process API.
109
109
 
110
110
  The primary OAuth store separates client registrations and named accounts from authorization codes and access-token records. A separate Durable Object key owns refresh-token records so refresh support can be added without rewriting the live primary-store schema. A `client_id` identifies an MCP application and redirect URIs; account records identify the authorized human or service identity. Codes, access tokens, and refresh tokens bind client ID, account ID, account version, role, scope, resource, token version, and expiration. Only hashes of bearer tokens are persisted, and every public-client refresh invalidates the presented refresh token while returning a replacement. The Worker intersects the role with the active daemon policy before advertising or relaying tools, and the local runtime validates the relayed role again. One bridge-specific Durable Object and one local runtime remain the normal topology for a workspace/trust domain; see [MULTI_ACCOUNT.md](MULTI_ACCOUNT.md).
111
111
 
@@ -152,10 +152,10 @@ Remote OAuth binds each code, access token, and refresh token to a named Machine
152
152
  6. A valid verifier exchanges the one-time code for an expiring access token and refresh token; only their hashes are stored. A refresh request is bound to the original public client, account, scope, resource, and deployment token version, and atomically replaces the refresh token so replay returns `invalid_grant`.
153
153
  7. The MCP client initializes against the sole current protocol version; an obsolete client must upgrade rather than enter a legacy execution path. The Worker returns a stateless HMAC-bound `MCP-Session-Id`, and later request/cancellation correlation is scoped by OAuth token, MCP session, JSON-RPC id type, and id value. Two clients may therefore reuse the same JSON-RPC id concurrently without collision. Sessionless POSTs remain independent and are not inserted into a token-global cancellation index. When the daemon advertises `session_bootstrap`, the Worker requests bounded local instructions and appends them to the initialization result; failure degrades to static instructions.
154
154
  8. `tools/list` is derived from the active daemon handshake; without a daemon, only `server_info` is advertised.
155
- 9. `tools/call` receives a random relay call ID and is bound to the current socket and authenticated client request key.
155
+ 9. `tools/call` receives a random relay call ID and is bound to the current daemon socket, authenticated client request key, incoming HTTP abort signal, and local relay-session generation.
156
156
  10. The runtime validates policy and arguments, executes the tool, and returns a bounded result.
157
- 11. The Durable Object accepts a result only from the socket that received that call.
158
- 12. A matching cancellation notification removes the pending call, tells the daemon to cancel, and terminates any ordinary child processes bound to it.
157
+ 11. The Durable Object accepts a result only from the daemon socket that received that call, and the local runtime sends it only through the relay generation that delivered the call.
158
+ 12. A matching cancellation notification or incoming HTTP client disconnect removes the pending indexes, tells the daemon to cancel, and terminates ordinary child processes bound to the call. If local completion races with cancellation, the eventual result is discarded rather than treated as a user-visible delivery failure.
159
159
  13. `start_job` is different: after durable acceptance, the detached runner is no longer bound to the relay call or socket. Later cancellation uses `cancel_job` or the local CLI.
160
160
 
161
161
  Duplicate in-flight JSON-RPC IDs are rejected only within the same authenticated MCP session. The request key includes OAuth token identity, the HMAC-bound MCP session, JSON-RPC id type, and id value, so separate initialized clients may safely reuse the same numeric id.
@@ -226,7 +226,7 @@ The local `RelayConnection` otherwise treats transport construction, transport o
226
226
 
227
227
  Reconnect uses bounded exponential backoff with jitter. Brief self-healing interruptions are debug-only. An unresolved outage is promoted to a rate-limited warning after a grace period, and recovery produces one summary. Raw close codes and reason strings remain debug-only.
228
228
 
229
- The Worker also treats a new connection as a bounded candidate until it authenticates and completes `hello`; a Durable Object alarm enforces the deadline across hibernation. Only a completed candidate replaces the old socket. Daemon messages must be valid JSON objects, duplicate `hello` and unknown message types are rejected with explicit protocol errors, and protocol violations close with standard WebSocket status codes. Pending calls retain their originating socket reference. A stale socket cannot complete or cancel replacement-socket calls. Closing a socket rejects only calls assigned to it.
229
+ The Worker also treats a new connection as a bounded candidate until it authenticates and completes `hello`; a Durable Object alarm enforces the deadline across hibernation. Only a completed candidate replaces the old socket. Daemon messages must be valid JSON objects, duplicate `hello` and unknown message types are rejected with explicit protocol errors, and protocol violations close with standard WebSocket status codes. Pending calls retain their originating socket reference. A stale socket cannot complete or cancel replacement-socket calls. Closing a socket rejects only calls assigned to it. The local relay generation supplies the symmetric boundary: work accepted by an older authenticated connection cannot publish its result through a newer connection even when the call IDs are otherwise well formed.
230
230
 
231
231
  ## Persistence
232
232
 
package/docs/AUDIT.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Engineering and security audit
2
2
 
3
+ ## 2026-07-17 version 1.2.2 relay-lifecycle and logging audit
4
+
5
+ The reported warning was the visible symptom of a call-lifecycle mismatch rather than a failed local tool. The Worker could remove a pending call after MCP cancellation, timeout, daemon disconnect, or client abandonment, while the local runtime still completed its asynchronous operation and entered one unconditional result-delivery path. When the original WebSocket had closed, the daemon emitted one `relay.tool_result.delivery_failed` warning per late completion. More seriously, if a replacement relay had already authenticated, the old result could be written to the new socket; the Worker rejected it because its pending record was socket-bound, but the local transport did not enforce the symmetric session boundary.
6
+
7
+ The correction gives each authenticated local relay connection a monotonically increasing in-memory generation and captures that generation with every incoming call. A result may be sent only through the generation that delivered the call. Explicit cancellation and relay disconnect also mark the eventual local result for disposal. Expected cancellation/disconnect/reconnect races are therefore debug-only `relay.tool_result.discarded` events, while a synchronous send failure on the still-current socket continues to invalidate the ambiguous transport and enter the ordinary outage state machine.
8
+
9
+ A second defect existed one layer earlier. The Worker already handled MCP `notifications/cancelled`, but an HTTP client that disconnected without sending that notification could leave the pending internal ID and session request-key index until the tool timeout. The pending-call registry now owns AbortSignal registration and cleanup atomically with its timer and indexes. The Worker deployment explicitly enables Cloudflare `enable_request_signal` and `request_signal_passthrough`, which are both required because the public Worker forwards the request into a named Durable Object. A deterministic registry test covers active and already-aborted requests; the complete local workerd suite continues to cover OAuth, explicit MCP cancellation, WebSocket routing, timeout, and socket loss.
10
+
11
+ Logging was reviewed as a user interface rather than a serialization dump. Human mode now uses the supplied natural-language explanation and omits the redundant stable event identifier; newline-delimited JSON retains that identifier for ingestion. Tool starts, outcomes, cancellation, timing, and late-result disposal remain debug-only. Worker observability adds an `unmatched_results` counter for a result that arrives after its pending record has been removed, preserving anomaly evidence without storing tool arguments, command text, paths, or result content.
12
+
13
+ The broader pass rechecked architecture boundaries, cancellation and reconnect branches, release/configuration contracts, privacy redaction, package version convergence, TypeScript and checked-JavaScript contracts, lint, Worker integration, and documentation drift. An additional deployment defect was found during fault reproduction: request cancellation support was implemented in code but the required Cloudflare compatibility flags were absent, so the signal could not reach the Durable Object in production. Architecture tests now make both flags non-optional. Version 1.2.2 retains local state schema 6 and policy revision 5; normal startup must converge the Worker and daemon versions, and the unpacked browser extension must be reloaded. No live deployment, credential rotation, global installation, daemon replacement, npm publication, tag, or GitHub Release is performed by this source change.
14
+
15
+ ## 2026-07-16 version 1.2.1 fail-closed input-contract audit
16
+
17
+ The review began from clean `v1.2.0` with the complete repository gate, dependency audit, Worker dry run, and package checks passing. Hostile-input reproduction nevertheless found one shared JavaScript failure mechanism outside those gates: ordinary objects were being used as external string-key maps. Inherited names such as `constructor` and `__proto__` were therefore accepted as account roles, policy profiles, top-level commands, resource actions, and keyboard-table entries. The CLI variants could exit successfully without performing a command; a Worker account could persist an invalid role and later fail during authority calculation. Adjacent review found the same semantic hazard in form-field aggregation and valid local-resource names.
18
+
19
+ The correction removes prototype-chain semantics rather than blacklisting a few names. Command/action dispatch and role registries use `Map` or exact own-key membership; profile and availability contracts use explicit sets/own-key checks; URL-encoded bodies and normalized resource registries use null-prototype records. Regression tests exercise the standard prototype property names at every affected public boundary. A malformed role already persisted under the unchanged current OAuth schema is repaired in place to a disabled `reviewer`, its version advances, and all codes/tokens are pruned. This preserves an operator-recoverable account record while ensuring the upgrade cannot retain or expand authority.
20
+
21
+ The deployment review found a separate validation split: Wrangler output parsing accepted any HTTPS URL containing `/mcp` or `/healthz`, while the health verifier later required an exact root `workers.dev` origin matching the Worker name. Because successful upload evidence is intentionally saved before secondary verification, unrelated output could persist a URL/fingerprint pair that later starts would refuse to verify. Parsing and verification now share one canonical origin function; nonmatching, path-bearing, credential-bearing, ported, queried, or fragmented candidates are ignored and cannot mutate state.
22
+
23
+ The browser source limit had an encoding-level off-by-contract defect. Truncating raw UTF-8 bytes inside a code point and decoding non-fatally produced `U+FFFD`, whose re-encoded size could exceed both the returned count and caller budget. The serializer now backs off to the longest valid prefix and reports its actual encoded byte count. Emoji and Chinese fixtures cover every partial boundary and aggregate source budgeting.
24
+
25
+ Ordinary CLI branching was also brought back into the repository's own architecture standard. Browser actions were split into named handlers; service status/install/start/stop/uninstall/remove moved to an independently injectable adapter with complete action coverage and over ninety percent branch coverage. The former 580-line architecture test was separated into module boundaries, repository hygiene, browser/security structure, and release/documentation contracts. Source-string checks remain useful drift alarms, but the security and lifecycle claims above are enforced by executable behavior and fault-path tests.
26
+
27
+ Version 1.2.1 retains local state schema 6 and policy revision 5. Existing 1.2.0 state upgrades in place; the versioned Worker is converged by normal startup and the unpacked extension must be reloaded. Historical changelog and audit records remain as history, while obsolete runtime fallback semantics and stale migration wording were removed.
28
+
3
29
  ## 2026-07-16 version 1.2 trustworthy-evolution audit
4
30
 
5
31
  The review began from clean released commit `58d54c21a0ac37f8e5f82a6f895738508efad0c6` (`v1.1.5`). The complete local gate, exact-commit Linux/macOS/Windows CI, CodeQL, Governance, and OpenSSF Scorecard were green; dependency audits reported zero vulnerabilities; and the GitHub Release asset, npm registry tarball, and a fresh local pack were byte-identical. That evidence ruled out an immediate release-integrity incident but did not answer whether the project could continue evolving safely: the main Worker, runtime, Agent-context, and browser-broker modules were all above ninety-seven percent of their own architecture limits, while high-authority local JavaScript had almost no static shape checking and several critical Worker modules had no branch floor.
@@ -55,6 +55,7 @@ Rules:
55
55
  - Every protocol control message emitted by one side must be explicitly accepted, rejected, or version-gated by the other side, with an end-to-end contract test covering the message name and semantics.
56
56
  - State transitions are explicit; readiness is not inferred from a lower-level event. For example, an open WebSocket is not an authenticated relay until `hello_ack` is received.
57
57
  - Every externally controlled input is bounded before expensive allocation, traversal, parsing, storage, or execution.
58
+ - Externally controlled string keys must not use prototype-chain membership or truthiness on ordinary objects. Use `Map`, `Set`, `Object.hasOwn`, or null-prototype records for command dispatch, enums, ACLs, form fields, registries, and other key-addressed contracts.
58
59
  - Repository text must not contain invisible ASCII controls other than tab, CR, and LF; architecture tests enforce this even when JavaScript syntax remains valid.
59
60
  - Persistent mutations use owner-only files, bounded no-follow reads, flushed atomic replacement, and integrity checks appropriate to the data.
60
61
  - Exclusive locks use the shared complete-before-visible hard-link claim. Reclamation requires process identity plus a matching file snapshot/token; do not unlink a path merely because an earlier read looked stale.
@@ -122,6 +123,8 @@ A higher branch count is acceptable only when the function is an explicit state
122
123
 
123
124
  Every defect fix includes a regression test that fails for the original reason. Prefer two layers when applicable:
124
125
 
126
+ Prototype-shaped strings such as `constructor`, `__proto__`, `hasOwnProperty`, `toString`, and `valueOf` are mandatory negative or ordinary-data cases for any new externally indexed object boundary. Byte-bounded text tests must include non-ASCII input and assert the encoded result size, not only JavaScript string length.
127
+
125
128
  1. a deterministic test of the extracted policy or lifecycle;
126
129
  2. an integration test proving the adapter uses it correctly.
127
130
 
package/docs/LOGGING.md CHANGED
@@ -26,6 +26,8 @@ The CLI accepts:
26
26
 
27
27
  Foreground mode defaults to `info` and human-readable text. Platform autostart services use `warn` with newline-delimited JSON. Foreground operators can select JSON explicitly with `--log-format json`.
28
28
 
29
+ Human mode treats the message as the primary interface: it uses a natural-language explanation and includes only bounded diagnostic fields that add meaning. It does not repeat the machine event key. JSON mode retains the stable `event` field for ingestion and correlation. Event identifiers such as `relay.tool_result.discarded` are implementation contracts for structured logs, not text that should be shown as the warning itself.
30
+
29
31
  | Level | Intended events |
30
32
  |---|---|
31
33
  | `error` | The operation or service cannot continue without intervention |
@@ -73,9 +75,11 @@ Examples:
73
75
 
74
76
  With `--verbose`, the same incident additionally includes bounded structured fields such as exact seconds, coarse error class, retry delay, and transport close diagnostics.
75
77
 
76
- ## Tool events
78
+ ## Tool and result-delivery events
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.
77
81
 
78
- All per-tool starts, successes, failures, cancellations, and timing 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.
82
+ A completed local result is bound to the authenticated relay session that delivered its call. If the caller cancelled, the relay disconnected, or a replacement relay session became active, the result is discarded rather than sent to a different session. This is a normal terminal race and does not emit a warning. Debug output records only a shortened call ID and a coarse reason such as `caller_cancelled`, `relay_disconnected`, or `session_ended`. A synchronous send failure on the still-current socket is different: the socket is invalidated because delivery is ambiguous, but the per-call detail remains debug-only while the normal relay-outage state machine decides whether a user-visible warning is warranted.
79
83
 
80
84
  Debug per-tool fields may include tool name, duration, coarse outcome class, and a shortened random call identifier. The identifier is for correlating adjacent local events and is not a stable audit identifier.
81
85
 
@@ -50,7 +50,9 @@ A successful diagnostic result applies only to that probe. An MCP host can still
50
50
 
51
51
  Machine Bridge supports concurrent calls: the Worker admits up to 32 pending daemon calls and the local runtime admits up to 16 active tool calls. These are capacity limits, not a single global execution queue. Each successful MCP initialization receives a signed `MCP-Session-Id`; JSON-RPC ids and cancellation are scoped to that session, so separate chat windows may reuse the same numeric ids safely even when they share one OAuth account and token.
52
52
 
53
- `server_info.worker.pending_calls` reports `active`, `request_keys`, `maximum`, `oldest_ms`, and `by_tool`. A nonzero `active` count means work is in flight, not that the bridge is locked. Calls for simple reads and probes should continue while another independent process call runs. A value that remains after the underlying local task completed indicates a transport-delivery defect; version 1 interrupts and reconnects the ambiguous daemon socket so Worker cleanup releases those records. Refreshing a chat page is not the recovery mechanism and should not be required.
53
+ `server_info.worker.pending_calls` reports `active`, `request_keys`, `maximum`, `oldest_ms`, and `by_tool`. A nonzero `active` count means work is in flight, not that the bridge is locked. Calls for simple reads and probes should continue while another independent process call runs. Explicit MCP cancellation, an incoming HTTP client disconnect, timeout, and daemon-socket closure remove the pending record and its session request key. Local results are bound to the authenticated relay session that originated the call, so a result that finishes after cancellation or reconnection is discarded instead of being sent over a replacement socket. Refreshing a chat page is not the recovery mechanism and should not be required.
54
+
55
+ `server_info.worker.observability.calls.unmatched_results` counts results that reached the Worker after their pending record was already removed. A small increase can accompany cancellation or timeout races, especially during mixed-version upgrade convergence; sustained growth together with old pending calls indicates incompatible components or a lifecycle defect. The counter contains no tool arguments or result data.
54
56
 
55
57
  ### Relay interruption messages
56
58
 
@@ -144,7 +146,7 @@ Uninstall acquires a state-root `maintenance.lock` that blocks new profile/state
144
146
 
145
147
  ### Lifecycle and pending-call diagnosis
146
148
 
147
- `server_info.runtime.lifecycle` reports `ready`, `starting`, `running`, `failed`, `stopping`, or `stopped`. `runtime.observability.in_flight_calls` and `runtime.observability.processes` distinguish a blocked call from a surviving process. Worker `server_info.worker.pending_calls` reports both the internal-call index and client request-key index; they must return to zero after a terminal result, cancellation, timeout, send failure, or daemon disconnect. Nonzero request-key counts after active calls reach zero indicate a lifecycle defect rather than normal load.
149
+ `server_info.runtime.lifecycle` reports `ready`, `starting`, `running`, `failed`, `stopping`, or `stopped`. `runtime.observability.in_flight_calls` and `runtime.observability.processes` distinguish a blocked call from a surviving process. Worker `server_info.worker.pending_calls` reports both the internal-call index and client request-key index; they must return to zero after a terminal result, explicit cancellation, client disconnect, timeout, send failure, or daemon disconnect. Nonzero request-key counts after active calls reach zero indicate a lifecycle defect rather than normal load. `worker.observability.calls.unmatched_results` is the bounded counter for late results that no longer have a receiver.
148
150
 
149
151
  Stable errors include `policy_denied`, `invalid_request`, `timeout`, `cancelled`, `network_error`, `unavailable`, `limit_exceeded`, and `integrity_error`, with retryability metadata. Diagnose by code first; free-form messages are guidance, not an API contract.
150
152
 
@@ -161,7 +163,7 @@ Logging is level-based:
161
163
  error unrecoverable local/transport/service failures
162
164
  warn persistent retryable relay degradation, supersession, and service problems
163
165
  info startup/deploy/connect transitions
164
- debug all per-tool starts/successes/failures/cancellations/timing, correlation and reconnect details
166
+ debug all per-tool starts/successes/failures/cancellations/timing, late-result disposal, correlation and reconnect details
165
167
  ```
166
168
 
167
169
  Foreground mode defaults to `info`; autostart uses `warn`. Use `--verbose` or `--log-level debug` only for diagnosis. `--quiet` is an alias for `--log-level error`.
@@ -64,6 +64,7 @@ The architectural dependency direction in [ENGINEERING.md](ENGINEERING.md) is no
64
64
  - **High cohesion and low coupling:** one source file or function owns one coherent responsibility and reason to change; collaboration occurs through narrow explicit contracts rather than cross-layer reach-through.
65
65
  - **KISS:** prefer the simplest explicit implementation that satisfies current requirements. Do not introduce factories, registries, inheritance, generic frameworks, or configuration layers without an observed variation that needs them.
66
66
  - **DRY:** extract repeated business rules, validation, security boundaries, or lifecycle logic into one authoritative implementation. Do not merge merely similar code when its semantics or failure policy differ.
67
+ - **Exact key membership:** external string keys used for dispatch, enums, ACLs, forms, or registries use `Map`, `Set`, `Object.hasOwn`, or null-prototype records. Prototype-chain membership and truthy ordinary-object lookup are not contract validation.
67
68
  - Design patterns are used only when they remove an observed variation or coupling. A direct function or small module is preferred over speculative abstractions.
68
69
 
69
70
  Any deliberate boundary exception must document the dependency, reason, owner, test coverage, and removal condition.
package/docs/TESTING.md CHANGED
@@ -54,6 +54,10 @@ The suite includes:
54
54
  - owner-only directory enforcement rejecting final symlinks, failing closed on POSIX chmod errors, verifying `0700`, and retaining Windows portability; Worker temporary-secret lifecycle coverage for process-start-bound names, valid stale-owner reclamation, ambiguous-owner retention, `0600` mode, deletion failures, and simultaneous deployment/cleanup failures;
55
55
  - SARIF security-gate behavior for unknown findings, exact accepted rule/path matches, path mismatch rejection, rationale quality, and exception expiry;
56
56
  - deterministic property tests over hostile browser-protocol byte strings, canonical/custom policy combinations, argv bounds/NULs, and a real direct process proving shell metacharacters remain literal argv;
57
+ - prototype-shaped command, action, role, profile, form-field, keyboard, and resource names proving that inherited object properties are never interpreted as dispatch or authority; current-schema malformed OAuth roles are repaired to disabled reviewer accounts with credential revocation;
58
+ - canonical Worker deployment URL extraction proving unrelated `/mcp`, `/healthz`, path-bearing, and wrong-name URLs cannot be persisted as upload evidence;
59
+ - byte-exact UTF-8 DOM-source truncation across emoji and Chinese partial-code-point boundaries, including equality between the reported byte count and the encoded returned source;
60
+ - independently injected service CLI status/install/start/stop/uninstall/remove paths, including provider failure, no selected workspace, no deployed Worker, aliases, and default output/exit adapters;
57
61
  - CLI parsing, policy profiles, and client configuration boundaries;
58
62
  - 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;
59
63
  - live local Worker OAuth registration, the unauthenticated `resource_metadata` challenge, protected-resource and authorization-server discovery, Streamable transport metadata, consent, URL-constructed `303` callbacks including the ChatGPT and hosted Claude redirect URIs with encoded state, PKCE, `offline_access`, form-encoded authorization-code and refresh-token exchanges, access/refresh rotation, stale refresh replay rejection, account-version refresh revocation, authorization-code replay rejection, pending-registration throttling that excludes already authorized DCR clients, exact built-in ChatGPT/Grok browser origins, additive custom origins, unrelated-origin preflight rejection, no CORS response sharing for unrelated or opaque origins, opaque-origin authorization-form routing, exact per-request redirect-origin CSP with narrowly scoped Microsoft regional-consent and final Copilot Studio handoff exceptions, accessible credential-error rendering, protocol negotiation, HMAC-bound MCP session issuance, two-session same-id concurrency, sessionless same-id independence, session-scoped cancellation isolation, same-session duplicate rejection, 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. The metadata/refresh contract is the path used by Claude DCR and Copilot Studio Dynamic discovery. The same integration runs an `editor` account against a canonical `full` daemon and proves that `server_info` and remote `project_overview` report effective `edit` authority while retaining the full daemon ceiling only in explicitly scoped fields.
@@ -75,7 +79,7 @@ For deterministic release validation, perform an isolated-profile smoke test wit
75
79
 
76
80
  ## Critical-module coverage gate
77
81
 
78
- `npm run coverage:test` runs selected in-process and lightweight entrypoint fixtures under V8 coverage and enforces per-module function and branch baselines. The measured set now includes policy, typed errors, call registration, execution middleware, lifecycle/observability, logging, Runtime/CLI orchestration, Agent configuration, capability ranking, browser protocol/operation boundaries, runtime reporting/diagnostics/capability composition, and Worker pending/policy/error modules. The full stdio and workerd OAuth/MCP integration still runs separately; a focused direct Node test covers `OAuthController` registration throttling, authorization failure/success, resource-bound access tokens, expiry pruning, invalid-state rejection, and identity-key failure.
82
+ `npm run coverage:test` runs selected in-process and lightweight entrypoint fixtures under V8 coverage and enforces per-module function and branch baselines. The measured set now includes policy, typed errors, call registration, execution middleware, lifecycle/observability, logging, Runtime/CLI orchestration, the independently injected service CLI adapter, Agent configuration, capability ranking, browser protocol/operation boundaries, runtime reporting/diagnostics/capability composition, and Worker pending/policy/error modules. The full stdio and workerd OAuth/MCP integration still runs separately; a focused direct Node test covers `OAuthController` registration throttling, authorization failure/success, resource-bound access tokens, expiry pruning, invalid-state rejection, and identity-key failure.
79
83
 
80
84
  The gate deliberately reports each module rather than one aggregate percentage. New extracted pure/domain modules carry explicit branch floors, while the broad CLI entrypoint remains reported and locked independently. Worker pending calls and policy now have branch minima instead of function-only gates. A refactor may raise a threshold, but must not lower one merely to make CI green without an explicit audit explanation.
81
85
 
@@ -124,4 +128,4 @@ The stdio integration test also sends an oversized line, verifies bounded reject
124
128
 
125
129
  ## Architecture and documentation regression checks
126
130
 
127
- `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.
131
+ `npm run architecture:test` runs independent module-boundary, repository-hygiene, browser/security-structure, and release/documentation-contract checks. It 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/docs/UPGRADING.md CHANGED
@@ -4,7 +4,11 @@
4
4
 
5
5
  Machine Bridge does not retain parallel implementations for obsolete MCP protocol dates, policy revisions, state schemas, lock formats, or browser-extension protocols. The supported path is a direct upgrade from the immediately preceding published package while its state already uses the current schema.
6
6
 
7
- Version 1.2 keeps local state schema version 6 and policy revision 5 unchanged. Existing 1.1.5 workspaces, named accounts, resource registrations, managed-job history, Worker identity, and browser pairing state are reused without conversion. The architecture refactor changes module ownership, not persisted formats or authority.
7
+ Version 1.2.2 keeps local state schema version 6 and policy revision 5 unchanged. Existing 1.2.1 workspaces, named accounts, resource registrations, managed-job history, Worker identity, and browser pairing state are reused without a schema conversion. The package change affects relay call/result lifetime, Worker request cancellation, logging presentation, and observability; it does not alter the stored authority model.
8
+
9
+ Version 1.2.2 requires the Worker, local daemon, and browser extension to converge on the same package version. Normal startup redeploys the versioned Worker when required. The Worker configuration now enables Cloudflare request-signal delivery and signal passthrough to the Durable Object; an old Worker therefore does not provide the new HTTP-disconnect cancellation path even when the local daemon has already been upgraded.
10
+
11
+ Version 1.2.0 could accept prototype-shaped account roles through malformed administration input. On the first 1.2.1 or later Worker access, such an account is preserved for recovery but repaired fail-closed: its role becomes `reviewer`, it is disabled, its account version advances, and its authorization codes and tokens are removed. An operator can then assign a valid role, enable the account, and rotate its password through the normal account administration flow. A local policy record with an unknown profile label is normalized to `custom` while retaining its explicit capability fields; an invalid explicit `--profile` is rejected.
8
12
 
9
13
  A state file from an older unsupported schema is rejected rather than guessed or silently rewritten. Upgrade an old installation through the last release that understands its schema, or initialize a new workspace and re-register resources. Do not edit schema numbers by hand.
10
14
 
@@ -25,6 +29,6 @@ Machine Bridge never treats an unreadable or foreign-schema state file as empty
25
29
 
26
30
  ## Rollback
27
31
 
28
- Rollback is supported only when the older package understands every persisted schema and protocol already written by the newer package. Version 1.2 does not advance local state or policy schemas, so rollback to 1.1.5 remains structurally possible, but the preferred recovery is to fix forward because the browser extension and deployed Worker must match the running package exactly.
32
+ Rollback is supported only when the older package understands every persisted schema and protocol already written by the newer package. Version 1.2.1 does not advance local state or policy schemas, so rollback to 1.2.0 remains structurally possible. A repaired malformed account remains a valid disabled reviewer record that 1.2.0 can read. The preferred recovery is still to fix forward because the browser extension and deployed Worker must match the running package exactly, and rollback would restore the defective input validation.
29
33
 
30
34
  Never roll back by copying only selected state files or changing version fields. Restore one complete verified state backup, package version, Worker build, and browser extension as a single operational unit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.2.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",
@@ -47,7 +47,7 @@
47
47
  "worker:types": "node scripts/generate-worker-types.mjs",
48
48
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit && npm run typecheck:local",
49
49
  "syntax": "node scripts/syntax-check.mjs",
50
- "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 worker-deployment:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run worker-oauth-controller:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-boundaries:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-environment:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation: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 install: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 && npm run oauth-browser:test",
50
+ "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 worker-deployment:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run worker-oauth-controller:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-boundaries:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run cli-service:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-environment:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation: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 install: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 && npm run oauth-browser:test",
51
51
  "worker:dry-run": "wrangler deploy --dry-run",
52
52
  "worker:integration-test": "node tests/worker-integration-test.mjs",
53
53
  "release:check": "node scripts/github-release.mjs --check",
@@ -116,7 +116,8 @@
116
116
  "worker-deployment:test": "node tests/worker-deployment-test.mjs",
117
117
  "typecheck:local": "tsc -p tsconfig.local.json --noEmit",
118
118
  "runtime-boundaries:test": "node tests/runtime-boundaries-test.mjs",
119
- "worker-oauth-controller:test": "node tests/worker-oauth-controller-test.mjs"
119
+ "worker-oauth-controller:test": "node tests/worker-oauth-controller-test.mjs",
120
+ "cli-service:test": "node tests/cli-service-test.mjs"
120
121
  },
121
122
  "dependencies": {
122
123
  "https-proxy-agent": "9.1.0",
@@ -15,6 +15,7 @@ const tests = [
15
15
  "tests/logging-structure-test.mjs",
16
16
  "tests/runtime-handler-matrix-test.mjs",
17
17
  "tests/cli-entrypoint-test.mjs",
18
+ "tests/cli-service-test.mjs",
18
19
  "tests/local-self-test.mjs",
19
20
  "tests/runtime-self-test.mjs",
20
21
  "tests/numbers-test.mjs",
@@ -53,7 +54,8 @@ try {
53
54
  "src/local/process-tracker.mjs": [65, 35],
54
55
  "src/local/log.mjs": [60, 40],
55
56
  "src/local/runtime.mjs": [75, 55],
56
- "src/local/cli.mjs": [45, 20],
57
+ "src/local/cli.mjs": [48, 21.9],
58
+ "src/local/cli-service.mjs": [90, 65],
57
59
  "src/local/cli-options.mjs": [65, 35],
58
60
  "src/local/cli-policy.mjs": [70, 35],
59
61
  "src/local/numbers.mjs": [100, 100],
@@ -3,20 +3,22 @@ import { BridgeError } from "./errors.mjs";
3
3
  import { policyProfile, toolNamesForPolicy, assertToolAllowed } from "./policy.mjs";
4
4
 
5
5
  export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
6
- export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(
7
- Object.entries(accessContract.roles).map(([name, value]) => [name, Object.freeze({ ...value })]),
8
- ));
6
+ const ACCOUNT_ROLE_ENTRIES = Object.entries(accessContract.roles)
7
+ .map(([name, value]) => [name, Object.freeze({ ...value })]);
8
+ const ACCOUNT_ROLE_BY_NAME = new Map(ACCOUNT_ROLE_ENTRIES);
9
+ export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(ACCOUNT_ROLE_ENTRIES));
10
+ export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole);
9
11
  export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole);
10
12
 
11
13
  export function normalizeAccountRole(value) {
12
14
  const role = String(value || "").trim().toLowerCase();
13
- if (!ACCOUNT_ROLES[role]) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
15
+ if (!ACCOUNT_ROLE_BY_NAME.has(role)) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
14
16
  return role;
15
17
  }
16
18
 
17
19
  export function accountRolePolicy(role) {
18
20
  const normalized = normalizeAccountRole(role);
19
- return policyProfile(ACCOUNT_ROLES[normalized].profile, "explicit");
21
+ return policyProfile(ACCOUNT_ROLE_BY_NAME.get(normalized).profile, "explicit");
20
22
  }
21
23
 
22
24
  export function accountRoleToolNames(role) {
@@ -100,12 +100,18 @@ export class CallRegistry {
100
100
  return true;
101
101
  }
102
102
 
103
+ /** @param {unknown} origin */
104
+ idsByOrigin(origin) {
105
+ const expected = String(origin || "");
106
+ return [...this.calls.values()]
107
+ .filter((record) => record.origin === expected)
108
+ .map((record) => record.id);
109
+ }
110
+
103
111
  /** @param {unknown} origin @param {unknown} [reason] */
104
112
  cancelOrigin(origin, reason = "transport disconnected") {
105
- const expected = String(origin || "");
106
113
  let cancelled = 0;
107
- for (const [id, record] of this.calls) {
108
- if (record.origin !== expected) continue;
114
+ for (const id of this.idsByOrigin(origin)) {
109
115
  if (this.cancel(id, reason)) cancelled += 1;
110
116
  }
111
117
  return cancelled;
@@ -25,17 +25,17 @@ export function createLocalAdminCommands(dependencies) {
25
25
  });
26
26
  }
27
27
 
28
- const RESOURCE_ACTION_HANDLERS = Object.freeze({
29
- list: resourceListAction,
30
- add: resourceAddAction,
31
- "generate-ssh-key": resourceGenerateSshKeyAction,
32
- remove: resourceRemoveAction,
33
- check: resourceCheckAction,
34
- });
28
+ const RESOURCE_ACTION_HANDLERS = new Map([
29
+ ["list", resourceListAction],
30
+ ["add", resourceAddAction],
31
+ ["generate-ssh-key", resourceGenerateSshKeyAction],
32
+ ["remove", resourceRemoveAction],
33
+ ["check", resourceCheckAction],
34
+ ]);
35
35
 
36
36
  async function resourceCommand(args, { chooseWorkspace }) {
37
37
  const action = String(args._[0] || "list").toLowerCase();
38
- const handler = RESOURCE_ACTION_HANDLERS[action];
38
+ const handler = RESOURCE_ACTION_HANDLERS.get(action);
39
39
  if (!handler) throw new Error(`Unknown resource action: ${action}`);
40
40
  const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
41
41
  const state = loadState(workspace, { stateDir: args.stateDir });
@@ -155,7 +155,7 @@ async function resourceRemoveAction({ args, workspace, state }) {
155
155
 
156
156
  function resourceCheckAction({ args, state }) {
157
157
  const name = validateResourceName(args._[1]);
158
- const resource = state.resources[name];
158
+ const resource = Object.hasOwn(state.resources, name) ? state.resources[name] : null;
159
159
  if (!resource) throw new Error(`local resource is not registered: ${name}`);
160
160
  const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
161
161
  const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
@@ -181,15 +181,47 @@ function publicResourceInspection(name, inspected, { includePath = false, ...ext
181
181
  };
182
182
  }
183
183
 
184
- async function browserCommand(args, { chooseWorkspace }) {
184
+ const BROWSER_ACTION_HANDLERS = new Map([
185
+ ["path", browserPathAction],
186
+ ["status", browserStatusAction],
187
+ ["setup", browserPairAction],
188
+ ["pair", browserPairAction],
189
+ ]);
190
+
191
+ async function browserCommand(args, dependencies) {
185
192
  const action = String(args._[0] || "status").toLowerCase();
193
+ const handler = BROWSER_ACTION_HANDLERS.get(action);
194
+ if (!handler) throw new Error(`Unknown browser action: ${action}`);
195
+ return handler(args, dependencies);
196
+ }
197
+
198
+ function browserPathAction(args) {
186
199
  const extensionPath = resolve(packageRoot, "browser-extension");
187
- if (action === "path") {
188
- if (args.json) console.log(JSON.stringify({ extension_path: extensionPath }, null, 2));
189
- else console.log(extensionPath);
200
+ if (args.json) console.log(JSON.stringify({ extension_path: extensionPath }, null, 2));
201
+ else console.log(extensionPath);
202
+ }
203
+
204
+ async function browserStatusAction(args, { chooseWorkspace }) {
205
+ const context = await browserCommandContext(args, chooseWorkspace);
206
+ renderBrowserStatus(context.result, args.json === true);
207
+ }
208
+
209
+ async function browserPairAction(args, { chooseWorkspace }) {
210
+ const context = await browserCommandContext(args, chooseWorkspace);
211
+ if (!context.result.running) throw new Error("browser bridge is not reachable; keep machine-mcp running and retry");
212
+ await openExternal(context.pairingUrl);
213
+ if (args.json) {
214
+ console.log(JSON.stringify({ ...context.result, pairing_page_opened: true }, null, 2));
190
215
  return;
191
216
  }
192
- if (!["status", "setup", "pair"].includes(action)) throw new Error(`Unknown browser action: ${action}`);
217
+ console.log(`Extension path: ${context.extensionPath}`);
218
+ console.log("Load this directory in the Chromium profile you use every day; Machine Bridge does not install it into Playwright or a separate automation profile.");
219
+ console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
220
+ console.log(`Pairing page opened: ${context.pairingUrl}`);
221
+ }
222
+
223
+ async function browserCommandContext(args, chooseWorkspace) {
224
+ const extensionPath = resolve(packageRoot, "browser-extension");
193
225
  const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
194
226
  const state = loadState(workspace, { stateDir: args.stateDir });
195
227
  const pairingFile = join(state.paths.stateRoot, "browser-bridge.json");
@@ -197,6 +229,13 @@ async function browserCommand(args, { chooseWorkspace }) {
197
229
  throw new Error("browser bridge is not initialized; start machine-mcp once, then run this command again");
198
230
  }
199
231
  ownerOnlyFile(pairingFile);
232
+ const pairing = readBrowserPairingState(pairingFile);
233
+ const pairingUrl = `http://127.0.0.1:${pairing.port}/pair`;
234
+ const health = await readBrowserHealth(`http://127.0.0.1:${pairing.port}/healthz`);
235
+ return { extensionPath, pairingUrl, result: browserStatusResult(health, extensionPath, pairingUrl) };
236
+ }
237
+
238
+ function readBrowserPairingState(pairingFile) {
200
239
  let pairing;
201
240
  try {
202
241
  pairing = JSON.parse(readBoundedRegularFileSync(pairingFile, 64 * 1024).toString("utf8"));
@@ -206,12 +245,17 @@ async function browserCommand(args, { chooseWorkspace }) {
206
245
  const port = Number(pairing.port);
207
246
  if (!/^[A-Za-z0-9_-]{32,100}$/.test(String(pairing.token || ""))) throw new Error("browser bridge state contains an invalid token");
208
247
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("browser bridge state contains an invalid port");
209
- const pairingUrl = `http://127.0.0.1:${port}/pair`;
210
- const healthUrl = `http://127.0.0.1:${port}/healthz`;
211
- const health = await fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
248
+ return { port };
249
+ }
250
+
251
+ function readBrowserHealth(healthUrl) {
252
+ return fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
212
253
  .then(async (response) => response.ok ? await response.json() : null)
213
254
  .catch(() => null);
214
- const result = {
255
+ }
256
+
257
+ function browserStatusResult(health, extensionPath, pairingUrl) {
258
+ return {
215
259
  running: health?.ok === true && health?.broker === "machine-bridge-browser",
216
260
  connected: health?.broker === "machine-bridge-browser" && health?.connected === true,
217
261
  extension_path: extensionPath,
@@ -227,28 +271,20 @@ async function browserCommand(args, { chooseWorkspace }) {
227
271
  profile_identity_verifiable: health?.profile_identity_verifiable === true,
228
272
  token_exposed: false,
229
273
  };
230
- if (action === "status") {
231
- if (args.json) console.log(JSON.stringify(result, null, 2));
232
- else {
233
- console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
234
- console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
235
- if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
236
- if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
237
- console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
238
- if (result.controls_extension_profile) console.log(`Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.`);
239
- console.log(`Extension path: ${extensionPath}`);
240
- }
274
+ }
275
+
276
+ function renderBrowserStatus(result, json) {
277
+ if (json) {
278
+ console.log(JSON.stringify(result, null, 2));
241
279
  return;
242
280
  }
243
- if (!result.running) throw new Error("browser bridge is not reachable; keep machine-mcp running and retry");
244
- await openExternal(pairingUrl);
245
- if (args.json) console.log(JSON.stringify({ ...result, pairing_page_opened: true }, null, 2));
246
- else {
247
- console.log(`Extension path: ${extensionPath}`);
248
- console.log("Load this directory in the Chromium profile you use every day; Machine Bridge does not install it into Playwright or a separate automation profile.");
249
- console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
250
- console.log(`Pairing page opened: ${pairingUrl}`);
251
- }
281
+ console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
282
+ console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
283
+ if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
284
+ if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
285
+ console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
286
+ if (result.controls_extension_profile) console.log("Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.");
287
+ console.log(`Extension path: ${result.extension_path}`);
252
288
  }
253
289
 
254
290
  function openExternal(target) {