machine-bridge-mcp 1.2.6 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.7 - 2026-07-17
4
+
5
+ ### Process supervision, lifecycle, and isolation audit
6
+
7
+ - Separate argv validation, execution limits, process-tree supervision, one-shot execution, and interactive process sessions into explicit modules. Shell helpers, managed jobs, call cancellation, runtime shutdown, and process sessions now share one graceful `SIGTERM` followed by forced tree-termination contract instead of importing session internals or maintaining duplicate platform branches.
8
+ - Reclaim an unresponsive detached service daemon only after revalidating PID, process start time, entrypoint, command line, daemon mode, workspace, and state root immediately before `SIGKILL`. PID reuse, identity drift, foreground ownership, and ambiguous records remain fail closed. Process-session termination now also escalates after a bounded grace period.
9
+ - Add a machine-readable `server_info.runtime.execution_guardrails` contract for tool-call concurrency, process timeout/stdin/output limits, process-session limits, and cleanup semantics. CPU quota, memory quota, and network isolation are reported explicitly as `not-enforced`; hard isolation still requires a dedicated account, container, or VM.
10
+ - Make browser-broker startup generation-aware so `stop()` cannot race an asynchronous listen/proxy connection and leave a listener alive. Pending proxy routes now receive a terminal error during shutdown, broker recovery failures emit structured debug events, and the local browser-health probe uses bounded direct `127.0.0.1` HTTP instead of environment-routed `fetch`.
11
+ - Reject detached managed-job launch when no process ID was obtained, attach an asynchronous child-error observer, make `shell: false` explicit, and remove duplicate plan-scrubbing logic. Correct stale relay-readiness and runtime-observability documentation, and add fault-path tests for resistant descendants, forced daemon reclamation, startup cancellation, proxy-bypassed loopback health, runner spawn failure, and honest OS-enforcement reporting.
12
+
3
13
  ## 1.2.6 - 2026-07-17
4
14
 
5
15
  ### Relay ready-context and implicit service-daemon takeover
package/README.md CHANGED
@@ -264,7 +264,7 @@ Machine Bridge can discover, refresh, rank, and load capabilities automatically.
264
264
 
265
265
  ## Runtime lifecycle and observability
266
266
 
267
- Every tool call passes through one execution pipeline: bounded call registration and deadline/cancellation ownership, structured observability, shared policy authorization, then the typed handler. Errors use stable codes and retryability metadata instead of transport-specific message parsing. `server_info` exposes the local lifecycle state, in-flight calls, active process ownership, per-tool outcomes, durations, and error-code counts. The Worker adds pending internal/request-key indexes, daemon candidate/socket counters, and Worker-side per-tool outcomes.
267
+ Every tool call passes through one execution pipeline: bounded call registration and deadline/cancellation ownership, structured observability, shared policy authorization, then the typed handler. Errors use stable codes and retryability metadata instead of transport-specific message parsing. `server_info` exposes the local lifecycle state, in-flight calls, active process ownership, per-tool outcomes, durations, error-code counts, enforced execution limits, and explicit `not-enforced` CPU/memory/network-isolation fields. The Worker adds pending internal/request-key indexes, daemon candidate/socket counters, and Worker-side per-tool outcomes.
268
268
 
269
269
  Foreground logging defaults to human-readable text. Installed background services use warning-level JSON events by default. Use `--log-format json` for machine-readable foreground logs; arguments, outputs, credentials, resource contents, and local paths remain excluded or redacted.
270
270
 
@@ -526,7 +526,7 @@ Default state roots:
526
526
 
527
527
  State/config writes use owner-only temporary files, `fsync`, and atomic replacement. Exclusive locks are fully written before a same-directory hard-link claim becomes visible; lock ownership includes a token and process start time so PID reuse and lock replacement cannot silently transfer ownership. Only successfully read but invalid JSON is retained as a bounded corrupt backup; permission, symbolic-link, size, encoding, and I/O failures remain explicit. A custom state root must not equal, contain, or be contained by the selected workspace. Resource source paths are redacted from `status` output. Active managed-job plans are owner-only and are deleted after a terminal result; bounded redacted results are retained temporarily. Uninstall first acquires a state-root maintenance lock that blocks new profile/state operations and state-backed operations from already constructed managed-job/browser managers, stops the platform service and all verified workspace daemons before removing definitions, rechecks jobs and locks, then validates markers, canonical paths, workspace/source exclusions, and known contents before recursive deletion.
528
528
 
529
- Worker health probes and the relay honor standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment routing. Invalid or unsupported proxy configuration fails fast. Health and relay diagnostics report only coarse `direct`, `proxy`, or invalid-route state and never proxy URLs or credentials.
529
+ Remote Worker health probes and the relay honor standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment routing. Invalid or unsupported proxy configuration fails fast. Browser-broker health is strictly loopback-direct and does not rely on `NO_PROXY`. Health and relay diagnostics report only coarse `direct`, `proxy`, or invalid-route state and never proxy URLs or credentials.
530
530
 
531
531
  Default foreground logs report authenticated relay readiness, readable persistent-degradation summaries, and recovery rather than raw WebSocket callbacks or JSON field dumps. Brief self-healing disconnects and close codes/reasons are debug-only. Stalled connection attempts have a deadline, and sustained-outage reminders use autonomous exponential backoff. Every per-tool event—including success, failure, cancellation, and slow-call timing—also appears only at `--log-level debug` or `--verbose`. Background services use `warn`, so ordinary tool outcomes and brief network changes do not fill daemon logs. Log messages and structured fields are bounded, secret-like keys and known token formats are redacted, and tool arguments/results are not written. See [docs/LOGGING.md](docs/LOGGING.md) and [docs/OPERATIONS.md](docs/OPERATIONS.md).
532
532
 
package/SECURITY.md CHANGED
@@ -59,7 +59,7 @@ The default for newly selected workspaces is `full`, which prioritizes ease of u
59
59
 
60
60
  `exec_command` has both executable authority and shell expansion. Use `--no-exec` or `review`/`edit` when process execution is unnecessary.
61
61
 
62
- For untrusted repositories or instructions, run the bridge inside a disposable VM/container or under a dedicated low-privilege OS account. On macOS and Windows, this external isolation is especially important. The project does not claim an in-process OS sandbox.
62
+ For untrusted repositories or instructions, run the bridge inside a disposable VM/container or under a dedicated low-privilege OS account. On macOS and Windows, this external isolation is especially important. The project does not claim an in-process OS sandbox, CPU quota, resident-memory quota, or network egress policy. `server_info.runtime.execution_guardrails.operating_system_enforcement` reports these gaps explicitly.
63
63
 
64
64
  ## Agent instructions, skills, and command manifests
65
65
 
@@ -69,7 +69,7 @@ Repository and user instruction files remain untrusted content from the model's
69
69
 
70
70
  Automatic `package.*` commands expose only validated script names and fixed package-manager argv; they do not expose script bodies. Executing one still runs repository-controlled package-script code with local-user authority and is not a sandbox or trust upgrade.
71
71
 
72
- Relay proxy selection honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. Only HTTP(S) proxy URLs are accepted. Autostart installation persists an allowlist of proxy and custom-CA environment values in `service-environment.json` so a background daemon can reproduce the foreground network route; a proxy URL may itself contain credentials, so this file is sensitive local state and must remain protected with the rest of the state root. Proxy endpoints, credentials, certificate paths, and authorization headers are not returned through MCP or written to operational logs; service status exposes only configured environment key names and relay status exposes only coarse route state.
72
+ Remote Worker-health and relay proxy selection honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. Only HTTP(S) proxy URLs are accepted. The local browser-broker health probe never uses those proxy variables: it accepts only canonical `127.0.0.1`, uses a direct bounded HTTP request, and rejects alternate hosts, credentials, query strings, and fragments. Autostart installation persists an allowlist of proxy and custom-CA environment values in `service-environment.json` so a background daemon can reproduce the foreground network route; a proxy URL may itself contain credentials, so this file is sensitive local state and must remain protected with the rest of the state root. Proxy endpoints, credentials, certificate paths, and authorization headers are not returned through MCP or written to operational logs; service status exposes only configured environment key names and relay status exposes only coarse route state.
73
73
 
74
74
  Skill loading is non-executing. `load_local_skill` returns an entrypoint and bounded file inventory; scripts remain inert until a separate process or command tool is called. Symlinked skill directories are followed only after canonical path-policy validation, while symbolic-link `SKILL.md` entrypoints are rejected. Traversal, cycles, content, summaries, and inventory are bounded.
75
75
 
@@ -154,7 +154,7 @@ Active plans are owner-only and may temporarily contain argv, non-secret stdin,
154
154
 
155
155
  Exact canonical and registration-time resource path aliases, exact resource bytes interpreted as text, and bounded exact base64/hex forms are redacted from retained output. This cannot detect partial, transformed, encrypted, compressed, or application-specific encodings. It also cannot redact unrelated secrets inherited through the full parent environment. Use `capture_output: "discard"` whenever a process may echo credentials, and never place a secret directly in argv, ordinary env, stdin, temporary-file content, or a JSON plan.
156
156
 
157
- `finally_steps` run after ordinary success, failure, timeout, and cancellation. Cancellation uses an owner-only marker rather than signaling the runner process itself, so the coordinator remains alive to execute cleanup consistently across platforms. Timeout/cancellation target the process group/tree and keep a forced-termination escalation alive even if the direct child exits before a resistant descendant. Runner identity includes process start time; recovery does not trust a reused PID. A dead runner is detected on the next daemon or local job-CLI start; stale private resource copies are removed and cleanup is retried. This is best effort. Power loss, disk failure, permanent loss of credentials/network access, SIGKILL without later recovery, or security software denying the cleanup executable can prevent cleanup. Finally steps must be idempotent and safe to repeat. Automatic recovery is capped at three attempts so persistent endpoint-security or executable-policy denial cannot create an endless launch loop. Uninstall refuses to remove local state while managed jobs are active; operators must inspect or cancel them first.
157
+ `finally_steps` run after ordinary success, failure, timeout, and cancellation. Cancellation uses an owner-only marker rather than signaling the runner process itself, so the coordinator remains alive to execute cleanup consistently across platforms. Timeout/cancellation target the process group/tree through one shared supervisor and keep a forced-termination escalation alive even if the direct child exits before a resistant descendant. Verified service-daemon stop uses the same graceful-then-force principle but revalidates the complete process identity immediately before escalation. Runner identity includes process start time; recovery does not trust a reused PID. A dead runner is detected on the next daemon or local job-CLI start; stale private resource copies are removed and cleanup is retried. This is best effort. Power loss, disk failure, permanent loss of credentials/network access, SIGKILL without later recovery, or security software denying the cleanup executable can prevent cleanup. Finally steps must be idempotent and safe to repeat. Automatic recovery is capped at three attempts so persistent endpoint-security or executable-policy denial cannot create an endless launch loop. Uninstall refuses to remove local state while managed jobs are active; operators must inspect or cancel them first.
158
158
 
159
159
  Job-scoped `temporary_files` should be used instead of loose helper scripts. They are materialized only below the private job runtime. Remote scripts should preferably be sent through a process stdin instead of written to the remote filesystem.
160
160
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "1.2.6",
4
+ "version": "1.2.7",
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.6"
33
+ "version_name": "1.2.7"
34
34
  }
@@ -28,7 +28,7 @@ A canonical workspace receives an independent profile, Worker name, secret set,
28
28
  `LocalRuntime` is the transport-independent local tool orchestrator. It owns the shared authorization/execution pipeline, manager construction, mutation serialization, cancellation, and the narrow delegation surface used by stdio and relay transports. Domain behavior remains in focused services:
29
29
 
30
30
  - `workspace-file-service.mjs` and `git-service.mjs` own canonical filesystem/Git operations;
31
- - `process-execution.mjs`, `process-sessions.mjs`, and `process-tracker.mjs` own child-process authority and lifecycle;
31
+ - `process-contract.mjs` owns argv shape/size validation, `process-tree.mjs` owns cross-platform tree termination, `process-execution.mjs` and `process-sessions.mjs` own one-shot and interactive execution, and `process-tracker.mjs` owns runtime process accounting;
32
32
  - `runtime-reporting.mjs` builds privacy-aware runtime and project snapshots;
33
33
  - `runtime-diagnostics.mjs` owns fixed local probes and their stable interpretation;
34
34
  - `runtime-capabilities.mjs` composes agent, application, and browser capability results;
@@ -36,7 +36,7 @@ A canonical workspace receives an independent profile, Worker name, secret set,
36
36
 
37
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, `hello_ack` authentication, end-to-end `relay_probe`/`ready_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. Inbound WebSocket dispatch captures that generation at receive time and passes it to the runtime handler, so a successful local tool execution can publish only on the same generation. Stdio mode invokes `LocalRuntime` directly without that adapter.
38
38
 
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, accepts lock-backed `--daemon-only` recovery processes that omit repeated path flags, and sends `SIGTERM` only to a verified same-workspace service daemon. 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.
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 accepts lock-backed `--daemon-only` recovery processes that omit repeated path flags. Stop/takeover sends `SIGTERM` only to a verified same-workspace service daemon. If it remains alive after the grace period, the code revalidates PID, process-start identity, command line, entrypoint, daemon mode, workspace, and state root before sending `SIGKILL`; a foreground, replaced-PID, or otherwise unverifiable process remains untouched. CLI orchestration never treats a missing launchd/systemd job as proof that the process exited.
40
40
 
41
41
  ### Agent context and capability resolver
42
42
 
@@ -54,7 +54,7 @@ See [Session instructions, skills, commands, and capability discovery](AGENT_CON
54
54
 
55
55
  ### Browser extension and machine broker
56
56
 
57
- `BrowserBridgeManager` owns only connection orchestration for the loopback HTTP/WebSocket broker, owner/client failover, routed requests, cancellation, and extension replacement. `browser-operation-service.mjs` owns MCP-facing browser argument normalization, resource-backed values/uploads, form semantics, screenshot conversion, and status presentation. Extension version/capability parsing lives in the strict checked `browser-extension-protocol.mjs`; pairing files and local HTML/Host/Origin helpers live in `browser-pairing-store.mjs`. The first runtime for the machine-level state root becomes broker owner; additional workspaces and stdio runtimes authenticate to `/runtime` and proxy through the same extension socket. This preserves one extension pairing while allowing multiple local MCP runtimes.
57
+ `BrowserBridgeManager` owns only connection orchestration for the loopback HTTP/WebSocket broker, owner/client failover, routed requests, cancellation, extension replacement, and start/stop generation control. Every asynchronous startup boundary rechecks the generation so a listener or upstream socket cannot appear after `stop()` has invalidated that start. `browser-operation-service.mjs` owns MCP-facing browser argument normalization, resource-backed values/uploads, form semantics, screenshot conversion, and status presentation. Extension version/capability parsing lives in the strict checked `browser-extension-protocol.mjs`; pairing files and local HTML/Host/Origin helpers live in `browser-pairing-store.mjs`. The first runtime for the machine-level state root becomes broker owner; additional workspaces and stdio runtimes authenticate to `/runtime` and proxy through the same extension socket. This preserves one extension pairing while allowing multiple local MCP runtimes.
58
58
 
59
59
  The packaged Manifest V3 extension runs in the user's existing Chromium profile. Its service worker is limited to pairing, transport, acknowledged protocol readiness, cancellation, and response routing. Fixed `browser-operations.js` owns tab lifecycle, aggregate frame/source budgets, waits, screenshots, and input-backend selection; fixed `page-automation.js` is injected into selected frames for snapshot-version-2 semantics, stable refs, bounded DOM/text traversal, actionability checks, open-Shadow-DOM traversal, structured DOM operations, multi-field forms, and resource-backed file inputs. Fixed `devtools-input.js` exposes only bounded mouse, keyboard, and text sequences through the Chromium debugger API; callers cannot select CDP methods. Trusted sessions attach for one action and detach in `finally`; DOM fallback is allowed only before any DevTools Input dispatch, preventing duplicate side effects after an ambiguous command failure. Protocol 3 requires bidirectional `hello`/`hello_ack` plus exact packaged-version and capability equality; pairing state and replacement are committed only after validation, so an invalid candidate cannot displace or overwrite the working configuration. The broker validates loopback hostnames, canonical extension IDs, matching pairing/broker ports, bearer subprotocols, message sizes, concurrency, and deadlines. Pairing material is owner-only and omitted from MCP/log output.
60
60
 
@@ -205,7 +205,7 @@ This is a process-level transaction, not a filesystem-wide atomic transaction ac
205
205
 
206
206
  The default `full` profile passes the complete parent environment. Isolated environment mode, used by the narrower named profiles unless overridden, creates private runtime HOME, temporary, and cache directories and passes only a small set of path/locale/platform variables. It reduces accidental credential inheritance but cannot prevent explicit access to known filesystem paths, credential stores, network services, or other user resources.
207
207
 
208
- One-shot calls have bounded output and timeouts. Startup-lock waits, daemon takeover, process-session reads, managed-job recovery handoff, browser/page waits, application-cache freshness, and in-memory duration metrics use monotonic elapsed time, so wall-clock correction cannot extend or prematurely terminate their configured duration. Persisted timestamps and retention/credential expiry continue to use wall time. Process sessions retain bounded byte buffers with monotonic offsets, accept bounded stdin, support short output/exit waits, and are capped per runtime. Valid UTF-8 is returned as text; byte slices that are not valid UTF-8 also include lossless base64 data. Session IDs are random. Sessions are memory-only and are killed on runtime stop, remote disconnect, or daemon replacement.
208
+ `execution-limits.mjs` is the shared source for local tool-call concurrency, one-shot process timeout/stdin/output limits, and process-session count/stdin/output/retention limits. `server_info.runtime.execution_guardrails` reports those enforced limits together with explicit `not-enforced` values for CPU quota, memory quota, and network isolation. One-shot calls have bounded output and timeouts. Startup-lock waits, daemon takeover, process-session reads, managed-job recovery handoff, browser/page waits, application-cache freshness, and in-memory duration metrics use monotonic elapsed time, so wall-clock correction cannot extend or prematurely terminate their configured duration. Persisted timestamps and retention/credential expiry continue to use wall time. Process sessions retain bounded byte buffers with monotonic offsets, accept bounded stdin, support short output/exit waits, and are capped per runtime. Valid UTF-8 is returned as text; byte slices that are not valid UTF-8 also include lossless base64 data. Session IDs are random. Sessions are memory-only and are killed on runtime stop, remote disconnect, or daemon replacement.
209
209
 
210
210
  Child processes run in a separate process group where supported. Timeout, cancellation, disconnect, and replacement send termination to process trees, with a referenced forced-escalation timer that remains alive even when the direct child exits before a resistant descendant. Windows uses tree-aware task termination.
211
211
 
@@ -215,7 +215,7 @@ Managed jobs use the same argv/environment primitives but a different lifecycle.
215
215
 
216
216
  Worker deployment is an explicit two-evidence state machine owned by `worker-deployment.mjs`. Wrangler upload is the authoritative remote write. Public `/healthz` is a subsequent read used to verify identity and package version; it is not a transaction commit signal for the upload. After a successful Wrangler result, local state atomically records the detected `workers.dev` URL, MCP URL, content/secret fingerprint, deployed package version, and timestamp before health verification begins. If verification is ambiguous, the next start compares the same fingerprint and performs a read-only verification rather than repeating the remote write.
217
217
 
218
- `worker-health.mjs` owns bounded health I/O: exact HTTPS `workers.dev` origin and Worker-name validation, environment-proxy selection, request timeout, redirect rejection, response-size limit, JSON/identity/version validation, and coarse error classification. `network-proxy.mjs` is shared by HTTP health probes and WebSocket relay construction so both paths honor `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` without exposing proxy details. Definitive stale evidence is retried for propagation and then permits a same-name redeploy; timeout, TLS, network, proxy, and temporary server failure remain ambiguous and fail without upload.
218
+ `worker-health.mjs` owns bounded health I/O: exact HTTPS `workers.dev` origin and Worker-name validation, environment-proxy selection, request timeout, redirect rejection, response-size limit, JSON/identity/version validation, and coarse error classification. `network-proxy.mjs` is shared by remote HTTP health probes and WebSocket relay construction so both paths honor `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` without exposing proxy details. Local browser-broker health uses `loopback-health.mjs`, which accepts only canonical `http://127.0.0.1:<port>/healthz`, disables agent reuse, bounds the response, and deliberately bypasses environment proxies. Definitive stale evidence is retried for propagation and then permits a same-name redeploy; timeout, TLS, network, proxy, and temporary server failure remain ambiguous and fail without upload.
219
219
 
220
220
  Worker-name mutation is a separate identity transition. Existing state rejects a different name unless the caller also supplies the explicit force option. An authorized transition clears the current URL/fingerprint and appends the prior validated name to bounded uninstall inventory. This prevents a health-retry workaround from silently becoming a new remote resource while preserving cleanup of intentional replacements.
221
221
 
@@ -247,7 +247,7 @@ Browser-origin handling separates CORS response sharing from protocol authentica
247
247
 
248
248
  ## Observability
249
249
 
250
- Public health exposes only server identity and version. Authenticated `server_info` exposes bounded runtime status, managed-job counts, resource alias names without paths or values, relay route state without endpoint details, authenticated/probing/ready socket counts, end-to-end readiness evidence, and privacy-preserving capability-routing evidence. It separates the daemon capability ceiling from the authenticated account authority: `daemon.policy`/`daemon.tools` retain the pre-role ceiling, while `authorization.effective_policy`/`authorization.effective_tools` and the top-level `tools` report the role-intersected authority before any host-side filtering. It explicitly reports that the host-exposed subset is unknown to the server. `diagnose_runtime` runs fixed local probes and explicitly reports that its own request reached the daemon.
250
+ Public health exposes only server identity and version. Authenticated `server_info` exposes bounded runtime status, managed-job counts, resource alias names without paths or values, relay route state without endpoint details, authenticated/probing/ready socket counts, end-to-end readiness evidence, local execution guardrails, explicit OS-enforcement gaps, and privacy-preserving capability-routing evidence. It separates the daemon capability ceiling from the authenticated account authority: `daemon.policy`/`daemon.tools` retain the pre-role ceiling, while `authorization.effective_policy`/`authorization.effective_tools` and the top-level `tools` report the role-intersected authority before any host-side filtering. It explicitly reports that the host-exposed subset is unknown to the server. `diagnose_runtime` runs fixed local probes and explicitly reports that its own request reached the daemon.
251
251
 
252
252
  Foreground logging defaults to `info`; autostart uses `warn`. Authenticated readiness, persistent degradation, and recovery are user-visible state transitions. Brief relay interruptions, raw transport close details, retry timing, and all per-tool starts/successes/failures/cancellations/durations are debug-only. Unexpected local and Worker infrastructure errors are reduced to classes. Messages, strings, arrays, object depth/key counts, and serialized fields are bounded.
253
253
 
package/docs/AUDIT.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Security and privacy audit notes
2
2
 
3
+ ## 2026-07-17 version 1.2.7 process-supervision and lifecycle audit
4
+
5
+ The core question in this review was not whether the existing green suite covered many paths; it was whether failure and ownership boundaries remained truthful when processes resist termination, startup is interrupted, proxy state is hostile, or the operating system refuses a spawn. The audit found that generic argv validation and process-tree termination had drifted into the interactive-session module, while shell and managed-job paths maintained adjacent termination branches. This created a dependency inversion: one-shot execution and process accounting depended on an adapter whose responsibility should have been only interactive session state.
6
+
7
+ Version 1.2.7 extracts `process-contract.mjs`, `process-tree.mjs`, and `execution-limits.mjs`. All process-owning paths now share the same cross-platform tree supervisor and bounded graceful-to-forced escalation. A verified detached service daemon that ignores `SIGTERM` is no longer left indefinitely alive: immediately before `SIGKILL`, Machine Bridge repeats process-start identity and complete daemon command/workspace/state validation. A reused PID, changed command, foreground process, partial identity, or other ambiguity blocks escalation. Process sessions use the same escalation, and tests use resistant descendants rather than source-shape assertions alone.
8
+
9
+ A separate lifecycle race existed in the browser broker. `stop()` could run while pairing/listen/proxy startup was awaiting I/O; the old start could then finish and repopulate a listener after shutdown. Startup now carries a monotonically increasing generation through every asynchronous boundary. Stop invalidates that generation, and stale completion closes all HTTP/WebSocket transports, pending calls, and routed proxy requests. Browser status also no longer relies on global `fetch` for loopback health: a strict bounded direct-HTTP adapter accepts only canonical `127.0.0.1` and cannot be diverted by environment proxy configuration. Remote Worker health and relay traffic continue to use the documented proxy resolver.
10
+
11
+ The resource review distinguished application bounds from kernel controls. The implementation has real concurrency, timeout, stdin, output, retention, and job-size limits, but it does not create CPU quotas, resident-memory ceilings, syscall sandboxes, or egress filters. `server_info.runtime.execution_guardrails` now states both categories explicitly so a timeout cannot be misread as a CPU quota and a proxy route cannot be misread as network isolation. Hard isolation remains an external deployment responsibility: dedicated low-privilege account, container, or VM.
12
+
13
+ Managed-job launch previously assumed `spawn()` returned a usable child. A system-level launch failure could emit an unhandled child `error` after the job had been reported accepted. Launch now attaches the error observer before returning, refuses acceptance without a positive PID, records a coarse structured failure class, and keeps shell parsing disabled explicitly. The audit also corrected stale `hello_ack`-only readiness wording, incorrect `server_info` field paths, a duplicated plan-retention guard, and documentation that still described daemon takeover as non-escalating.
14
+
15
+ Policy revision 5, state schema 6, OAuth/account records, browser-extension protocol 3, and the account role mapping are unchanged. Profile ACL remains a two-sided intersection: the Worker filters the account role against the daemon ceiling, and the local runtime repeats the role check before handler dispatch. This remains application authorization, not per-account OS tenancy. No Worker deployment, daemon/service replacement, credential rotation, global installation, npm publication, tag, or GitHub Release is performed by the source changes themselves.
16
+
3
17
  ## 2026-07-17 version 1.2.6 relay ready-context and daemon-takeover audit
4
18
 
5
19
  Version 1.2.5 introduced fail-closed rejection of ordinary tool calls before end-to-end readiness. That boundary is correct, but an incomplete inbound dispatch object that carried only `sessionId` (the 1.2.4 session-binding shape) evaluated as not ready and converted the first real tool call into a permanent local protocol error. Version 1.2.6 keeps the explicit `ready: false` fail-closed path, requires RelayConnection to forward boolean `authenticated`/`ready`, and allows a live ready relay status to satisfy readiness only when the snapshot omitted the field.
@@ -61,7 +61,7 @@ A brief relay interruption is retried automatically and is visible only with `--
61
61
 
62
62
  Use `--verbose` only when close codes, close reasons, heartbeat timeouts, and retry delays are needed for diagnosis. A close code of 1006 means the transport ended without a normal close handshake; it does not by itself identify the cause.
63
63
 
64
- The daemon honors `HTTPS_PROXY`/`HTTP_PROXY` and `NO_PROXY` through standard environment-proxy resolution. `wss:` targets use HTTPS proxy selection and `ws:` targets use HTTP proxy selection. Only HTTP and HTTPS proxy URLs are accepted. Invalid URLs or unsupported protocols fail startup with corrective guidance instead of entering the reconnect loop. `server_info.runtime.relay.network_route` reports only `direct`, `proxy`, or `invalid-proxy-configuration`; proxy endpoints and credentials are never returned or logged.
64
+ The daemon honors `HTTPS_PROXY`/`HTTP_PROXY` and `NO_PROXY` through standard environment-proxy resolution for remote Worker health and relay traffic. `wss:` targets use HTTPS proxy selection and `ws:` targets use HTTP proxy selection. Only HTTP and HTTPS proxy URLs are accepted. Invalid URLs or unsupported protocols fail startup with corrective guidance instead of entering the reconnect loop. `server_info.runtime.relay.network_route` reports only `direct`, `proxy`, or `invalid-proxy-configuration`; proxy endpoints and credentials are never returned or logged. The browser-broker CLI health probe is a separate loopback-only path: it accepts only canonical `127.0.0.1`, uses direct Node HTTP with no proxy agent, and does not depend on `NO_PROXY`.
65
65
 
66
66
  ## Browser extension setup and diagnosis
67
67
 
@@ -95,7 +95,7 @@ Application UI inspection/actions require Accessibility permission for the Node/
95
95
 
96
96
  `machine-mcp` is a foreground command. It remains attached to the terminal, defaults to `info` logging, and stops on `Ctrl+C`. `machine-mcp service start` launches the installed platform service in the background and returns to the shell; that service uses `warn` logging.
97
97
 
98
- A global npm install changes the CLI files on disk but does not replace an already running Node process. Startup and other state-changing CLI operations use a token/process-identity lock and wait up to 30 seconds for a normal concurrent startup to finish; duration limits use monotonic elapsed time, so NTP or manual wall-clock correction does not lengthen or shorten the wait; a short launchd/systemd overlap is therefore serialized rather than reported immediately as an error. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd, systemd, or Task Scheduler no longer tracks. Only current lock records containing service mode, version, PID, process start time, entrypoint, workspace, and state root are eligible for takeover. Before sending `SIGTERM`, Machine Bridge verifies PID and process start time plus the live command line, entrypoint, and daemon-only flag. Explicit `--workspace` and `--state-dir` must both match the active state when present; a recovery daemon started with only `--daemon-only` is accepted when the lock owner already records that workspace and state root. Partial path identity (only one of the two flags) is rejected. It waits up to 15 seconds for both the PID and lock to disappear and does not add a later forced-kill escalation. On POSIX, a daemon that handles and ignores `SIGTERM` therefore reaches the bounded timeout and remains alive. Node maps the supported termination signals to immediate process termination on Windows, so the same verified-daemon stop normally completes rather than exercising the POSIX timeout branch. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
98
+ A global npm install changes the CLI files on disk but does not replace an already running Node process. Startup and other state-changing CLI operations use a token/process-identity lock and wait up to 30 seconds for a normal concurrent startup to finish; duration limits use monotonic elapsed time, so NTP or manual wall-clock correction does not lengthen or shorten the wait; a short launchd/systemd overlap is therefore serialized rather than reported immediately as an error. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd, systemd, or Task Scheduler no longer tracks. Only current lock records containing service mode, version, PID, process start time, entrypoint, workspace, and state root are eligible for takeover. Before sending `SIGTERM`, Machine Bridge verifies PID and process start time plus the live command line, entrypoint, and daemon-only flag. Explicit `--workspace` and `--state-dir` must both match the active state when present; a recovery daemon started with only `--daemon-only` is accepted when the lock owner already records that workspace and state root. Partial path identity (only one of the two flags) is rejected. If the verified daemon ignores graceful termination, Machine Bridge waits for the grace period, then repeats process-instance and full daemon-identity verification before sending `SIGKILL`. PID reuse, identity drift, foreground mode, or any ambiguity blocks escalation. The total stop remains bounded at 15 seconds and stale lock reclamation still uses token-aware ownership. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
99
99
 
100
100
  `machine-mcp service status [WORKSPACE]` reports two independent layers: the platform service (`active`) and `workspace_daemon`, plus `effective_active` and `orphaned_workspace_daemon` summary flags. On macOS it is possible for launchd to report inactive while a prior Node process remains alive with parent PID 1; that is an orphan-daemon condition, not proof that the daemon stopped. `service stop` unloads the provider when present and then terminates only a verified service-style workspace daemon. `service uninstall` and full uninstall are ordered fail-closed operations: provider stop → verified daemon stop(s) → definition removal. A failed or ambiguous stop leaves definitions and state intact. If takeover reaches its deadline, run:
101
101
 
@@ -147,7 +147,7 @@ Uninstall acquires a state-root `maintenance.lock` that blocks new profile/state
147
147
 
148
148
  ### Lifecycle and pending-call diagnosis
149
149
 
150
- `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.
150
+ `server_info.runtime.lifecycle` reports `ready`, `starting`, `running`, `failed`, `stopping`, or `stopped`. `server_info.observability.in_flight_calls` and `server_info.runtime.processes` distinguish a blocked call from a surviving process. `server_info.runtime.execution_guardrails` reports the enforced local concurrency/timeout/stdin/output/session limits and explicitly states that CPU quota, memory quota, and network isolation are not enforced in 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.
151
151
 
152
152
  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.
153
153
 
@@ -268,6 +268,8 @@ Defense-in-depth limits include:
268
268
  - application Accessibility inspection: 500 elements and depth 12; action text: 4,000 characters;
269
269
  - job-scoped temporary files: 16 files, 512 KiB total content.
270
270
 
271
+ The list above describes bounded application resources, not OS quotas. CPU time shares, resident-memory ceilings, syscall sandboxes, and egress policy must be imposed by the account/container/VM that runs Machine Bridge. Check `server_info.runtime.execution_guardrails.operating_system_enforcement`; current in-process values are intentionally `not-enforced` rather than inferred from timeouts or output limits.
272
+
271
273
  ## Upgrade behavior
272
274
 
273
275
  Policy revision 5 makes named profiles canonical and evaluates compound tool requirements from the shared contract. A state entry labelled `full` means writes, direct processes, process sessions, shell execution, unrestricted direct filesystem paths, absolute path output, the complete parent environment, and the complete tool catalog. CLI capability overrides are stored as `custom`. Persisted policies from another revision are rejected rather than interpreted.
package/docs/TESTING.md CHANGED
@@ -24,7 +24,7 @@ The suite includes:
24
24
  - foreground takeover of active and orphaned background daemons with current service-lock metadata, foreground-process protection, bounded final lock-handoff retry, actual-PID exit waiting, POSIX non-escalating timeout behavior, Windows verified-daemon stop semantics, daemon lock mode/version/process-start metadata, launchd service-target semantics, and silent idempotent duplicate service starts; daemon fixture subprocesses intentionally do not inherit V8 coverage because their purpose is ownership timing rather than code measurement;
25
25
  - fail-closed service lifecycle ordering for provider-stop, all-workspace daemon-stop, and definition removal, including platform/daemon/removal failure injection and normalized macOS/systemd/Windows results; Windows coverage reproduces an inline `/TR` command above 262 characters, proves the short launcher action remains bounded, verifies least-privilege logon registration, restart/log routing, language-independent `Ready`/`Running` observation, and state-observed stop/removal despite localized nonzero command output;
26
26
  - private service-environment capture/load coverage for exact allowlisting, value bounds and control-character rejection, non-proxy secret exclusion, runtime-value precedence, Windows case-insensitive replacement, explicit empty-value clearing, and preservation across a later environment-free startup;
27
- - machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, broker result redaction, pre-registered runtime-handshake listeners, bounded socket/HTTP waits, frozen-wall-clock browser deadline coverage, installed-application discovery caching, and name-based task matching;
27
+ - machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, broker result redaction, pre-registered runtime-handshake listeners, bounded socket/HTTP waits, direct loopback health despite hostile environment-proxy settings, stop-during-start generation invalidation, frozen-wall-clock browser deadline coverage, installed-application discovery caching, and name-based task matching;
28
28
  - Worker health direct/proxy routing through a real local HTTP CONNECT proxy, `NO_PROXY` bypass, exact `workers.dev` origin/name validation, redirect rejection, body/deadline bounds, error classification, bounded propagation retry, and invalid proxy fail-fast behavior without endpoint or credential disclosure;
29
29
  - Worker deployment ambiguity/idempotency: a successful Wrangler result followed by health timeout persists the fingerprint, a second start performs no upload, an actual process restart and disk-state reload still performs no upload, definitive stale-version evidence redeploys under the same name, accidental name changes are rejected, forced replacements clear current endpoint state, and prior names remain in uninstall inventory;
30
30
  - relay environment-proxy direct/bypass/agent selection, unsupported proxy rejection, fail-fast invalid configuration, and route observability without endpoint or credential disclosure;
@@ -38,13 +38,13 @@ The suite includes:
38
38
  - author-email privacy in `git_log`;
39
39
  - isolated command HOME/temp/cache behavior;
40
40
  - one-shot timeout, descendant process-group/tree termination including descendants that ignore graceful shutdown after the direct child has already exited, cancellation, and process-session interaction;
41
- - layered fixed runtime diagnostics for filesystem, direct process, shell, managed-job storage, and resource availability;
41
+ - layered fixed runtime diagnostics for filesystem, direct process, shell, managed-job storage, and resource availability; machine-readable execution guardrails that must keep CPU, memory, and network isolation marked unenforced unless an actual OS boundary is added;
42
42
  - local resource CLI registration, permission checks, dynamic reload, state-path redaction, and content non-disclosure;
43
43
  - real Ed25519 and RSA generation, idempotent reuse, public/private correspondence, mode enforcement, incomplete/mismatched/symlink rejection, and private-content non-disclosure;
44
44
  - real-machine canonical-full sandbox acceptance for outside-workspace I/O, direct/shell execution, full environment inheritance, SSH prerequisites, temporary authorized-key writing, and detached cleanup without external state changes;
45
45
  - deterministic injected atomic-replace failures, sustained transient Windows sharing contention beyond the old retry budget, bounded exponential delay selection, and repeated Windows full-sandbox runs;
46
46
  - canonical named-profile repair and full-only tool exposure parity between local and Worker policy filters;
47
- - managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, descendant-tree escalation, token/snapshot-safe transition locks, runner process identity, plan scrubbing, PID-reuse-safe dead-runner recovery, and rejection of numeric-only runner records;
47
+ - managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, descendant-tree escalation, token/snapshot-safe transition locks, runner process identity, spawn-without-PID rejection, asynchronous runner-error observability, plan scrubbing, PID-reuse-safe dead-runner recovery, and rejection of numeric-only runner records;
48
48
  - daemon/startup locking, successfully-read corrupt-JSON recovery, and explicit propagation/preservation of oversized, symbolic-link, permission, and I/O failures;
49
49
  - guarded state-root removal, unsafe state-root/workspace overlap rejection before creation, all-profile lock/daemon scanning, strict current-schema validation, corrupt-JSON isolation, and policy-origin persistence;
50
50
  - no filename-based sensitive-file denial under unrestricted policy;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
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",
@@ -14,13 +14,14 @@ import {
14
14
  } from "./browser-pairing-store.mjs";
15
15
  import { clampInt } from "./browser-command.mjs";
16
16
  import { BrowserOperationService } from "./browser-operation-service.mjs";
17
+ import { classifyOperationalError } from "./log.mjs";
17
18
 
18
19
  const MAX_PORT_ATTEMPTS = 10;
19
20
  const MAX_PENDING = 32;
20
21
  const EXTENSION_HANDSHAKE_MS = 3_000;
21
22
 
22
23
  export class BrowserBridgeManager {
23
- constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
24
+ constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {}, logger = null }) {
24
25
  this.policy = policy || {};
25
26
  this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
26
27
  this.stateRoot = stateRoot ? resolve(stateRoot) : "";
@@ -28,6 +29,7 @@ export class BrowserBridgeManager {
28
29
  this.readResourceText = readResourceText;
29
30
  this.readResourceBinary = readResourceBinary;
30
31
  this.throwIfCancelled = throwIfCancelled;
32
+ this.logger = logger || { event() {} };
31
33
  this.server = null;
32
34
  this.wss = null;
33
35
  this.socket = null;
@@ -44,6 +46,7 @@ export class BrowserBridgeManager {
44
46
  this.stopping = false;
45
47
  this.pending = new Map();
46
48
  this.startPromise = null;
49
+ this.startGeneration = 0;
47
50
  this.port = 0;
48
51
  this.token = "";
49
52
  this.extensionPath = resolve(packageRoot, "browser-extension");
@@ -124,30 +127,59 @@ export class BrowserBridgeManager {
124
127
  if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
125
128
  this.stopping = false;
126
129
  if (this.server || this.upstream?.readyState === 1) return;
127
- if (!this.startPromise) this.startPromise = this.start();
128
- try { await this.startPromise; } finally { this.startPromise = null; }
130
+ if (!this.startPromise) {
131
+ const generation = ++this.startGeneration;
132
+ this.startPromise = this.start(generation);
133
+ }
134
+ const pending = this.startPromise;
135
+ try { await pending; } finally {
136
+ if (this.startPromise === pending) this.startPromise = null;
137
+ }
129
138
  }
130
139
 
131
- async start() {
132
- const pairing = await loadOrCreatePairing(this.stateRoot);
133
- this.token = pairing.token;
134
- for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
135
- const port = pairing.port + offset;
136
- try {
137
- await this.listen(port);
138
- if (port !== pairing.port && this.stateRoot) await savePairing(this.stateRoot, { token: this.token, port });
139
- return;
140
- } catch (error) {
141
- if (error?.code !== "EADDRINUSE") throw error;
142
- if (await this.connectProxy(port)) {
143
- this.port = port;
140
+ async start(generation = this.startGeneration) {
141
+ try {
142
+ const pairing = await loadOrCreatePairing(this.stateRoot);
143
+ this.assertStartCurrent(generation);
144
+ this.token = pairing.token;
145
+ for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
146
+ const port = pairing.port + offset;
147
+ try {
148
+ await this.listen(port);
149
+ this.assertStartCurrent(generation);
150
+ if (port !== pairing.port && this.stateRoot) {
151
+ await savePairing(this.stateRoot, { token: this.token, port });
152
+ this.assertStartCurrent(generation);
153
+ }
144
154
  return;
155
+ } catch (error) {
156
+ this.assertStartCurrent(generation);
157
+ if (error?.code !== "EADDRINUSE") throw error;
158
+ if (await this.connectProxy(port, generation)) {
159
+ this.assertStartCurrent(generation);
160
+ this.port = port;
161
+ return;
162
+ }
163
+ this.assertStartCurrent(generation);
164
+ if (offset === MAX_PORT_ATTEMPTS - 1) throw error;
145
165
  }
146
- if (offset === MAX_PORT_ATTEMPTS - 1) throw error;
147
166
  }
167
+ } catch (error) {
168
+ const cancelled = !this.isStartCurrent(generation);
169
+ this.closeBrokerTransports(cancelled ? "browser bridge start cancelled" : "browser bridge start failed");
170
+ if (cancelled) throw new Error("browser bridge start cancelled");
171
+ throw error;
148
172
  }
149
173
  }
150
174
 
175
+ isStartCurrent(generation) {
176
+ return !this.stopping && generation === this.startGeneration;
177
+ }
178
+
179
+ assertStartCurrent(generation) {
180
+ if (!this.isStartCurrent(generation)) throw new Error("browser bridge start cancelled");
181
+ }
182
+
151
183
  async listen(port) {
152
184
  this.port = port;
153
185
  const server = createServer((request, response) => this.handleHttp(request, response));
@@ -192,7 +224,7 @@ export class BrowserBridgeManager {
192
224
  this.wss = wss;
193
225
  }
194
226
 
195
- async connectProxy(port) {
227
+ async connectProxy(port, generation = this.startGeneration) {
196
228
  const url = `ws://127.0.0.1:${port}/runtime`;
197
229
  return new Promise((resolvePromise) => {
198
230
  let settled = false;
@@ -220,6 +252,11 @@ export class BrowserBridgeManager {
220
252
  closeProtocolSocket(ws, 1002, "runtime hello required");
221
253
  return;
222
254
  }
255
+ if (!this.isStartCurrent(generation)) {
256
+ try { ws.close(1001, "runtime stopped"); } catch {}
257
+ finish(false);
258
+ return;
259
+ }
223
260
  this.upstream = ws;
224
261
  const claimedExtension = message.extension_connected === true;
225
262
  this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
@@ -450,7 +487,11 @@ export class BrowserBridgeManager {
450
487
  if (this.stopping || this.recoveryTimer) return;
451
488
  this.recoveryTimer = setTimeout(() => {
452
489
  this.recoveryTimer = null;
453
- void this.ensureStarted().catch(() => this.scheduleBrokerRecovery());
490
+ void this.ensureStarted().catch((error) => {
491
+ this.logger.event?.("debug", "browser.broker.recovery_failed", { error_class: classifyOperationalError(error) },
492
+ "browser broker recovery failed; retrying");
493
+ this.scheduleBrokerRecovery();
494
+ });
454
495
  }, 250);
455
496
  this.recoveryTimer.unref?.();
456
497
  }
@@ -525,9 +566,15 @@ export class BrowserBridgeManager {
525
566
 
526
567
  stop() {
527
568
  this.stopping = true;
569
+ this.startGeneration += 1;
528
570
  clearTimeout(this.recoveryTimer);
529
571
  this.recoveryTimer = null;
530
- this.rejectPending("browser bridge stopped");
572
+ this.closeBrokerTransports("browser bridge stopped");
573
+ }
574
+
575
+ closeBrokerTransports(message) {
576
+ this.rejectPending(message);
577
+ this.rejectProxyRoutes(message);
531
578
  try { this.upstream?.close(1001, "runtime stopped"); } catch {}
532
579
  try { this.socket?.close(1001, "runtime stopped"); } catch {}
533
580
  try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
@@ -541,8 +588,6 @@ export class BrowserBridgeManager {
541
588
  this.proxyExtensionReloadRequired = false;
542
589
  this.extensionReloadRequiredFlag = false;
543
590
  this.runtimeClients.clear();
544
- for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
545
- this.proxyRoutes.clear();
546
591
  try { this.wss?.close(); } catch {}
547
592
  try { this.server?.close(); } catch {}
548
593
  this.wss = null;
@@ -10,6 +10,7 @@ import {
10
10
  acquireStartupLockWithWait, expandHome, loadState, ownerOnlyFile, packageRoot, saveState,
11
11
  } from "./state.mjs";
12
12
  import { resolvePolicy } from "./cli-policy.mjs";
13
+ import { readLoopbackJson } from "./loopback-health.mjs";
13
14
 
14
15
  export function createLocalAdminCommands(dependencies) {
15
16
  const chooseWorkspace = dependencies.chooseWorkspace;
@@ -249,9 +250,7 @@ function readBrowserPairingState(pairingFile) {
249
250
  }
250
251
 
251
252
  function readBrowserHealth(healthUrl) {
252
- return fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
253
- .then(async (response) => response.ok ? await response.json() : null)
254
- .catch(() => null);
253
+ return readLoopbackJson(healthUrl, { pathname: "/healthz" });
255
254
  }
256
255
 
257
256
  function browserStatusResult(health, extensionPath, pairingUrl) {
@@ -11,6 +11,7 @@ import { inspectProcessInstance, isPidAlive, processCommandLine, splitProcessCom
11
11
 
12
12
  const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
13
13
  const DEFAULT_TAKEOVER_POLL_MS = 100;
14
+ const DEFAULT_FORCE_AFTER_MS = 2_000;
14
15
 
15
16
  export async function acquireDaemonLockWithTakeover(state, options = {}) {
16
17
  const ownerMetadata = options.ownerMetadata || {};
@@ -50,9 +51,10 @@ export async function acquireDaemonLockWithTakeover(state, options = {}) {
50
51
  export async function stopWorkspaceServiceDaemon(state, options = {}) {
51
52
  const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
52
53
  const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
54
+ const forceAfterMs = Math.min(timeoutMs, boundedPositiveInt(options.forceAfterMs, DEFAULT_FORCE_AFTER_MS));
53
55
  const logger = options.logger || { info() {}, warn() {} };
54
56
  const deadline = createMonotonicDeadline(timeoutMs);
55
- const signalled = new Set();
57
+ const signalled = new Map();
56
58
  let owner = options.owner || readDaemonLockOwner(daemonLockPathForState(state));
57
59
  let verified = false;
58
60
  let lastOwner = owner;
@@ -89,12 +91,42 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
89
91
  };
90
92
  }
91
93
  }
92
- signalled.add(Number(owner.pid));
94
+ signalled.set(Number(owner.pid), {
95
+ owner: { ...owner },
96
+ forceDeadline: createMonotonicDeadline(forceAfterMs),
97
+ forced: false,
98
+ });
93
99
  }
94
100
  }
95
101
 
96
- const liveSignalled = [...signalled].filter((pid) => isPidAlive(pid));
97
- const currentOwnerAlive = Boolean(owner?.pid && isPidAlive(owner.pid));
102
+ for (const [pid, signalState] of signalled) {
103
+ if (signalState.forced || !signalState.forceDeadline.expired()) continue;
104
+ const current = inspectProcessInstance(signalState.owner);
105
+ if (!current.current) continue;
106
+ const identity = inspectWorkspaceDaemonOwner(state, signalState.owner);
107
+ if (!identity.verified_service_daemon) continue;
108
+ logger.warn?.(`detached background daemon ignored graceful termination; forcing process ${pid} to stop`);
109
+ try {
110
+ process.kill(pid, "SIGKILL");
111
+ signalState.forced = true;
112
+ } catch (error) {
113
+ if (error?.code !== "ESRCH") {
114
+ return {
115
+ ok: false,
116
+ found: true,
117
+ verified_service_daemon: true,
118
+ reason: "force_signal_failed",
119
+ timeout_ms: timeoutMs,
120
+ ...publicDaemonOwner(signalState.owner),
121
+ };
122
+ }
123
+ }
124
+ }
125
+
126
+ const liveSignalled = [...signalled.entries()]
127
+ .filter(([, signalState]) => inspectProcessInstance(signalState.owner).current)
128
+ .map(([pid]) => pid);
129
+ const currentOwnerAlive = Boolean(owner?.pid && inspectProcessInstance(owner).current);
98
130
  if (!currentOwnerAlive && liveSignalled.length === 0) break;
99
131
 
100
132
  if (deadline.expired()) {
@@ -107,6 +139,7 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
107
139
  verified_service_daemon: verified,
108
140
  reason: "timeout",
109
141
  timeout_ms: timeoutMs,
142
+ force_after_ms: forceAfterMs,
110
143
  pid: remainingPid,
111
144
  mode: publicDaemonMode(lastOwner),
112
145
  version: typeof lastOwner?.version === "string" ? lastOwner.version : "unknown",
@@ -126,6 +159,8 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
126
159
  verified_service_daemon: verified,
127
160
  reason: verified ? "stopped" : "not_running",
128
161
  timeout_ms: timeoutMs,
162
+ force_after_ms: forceAfterMs,
163
+ forced: [...signalled.values()].some((item) => item.forced),
129
164
  ...(lastOwner ? publicDaemonOwner(lastOwner) : {}),
130
165
  };
131
166
  }
@@ -0,0 +1,36 @@
1
+ export const MAX_CONCURRENT_TOOL_CALLS = 16;
2
+ export const MAX_PROCESS_SESSIONS = 8;
3
+ export const MIN_PROCESS_TIMEOUT_SECONDS = 1;
4
+ export const MAX_PROCESS_TIMEOUT_SECONDS = 600;
5
+ export const DEFAULT_PROCESS_OUTPUT_BYTES = 512 * 1024;
6
+ export const MAX_PROCESS_STDIN_BYTES = 1024 * 1024;
7
+ export const MAX_PROCESS_SESSION_OUTPUT_BYTES = 1024 * 1024;
8
+ export const MAX_PROCESS_SESSION_STDIN_BYTES = 64 * 1024;
9
+ export const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
10
+
11
+ export function executionGuardrailsSnapshot() {
12
+ return {
13
+ tool_calls: {
14
+ maximum_concurrent: MAX_CONCURRENT_TOOL_CALLS,
15
+ },
16
+ one_shot_processes: {
17
+ timeout_seconds: { minimum: MIN_PROCESS_TIMEOUT_SECONDS, maximum: MAX_PROCESS_TIMEOUT_SECONDS },
18
+ stdin_max_bytes: MAX_PROCESS_STDIN_BYTES,
19
+ output_max_bytes_per_stream: DEFAULT_PROCESS_OUTPUT_BYTES,
20
+ process_tree_termination: "sigterm-then-sigkill",
21
+ },
22
+ process_sessions: {
23
+ maximum_concurrent: MAX_PROCESS_SESSIONS,
24
+ retained_output_max_bytes_per_stream: MAX_PROCESS_SESSION_OUTPUT_BYTES,
25
+ write_stdin_max_bytes: MAX_PROCESS_SESSION_STDIN_BYTES,
26
+ exited_retention_ms: PROCESS_SESSION_RETENTION_MS,
27
+ process_tree_termination: "sigterm-then-sigkill",
28
+ },
29
+ operating_system_enforcement: {
30
+ cpu_quota: "not-enforced",
31
+ memory_quota: "not-enforced",
32
+ network_isolation: "not-enforced",
33
+ required_boundary: "dedicated-low-privilege-account-or-vm-container",
34
+ },
35
+ };
36
+ }
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { executionEnv } from "./shell.mjs";
7
- import { terminateProcessTree } from "./process-sessions.mjs";
7
+ import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
8
8
  import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
9
9
  import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
10
10
  import { currentProcessStartTimeMs } from "./process-identity.mjs";
@@ -279,8 +279,7 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
279
279
  let killTimer = null;
280
280
  const timer = setTimeout(() => {
281
281
  timedOut = true;
282
- terminateProcessTree(child, "SIGTERM");
283
- killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
282
+ killTimer = terminateProcessTreeWithEscalation(child);
284
283
  }, timeoutMs);
285
284
  timer.unref?.();
286
285
  const cancellationPoll = setInterval(() => {
@@ -474,12 +473,10 @@ function requestCancellation() {
474
473
  cancelRequested = true;
475
474
  const child = activeChild;
476
475
  if (!child || !activeChildCancellationAware) return;
477
- terminateProcessTree(child, "SIGTERM");
478
476
  if (cancellationEscalation) return;
479
- cancellationEscalation = setTimeout(() => {
480
- terminateProcessTree(child, "SIGKILL");
481
- cancellationEscalation = null;
482
- }, 2000);
477
+ cancellationEscalation = terminateProcessTreeWithEscalation(child, {
478
+ onEscalated: () => { cancellationEscalation = null; },
479
+ });
483
480
  }
484
481
 
485
482
  function isCancellationRequested() {
@@ -0,0 +1,67 @@
1
+ import { request as requestHttp } from "node:http";
2
+
3
+ export function readLoopbackJson(input, options = {}) {
4
+ let target;
5
+ try { target = new URL(String(input)); } catch { return Promise.resolve(null); }
6
+ const pathname = typeof options.pathname === "string" && options.pathname.startsWith("/") ? options.pathname : "/healthz";
7
+ const port = Number(target.port);
8
+ if (target.protocol !== "http:"
9
+ || target.hostname !== "127.0.0.1"
10
+ || target.pathname !== pathname
11
+ || target.username || target.password || target.search || target.hash
12
+ || !Number.isInteger(port) || port < 1024 || port > 65535) {
13
+ return Promise.resolve(null);
14
+ }
15
+ const request = typeof options.request === "function" ? options.request : requestHttp;
16
+ const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 2000;
17
+ const maximumBytes = Number.isFinite(Number(options.maximumBytes)) ? Math.max(1, Number(options.maximumBytes)) : 64 * 1024;
18
+ return new Promise((resolvePromise) => {
19
+ let settled = false;
20
+ let client;
21
+ const finish = (value) => {
22
+ if (settled) return;
23
+ settled = true;
24
+ resolvePromise(value);
25
+ };
26
+ try {
27
+ client = request({
28
+ protocol: "http:", hostname: "127.0.0.1", port, path: pathname, method: "GET", agent: false,
29
+ headers: { accept: "application/json", connection: "close" },
30
+ }, (response) => {
31
+ if (response.statusCode !== 200) {
32
+ response.resume();
33
+ finish(null);
34
+ return;
35
+ }
36
+ const chunks = [];
37
+ let total = 0;
38
+ response.on("data", (chunk) => {
39
+ if (settled) return;
40
+ total += chunk.length;
41
+ if (total > maximumBytes) {
42
+ client.destroy();
43
+ finish(null);
44
+ return;
45
+ }
46
+ chunks.push(chunk);
47
+ });
48
+ response.on("end", () => {
49
+ if (settled) return;
50
+ try {
51
+ const value = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
52
+ finish(value && typeof value === "object" && !Array.isArray(value) ? value : null);
53
+ } catch {
54
+ finish(null);
55
+ }
56
+ });
57
+ response.on("error", () => finish(null));
58
+ });
59
+ } catch {
60
+ finish(null);
61
+ return;
62
+ }
63
+ client.setTimeout(timeoutMs, () => client.destroy(new Error("loopback health request timed out")));
64
+ client.on("error", () => finish(null));
65
+ client.end();
66
+ });
67
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
7
7
  import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
@@ -12,6 +12,7 @@ import { BridgeError } from "./errors.mjs";
12
12
  import { inspectResourceFile, normalizeResourceRegistry, validatePlan } from "./managed-job-plan.mjs";
13
13
  export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
14
14
  import { clampInteger } from "./numbers.mjs";
15
+ import { classifyOperationalError } from "./log.mjs";
15
16
 
16
17
  const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
17
18
  const MAX_JOBS = 50;
@@ -124,7 +125,7 @@ export class ManagedJobManager {
124
125
  status.cleanup_guarantee = "best-effort-finally-and-recovery";
125
126
  atomicWriteJson(statusFile, status, 256 * 1024);
126
127
  try {
127
- launchRunner(dir);
128
+ launchRunner(dir, false, "", { logger: this.logger });
128
129
  } catch (error) {
129
130
  failRunnerLaunch(dir, status, error);
130
131
  throw error;
@@ -181,7 +182,7 @@ export class ManagedJobManager {
181
182
  atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
182
183
  if (launch) {
183
184
  try {
184
- launchRunner(dir);
185
+ launchRunner(dir, false, "", { logger: this.logger });
185
186
  } catch (error) {
186
187
  failRunnerLaunch(dir, status, error);
187
188
  throw error;
@@ -356,7 +357,7 @@ export class ManagedJobManager {
356
357
  markRecoveryExhausted(dir, file, status, recoveryAttempts);
357
358
  return;
358
359
  }
359
- const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts, recoveryLock.token);
360
+ const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts, recoveryLock.token, this.logger);
360
361
  recoveryLock.handoff(runnerPid);
361
362
  handedOff = true;
362
363
  } finally {
@@ -513,7 +514,7 @@ function markRecoveryExhausted(dir, statusFile, status, recoveryAttempts) {
513
514
  scrubFinishedPlan(dir, status);
514
515
  }
515
516
 
516
- function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recoveryToken) {
517
+ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recoveryToken, logger) {
517
518
  status.status = "interrupted";
518
519
  status.updated_at = new Date().toISOString();
519
520
  status.finished_at = status.updated_at;
@@ -522,7 +523,7 @@ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recov
522
523
  atomicWriteJson(statusFile, status, 256 * 1024);
523
524
  rmSync(join(dir, "runtime"), { recursive: true, force: true });
524
525
  rmSync(join(dir, "runner.pid"), { force: true });
525
- return launchRunner(dir, true, recoveryToken);
526
+ return launchRunner(dir, true, recoveryToken, { logger });
526
527
  }
527
528
 
528
529
  function acquireRecoveryLock(dir) {
@@ -634,7 +635,7 @@ function replacePrivateTextFile(file, content) {
634
635
  ownerOnlyFile(file);
635
636
  }
636
637
 
637
- function launchRunner(dir, recover = false, recoveryToken = "") {
638
+ export function launchRunner(dir, recover = false, recoveryToken = "", options = {}) {
638
639
  const args = [RUNNER_PATH, "--job-dir", dir];
639
640
  if (recover) args.push("--recover");
640
641
  const stdoutFile = join(dir, "runner.out.log");
@@ -647,10 +648,12 @@ function launchRunner(dir, recover = false, recoveryToken = "") {
647
648
  try {
648
649
  stdoutFd = openPrivateAppendFile(stdoutFile);
649
650
  stderrFd = openPrivateAppendFile(stderrFile);
650
- child = spawn(process.execPath, args, {
651
+ const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
652
+ child = spawnProcess(process.execPath, args, {
651
653
  detached: true,
652
654
  stdio: ["ignore", stdoutFd, stderrFd],
653
655
  windowsHide: true,
656
+ shell: false,
654
657
  env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
655
658
  });
656
659
  } finally {
@@ -659,8 +662,18 @@ function launchRunner(dir, recover = false, recoveryToken = "") {
659
662
  }
660
663
  ownerOnlyFile(stdoutFile);
661
664
  ownerOnlyFile(stderrFile);
665
+ const logger = options.logger || console;
666
+ child.once?.("error", (error) => {
667
+ logger.error?.("managed job runner process reported an asynchronous failure", {
668
+ job_id: basename(dir),
669
+ recovery: recover,
670
+ error_class: classifyOperationalError(error),
671
+ });
672
+ });
673
+ const pid = Number(child.pid);
674
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("managed job runner did not receive a process id");
662
675
  child.unref();
663
- return child.pid;
676
+ return pid;
664
677
  }
665
678
 
666
679
 
@@ -0,0 +1,19 @@
1
+ export const MAX_COMMAND_BYTES = 64 * 1024;
2
+ export const MAX_ARGV_ITEMS = 256;
3
+
4
+ export function validateArgv(value) {
5
+ if (!Array.isArray(value) || !value.length || value.length > MAX_ARGV_ITEMS) {
6
+ throw new Error(`argv must contain 1-${MAX_ARGV_ITEMS} strings`);
7
+ }
8
+ const argv = value.map((item) => {
9
+ if (typeof item !== "string" || item.includes("\0")) {
10
+ throw new Error("argv entries must be strings without NUL bytes");
11
+ }
12
+ return item;
13
+ });
14
+ if (!argv[0]) throw new Error("argv[0] must not be empty");
15
+ if (Buffer.byteLength(JSON.stringify(argv)) > MAX_COMMAND_BYTES) {
16
+ throw new Error(`argv exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
17
+ }
18
+ return argv;
19
+ }
@@ -2,12 +2,13 @@ import { spawn } from "node:child_process";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { BoundedOutput } from "./bounded-output.mjs";
4
4
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
5
- import { MAX_COMMAND_BYTES, terminateProcessTreeWithEscalation, validateArgv } from "./process-sessions.mjs";
5
+ import { MAX_COMMAND_BYTES, validateArgv } from "./process-contract.mjs";
6
+ import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
6
7
  import { BridgeError } from "./errors.mjs";
7
8
  import { clampInteger } from "./numbers.mjs";
8
-
9
- const MAX_STDIN_BYTES = 1024 * 1024;
10
- const DEFAULT_OUTPUT_BYTES = 512 * 1024;
9
+ import {
10
+ DEFAULT_PROCESS_OUTPUT_BYTES, MAX_PROCESS_STDIN_BYTES, MAX_PROCESS_TIMEOUT_SECONDS, MIN_PROCESS_TIMEOUT_SECONDS,
11
+ } from "./execution-limits.mjs";
11
12
 
12
13
  function spawnDirectProcess(command, args, options) {
13
14
  // Keep the production child_process API call structurally separate from the
@@ -41,7 +42,7 @@ export class ProcessExecutionService {
41
42
  const argv = validateArgv(args.argv);
42
43
  const cwd = await this.resolveExistingPath(args.cwd || ".");
43
44
  if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "cwd is not a directory");
44
- return this.run(argv[0], argv.slice(1), clampInteger(args.timeout_seconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
45
+ return this.run(argv[0], argv.slice(1), clampInteger(args.timeout_seconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000, false, DEFAULT_PROCESS_OUTPUT_BYTES, context, cwd);
45
46
  }
46
47
 
47
48
  async runRegistered(args, context = {}) {
@@ -52,9 +53,9 @@ export class ProcessExecutionService {
52
53
  if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "registered command cwd is not a directory");
53
54
  const requested = args.timeout_seconds === undefined
54
55
  ? command.timeoutSeconds
55
- : clampInteger(args.timeout_seconds, command.timeoutSeconds, 1, 600);
56
+ : clampInteger(args.timeout_seconds, command.timeoutSeconds, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS);
56
57
  const timeoutSeconds = Math.min(requested, command.timeoutSeconds);
57
- const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
58
+ const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false, DEFAULT_PROCESS_OUTPUT_BYTES, context, cwd);
58
59
  return { name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result };
59
60
  }
60
61
 
@@ -69,16 +70,16 @@ export class ProcessExecutionService {
69
70
  if (command.includes("\0")) throw new BridgeError("invalid_request", "command contains a NUL byte");
70
71
  if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new BridgeError("limit_exceeded", `command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
71
72
  const shell = workspaceShellCommand(command);
72
- return this.run(shell.cmd, shell.args, clampInteger(timeoutSeconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context);
73
+ return this.run(shell.cmd, shell.args, clampInteger(timeoutSeconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000, false, DEFAULT_PROCESS_OUTPUT_BYTES, context);
73
74
  }
74
75
 
75
76
  terminateAll(signal = "SIGTERM", escalate = false) {
76
77
  this.processTracker.terminateAll(signal, escalate);
77
78
  }
78
79
 
79
- async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null) {
80
+ async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_PROCESS_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null) {
80
81
  this.throwIfCancelled(context);
81
- if (stdin !== null && Buffer.byteLength(String(stdin)) > MAX_STDIN_BYTES) {
82
+ if (stdin !== null && Buffer.byteLength(String(stdin)) > MAX_PROCESS_STDIN_BYTES) {
82
83
  throw new BridgeError("limit_exceeded", "process stdin exceeds 1 MiB");
83
84
  }
84
85
 
@@ -2,16 +2,14 @@ import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { basename } from "node:path";
4
4
  import { executionEnv } from "./shell.mjs";
5
+ import { validateArgv } from "./process-contract.mjs";
6
+ import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./process-tree.mjs";
5
7
  import { createToolAuthorizer } from "./policy.mjs";
6
8
  import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
7
9
  import { clampInteger } from "./numbers.mjs";
8
-
9
- export const MAX_COMMAND_BYTES = 64 * 1024;
10
- const MAX_ARGV_ITEMS = 256;
11
- const MAX_PROCESS_SESSIONS = 8;
12
- const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
13
- const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
14
- const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
10
+ import {
11
+ MAX_PROCESS_SESSIONS, MAX_PROCESS_SESSION_OUTPUT_BYTES, MAX_PROCESS_SESSION_STDIN_BYTES, PROCESS_SESSION_RETENTION_MS,
12
+ } from "./execution-limits.mjs";
15
13
 
16
14
  export class ProcessSessionManager {
17
15
  constructor({ workspace, policy, authorizeTool = null, runtimeDir, processTracker, resolveCwd, displayPath, throwIfCancelled }) {
@@ -70,6 +68,7 @@ export class ProcessSessionManager {
70
68
  signal: null,
71
69
  stdinClosed: false,
72
70
  waiters: new Set(),
71
+ terminationTimer: null,
73
72
  };
74
73
  this.sessions.set(session.id, session);
75
74
  this.trackChild(child, context.callId);
@@ -94,6 +93,11 @@ export class ProcessSessionManager {
94
93
  notifySessionWaiters(session);
95
94
  });
96
95
 
96
+ child.on("error", (error) => {
97
+ appendSessionStream(session.stderr, Buffer.from(`${boundedErrorMessage(error)}\n`));
98
+ session.lastActivity = Date.now();
99
+ notifySessionWaiters(session);
100
+ });
97
101
  try {
98
102
  await waitForSpawn(child);
99
103
  } catch (error) {
@@ -101,12 +105,6 @@ export class ProcessSessionManager {
101
105
  this.untrackChild(child);
102
106
  throw error;
103
107
  }
104
-
105
- child.on("error", (error) => {
106
- appendSessionStream(session.stderr, Buffer.from(`${boundedErrorMessage(error)}\n`));
107
- session.lastActivity = Date.now();
108
- notifySessionWaiters(session);
109
- });
110
108
  this.throwIfCancelled(context);
111
109
  return this.summary(session);
112
110
  }
@@ -144,7 +142,7 @@ export class ProcessSessionManager {
144
142
  const session = this.get(args.session_id);
145
143
  if (session.closedAt !== null) throw new Error("process session has already exited");
146
144
  const data = String(args.data ?? "");
147
- if (Buffer.byteLength(data) > MAX_PROCESS_STDIN_BYTES) throw new Error(`stdin data exceeds maximum size (${MAX_PROCESS_STDIN_BYTES} bytes)`);
145
+ if (Buffer.byteLength(data) > MAX_PROCESS_SESSION_STDIN_BYTES) throw new Error(`stdin data exceeds maximum size (${MAX_PROCESS_SESSION_STDIN_BYTES} bytes)`);
148
146
  if (session.stdinClosed || session.child.stdin.destroyed) throw new Error("process session stdin is closed");
149
147
  this.throwIfCancelled(context);
150
148
  if (data) {
@@ -165,9 +163,18 @@ export class ProcessSessionManager {
165
163
  const session = this.get(args.session_id);
166
164
  this.throwIfCancelled(context);
167
165
  const wasRunning = session.closedAt === null;
168
- if (wasRunning) terminateProcessTree(session.child, args.force === true ? "SIGKILL" : "SIGTERM");
166
+ const force = args.force === true;
167
+ if (wasRunning && force) terminateProcessTree(session.child, "SIGKILL");
168
+ else if (wasRunning && !session.terminationTimer) {
169
+ session.terminationTimer = terminateProcessTreeWithEscalation(session.child);
170
+ }
169
171
  session.lastActivity = Date.now();
170
- return { ...this.summary(session), termination_requested: wasRunning, force: args.force === true };
172
+ return {
173
+ ...this.summary(session),
174
+ termination_requested: wasRunning,
175
+ force,
176
+ force_after_ms: wasRunning && !force ? 2000 : null,
177
+ };
171
178
  }
172
179
 
173
180
  get(sessionId) {
@@ -220,38 +227,6 @@ export class ProcessSessionManager {
220
227
  }
221
228
  }
222
229
 
223
- export function validateArgv(value) {
224
- if (!Array.isArray(value) || !value.length || value.length > MAX_ARGV_ITEMS) throw new Error(`argv must contain 1-${MAX_ARGV_ITEMS} strings`);
225
- const argv = value.map((item) => {
226
- if (typeof item !== "string" || item.includes("\0")) throw new Error("argv entries must be strings without NUL bytes");
227
- return item;
228
- });
229
- if (!argv[0]) throw new Error("argv[0] must not be empty");
230
- if (Buffer.byteLength(JSON.stringify(argv)) > MAX_COMMAND_BYTES) throw new Error(`argv exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
231
- return argv;
232
- }
233
-
234
- export function terminateProcessTree(child, signal) {
235
- if (!child?.pid) return;
236
- if (process.platform === "win32") {
237
- try {
238
- const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
239
- killer.unref();
240
- return;
241
- } catch {}
242
- }
243
- try { process.kill(-child.pid, signal); } catch {
244
- try { child.kill(signal); } catch {}
245
- }
246
- }
247
-
248
- export function terminateProcessTreeWithEscalation(child, options = {}) {
249
- const graceMs = Number.isFinite(Number(options.graceMs)) ? Math.max(0, Number(options.graceMs)) : 2000;
250
- const schedule = typeof options.setTimeout === "function" ? options.setTimeout : setTimeout;
251
- terminateProcessTree(child, "SIGTERM");
252
- return schedule(() => terminateProcessTree(child, "SIGKILL"), graceMs);
253
- }
254
-
255
230
  function waitForSpawn(child) {
256
231
  return new Promise((resolvePromise, rejectPromise) => {
257
232
  const onSpawn = () => { cleanup(); resolvePromise(); };
@@ -273,8 +248,8 @@ function appendSessionStream(stream, chunk) {
273
248
  const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
274
249
  stream.totalBytes += input.length;
275
250
  let combined = stream.buffer.length ? Buffer.concat([stream.buffer, input]) : Buffer.from(input);
276
- if (combined.length > MAX_SESSION_OUTPUT_BYTES) {
277
- const dropped = combined.length - MAX_SESSION_OUTPUT_BYTES;
251
+ if (combined.length > MAX_PROCESS_SESSION_OUTPUT_BYTES) {
252
+ const dropped = combined.length - MAX_PROCESS_SESSION_OUTPUT_BYTES;
278
253
  combined = combined.subarray(dropped);
279
254
  stream.baseOffset += dropped;
280
255
  }
@@ -1,4 +1,4 @@
1
- import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./process-sessions.mjs";
1
+ import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./process-tree.mjs";
2
2
 
3
3
  export class ProcessTracker {
4
4
  constructor(options = {}) {
@@ -0,0 +1,53 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000;
4
+
5
+ export function terminateProcessTree(child, signal = "SIGTERM", options = {}) {
6
+ if (!child?.pid) return false;
7
+ const platform = options.platform || process.platform;
8
+ if (platform === "win32") {
9
+ try {
10
+ const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
11
+ const force = signal === "SIGKILL";
12
+ const killer = spawnProcess("taskkill.exe", ["/PID", String(child.pid), "/T", ...(force ? ["/F"] : [])], {
13
+ stdio: "ignore",
14
+ windowsHide: true,
15
+ shell: false,
16
+ });
17
+ const fallback = () => {
18
+ try { child.kill(signal); } catch {}
19
+ };
20
+ killer?.once?.("error", fallback);
21
+ killer?.once?.("exit", (code) => { if (code !== 0) fallback(); });
22
+ killer?.unref?.();
23
+ return true;
24
+ } catch {
25
+ // Fall through to ChildProcess.kill for environments without taskkill.
26
+ }
27
+ }
28
+
29
+ const killProcess = typeof options.killProcess === "function" ? options.killProcess : process.kill.bind(process);
30
+ try {
31
+ killProcess(-child.pid, signal);
32
+ return true;
33
+ } catch {
34
+ try {
35
+ return child.kill(signal) !== false;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ }
41
+
42
+ export function terminateProcessTreeWithEscalation(child, options = {}) {
43
+ const graceMs = Number.isFinite(Number(options.graceMs))
44
+ ? Math.max(0, Number(options.graceMs))
45
+ : DEFAULT_PROCESS_TERMINATION_GRACE_MS;
46
+ const schedule = typeof options.setTimeout === "function" ? options.setTimeout : setTimeout;
47
+ const terminate = typeof options.terminate === "function" ? options.terminate : terminateProcessTree;
48
+ terminate(child, "SIGTERM", options);
49
+ return schedule(() => {
50
+ terminate(child, "SIGKILL", options);
51
+ options.onEscalated?.();
52
+ }, graceMs);
53
+ }
@@ -3,6 +3,7 @@ import {
3
3
  allToolNames, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS,
4
4
  POLICY_PROFILES, SERVER_NAME,
5
5
  } from "./tools.mjs";
6
+ import { executionGuardrailsSnapshot } from "./execution-limits.mjs";
6
7
 
7
8
  export function buildRuntimeInfo({
8
9
  workspace,
@@ -47,7 +48,7 @@ export function buildRuntimeInfo({
47
48
  },
48
49
  tools: ["server_info", ...toolNames],
49
50
  observability: {
50
- relay_readiness: "authenticated-hello-acknowledged",
51
+ relay_readiness: "end-to-end-relay-probe-verified",
51
52
  brief_relay_interruptions: "debug-only",
52
53
  raw_transport_details: "debug-only",
53
54
  per_tool_events: "structured-debug-events",
@@ -63,6 +64,7 @@ export function buildRuntimeInfo({
63
64
  relay: relayStatus(),
64
65
  runtime_dir: policy.exposeAbsolutePaths ? runtimeDir : "<private-runtime-dir>",
65
66
  processes: processTracker.snapshot(),
67
+ execution_guardrails: executionGuardrailsSnapshot(),
66
68
  process_sessions: processSessionManager.status(),
67
69
  managed_jobs: managedJobManager.status(),
68
70
  local_resources: managedJobManager.resourceInfo(),
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { isRelayReadyContext, RelayConnection } from "./relay-connection.mjs";
6
6
  import { ProcessSessionManager } from "./process-sessions.mjs";
7
- export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
7
+ import { MAX_CONCURRENT_TOOL_CALLS } from "./execution-limits.mjs";
8
+ export { MAX_COMMAND_BYTES } from "./process-contract.mjs";
8
9
  import { MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, PolicyGate, SERVER_NAME } from "./tools.mjs";
9
10
  import { publicError } from "./errors.mjs";
10
11
  import { ProcessTracker } from "./process-tracker.mjs";
@@ -36,7 +37,6 @@ import {
36
37
  } from "./runtime-capabilities.mjs";
37
38
 
38
39
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
39
- const MAX_CONCURRENT_TOOL_CALLS = 16;
40
40
  const SLOW_TOOL_CALL_MS = 30_000;
41
41
 
42
42
  const RUNTIME_TOOL_HANDLERS = Object.freeze({
@@ -212,6 +212,7 @@ export class LocalRuntime {
212
212
  readResourceText,
213
213
  readResourceBinary,
214
214
  throwIfCancelled: (context) => this.throwIfCancelled(context),
215
+ logger: this.logger,
215
216
  });
216
217
  this.toolExecutor = new ToolExecutor({
217
218
  handlers: Object.fromEntries(Object.entries(RUNTIME_TOOL_HANDLERS).map(([name, handler]) => [
@@ -234,9 +235,7 @@ export class LocalRuntime {
234
235
  });
235
236
  }
236
237
 
237
- tools() {
238
- return this.policyGate.names().filter((name) => name !== "server_info");
239
- }
238
+ tools() { return this.policyGate.names().filter((name) => name !== "server_info"); }
240
239
 
241
240
  runtimeInfo() {
242
241
  return buildRuntimeInfo({
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { packageRoot } from "./state.mjs";
5
5
  import { BoundedOutput } from "./bounded-output.mjs";
6
+ import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
6
7
 
7
8
  export function runExecutable(command, args = [], options = {}) {
8
9
  const executable = validateExecutable(command);
@@ -28,8 +29,7 @@ export function runExecutable(command, args = [], options = {}) {
28
29
  if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
29
30
  timer = setTimeout(() => {
30
31
  timedOut = true;
31
- terminateCommandTree(child, false);
32
- killTimer = setTimeout(() => terminateCommandTree(child, true), 2000);
32
+ killTimer = terminateProcessTreeWithEscalation(child);
33
33
  }, timeoutMs);
34
34
  timer.unref?.();
35
35
  }
@@ -84,23 +84,6 @@ function validateExecutableArgs(value) {
84
84
  });
85
85
  }
86
86
 
87
- function terminateCommandTree(child, force) {
88
- if (!child?.pid) return;
89
- if (process.platform === "win32") {
90
- try {
91
- const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", ...(force ? ["/F"] : [])], {
92
- stdio: "ignore",
93
- windowsHide: true,
94
- });
95
- killer.unref();
96
- return;
97
- } catch {}
98
- }
99
- const signal = force ? "SIGKILL" : "SIGTERM";
100
- try { process.kill(-child.pid, signal); } catch {
101
- try { child.kill(signal); } catch {}
102
- }
103
- }
104
87
 
105
88
  function capturedResult(code, stdout, stderr, extraStderr = "") {
106
89
  const stderrText = [stderr.text(), extraStderr].filter(Boolean).join("\n");
@@ -27,7 +27,7 @@ import {
27
27
  } from "./http.ts";
28
28
 
29
29
  const SERVER_NAME = String(serverMetadata.name);
30
- const SERVER_VERSION = "1.2.6";
30
+ const SERVER_VERSION = "1.2.7";
31
31
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
32
32
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
33
33
  const JSONRPC_VERSION = "2.0";