machine-bridge-mcp 3.0.0-beta.197 → 3.0.0-beta.198

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 (111) hide show
  1. package/CHANGELOG.md +29 -9
  2. package/README.md +16 -2
  3. package/SECURITY.md +1 -1
  4. package/bin/machine-mcp.mjs +2 -2
  5. package/browser-extension/browser-operations.js +57 -37
  6. package/browser-extension/manifest.json +1 -1
  7. package/browser-extension/page-automation.js +49 -27
  8. package/browser-extension/service-worker.js +3 -9
  9. package/docs/AGENT_CONTEXT.md +3 -16
  10. package/docs/AUDIT.md +28 -5
  11. package/docs/CLIENTS.md +2 -11
  12. package/docs/COMPUTER_USE.md +3 -3
  13. package/docs/GETTING_STARTED.md +1 -10
  14. package/docs/LOCAL_AUTHORIZATION.md +1 -1
  15. package/docs/LOGGING.md +10 -7
  16. package/docs/MANAGED_JOBS.md +3 -3
  17. package/docs/OPERATIONS.md +32 -14
  18. package/docs/THREAT_MODEL.md +2 -2
  19. package/docs/UPGRADING.md +17 -3
  20. package/mbm.cmd +3 -3
  21. package/native/macos/MachineBridgeTrustBroker.swift +52 -8
  22. package/package.json +5 -4
  23. package/scripts/consumer-package-security.mjs +73 -48
  24. package/scripts/github-release.mjs +4 -4
  25. package/scripts/hardened-npm-session.mjs +2 -0
  26. package/scripts/install-published-prerelease.mjs +1 -1
  27. package/scripts/publish-npm.mjs +12 -12
  28. package/scripts/release-channel.mjs +1 -1
  29. package/scripts/start-release-candidate.mjs +1 -1
  30. package/src/local/account-admin.mjs +2 -1
  31. package/src/local/agent-context-projection.mjs +4 -1
  32. package/src/local/agent-context.mjs +11 -10
  33. package/src/local/agent-skill-discovery.mjs +49 -14
  34. package/src/local/agent-text-file.mjs +38 -13
  35. package/src/local/app-automation.mjs +71 -120
  36. package/src/local/browser-broker-server.mjs +8 -2
  37. package/src/local/browser-command.mjs +3 -1
  38. package/src/local/browser-computer-observation-service.mjs +10 -17
  39. package/src/local/browser-operation-service.mjs +8 -13
  40. package/src/local/cf-authentication.mjs +8 -7
  41. package/src/local/cli-local-admin.mjs +4 -9
  42. package/src/local/cli.mjs +16 -22
  43. package/src/local/computer-use-observation-contract.mjs +53 -13
  44. package/src/local/computer-use-observation.mjs +19 -9
  45. package/src/local/computer-use.mjs +6 -3
  46. package/src/local/daemon-http-relay-connection.mjs +7 -4
  47. package/src/local/daemon-http-relay-sequence.mjs +6 -4
  48. package/src/local/delegated-process-sandbox.mjs +28 -6
  49. package/src/local/device-identity.mjs +1 -1
  50. package/src/local/durable-process-spec.mjs +2 -6
  51. package/src/local/exclusive-publication-recovery.mjs +44 -1
  52. package/src/local/full-access-test.mjs +11 -5
  53. package/src/local/git-metadata-tree-safety.mjs +20 -20
  54. package/src/local/hardened-npm-launcher.mjs +48 -0
  55. package/src/local/job-runner.mjs +7 -2
  56. package/src/local/loopback-health.mjs +5 -0
  57. package/src/local/macos-trust-broker.mjs +15 -15
  58. package/src/local/managed-job-output-redaction.mjs +21 -10
  59. package/src/local/managed-job-runner.mjs +11 -11
  60. package/src/local/npm-audit-report.mjs +21 -0
  61. package/src/local/npm-environment.mjs +19 -25
  62. package/src/local/observability.mjs +4 -7
  63. package/src/local/process-contract.mjs +9 -0
  64. package/src/local/process-execution.mjs +14 -12
  65. package/src/local/process-sessions.mjs +7 -4
  66. package/src/local/relay-connection.mjs +1 -2
  67. package/src/local/resource-operations.mjs +2 -2
  68. package/src/local/runtime-activation.mjs +13 -8
  69. package/src/local/runtime-relay-connection-options.mjs +13 -6
  70. package/src/local/runtime.mjs +6 -1
  71. package/src/local/secure-file.mjs +4 -4
  72. package/src/local/service-convergence.mjs +1 -1
  73. package/src/local/service-ownership.mjs +1 -1
  74. package/src/local/shell.mjs +2 -1
  75. package/src/local/ssh-key.mjs +15 -19
  76. package/src/local/state.mjs +6 -5
  77. package/src/local/stdio.mjs +2 -2
  78. package/src/local/windows-service.mjs +13 -7
  79. package/src/local/worker-deployment-fingerprint.mjs +32 -12
  80. package/src/local/worker-deployment.mjs +40 -18
  81. package/src/local/workspace-file-service.mjs +4 -4
  82. package/src/local/workspace-file-transaction.mjs +5 -5
  83. package/src/local/workspace-profile-migration.mjs +2 -0
  84. package/src/local/wrangler-toolchain.mjs +39 -43
  85. package/src/shared/activation-recovery.mjs +2 -2
  86. package/src/shared/authority-revocation.mjs +8 -7
  87. package/src/shared/log-redaction.mjs +15 -2
  88. package/src/shared/sensitive-value-patterns.mjs +2 -2
  89. package/src/shared/tool-argument-validation.d.mts +1 -1
  90. package/src/worker/authority-revocations.ts +50 -16
  91. package/src/worker/daemon-http-channel.ts +18 -9
  92. package/src/worker/daemon-http-controller.ts +24 -7
  93. package/src/worker/daemon-ready-dispatch.ts +2 -0
  94. package/src/worker/daemon-ready-waiters.ts +1 -1
  95. package/src/worker/daemon-registry.ts +1 -1
  96. package/src/worker/daemon-relay-diagnostics.ts +1 -0
  97. package/src/worker/device-session-verifier.ts +1 -1
  98. package/src/worker/dpop.ts +11 -6
  99. package/src/worker/http.ts +5 -1
  100. package/src/worker/index.ts +42 -41
  101. package/src/worker/mcp-job-monitor-store.ts +2 -2
  102. package/src/worker/mcp-job-monitor-ui.ts +5 -5
  103. package/src/worker/mcp-response-stream.ts +13 -14
  104. package/src/worker/oauth-controller.ts +30 -37
  105. package/src/worker/oauth-refresh-exchange.ts +6 -2
  106. package/src/worker/oauth-refresh-families.ts +18 -5
  107. package/src/worker/oauth-state.ts +7 -1
  108. package/src/worker/oauth-token-issuance.ts +6 -9
  109. package/src/worker/oauth-tokens.ts +37 -1
  110. package/src/worker/observability.ts +5 -10
  111. package/src/worker/worker-entry.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,15 +1,35 @@
1
1
  # Changelog
2
2
 
3
- ## 3.0.0-beta.197 - 2026-09-30
3
+ ## 3.0.0-beta.198 - 2026-10-03
4
4
 
5
- - Pin source tooling and the integrity-verified hardened npm bootstrap to brace-expansion 5.0.12, fixing the newly disclosed parser denial-of-service advisories. Preserve clean global consumer installs without postinstall hooks; declare the audited private Wrangler build provider and supply its executable runner before native cf builds.
6
-
7
- - Migrate Worker builds and deployments to native cf configuration and prebuilt artifacts; preserve the Worker name, SQLite Durable Object export, rate-limit namespaces, existing variables, and secrets.
8
- - Require structured cf authentication before unattended activation and bind deployment fingerprints to the exact private source snapshot and pinned toolchain.
9
-
10
- - Add an isolated, exact cf deployment toolchain with a compatible Wrangler builder and patched Undici.
11
- - Verify the deterministic compatibility artifact for cf beta.5 before its network factory runs; reject unknown or tampered bundle bytes.
12
- - Synchronize the package, Worker, and browser extension prerelease version declarations.
5
+ - Redact macOS temporary-path aliases and truncated Windows case aliases from managed-job output.
6
+ - Decode sensitive URL parameter names before log redaction; redact complete and truncated private-key bodies.
7
+ - Reject coerced Cloudflare account identifiers and malformed token-validity evidence.
8
+ - Stream deployment-source enumeration with entry, depth, file-count and byte budgets.
9
+ - Persist confirmed Worker upload evidence before endpoint parsing or staging cleanup can fail; reconcile unknown endpoints instead of automatically uploading again.
10
+ - Consolidate duplicate engineering release guidance under the canonical releasing procedure.
11
+ - Drain durable authority revocations in bounded HTTPS batches without losing acknowledgements or invalidating healthy channels; include sequence/envelope overhead in response budgets and atomically persist replay-capacity family eviction with its revocation.
12
+ - Preserve replacement WebSocket connection deadlines when stale events arrive, and settle asynchronous gateway/signature failures through typed responses.
13
+ - Reject malformed revocation/OAuth/DPoP inputs, non-boolean account activation values and inherited enum keys; revalidate current authority and respect pre-existing cancellation before daemon dispatch.
14
+ - Preserve monitor transitions across sequence rollover and bound observability overflow without recursive failure.
15
+ - Bind instruction/skill reads, file rollback, lock recovery, directory traversal, SSH publication and state cleanup to verified identities; validate config paths before reading and preserve unrelated replacements and failure evidence.
16
+ - Keep process, session and runtime ownership through asynchronous settlement and retain candidate daemon ownership after failed shutdown; close late-spawn shutdown races, terminate SSE encoding/projection failures, bound invocation inputs, and give isolated jobs private runtime directories.
17
+ - Fence browser renderer mutations by cancellation/deadline authority, including asynchronous actionability waits and socket replacement.
18
+ - Verify Secure Enclave provenance before native export, signing or deletion; reject oversized signing input before EOF or Keychain access.
19
+ - Sanitize default CLI errors and reject incomplete, failed, inconsistent or scalar-coerced SARIF evidence; prevent conformance credentials from reaching child environments and release proxy readers after disconnect.
20
+ - Implement the declared empty resource-template list, remove stale resource conformance exclusions, and keep unknown-resource errors bounded without reflecting their URI.
21
+ - Apply path-display policy before skill warning sanitization/truncation and preserve dollar signs as literal text in path projections and file edits.
22
+ - Harden generated Worker type recovery with bounded no-follow reads; share strict consumer/deployment audit validation and fixed Worker persistence error classes without copying arbitrary exception names.
23
+ - Wait for the full-access fixture's verified runner exit before deleting job state, restore its environment on shutdown failure, and use the production Swift driver for native provenance tests.
24
+ - Remove arbitrary peer-supplied WebSocket close reasons from debug logs while retaining fixed protocol classifications and outage recovery summaries.
25
+ - Use locked dependency installation in the Windows checkout launcher and fail on setup errors without stale exit-code expansion; share private npm launchers across CI and temporary toolchains so nested lifecycle calls retain the pinned CLI.
26
+ - Correct the public validator's readonly-array declaration and inspect dependencies through one syntax-aware source graph, including escaped paths and declaration files.
27
+ - Share bounded child readiness, capture and exit cleanup across managed-job/process-lock fixtures, with one deadline for preparation and settlement. Prove recovery-lock handoff before corruption assertions and descendant readiness before publication timeout assertions; await runtime teardown and consolidate screenshot rejection setup while retaining distinct cleanup assertions and browser limits.
28
+ - Require both markers in source-order guards and share exact script checks/source reads. Reject event-derived shell expressions with a bounded scan and alternate YAML mappings before dependency-free workflow policy reads.
29
+ - Recheck Git metadata directory identity after opening and traversal, and preserve cancellation during empty-directory opening.
30
+ - Share bounded browser screenshot validation and one observation decode; assert fixture outcomes and clear failed-handshake deadlines.
31
+ - Reject coerced or malformed consumer dependency/SBOM evidence and duplicate graph edges without string normalization.
32
+ - Replace the repeated test-case inventory with a current regression map, and remove repeated overview/release/install instructions while retaining canonical recovery, authority and privacy contracts.
13
33
 
14
34
  ## Historical releases
15
35
 
package/README.md CHANGED
@@ -176,7 +176,13 @@ The shared source of truth is `src/shared/policy-contract.json`. The generated m
176
176
 
177
177
  For routine remote health checks, prefer `server_info` with `detail: "summary"`; the empty/default call remains full diagnostics and, for owner/full callers, includes a privacy-bounded durable continuity summary that survives Worker isolate replacement without retaining identities, call IDs, tool arguments, results, endpoints, or close reasons. For routine workspace inventory, `project_overview` also accepts `detail: "summary"`; it preserves policy/tool counts and top-level names/types without repeating exact tool arrays, account identity, routing fingerprints, or per-entry paths/sizes. Its empty/default call likewise remains full for compatibility. For remote calls, `server_info.authorization.effective_policy` and, when exact membership is needed, the full projection's `effective_tools` are authoritative. Daemon policy and tools describe only the local capability ceiling before account-role and host-side filtering.
178
178
 
179
- `tools/list` is the authenticated account's current discovery catalog. Discovery instructions and tool descriptions carry execution/orchestration semantics, so both `server/discover` and `tools/list` advertise `ttlMs=0`. Current MCP 2026-07-28 remote discovery also advertises `tools.listChanged=true`: a client that opts into `toolsListChanged` through `subscriptions/listen` receives a correlated acknowledgement and level-trigger `notifications/tools/list_changed` event, then re-fetches `tools/list`. The request-scoped subscription remains open until explicit cancellation or the advertised bounded server lease expires; the lease is a fail-safe for HTTP disconnects that the Worker runtime cannot reliably observe and does not replace the initial level-trigger/refetch contract. Every host-visible tool description carries `Tool schema generation N`; `server_info.tool_delivery` exposes the current `tool_schema_generation`, `tool_schema_server_version`, and `tool_list_ttl_ms`, while explicitly reporting that Machine Bridge cannot observe which schema generation an external host has actually cached. A generation change therefore requires the subscription/refetch path or another host-side schema refresh plus post-activation verification. Discovery is not authority: every `tools/call` is still intersected with the current end-to-end-ready daemon policy and tool ceiling, and fails retryably with `unavailable` when no daemon is ready. `server_info.tool_delivery` also distinguishes the advertised catalog from the currently effective daemon/account intersection. WebSocket is the preferred daemon transport: verified ready traffic resumes immediately, but reconnect attempt history resets only after five seconds of generation-stable ready uptime so a shorter ready/close flap keeps its prior exponential backoff position. It requests a protocol-level Ping after five seconds and gives an actually dispatched Ping its full ten-second Pong deadline, then uses one independent fifteen-second application-confirmation window before a ready WSS may be terminated as a transport black hole. A protocol Pong or explicit application `pong` during that second stage preserves WSS; ordinary tool/control inbound remains receive-side evidence and cannot clear transport suspicion, while local event-loop stalls cancel remote suspicion and use the separate recovery-grace path. The periodic application heartbeat remains twenty-five/seventy-five seconds after end-to-end readiness, and the Worker keeps a wider ninety-second fallback. WSS connect attempts have a thirty-second outer budget. Signed HTTPS is independent of that budget: a verified healthy WSS keeps HTTPS fallback stopped; on first-stage WSS suspicion the same root-certified ephemeral device identity may prewarm HTTPS in standby, verified WSS recovery stops that prewarm, and a real WSS loss promotes the path to exact-generation takeover while aborting any obsolete standby request. Fallback requests bind the fixed route/origin/server/version, a short-lived nonce, timestamp, and exact body hash; they use a seven-second ordinary request deadline, a twelve-second liveness window, a five-second verified-ready idle poll cadence, a thirty-second standby retry cadence, and a 750 ms minimum request-start interval for handshake or queued outbound work. The first authenticated exchange enters probing immediately, so verified readiness requires at most two bounded exchanges rather than a separate challenge round trip. Candidate → probing → verified-ready handover prevents the Worker from dispatching until the daemon has processed `ready_ack` and returned sequenced `https_ready`; a same-instance takeover may retire a Worker-side zombie WSS only after the signed candidate preconditions pass. Both directions use bounded contiguous transport sequences, so a lost HTTP response retransmits the same transport envelope and duplicates are discarded before business handling; this does not restore MCP sessions, recovery GET, `Last-Event-ID`, or public result persistence. Same-instance `resume_calls` / `resume_calls_ack` remains authoritative for in-flight ownership. A planned daemon shutdown is a different boundary: before a ready runtime closes its relay it sends `daemon_draining`; the Worker settles calls still owned by that daemon with structured `daemon_planned_drain` recovery and acknowledges the drain. In-flight `read_job` is explicitly read-only recoverable with `recovery.mode=read_same_job` plus the original `job_id`; resume by reading that same job after reconnect. This does not transfer an executing call to a new daemon process, whose random instance identity remains intentionally distinct, and it cannot revive an external assistant turn that has already ended. The daemon sends `resume_calls_ack.missing_ids` only after replacement readiness, only for IDs absent from both its active-call set and unacknowledged-result ledger, and only while it still has fail-closed proof that missing ownership means the call did not execute locally. If a completed-but-unacknowledged result expires, `diagnose_runtime.runtime.relay_result_recovery.automatic_redelivery_safe` becomes false and missing-ID automatic redelivery is disabled rather than risking duplicate side effects. A safe proven-undelivered call may be retransmitted with the same call ID, arguments, authority, and a reduced timeout inside the original execution deadline; a call that may have executed is never automatically replayed. Separately, if that same daemon already executed a call and its terminal result is delayed by the transport break, the Worker may retain the original in-memory settlement owner for one fixed fifteen-second window beyond its original settlement deadline, including when that original settlement deadline was already at the ordinary per-tool ceiling. That delivery-only extension cannot authorize redelivery or new execution, cannot accumulate across repeated handovers, and does not create client-visible replay or durable result state. A new call may wait up to fifteen seconds for a verified daemon channel, but measured recovery time is deducted from that call's original execution budget instead of extending the hosted foreground envelope. Hosted synchronous calls otherwise retain their ordinary 20-second execution plus separate five-second Worker settlement margin; configurable browser/application tools retain 20-second ordinary defaults, compound `computer_observe` / `computer_act` retain 30-second defaults, and the explicit remote maximum remains 45 seconds. Remote `exec_command`, `run_process`, and `run_local_command` require a caller-held `idempotency_key`, commit a principal-bound one-step managed job, and remain recoverable through bounded same-response `read_job` follow-up when the current task needs terminal state. Hosted active `read_job` uses a server-side 40-second long-poll by default and returns earlier on meaningful job progress or terminal state; `wait_ms=0` requests an immediate checkpoint, while every public hosted call is capped at 60 seconds. The default remains 40 seconds: live hosted evidence carried both the default and an explicit 60-second read, while beta.151 later reproduced `mcp_network_error` on a second explicit 180-second read even though the Worker-to-daemon WebSocket remained continuously ready. Longer tasks therefore keep the same `job_id` and use another server-paced read rather than one overlong host request. This keeps long-task waiting inside Machine Bridge within the demonstrated per-call host lifetime; the 40-second interval also bounds interaction density to at most 150 reads for a synthetic unchanged 100-minute job, but that arithmetic does not prove that one assistant response can survive the aggregate duration or call count. If a real host/tool boundary ends a response, preserve the durable identifier and resume the same operation later rather than resubmitting its side effect. `start_process` remains daemon-lifetime interactive state; hosted `read_process` permits paced same-response follow-up, defaults an omitted relay `wait_ms` to the one-second blocking cap, and paces another would-block read inside the fifteen-second cooldown within that same MCP call until output/exit or the cooldown boundary. Active job/process reads do not force a user-turn handoff. Callers must not infer or preempt a host/tool deadline from elapsed wall-clock time: while calls continue to be accepted and the task still needs the result, bounded same-response follow-up may continue. Handoff is reserved for an actual observed host/tool boundary, required external input or authorization, or an explicit user checkpoint, while busy loops and status-surface substitution remain prohibited. The durable process façade preserves account/tool authority and delegated workspace sandbox rather than expanding privileges.
179
+ `tools/list` is the authenticated account's discovery catalog. Both `server/discover` and `tools/list` advertise `ttlMs=0`, and every host-visible tool description carries `Tool schema generation N`. Remote discovery supports `tools.listChanged=true`: `subscriptions/listen` acknowledges `toolsListChanged`, emits a level-trigger `notifications/tools/list_changed`, and stays open until cancellation or its bounded lease expires. Clients re-fetch the catalog; the server cannot prove which generation a host cached. Discovery does not grant authority: calls still require the current account/ready-daemon intersection.
180
+
181
+ WebSocket is preferred. An actually dispatched Ping has a ten-second Pong deadline, followed by one independent fifteen-second application-confirmation window. A protocol Pong or explicit application `pong` during that second stage preserves WSS. Local event-loop stalls use a separate recovery path. WSS connect attempts have a thirty-second outer budget. Healthy WSS keeps signed HTTPS fallback stopped; suspicion permits standby prewarming, and actual loss permits takeover of that exact generation. The daemon sends `resume_calls_ack.missing_ids` only after replacement readiness and only with fail-closed proof that absent ownership means non-execution. A possibly executed call is never automatically replayed. See [architecture](https://github.com/YuLeiFuYun/machine-bridge-mcp/blob/main/docs/ARCHITECTURE.md#daemon-reconnect-and-replacement) for transport sequencing, deadlines, and diagnostics.
182
+
183
+ Planned shutdown sends `daemon_draining` before relay close. An interrupted `read_job` returns `recovery.mode=read_same_job`; preserve its `job_id` and `recovery_key` and read that same job after reconnect. Replacement does not transfer ordinary executing calls to a new daemon.
184
+
185
+ Hosted synchronous calls otherwise retain their ordinary 20-second execution plus separate five-second Worker settlement margin. Configurable browser/application tools use 20-second defaults; compound `computer_observe` / `computer_act` retain 30-second defaults, with a 45-second explicit maximum. Durable process acceptance and continuation have separate budgets below. These limits do not establish external host receipt or a whole-assistant-turn deadline.
180
186
 
181
187
  `full` is the daemon capability ceiling. An authenticated owner may exercise it without per-operation approval IDs. Delegated reviewer, editor, and operator accounts remain inside immutable role ceilings; out-of-role operations are denied rather than converted into a temporary elevation workflow. Process sessions, retained output, and managed jobs are additionally bound to account, client, and refresh-token family. See [local authorization](docs/LOCAL_AUTHORIZATION.md).
182
188
 
@@ -197,7 +203,15 @@ For stateful GUI trajectories, owner/full callers can use the higher-level `comp
197
203
 
198
204
  ## Durable work and local resources
199
205
 
200
- Remote request-owned foreground work uses the hosted reply-safe budgets described above; configurable browser/application calls may explicitly request at most 45 seconds, while remote `exec_command`, `run_process`, and `run_local_command` are durable one-step jobs with a 10-second acceptance envelope and an independent 1–600-second child execution budget after admission. A continuous process that legitimately needs more than 600 seconds must use `start_job`: managed-job main/finally steps default to 600 seconds and may explicitly request up to 21,600 seconds (six hours), with resource admission occurring before that execution timer begins. The Worker retains separate settlement ownership for five additional seconds, but neither that margin nor its internal stream metrics prove that an external MCP host consumed the terminal frame. Keep unrelated mutations and validation independently terminal, but batch one coherent non-interactive command sequence into a repository umbrella command or multi-step `start_job` instead of creating one host-visible one-step job per tiny probe. A timeout is a protocol result, not proof that descendant cleanup has already completed; a remote owner can inspect `diagnose_runtime.runtime.processes`, while local stdio exposes `server_info.runtime.processes`. Non-owner accounts receive authority-scoped readiness rather than machine-wide process activity. Remote process sessions are for interactive stdin or incremental output, not a substitute for ordinary durable work: hosted `read_process` reports `status_polling_mode=paced_followup`, caps the actual output/exit blocking wait at one second, and paces a repeated would-block read inside the fifteen-second cooldown within that same MCP call until output/exit or the cooldown boundary instead of returning a rapid running checkpoint. When the current task needs more output or terminal state, the same session may be read again in the same assistant response without busy-looping. Non-interactive work should use durable `run_process`/`read_job`; multi-step, cleanup-sensitive, or daemon-restart-surviving workflows should use managed jobs, which persist ordered argv steps and `finally_steps` under owner-only local state and continue across an MCP disconnect. Hosted durable acceptance returns `job_id`, `recovery_key`, and `control_key`: preserve them together, use the read capability for `read_job` and `depends_on`, and use the control capability for `cancel_job`; a bare job ID is not remote recovery authority. On an MCP Apps-capable host, an active `start_job` may report `ui_monitor_candidate=true`, `ui_monitor_render_tool=render_job_monitor`, and a fresh random `ui_monitor_id`, but it remains a data/execution tool and carries no UI template. **Job polling ownership is not task ownership:** if remaining same-response work needs that job's terminal result, do not render the monitor merely to shorten the response; preserve `job_id` + `recovery_key` and continue bounded server-paced `read_job` calls. Render the monitor only when the current task can deliberately transfer that job's status polling without needing its terminal result for remaining same-response work. When intentionally rendering, preserve the start-result ID, call `render_job_monitor` once with the exact accepted `job_id` + `recovery_key` + `ui_monitor_id`, and do not depend on the render result being planner-visible. Then issue one model-visible `read_job` with the same three values. A matching claimed View on an ordinary job reports `ui_monitor_claimed=true`, `status_polling_mode=ui_monitor`, `host_turn_handoff_recommended=false`, and `follow_up_read_required=false`; those fields suppress duplicate model polling for that job only and do not require ending unrelated or still-actionable assistant work. The sole host-turn exception is an explicitly persisted `continuation_mode=task_supervisor`: only when that durable job's own reviewed plan already encapsulates all remaining noninteractive task work may a later active claimed read report `host_turn_handoff_recommended=true`. Machine Bridge can validate the persisted continuation declaration, active job state, and current monitor claim, but it cannot infer conversation-level task completeness; that completeness remains an owner/caller semantic assertion. That handoff is owned by the durable supervisor, not by the Job Monitor, and grants the View no execution authority. The monitor then uses app-only `read_job_monitor` for fixed 40-second status waits instead of emitting background model-visible `read_job` events. Each app-origin status call has a 60-second response watchdog; missing/transport-failed and explicitly retryable status responses receive at most six automatic retries with 1/2/4/8/15/30-second backoff. Exhausted or non-retryable failures pause for inspection, and no retry resubmits the durable job. An older View claim cannot transfer a newer turn because its monitor ID differs. Unsupported hosts, or hosts that can render MCP Apps but cannot proxy app-origin server tools, retain the existing contract: active relay-origin `read_job` reports `status_polling_mode=bounded_followup` and `host_turn_handoff_recommended=false`. Its hosted default is a 40-second server-side long-poll, so an unchanged long job occupies one bounded live MCP response rather than forcing rapid host-side checkpoints. Terminal settlement returns on the next bounded five-second internal poll; nonterminal status/phase/dependency progress is coalesced for at least 30 seconds by default, and `current_step`-only churn does not wake the hosted call. `wait_ms=0` is available only when an immediate checkpoint is actually wanted, and public hosted `wait_ms` is capped at 60 seconds; longer work continues through another paced read of the same `job_id` plus its preserved `recovery_key`. At the default, a synthetic 100-minute unchanged job has an anti-amplification ceiling of 150 status reads, while continuously changing nonterminal progress is separately bounded by the 30-second coalescing floor; those are density estimates rather than proof of aggregate same-response host lifetime. A known job may be followed through paced same-response `read_job` calls while those calls continue to be accepted and the task still needs the result; after an actual host/tool boundary, later recovery must continue from the same `job_id`. Completed one-step process carriers are lower-priority terminal retention than explicit managed jobs, so removable helper history is reclaimed first under the 512-state durable cap. A hosted helper whose current response still requires `read_job` continuation is non-evictable during the fixed 24-hour undelivered-result grace because Machine Bridge explicitly told the caller to follow that durable handle. If the 512-state store is full and every retained slot is non-evictable, the existing typed retryable `limit_exceeded` backpressure rejects the incoming job instead of deleting promised recovery evidence. Once Machine Bridge produces a terminal hosted response for the helper—during initial settlement or a later `read_job`—the private marker drops to the shorter thirty-minute bounded newest-16 transient delivery reserve because only outer response-delivery uncertainty remains; an undelivered marker older than 24 hours likewise stops hard protection and becomes ordinary eviction material. For relay non-owner one-step process carriers, acceptance additionally caps unresolved pending recovery at 16 records per account ID plus account version across all OAuth clients and refresh families; changing client/family does not reset that fairness bound. The check is serialized by the same root capacity transaction before new status publication or process launch and uses retained job state rather than a separate counter. A securely valid persisted same-key job can still be recovered at quota because it adds no reservation, but an empty/statusless deterministic directory is only an incomplete publication and must pass quota again. Hosted terminal delivery or the existing 24-hour terminal expiry releases the account slot. Owner/local work is not subject to this account quota, and the hosted inventory does not expose raw account-quota counts or principal identifiers. The daemon/local `list_jobs.jobs` inventory keeps a 50-record durable-first primary window and `recent_process_recovery` remains capped at 16 additional authority-visible handles for local administration. The hosted Worker projection deliberately removes those job IDs/names/handles and exposes aggregate retained/capacity/activity state only, so one hosted conversation cannot discover another workflow's durable handle through shared owner inventory. Hosted recovery therefore depends on the capability returned by the original acceptance; legacy or lost-capability jobs require local CLI/stdio administration. Owner/local `capacity` diagnostics expose only coarse `durable_terminal` and `transient_terminal` counts; this improves recovery visibility without pretending that Worker acknowledgement proves an external host rendered the final assistant message. Long cross-job workflows can declare `depends_on`: the dependent job remains pre-execution `queued/dependency_wait` without spawning its main child until all upstream jobs succeed, and an upstream failure settles `dependency_failed` instead of leaving a file-poll loop waiting for an artifact that can never appear. Active/staged dependency plans pin referenced retained results until the dependency-bearing plan is terminal. A valid `job_id` that is no longer retained returns typed `not_found`; that absence is not proof that its underlying side effect never executed. `list_jobs` remains inventory rather than a substitute polling loop, and `server_info`/`diagnose_runtime` remain diagnostic surfaces rather than alternate wait channels. Elapsed minutes are not evidence that an external host deadline is near; return the durable recovery identifier for a later turn only after an actual host/tool boundary is observed, external input or authorization is required, or the user explicitly requested a checkpoint.
206
+ Remote `exec_command`, `run_process`, and `run_local_command` require a caller-held `idempotency_key` and become principal-bound one-step jobs before execution. Their acceptance envelope is ten seconds; child execution is independently bounded to 1–600 seconds after resource admission. Use `start_job` for a continuous command needing more time: main/finally steps default to 600 seconds and may request up to 21,600 seconds (six hours). Batch coherent non-interactive work into one plan or repository umbrella command. Process sessions serve interactive stdin/incremental output and retain daemon-lifetime ownership; hosted `read_process` reports `status_polling_mode=paced_followup`, with a one-second actual blocking cap and a fifteen-second would-block cooldown.
207
+
208
+ Preserve `job_id`, `recovery_key`, and `control_key` from acceptance. Reads/dependencies require the recovery capability; cancellation requires the control capability. A bare ID is not hosted authority. When terminal state is needed, continue bounded same-response `read_job` follow-up: active reads report `status_polling_mode=bounded_followup` and `host_turn_handoff_recommended=false`. The default long-poll is 40 seconds, terminal checks are bounded to five-second intervals, and nonterminal progress is coalesced for at least 30 seconds; step-name changes alone do not wake a hosted read. Explicit `wait_ms=0` requests a checkpoint and the public maximum is 60 seconds. Do not busy-loop or substitute diagnostic calls for known-job reads. Elapsed minutes do not justify handoff; after an actual host/tool boundary, recover the same job instead of repeating its side effect.
209
+
210
+ An active `start_job` may offer a Job Monitor on an MCP Apps-capable host. Polling ownership is separate from task ownership: if remaining work needs the result, keep following `read_job`. Otherwise render once with the accepted `job_id`, `recovery_key`, and `ui_monitor_id`, then read with those same values to verify the View claim. A claimed ordinary job reports `status_polling_mode=ui_monitor`, `follow_up_read_required=false`, and `host_turn_handoff_recommended=false`. Only an explicit `continuation_mode=task_supervisor` plan that already owns all remaining noninteractive work may recommend handoff; completeness remains an owner/caller semantic assertion. Monitor failures never resubmit execution.
211
+
212
+ The retained-state cap is 512. A hosted helper awaiting its promised terminal response is non-evictable during the fixed 24-hour undelivered-result grace; a store with no removable slot rejects new work with retryable `limit_exceeded`. After terminal delivery, helpers have a bounded newest-16/thirty-minute reserve and lower eviction priority than explicit durable history. Delegated one-step pending recovery is capped at 16 records per account ID plus account version across all OAuth clients and refresh families, checked before new state or process launch; a valid same-key replay adds no slot, while a statusless directory must pass admission again. Local CLI/stdio retains detailed inventory; hosted `list_jobs` exposes aggregates, so lost capabilities require local administration. A retained-record `not_found` does not prove non-execution.
213
+
214
+ Jobs can declare `depends_on`: the dependent remains `queued/dependency_wait` before main execution, upstream failure yields `dependency_failed`, and active/staged dependencies protect referenced results. Register secrets as local resource aliases and put idempotent cleanup in `finally_steps`. Timeout or cancellation alone does not prove process-tree settlement. See [managed jobs](docs/MANAGED_JOBS.md) for examples, retention, recovery, and privacy boundaries.
201
215
 
202
216
  The repository-tracked `workflow-bundle.json` is the project-native lifecycle authority for local readiness. Its `project-native:npm:check:fast` and `project-native:npm:check:full` stages must remain `sandbox: required`; a machine-local or untracked lifecycle file must not silently lower high-risk containment. Release architecture checks enforce both Git tracking and those required Seatbelt modes, so the job-local continuation contract above is validated under the same repository-owned verification authority used for the candidate.
203
217
 
package/SECURITY.md CHANGED
@@ -113,7 +113,7 @@ Remote daemon authentication uses a long-term device root and a root-certified e
113
113
 
114
114
  The default provider on every platform, including macOS, is an owner-only portable P-256 root. Its private JWK is exportable by the daemon OS user and is reported as such in `server_info`. The root signs a 24-hour ephemeral session certificate at daemon startup; the ephemeral private key remains in memory and signs WebSocket preflight, Worker challenge, reconnect, and account-administration requests. Portable startup does not access Keychain and does not request user presence.
115
115
 
116
- macOS can opt into a non-exportable Secure Enclave root only through `MBM_MACOS_TRUST_BROKER`. The configured executable must be an app-like broker signed by an Apple development or distribution identity with provisioning-profile-validated data-protection Keychain entitlements. Machine Bridge validates its canonical path, file mutability, strict code signature, signing identifier, Team ID, protocol responses, and a real create/delete Secure Enclave key probe before enrollment. The broker identity remains bound to the root and is revalidated before later use.
116
+ macOS can opt into a non-exportable Secure Enclave root only through `MBM_MACOS_TRUST_BROKER`. The configured executable must be an app-like broker signed by an Apple development or distribution identity with provisioning-profile-validated data-protection Keychain entitlements. Machine Bridge validates its canonical path, file mutability, strict code signature, signing identifier, Team ID, protocol responses, and a real create/delete Secure Enclave key probe before enrollment. The broker identity remains bound to the root and is revalidated before later use. The native broker verifies the returned key's Secure Enclave token, private-key class, EC type, and 256-bit size before use or export. Only an explicit key-not-found result permits creation; other lookup failures preserve existing state. Deletion binds the validated key reference, and signing input is bounded before Keychain access.
117
117
 
118
118
  The prompt frequency is therefore:
119
119
 
@@ -4,8 +4,8 @@ import { sanitizeLogText } from "../src/local/log.mjs";
4
4
 
5
5
  main().catch(error => {
6
6
  const message = process.env.MBM_DEBUG === "1"
7
- ? sanitizeLogText(error?.stack || error?.message || String(error))
7
+ ? (error?.stack || error?.message || String(error))
8
8
  : (error?.message || String(error));
9
- console.error(`Error: ${message}`);
9
+ console.error(`Error: ${sanitizeLogText(message)}`);
10
10
  process.exitCode = 1;
11
11
  });
@@ -2,7 +2,9 @@
2
2
  if (globalThis.__machineBridgeBrowserOperations) return;
3
3
 
4
4
  const MAX_ACCESSIBLE_FRAMES = 64;
5
- const PAGE_AUTOMATION_VERSION = 4;
5
+ const PAGE_AUTOMATION_VERSION = 5;
6
+ const rendererRequests = new Map();
7
+ const MAX_RENDERER_REQUESTS = 64;
6
8
  const MUTATING_METHODS = new Set([
7
9
  "manage_tabs", "point_action", "backend_node_action", "action", "fill_form", "upload_files", "screenshot",
8
10
  ]);
@@ -158,16 +160,16 @@
158
160
  }
159
161
  throwIfCancelled(state);
160
162
  if (action === "new") {
161
- const tab = await invokeBrowserTabMutation(
163
+ const tab = await invokeBrowserMutation(
162
164
  () => chrome.tabs.create({ url: newTabUrl || "about:blank", active: newTabActive }),
163
- "create",
165
+ "during create", "browser tab mutation",
164
166
  );
165
167
  return { action: "new", ...publicTab(tab) };
166
168
  }
167
169
  const tab = await chrome.tabs.get(params.tabId);
168
170
  throwIfCancelled(state);
169
171
  if (action === "activate") {
170
- const activated = await invokeBrowserTabMutation(() => chrome.tabs.update(tab.id, { active: true }), "activate_tab");
172
+ const activated = await invokeBrowserMutation(() => chrome.tabs.update(tab.id, { active: true }), "during activate_tab", "browser tab mutation");
171
173
  throwIfCancelled(state);
172
174
  if (!Number.isInteger(activated?.windowId) || activated.windowId < 1) {
173
175
  throw new Error("browser tab activation completed but its current window is unavailable before focus; inspect tabs before retrying");
@@ -183,7 +185,7 @@
183
185
  throw new Error("browser tab activation completed but the target tab was no longer active before focus; inspect tabs before retrying");
184
186
  }
185
187
  const focusWindowId = focusTarget.windowId;
186
- await invokeBrowserTabMutation(() => chrome.windows.update(focusWindowId, { focused: true }), "focus_window");
188
+ await invokeBrowserMutation(() => chrome.windows.update(focusWindowId, { focused: true }), "during focus_window", "browser tab mutation");
187
189
  let settled;
188
190
  try { settled = await chrome.tabs.get(tab.id); }
189
191
  catch { throw new Error("browser tab activation and window focus completed but the target tab could not be verified; inspect tabs before retrying"); }
@@ -196,7 +198,7 @@
196
198
  return { action: "activate", ...publicTab(settled) };
197
199
  }
198
200
  const result = { action: "close", closed: true, ...publicTab(tab) };
199
- await invokeBrowserTabMutation(() => chrome.tabs.remove(tab.id), "close");
201
+ await invokeBrowserMutation(() => chrome.tabs.remove(tab.id), "during close", "browser tab mutation");
200
202
  return result;
201
203
  }
202
204
 
@@ -252,7 +254,7 @@
252
254
  if (urlMatched && page.matched === true) {
253
255
  return { ok: true, tab_id: current.id, title: current.title || "", url: current.url || "", condition: last };
254
256
  }
255
- await delay(200);
257
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
256
258
  }
257
259
  throw new Error(`browser wait timed out; last condition: ${JSON.stringify(last || {})}`);
258
260
  }
@@ -1360,7 +1362,7 @@
1360
1362
  try {
1361
1363
  await assertExpectedNavigationTab(tab.id, params.expectedTabUrl);
1362
1364
  throwIfCancelled(state);
1363
- const updated = await invokeBrowserNavigationMutation(() => chrome.tabs.update(tab.id, { url: params.url }));
1365
+ const updated = await invokeBrowserMutation(() => chrome.tabs.update(tab.id, { url: params.url }), "because the navigation mutation API failed");
1364
1366
  await awaitPostDispatchWait(waiter);
1365
1367
  return postActionTabMetadata(updated.id);
1366
1368
  } catch (error) {
@@ -1378,7 +1380,7 @@
1378
1380
  tab.id, params.expectedTabUrl, params.expectedDocumentEpoch, params.expectedHistoryEntryKey,
1379
1381
  );
1380
1382
  throwIfCancelled(state);
1381
- await invokeBrowserNavigationMutation(() => chrome.tabs.reload(tab.id));
1383
+ await invokeBrowserMutation(() => chrome.tabs.reload(tab.id), "because the navigation mutation API failed");
1382
1384
  }
1383
1385
  await awaitPostDispatchWait(waiter);
1384
1386
  return postActionTabMetadata(tab.id);
@@ -1397,8 +1399,8 @@
1397
1399
  tab.id, params.expectedTabUrl, params.expectedDocumentEpoch, params.expectedHistoryEntryKey,
1398
1400
  );
1399
1401
  throwIfCancelled(state);
1400
- if (params.action === "back") await invokeBrowserNavigationMutation(() => chrome.tabs.goBack(tab.id));
1401
- else await invokeBrowserNavigationMutation(() => chrome.tabs.goForward(tab.id));
1402
+ if (params.action === "back") await invokeBrowserMutation(() => chrome.tabs.goBack(tab.id), "because the navigation mutation API failed");
1403
+ else await invokeBrowserMutation(() => chrome.tabs.goForward(tab.id), "because the navigation mutation API failed");
1402
1404
  }
1403
1405
  await awaitPostDispatchWait(waiter);
1404
1406
  return postActionTabMetadata(tab.id);
@@ -1760,55 +1762,51 @@
1760
1762
  return { promise, cancel: () => settle() };
1761
1763
  }
1762
1764
 
1763
- async function awaitPostDispatchWait(waiter) {
1764
- try {
1765
- await waiter.promise;
1766
- } catch (error) {
1767
- const detail = String(error?.message || error).replace(/\s+/g, " ").slice(0, 500);
1768
- throw new Error(`browser action may have been dispatched; the action outcome is unknown because post-dispatch wait failed. Inspect the page before retrying. (${detail})`);
1769
- }
1765
+ function awaitPostDispatchWait(waiter) {
1766
+ return invokeBrowserMutation(() => waiter.promise, "because post-dispatch wait failed");
1770
1767
  }
1771
1768
 
1772
- async function invokeBrowserNavigationMutation(operation) {
1769
+ async function invokeBrowserMutation(operation, failure, subject = "browser action") {
1773
1770
  try {
1774
1771
  return await operation();
1775
1772
  } catch (error) {
1776
1773
  const detail = String(error?.message || error).replace(/\s+/g, " ").slice(0, 500);
1777
- throw new Error(`browser action may have been dispatched; the action outcome is unknown because the navigation mutation API failed. Inspect the page before retrying. (${detail})`);
1774
+ throw new Error(`${subject} may have been dispatched; the ${subject === "browser action" ? "action " : ""}outcome is unknown ${failure}. Inspect ${subject === "browser action" ? "the page" : "tabs"} before retrying. (${detail})`);
1778
1775
  }
1779
1776
  }
1780
1777
 
1781
- async function invokeBrowserTabMutation(operation, phase) {
1782
- try {
1783
- return await operation();
1784
- } catch (error) {
1785
- const detail = String(error?.message || error).replace(/\s+/g, " ").slice(0, 500);
1786
- throw new Error(`browser tab mutation may have been dispatched; the outcome is unknown during ${phase}. Inspect tabs before retrying. (${detail})`);
1787
- }
1788
- }
1789
-
1790
1778
  async function executePageMutation(target, method, params, state) {
1791
1779
  await chrome.scripting.executeScript({ target, files: ["page-automation.js"] });
1792
1780
  throwIfCancelled(state);
1781
+ if (rendererRequests.size >= MAX_RENDERER_REQUESTS) throw new Error("too many active renderer requests");
1782
+ const token = crypto.randomUUID();
1783
+ if (rendererRequests.has(token)) throw new Error("renderer request identity collision");
1784
+ const lease = {
1785
+ state, tabId: target.tabId, frameId: target.frameIds?.[0] ?? 0,
1786
+ deadline: performance.now() + boundedRequestTimeout(state?.timeoutMs),
1787
+ };
1788
+ rendererRequests.set(token, lease);
1793
1789
  let executions;
1794
1790
  try {
1795
1791
  executions = await chrome.scripting.executeScript({
1796
1792
  target,
1797
- func: async (operation, payload, expectedVersion) => {
1793
+ func: async (operation, payload, expectedVersion, requestToken) => {
1798
1794
  const protocol = "machine_bridge_page_mutation_v1";
1799
1795
  try {
1800
1796
  const api = globalThis.__machineBridgePageAutomation;
1801
1797
  if (!api || api.version !== expectedVersion) throw new Error("page automation module version mismatch");
1802
1798
  if (typeof api[operation] !== "function") throw new Error("page automation module is unavailable");
1803
- return { protocol, ok: true, result: await api[operation](payload) };
1799
+ return { protocol, ok: true, result: await api[operation](payload, { token: requestToken }) };
1804
1800
  } catch (error) {
1805
1801
  return { protocol, ok: false, error: String(error?.message || error).replace(/\s+/g, " ").slice(0, 1000) };
1806
1802
  }
1807
1803
  },
1808
- args: [method, params, PAGE_AUTOMATION_VERSION],
1804
+ args: [method, params, PAGE_AUTOMATION_VERSION, token],
1809
1805
  });
1810
1806
  } catch (error) {
1811
1807
  throw pageMutationDispatchUnknown(error);
1808
+ } finally {
1809
+ if (rendererRequests.get(token) === lease) rendererRequests.delete(token);
1812
1810
  }
1813
1811
  const settlement = Array.isArray(executions) && executions.length === 1 ? executions[0]?.result : null;
1814
1812
  if (settlement?.protocol !== "machine_bridge_page_mutation_v1") {
@@ -1824,6 +1822,32 @@
1824
1822
  return [{ ...executions[0], result: settlement.result }];
1825
1823
  }
1826
1824
 
1825
+ function rendererProgress(token, sender) {
1826
+ const lease = typeof token === "string" ? rendererRequests.get(token) : null;
1827
+ const active = Boolean(lease && sender?.tab?.id === lease.tabId
1828
+ && (sender?.frameId ?? 0) === lease.frameId
1829
+ && lease.state?.cancelled !== true && performance.now() < lease.deadline);
1830
+ return { ok: true, active };
1831
+ }
1832
+
1833
+ function handleRendererProgressMessage(message, sender, sendResponse) {
1834
+ if (message.type === "machine_bridge_internal_progress") {
1835
+ sendResponse(rendererProgress(message.request_token, sender));
1836
+ return false;
1837
+ }
1838
+ const delayMs = message.delay_ms;
1839
+ if (!Number.isSafeInteger(delayMs) || delayMs < 1 || delayMs > 250) {
1840
+ sendResponse({ ok: false });
1841
+ return false;
1842
+ }
1843
+ const scoped = message.request_token !== undefined;
1844
+ const response = () => scoped ? rendererProgress(message.request_token, sender) : { ok: true };
1845
+ const current = response();
1846
+ if (scoped && current.active !== true) { sendResponse(current); return false; }
1847
+ setTimeout(() => sendResponse(response()), delayMs);
1848
+ return true;
1849
+ }
1850
+
1827
1851
  function pageMutationSettlementUnknown() {
1828
1852
  return new Error("browser action may have been dispatched; the action outcome is unknown because the page mutation settlement was malformed. Inspect the page before retrying.");
1829
1853
  }
@@ -1846,10 +1870,6 @@
1846
1870
  }));
1847
1871
  }
1848
1872
 
1849
- function delay(ms) {
1850
- return new Promise((resolve) => { setTimeout(resolve, ms); });
1851
- }
1852
-
1853
1873
  function boundedRequestTimeout(value) {
1854
1874
  if (!Number.isSafeInteger(value)) return 30000;
1855
1875
  return Math.max(1000, Math.min(185000, value));
@@ -1871,7 +1891,7 @@
1871
1891
  }
1872
1892
 
1873
1893
  Object.defineProperty(globalThis, "__machineBridgeBrowserOperations", {
1874
- value: Object.freeze({ dispatch, methodMayMutate, responsePayload, boundedRequestTimeout, boundedDocumentSource }),
1894
+ value: Object.freeze({ dispatch, methodMayMutate, responsePayload, boundedRequestTimeout, boundedDocumentSource, rendererProgress, handleRendererProgressMessage }),
1875
1895
  configurable: false,
1876
1896
  });
1877
1897
  })();
@@ -30,6 +30,6 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "3.0.0-beta.197",
33
+ "version_name": "3.0.0-beta.198",
34
34
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxryYkpZhq8+VAQLHcGS9BAHQcyKX8RHGIpIwvtIVRU/rcOcE0bNdnM0aZJ/h6xWQsGDHlhvjT2+1aJaAn/9k8473BRWajzVXld961CdHYVFVHoce2hHiSJ0xydWrHMMZhAm0mN0UzjEpgZ0tMw209efcZHIvSwuxhteZMRy4kyiVjwFlOf5oXFCxRuCJnPj3AK9CmCf4XgEBuPIJ0TZmjGHOOdBvJmbCNnAWXYEo5/mf7MfCGhV4IJ1hNuhpoNQfOFKMUcw9/v/IpT62XpfXdGYTfGYCmCjC+gntK1spbkr2P4/2+sYMQtLpse71mpSNGXfcf3abU55Vpn+gncSxRQIDAQAB"
35
35
  }
@@ -1,5 +1,5 @@
1
1
  (() => {
2
- const PAGE_AUTOMATION_VERSION = 4;
2
+ const PAGE_AUTOMATION_VERSION = 5;
3
3
  if (globalThis.__machineBridgePageAutomation?.version === PAGE_AUTOMATION_VERSION) return;
4
4
 
5
5
  const INTERACTIVE_SELECTOR = "a,button,input,select,textarea,[role],[contenteditable]:not([contenteditable='false']),summary";
@@ -486,8 +486,8 @@
486
486
  return { selector, timeoutMs, files };
487
487
  }
488
488
 
489
- async function prepareAction(params) {
490
- const element = await prepareElementForAction(params);
489
+ async function prepareAction(params, progress = null) {
490
+ const element = await prepareElementForAction(params, progress);
491
491
  try {
492
492
  const result = actionTarget(element);
493
493
  return { ok: true, element: describeElement(element, 0, false), point: result.point };
@@ -496,11 +496,12 @@
496
496
  }
497
497
  }
498
498
 
499
- async function action(params) {
499
+ async function action(params, progress = null) {
500
500
  const prepared = validateActionPayload(params);
501
- const element = await waitForActionable(prepared.selector, prepared.action, prepared.timeoutMs);
501
+ const element = await waitForActionable(prepared.selector, prepared.action, prepared.timeoutMs, progress);
502
+ await checkProgress(progress);
502
503
  assertSnapshotIdentity(element, prepared.expectedIdentity);
503
- const mutationStarted = await applyOne(element, prepared.action, prepared.value, prepared.key, prepared.timeoutMs);
504
+ const mutationStarted = await applyOne(element, prepared.action, prepared.value, prepared.key, prepared.timeoutMs, progress);
504
505
  try {
505
506
  return { ok: true, element: describeElement(element, 0, false), input_mode: "dom" };
506
507
  } catch (error) {
@@ -509,9 +510,10 @@
509
510
  }
510
511
  }
511
512
 
512
- async function prepareElementForAction(params) {
513
+ async function prepareElementForAction(params, progress = null) {
513
514
  const prepared = validateActionPayload(params);
514
- const element = await waitForActionable(prepared.selector, prepared.action, prepared.timeoutMs);
515
+ const element = await waitForActionable(prepared.selector, prepared.action, prepared.timeoutMs, progress);
516
+ await checkProgress(progress);
515
517
  assertSnapshotIdentity(element, prepared.expectedIdentity);
516
518
  let mutationStarted = false;
517
519
  try {
@@ -522,8 +524,8 @@
522
524
  if (["click", "double_click", "hover"].includes(prepared.action)) {
523
525
  mutationStarted = true;
524
526
  scrollElementIntoView(element);
525
- await waitForStableBox(element, prepared.timeoutMs);
526
- await waitForPointerTarget(element, prepared.timeoutMs);
527
+ await waitForStableBox(element, prepared.timeoutMs, progress);
528
+ await waitForPointerTarget(element, prepared.timeoutMs, progress);
527
529
  } else if (prepared.action === "scroll_into_view") {
528
530
  mutationStarted = true;
529
531
  scrollElementIntoView(element);
@@ -535,15 +537,15 @@
535
537
  return element;
536
538
  }
537
539
 
538
- async function fillForm(params) {
540
+ async function fillForm(params, progress = null) {
539
541
  const prepared = validateFormPayload(params);
540
542
  const results = [];
541
543
  const timeoutMs = prepared.timeoutMs;
542
544
  for (let index = 0; index < prepared.fields.length; index += 1) {
543
545
  const field = prepared.fields[index];
544
546
  try {
545
- const element = await waitForActionable(field.selector, field.action, timeoutMs);
546
- const mutationStarted = await applyOne(element, field.action, field.value, "", timeoutMs);
547
+ const element = await waitForActionable(field.selector, field.action, timeoutMs, progress);
548
+ const mutationStarted = await applyOne(element, field.action, field.value, "", timeoutMs, progress);
547
549
  let description;
548
550
  try { description = describeElement(element, index, false); }
549
551
  catch (error) { if (mutationStarted) throw domMutationUnknown("fill_form", error); throw error; }
@@ -559,7 +561,8 @@
559
561
  if (prepared.submit) {
560
562
  let submissionStarted = false;
561
563
  try {
562
- const submitter = prepared.submitSelector ? await waitForActionable(prepared.submitSelector, "click", timeoutMs) : deepQuerySelectorAll("button[type='submit'],input[type='submit']", 1)[0];
564
+ const submitter = prepared.submitSelector ? await waitForActionable(prepared.submitSelector, "click", timeoutMs, progress) : deepQuerySelectorAll("button[type='submit'],input[type='submit']", 1)[0];
565
+ await checkProgress(progress);
563
566
  if (submitter) {
564
567
  submissionStarted = true;
565
568
  submitter.click();
@@ -580,10 +583,11 @@
580
583
  return { ok: true, fields: results, submitted: prepared.submit, values_exposed: false };
581
584
  }
582
585
 
583
- async function uploadFiles(params) {
586
+ async function uploadFiles(params, progress = null) {
584
587
  const prepared = validateUploadPayload(params);
585
- const input = await waitForActionable(prepared.selector, "upload", prepared.timeoutMs);
588
+ const input = await waitForActionable(prepared.selector, "upload", prepared.timeoutMs, progress);
586
589
  if (!(input instanceof HTMLInputElement) || input.type !== "file") throw new Error("matched element is not a file input");
590
+ await checkProgress(progress);
587
591
  const transfer = new DataTransfer();
588
592
  for (const item of prepared.files) {
589
593
  const binary = atob(item.data);
@@ -717,18 +721,19 @@
717
721
  return /(?:password|passwd|secret|token|api[-_ ]?key|otp|one[-_ ]?time|verification|cvc|cvv|security[-_ ]?code|card[-_ ]?number)/.test(identity);
718
722
  }
719
723
 
720
- async function waitForActionable(selector, actionName, timeoutMs) {
724
+ async function waitForActionable(selector, actionName, timeoutMs, progress) {
721
725
  const boundedTimeoutMs = Math.max(1, timeoutMs);
722
726
  const startedAt = performance.now();
723
727
  let lastProblem = "no element matched selector";
724
728
  while (performance.now() - startedAt <= boundedTimeoutMs) {
729
+ await checkProgress(progress);
725
730
  const element = findOne(selector);
726
731
  if (!element && selector?.ref) throw new Error("element reference is stale; inspect the page again");
727
732
  if (element) {
728
733
  lastProblem = actionabilityProblem(element, actionName);
729
734
  if (!lastProblem) return element;
730
735
  }
731
- await delay(100);
736
+ await delay(100, progress);
732
737
  }
733
738
  throw new Error(`element was not actionable before timeout: ${lastProblem}`);
734
739
  }
@@ -745,12 +750,12 @@
745
750
  return "";
746
751
  }
747
752
 
748
- async function waitForStableBox(element, timeoutMs) {
753
+ async function waitForStableBox(element, timeoutMs, progress) {
749
754
  const boundedTimeoutMs = Math.max(1, timeoutMs);
750
755
  const startedAt = performance.now();
751
756
  let previous = boundingBox(element);
752
757
  while (performance.now() - startedAt <= boundedTimeoutMs) {
753
- await delay(50);
758
+ await delay(50, progress);
754
759
  const current = boundingBox(element);
755
760
  if (current && previous && boxDistance(previous, current) <= 0.5) return current;
756
761
  previous = current;
@@ -758,17 +763,18 @@
758
763
  throw new Error("element did not become geometrically stable before timeout");
759
764
  }
760
765
 
761
- async function waitForPointerTarget(element, timeoutMs) {
766
+ async function waitForPointerTarget(element, timeoutMs, progress) {
762
767
  const boundedTimeoutMs = Math.max(1, timeoutMs);
763
768
  const startedAt = performance.now();
764
769
  let lastProblem = "element does not receive pointer events";
765
770
  while (performance.now() - startedAt <= boundedTimeoutMs) {
771
+ await checkProgress(progress);
766
772
  const point = actionTarget(element).point;
767
773
  if (!point) lastProblem = "element has no usable viewport box";
768
774
  else if (point.x < 0 || point.y < 0 || point.x >= innerWidth || point.y >= innerHeight) lastProblem = "element is outside the viewport";
769
775
  else if (receivesPointerEvents(element, point)) return;
770
776
  else lastProblem = "element is obscured by another element";
771
- await delay(100);
777
+ await delay(100, progress);
772
778
  }
773
779
  throw new Error(`element was not clickable before timeout: ${lastProblem}`);
774
780
  }
@@ -809,7 +815,8 @@
809
815
  return { point: { x: left + (right - left) / 2, y: top + (bottom - top) / 2 } };
810
816
  }
811
817
 
812
- async function applyOne(element, operation, value, key, timeoutMs = 10000) {
818
+ async function applyOne(element, operation, value, key, timeoutMs = 10000, progress = null) {
819
+ await checkProgress(progress);
813
820
  const wanted = operation === "check" ? true : operation === "uncheck" ? false : null;
814
821
  if (wanted !== null && Boolean(element.checked) === wanted) return false;
815
822
  let selectedOption = null;
@@ -824,8 +831,9 @@
824
831
  if (["click", "double_click", "hover"].includes(operation)) {
825
832
  mutationStarted = true;
826
833
  scrollElementIntoView(element);
827
- await waitForStableBox(element, timeoutMs);
828
- await waitForPointerTarget(element, timeoutMs);
834
+ await waitForStableBox(element, timeoutMs, progress);
835
+ await waitForPointerTarget(element, timeoutMs, progress);
836
+ await checkProgress(progress);
829
837
  if (operation === "click") element.click();
830
838
  else if (operation === "double_click") {
831
839
  element.click();
@@ -1153,16 +1161,30 @@
1153
1161
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1154
1162
  }
1155
1163
 
1156
- function delay(ms) {
1164
+ async function checkProgress(progress) {
1165
+ if (progress === null || progress === undefined) return;
1166
+ if (typeof progress?.token !== "string" || !/^[a-f0-9-]{36}$/.test(progress.token)) {
1167
+ throw new Error("browser request progress identity is invalid");
1168
+ }
1169
+ const response = await globalThis.chrome?.runtime?.sendMessage?.({
1170
+ type: "machine_bridge_internal_progress", request_token: progress.token,
1171
+ });
1172
+ if (response?.ok !== true || response.active !== true) throw new Error("browser request cancelled");
1173
+ }
1174
+
1175
+ function delay(ms, progress = null) {
1157
1176
  const runtime = globalThis.chrome?.runtime;
1158
1177
  if (runtime && typeof runtime.sendMessage === "function") {
1159
1178
  const delayMs = Number.isFinite(ms) ? Math.ceil(ms) : 0;
1160
1179
  if (delayMs < 1 || delayMs > 250) return Promise.reject(new Error("browser action delay is invalid"));
1161
- return Promise.resolve(runtime.sendMessage({ type: "machine_bridge_internal_delay", delay_ms: delayMs }))
1180
+ return Promise.resolve(runtime.sendMessage({ type: "machine_bridge_internal_delay", delay_ms: delayMs,
1181
+ ...(progress ? { request_token: progress.token } : {}) }))
1162
1182
  .then((response) => {
1163
1183
  if (response?.ok !== true) throw new Error("browser action timing service unavailable");
1184
+ if (progress && response.active !== true) throw new Error("browser request cancelled");
1164
1185
  });
1165
1186
  }
1187
+ if (progress) return Promise.reject(new Error("browser request progress service unavailable"));
1166
1188
  return new Promise((resolve) => { setTimeout(resolve, ms); });
1167
1189
  }
1168
1190
 
@@ -15,15 +15,9 @@ chrome.alarms.onAlarm.addListener((alarm) => {
15
15
  if (alarm.name === "machine-bridge-reconnect") void connectFromStorage();
16
16
  });
17
17
  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
18
- if (message?.type === "machine_bridge_internal_delay") {
18
+ if (message?.type === "machine_bridge_internal_progress" || message?.type === "machine_bridge_internal_delay") {
19
19
  if (sender?.id !== chrome.runtime.id) return false;
20
- const delayMs = message.delay_ms;
21
- if (!Number.isSafeInteger(delayMs) || delayMs < 1 || delayMs > 250) {
22
- sendResponse({ ok: false });
23
- return false;
24
- }
25
- setTimeout(() => sendResponse({ ok: true }), delayMs);
26
- return true;
20
+ return browserOperations().handleRendererProgressMessage(message, sender, sendResponse);
27
21
  }
28
22
  if (message?.type !== "pair_bootstrap") return false;
29
23
  pairFromBootstrap(message.port, message.grant, { replace: false })
@@ -288,7 +282,7 @@ async function handleMessage(ws, raw, onReady = () => {}) {
288
282
  if (message?.type === "pong") return brokerLiveness.handlePong(ws, message, closeSocketQuietly);
289
283
  if (message?.type === "cancel" && typeof message.id === "string") {
290
284
  const state = activeRequests.get(message.id);
291
- if (state) state.cancelled = true;
285
+ if (state?.socket === ws) state.cancelled = true;
292
286
  return;
293
287
  }
294
288
  if (message?.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") return;