machine-bridge-mcp 1.1.0 → 1.1.1

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1 - 2026-07-15
4
+
5
+ ### Windows Worker deployment convergence
6
+
7
+ - Make Worker health probes use the same standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` routing model as the relay. The previous direct `fetch` probe could time out on Windows or managed networks after Wrangler had already completed a valid deployment. Restrict probes to the recorded HTTPS `workers.dev` origin and reject redirects so persisted state cannot redirect the verifier to an arbitrary network target.
8
+ - Persist the successful Wrangler deployment URL, content/secret fingerprint, and deployed package version before the secondary health probe. A timeout, proxy failure, TLS failure, network failure, or temporary HTTP 5xx response now stops with corrective guidance and a later start verifies the same Worker instead of uploading it again; only bounded, definitive stale-version/identity evidence triggers automatic same-name redeployment.
9
+ - Prevent accidental Worker proliferation by rejecting a changed `--worker-name` for an initialized workspace unless `--force-worker` explicitly authorizes the replacement. Retain prior names in local inventory so full uninstall can delete intentionally replaced Workers. Add a real local CONNECT-proxy regression, deployment ambiguity/idempotency tests, worker-name replacement tests, expanded CLI/state coverage, and higher critical-module coverage floors.
10
+
3
11
  ## 1.1.0 - 2026-07-15
4
12
 
5
13
  ### Claude and Microsoft Copilot Studio remote MCP
package/README.md CHANGED
@@ -120,6 +120,10 @@ On first remote start, the CLI:
120
120
  7. creates the initial `owner` account when needed and prints its password once;
121
121
  8. prints the Remote MCP URL.
122
122
 
123
+ Worker deployment is convergent rather than append-only. Each workspace retains one stable Worker name. After Wrangler reports a successful upload, Machine Bridge records the Worker URL, package/secret fingerprint, and deployed version before performing the independent `/healthz` verification. A proxy, TLS, network, timeout, or temporary service failure therefore does not erase the successful deployment evidence or cause the next start to upload again. Only a bounded health result proving that the recorded Worker is stale causes an automatic redeploy under the same name.
124
+
125
+ Do not append suffixes such as `-r2` to work around a health timeout: a different `--worker-name` is a different Cloudflare Worker. Changing an initialized workspace's Worker name is rejected unless the same command also supplies `--force-worker`, which makes the replacement intentional and keeps the prior name in uninstall inventory.
126
+
123
127
  A normal `machine-mcp` invocation is a foreground start: it remains attached to the terminal and prints `info` logs. Startup and other state-changing operations use an owner-token/process-identity lock and wait up to 30 seconds for an ordinary concurrent startup to finish instead of failing on a brief launchd/systemd race. After a global package upgrade, the CLI unloads the platform service and also checks the workspace lock for a detached/orphan `--daemon-only` process that the service manager no longer tracks. It terminates only a process whose PID, process start time, entrypoint, canonical workspace, canonical state root, and daemon-only arguments all match, waits up to 15 seconds for the PID and lock to disappear, and then starts the installed version in the foreground. A genuine foreground or unverifiable conflict is left untouched and reported with actionable guidance. To run only in the background, use `machine-mcp service start`; inspect its owner-only logs under `~/.local/state/machine-bridge-mcp/logs/`.
124
128
 
125
129
  Recommended upgrade sequence: repeat the package-free temporary-directory installation above, then run:
@@ -492,7 +496,7 @@ Default state roots:
492
496
 
493
497
  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.
494
498
 
495
- The relay honors standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment routing. Invalid or unsupported proxy configuration fails fast; runtime status reports only `direct`, `proxy`, or `invalid-proxy-configuration`, never proxy URLs or credentials.
499
+ 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.
496
500
 
497
501
  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).
498
502
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "1.1.0",
4
+ "version": "1.1.1",
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.1.0"
33
+ "version_name": "1.1.1"
34
34
  }
@@ -211,9 +211,17 @@ Child processes run in a separate process group where supported. Timeout, cancel
211
211
 
212
212
  Managed jobs use the same argv/environment primitives but a different lifecycle. Each job is capped at 16 main and 16 finally steps, 50 retained jobs, 64 registered resources, 8 MiB of referenced resource bytes, 512 KiB of temporary-file content, and bounded per-step output. They are non-interactive. Resource paths/stdin/environment are injected only inside the runner. Exact resource output redaction is defense in depth; discard capture is the strong option when a command may echo credentials.
213
213
 
214
+ ## Worker deployment convergence
215
+
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
+
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.
219
+
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
+
214
222
  ## Daemon reconnect and replacement
215
223
 
216
- The local `RelayConnection` treats proxy selection, transport construction, transport open, authenticated readiness, and outage recovery as separate states. `network-proxy.mjs` maps WebSocket targets to standard HTTP(S) environment-proxy resolution, honors `NO_PROXY`, rejects non-HTTP(S) proxy schemes, and creates the proxy agent without exposing its URL or credentials. Invalid proxy configuration is a fatal configuration error rather than a retryable outage.
224
+ The local `RelayConnection` treats proxy selection, transport construction, transport open, authenticated readiness, and outage recovery as separate states. The shared proxy module maps WebSocket targets to standard HTTP(S) environment-proxy resolution, honors `NO_PROXY`, rejects non-HTTP(S) proxy schemes, and creates the proxy agent without exposing its URL or credentials. Invalid proxy configuration is a fatal configuration error rather than a retryable outage.
217
225
 
218
226
  The local `RelayConnection` otherwise treats transport construction, transport open, authenticated readiness, and outage recovery as separate states. A connection-attempt deadline terminates sockets stuck in `CONNECTING`; after WebSocket open it sends `hello` and reports readiness only after `hello_ack`. A missing acknowledgement reaches a distinct handshake deadline and the candidate is terminated. Once ready, application heartbeats require inbound activity; a silent half-open socket is terminated and reconnected. Outage reminders run on their own exponential-backoff timer rather than depending on another transport callback.
219
227
 
package/docs/AUDIT.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Engineering and security audit
2
2
 
3
+ ## 2026-07-15 Windows Worker deployment convergence incident
4
+
5
+ A Windows report showed Wrangler creating a Worker successfully, followed by `Worker deployment did not become healthy at the expected version: timeout`. Re-running startup, and then changing the name to a suffixed `-r2` variant, left multiple Workers in the Cloudflare account. The causal defect was not Worker creation itself: Wrangler and the long-lived relay already followed standard proxy routing, while the independent health verifier used a direct global `fetch`. On networks where outbound HTTPS required an environment proxy, deployment could succeed and health verification could time out.
6
+
7
+ The state transition amplified that routing split. The previous path removed or withheld the deployment fingerprint until `/healthz` succeeded. A successful remote write followed by an ambiguous read therefore looked identical to “not deployed” on the next start and caused another Wrangler upload. Supplying another `--worker-name` made the effect more visible by asking Cloudflare for a distinct resource; Machine Bridge did not generate the suffix automatically, but it also did not guard that identity change.
8
+
9
+ The correction separates upload evidence from verification evidence. `worker-deployment.mjs` persists the detected URL, exact content/secret fingerprint, deployed package version, and timestamp immediately after Wrangler success. `worker-health.mjs` accepts only the recorded HTTPS `workers.dev` origin matching the Worker name, rejects redirects, and performs the bounded request through the same reviewed proxy resolver used by the WebSocket path, with response-size and deadline limits. A subsequent timeout, proxy/TLS/network fault, or temporary 5xx now returns an actionable verification error while preserving the fingerprint, so the next start performs a read-only verification. Persistent version/identity mismatch or `404`/`410` is retried for propagation and is the only automatic same-name redeployment trigger.
10
+
11
+ Existing Worker names are stable. A different `--worker-name` is rejected unless `--force-worker` explicitly authorizes an identity replacement; the prior validated name is retained in bounded uninstall inventory. The test reproduces the original network topology with a real local CONNECT proxy and an otherwise unresolvable hostname, proves one deployment across repeated health timeouts, proves stale-version convergence, and covers accidental/intentional name transitions. Coverage gates now include the extracted proxy, health, and deployment modules and raise the remaining CLI floors. Live duplicate deletion remains an operator decision because repository code cannot safely infer which pre-fix Worker is externally referenced.
12
+
3
13
  ## 2026-07-15 hosted-client OAuth interoperability audit
4
14
 
5
15
  The Claude and Microsoft Copilot Studio request was first tested against the existing protocol boundary rather than implemented as a brand allowlist. Both clients already use the deployed Streamable HTTP endpoint; the material gap was token continuity. Anthropic's connector contract advertises `offline_access`, performs form-encoded refreshes, and requires public-client refresh-token rotation. Copilot Studio's recommended Dynamic discovery path likewise discovers DCR/OAuth endpoints and exchanges refresh tokens. Adding browser origins alone would not implement either contract: Claude remote connectors originate from Anthropic cloud infrastructure, while Copilot Studio uses Power Platform connectivity. The fixed ChatGPT/Grok browser CORS set therefore remains unchanged.
@@ -18,6 +18,7 @@ This document records project-wide decisions that must survive individual fixes,
18
18
  12. **Read failure is not empty state.** Permission, type, symbolic-link, size, encoding, and I/O errors must propagate. Corrupt backup/reconstruction applies only after a successful read proves that JSON content is invalid.
19
19
  13. **The public protocol contract is current-only.** Shared metadata advertises only the current MCP protocol version. Compatibility code for obsolete protocol dates, lock formats, or state schemas is not retained in the final runtime; upgrade safety comes from explicit version negotiation, fail-closed state validation, and bounded operator convergence.
20
20
  14. **Security analysis is a failing gate.** CodeQL or Scorecard execution alone is not success. Generated SARIF must contain no unaccepted result, and missing rule metadata fails closed rather than being interpreted as non-security. An intentional or externally constrained finding requires an exact rule/path record with a substantive rationale and an expiry date.
21
+ 15. **Ambiguous health is not permission to repeat a remote write.** A successful Wrangler deployment is recorded before secondary health verification. Timeout, proxy, TLS, network, and temporary service failures preserve the deployment fingerprint and fail for diagnosis; only bounded evidence of a stale identity/version permits automatic same-name redeployment. Changing the Worker name is an explicit remote-resource transition, not a retry strategy.
21
22
 
22
23
  A proposed change that conflicts with an invariant requires an explicit owner decision and corresponding documentation update. It must not be hidden inside an unrelated refactor.
23
24
 
@@ -109,7 +110,7 @@ A higher branch count is acceptable only when the function is an explicit state
109
110
 
110
111
  - Preserve a stable coarse error class for automation and a concise human message for operators.
111
112
  - Do not retry an operation merely because it failed; retry only when the error is positively classified as transient and the operation is idempotent or server state is checked after ambiguity.
112
- - When a remote write may have succeeded before the response was lost, query authoritative state before repeating it.
113
+ - When a remote write may have succeeded before the response was lost, query authoritative state before repeating it. When the write itself returned success but a later verification read failed, persist the successful write evidence before returning the verification error.
113
114
  - Duration-based deadlines must use a monotonic clock. Wall time remains appropriate for persisted timestamps, credential expiry, retention, and operator-visible dates, but a system clock correction must not extend or prematurely terminate an in-process wait.
114
115
  - Timeouts must terminate the relevant process tree. Forced escalation must remain referenced until resistant descendants are handled; do not clear it merely because the direct child exited.
115
116
  - Half-open connections need liveness detection, not only periodic writes.
@@ -140,6 +140,7 @@ Common installation failures:
140
140
  | `Invalid property "node"` or `Invalid property "devEngines.node"` | An old npm parser inspected incompatible project metadata | Run the install from a new empty temporary directory |
141
141
  | `machine-mcp: command not found` | The global npm binary directory is not on `PATH` | Reopen the terminal, inspect `npm prefix -g`, and add its binary directory to `PATH` |
142
142
  | Native package install warning | npm did not approve reviewed native build scripts | Use the documented `--allow-scripts` and `--omit=optional` command exactly |
143
+ | Worker deployment completed, then health verification timed out | The independent `/healthz` route is blocked, mis-proxied, or temporarily unavailable; the upload may already be valid | Keep the recorded Worker name, run `machine-mcp status` and `machine-mcp doctor` from the same proxy environment, then retry normally. Do not invent a suffixed `--worker-name`; the saved fingerprint prevents a duplicate upload. |
143
144
 
144
145
  ## 6. First remote start
145
146
 
@@ -168,6 +169,8 @@ The first start performs these operations:
168
169
 
169
170
  The foreground command remains attached to the terminal. Keep it running while testing. The remote Worker cannot execute local tools when no authenticated daemon is connected.
170
171
 
172
+ The Worker name is a persistent workspace identity, not a retry counter. A successful Wrangler upload is recorded before health verification, so a later timeout does not require a new name and does not make the next start upload again. Supplying a different `--worker-name` for an initialized workspace requires `--force-worker` because it intentionally creates/replaces a separate Cloudflare Worker identity.
173
+
171
174
  To run only in the background after setup:
172
175
 
173
176
  ```sh
package/docs/LOGGING.md CHANGED
@@ -33,6 +33,12 @@ Foreground mode defaults to `info` and human-readable text. Platform autostart s
33
33
  | `info` | Deployment/startup transitions, authenticated relay readiness, and recovery after a visible outage |
34
34
  | `debug` | Transport open/close codes and reasons, brief self-healing interruptions, retry timing, per-tool outcomes, and shortened correlation IDs |
35
35
 
36
+ ## Worker deployment and health event policy
37
+
38
+ Wrangler upload and `/healthz` verification are logged as separate state transitions. A successful upload followed by ambiguous health failure must say that the deployment fingerprint was retained and that retry will verify rather than redeploy. Existing-state timeout, proxy, TLS, network, and temporary server failures must say that no deployment was attempted. Only a persistent stale identity/version result may emit the warning that the same Worker is being redeployed.
39
+
40
+ Health routing records only `direct` or `proxy` at debug level. Proxy URLs, credentials, request headers, Worker secrets, and raw response bodies are never fields. Repeated per-attempt health failures remain debug-only; the terminal startup error contains one user-readable classification and corrective commands.
41
+
36
42
  ## Relay connection event policy
37
43
 
38
44
  A TCP/WebSocket `open` event is only transport availability. The daemon is reported as connected only after the Worker returns `hello_ack` for the authenticated `hello` message.
@@ -10,6 +10,24 @@ machine-mcp service status
10
10
 
11
11
  `status` prints redacted profile state and verifies the deployed Worker version. Resource source paths remain redacted. `doctor` checks Node.js, the package-installed Wrangler binary, Cloudflare login, Worker health, and the same fixed local filesystem/process/shell/job-storage/resource probes exposed by `diagnose_runtime`. Public `/healthz` output contains only server identity and version; daemon details require an authenticated `server_info` call.
12
12
 
13
+ ### Worker deployment and health convergence
14
+
15
+ Wrangler upload and public health verification are two separate observations. Once Wrangler reports a successful deployment and supplies the `workers.dev` URL, Machine Bridge immediately records that URL together with the exact deployment fingerprint and package version. It then verifies `/healthz` through the standard `HTTPS_PROXY`/`HTTP_PROXY` and `NO_PROXY` environment route. If that secondary probe times out or encounters a proxy, TLS, network, or temporary HTTP 5xx failure, startup stops with an actionable error, but the successful deployment evidence remains. The next ordinary start verifies the same Worker and does not repeat the upload.
16
+
17
+ Automatic redeployment is limited to bounded health evidence that the recorded endpoint is genuinely stale: a persistent package-version mismatch, an unexpected Machine Bridge identity, or a persistent `404`/`410`. Unreachability is not proof of absence. `--force-worker` remains the explicit override when an operator deliberately wants an upload despite matching state.
18
+
19
+ Each workspace has one stable Worker name. A command that supplies a different `--worker-name` after initialization is rejected unless `--force-worker` is present. A name such as `mbm-example-r2` is a separate Cloudflare Worker, not a revision of `mbm-example`. If an older release already created duplicates, first run `machine-mcp status` and verify the active URL/name, then remove only the confirmed unused Workers in the Cloudflare dashboard or with Wrangler. Source upgrades do not delete ambiguous live deployments automatically.
20
+
21
+ For a health timeout on Windows or a managed network:
22
+
23
+ ```powershell
24
+ machine-mcp status
25
+ machine-mcp doctor
26
+ machine-mcp --verbose
27
+ ```
28
+
29
+ Run these commands from the same environment used for startup so `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` are identical. Debug logs expose only the selected route and classified error; they never print a proxy endpoint or credentials.
30
+
13
31
  ### Blocking-layer decision table
14
32
 
15
33
  | Result | Interpretation |
package/docs/TESTING.md CHANGED
@@ -24,6 +24,8 @@ 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;
26
26
  - 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
+ - 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;
28
+ - Worker deployment ambiguity/idempotency: a successful Wrangler result followed by health timeout persists the fingerprint, a second start 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;
27
29
  - relay environment-proxy direct/bypass/agent selection, unsupported proxy rejection, fail-fast invalid configuration, and route observability without endpoint or credential disclosure;
28
30
  - canonical path and symbolic-link escape tests;
29
31
  - relative-path privacy and error-path redaction tests;
@@ -74,7 +76,7 @@ For deterministic release validation, perform an isolated-profile smoke test wit
74
76
 
75
77
  `npm run coverage:test` runs selected in-process and lightweight entrypoint fixtures under V8 coverage and enforces per-module baselines for policy, typed errors, call registration, execution middleware, lifecycle/observability, logging, Runtime/CLI orchestration, and Worker pending/policy/error modules. The full stdio and Worker OAuth/MCP integrations run separately in the main suite and are not duplicated inside coverage collection. The gate deliberately reports each module rather than one aggregate percentage. JavaScript modules enforce function and branch minima; Node's direct TypeScript stripping does not expose trustworthy branch ranges, so TypeScript modules enforce function coverage plus explicit state-machine tests and the real workerd integration suite.
76
78
 
77
- The current CLI entrypoint remains the weakest covered orchestration surface; its baseline is therefore reported and locked independently, while extracted `cli-options`, `cli-policy`, `records`, and `state-inventory` modules carry substantially higher thresholds. The state-inventory extraction raised the CLI branch floor from 5% to 10%; a refactor may raise a threshold, but must not lower one merely to make CI green without an explicit audit explanation.
79
+ The current CLI entrypoint remains the weakest covered orchestration surface; its baseline is therefore reported and locked independently, while extracted `cli-options`, `cli-policy`, `records`, `state-inventory`, `network-proxy`, `worker-health`, and `worker-deployment` modules carry substantially higher thresholds. The Worker convergence extraction and expanded command fixtures raised the CLI function floor from 40% to 45% and branch floor from 10% to 20%; a refactor may raise a threshold, but must not lower one merely to make CI green without an explicit audit explanation.
78
80
 
79
81
  ## Additional release checks
80
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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",
@@ -43,7 +43,7 @@
43
43
  "worker:types": "node scripts/generate-worker-types.mjs",
44
44
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
45
45
  "syntax": "node scripts/syntax-check.mjs",
46
- "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run install:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test && npm run oauth-browser:test",
46
+ "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run worker-deployment:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run install:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test && npm run oauth-browser:test",
47
47
  "worker:dry-run": "wrangler deploy --dry-run",
48
48
  "worker:integration-test": "node tests/worker-integration-test.mjs",
49
49
  "release:check": "node scripts/github-release.mjs --check",
@@ -107,7 +107,8 @@
107
107
  "lint:test": "node tests/lint-gate-test.mjs",
108
108
  "shell:test": "node tests/shell-test.mjs",
109
109
  "deadline:test": "node tests/monotonic-deadline-test.mjs",
110
- "oauth-browser:test": "node tests/oauth-browser-navigation-test.mjs"
110
+ "oauth-browser:test": "node tests/oauth-browser-navigation-test.mjs",
111
+ "worker-deployment:test": "node tests/worker-deployment-test.mjs"
111
112
  },
112
113
  "dependencies": {
113
114
  "https-proxy-agent": "9.1.0",
@@ -18,6 +18,7 @@ const tests = [
18
18
  "tests/records-test.mjs",
19
19
  "tests/project-metadata-test.mjs",
20
20
  "tests/state-inventory-test.mjs",
21
+ "tests/worker-deployment-test.mjs",
21
22
  ];
22
23
 
23
24
  try {
@@ -45,13 +46,16 @@ try {
45
46
  "src/local/process-tracker.mjs": [65, 35],
46
47
  "src/local/log.mjs": [60, 40],
47
48
  "src/local/runtime.mjs": [60, 35],
48
- "src/local/cli.mjs": [40, 10],
49
+ "src/local/cli.mjs": [45, 20],
49
50
  "src/local/cli-options.mjs": [65, 35],
50
51
  "src/local/cli-policy.mjs": [70, 35],
51
52
  "src/local/numbers.mjs": [100, 100],
52
53
  "src/local/records.mjs": [100, 100],
53
54
  "src/local/project-metadata.mjs": [85, 55],
54
- "src/local/state-inventory.mjs": [80, 50],
55
+ "src/local/state-inventory.mjs": [85, 55],
56
+ "src/local/network-proxy.mjs": [90, 65],
57
+ "src/local/worker-health.mjs": [85, 60],
58
+ "src/local/worker-deployment.mjs": [80, 55],
55
59
  "src/worker/pending-calls.ts": [85, null],
56
60
  "src/worker/policy.ts": [90, null],
57
61
  "src/worker/errors.ts": [100, null],
@@ -22,7 +22,7 @@ const COMMAND_OPTIONS = {
22
22
  status: new Set(["workspace", "stateDir"]),
23
23
  doctor: new Set(["workspace", "stateDir"]),
24
24
  "full-test": new Set(["workspace", "stateDir", "json"]),
25
- "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "quiet"]),
25
+ "rotate-secrets": new Set(["workspace", "stateDir", "quiet"]),
26
26
  workspace: new Set(["workspace", "stateDir"]),
27
27
  service: new Set(["workspace", "stateDir", "quiet"]),
28
28
  autostart: new Set(["workspace", "stateDir", "quiet"]),
package/src/local/cli.mjs CHANGED
@@ -1,6 +1,5 @@
1
- import { createHmac } from "node:crypto";
2
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
- import path, { join, resolve } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
4
3
  import process from "node:process";
5
4
  import readline from "node:readline/promises";
6
5
  import { LocalRuntime } from "./runtime.mjs";
@@ -19,12 +18,13 @@ import { classifyOperationalError, createLogger, sanitizeLogText } from "./log.m
19
18
  import { runExecutable, runWrangler } from "./shell.mjs";
20
19
  import { runFullAccessTest } from "./full-access-test.mjs";
21
20
  import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
21
+ import { ensureWorkerDeployment } from "./worker-deployment.mjs";
22
+ import { workerHealth } from "./worker-health.mjs";
23
+ export { workerHealthUserReason } from "./worker-health.mjs";
22
24
  import { activeStateJobs, activeStateLocks, knownProfileStates, knownWorkerNames } from "./state-inventory.mjs";
23
- import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
24
25
  import {
25
26
  acquireMaintenanceLock,
26
27
  acquireStartupLockWithWait,
27
- appName,
28
28
  daemonLockPathForState,
29
29
  defaultFirstRunWorkspace,
30
30
  defaultStateRoot,
@@ -225,7 +225,7 @@ function reportExistingDaemon(args, state, owner, logger) {
225
225
  logger.plain(` Workspace: ${state.workspace.path}`);
226
226
  }
227
227
 
228
- function isIdempotentDaemonOnlyStart(args) {
228
+ export function isIdempotentDaemonOnlyStart(args) {
229
229
  if (!args.daemonOnly || args.json) return false;
230
230
  return !Boolean(
231
231
  args.profile
@@ -258,7 +258,11 @@ async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }
258
258
 
259
259
  async function prepareRemoteState({ args, workspace, state, logger }) {
260
260
  const workerName = validateWorkerName(args.workerName);
261
- ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
261
+ ensureWorkerSecrets(state, {
262
+ rotateSecrets: Boolean(args.rotateSecrets),
263
+ workerName,
264
+ allowWorkerRename: Boolean(args.forceWorker),
265
+ });
262
266
  state.policy = resolvePolicy(args, state.policy);
263
267
  state.policy.updatedAt = new Date().toISOString();
264
268
  saveState(state);
@@ -366,146 +370,9 @@ async function clientConfigCommand(args) {
366
370
 
367
371
  async function ensureWorker(state, args) {
368
372
  const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "worker" });
369
- const desiredHash = workerDeploymentFingerprint(state);
370
- const expectedVersion = currentPackageVersion();
371
- const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.accountAdminSecret && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
372
- if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
373
- const health = await workerHealth(state.worker.url, expectedVersion);
374
- if (health.ok) {
375
- logger.success("Worker unchanged and healthy", { url: state.worker.url });
376
- return state.worker;
377
- }
378
- logger.warn("Worker is not healthy at the expected version; redeploying automatically", { reason: workerHealthUserReason(health.error) });
379
- logger.debug("Worker health check detail", { health_error: health.error });
380
- }
381
-
382
- logger.info("Checking Cloudflare Wrangler login");
383
- const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
384
- if (whoami.code !== 0) {
385
- logger.info("Wrangler is not logged in; opening Cloudflare login");
386
- await runWrangler(["login"]);
387
- }
388
-
389
- logger.info("Deploying Cloudflare Worker", { name: state.worker.name });
390
- const deploy = await withWorkerSecretsFile(state, secretFile => runWrangler([
391
- "deploy",
392
- "--name", state.worker.name,
393
- "--minify",
394
- "--keep-vars",
395
- "--secrets-file", secretFile,
396
- ], { capture: true }));
397
-
398
- const workerUrl = extractWorkerUrl(deploy.stdout) || extractWorkerUrl(deploy.stderr) || state.worker.url;
399
- if (!workerUrl) throw new Error("Worker deployed but URL could not be detected. Re-run with --worker-name or inspect Wrangler output.");
400
- state.worker.url = workerUrl.replace(/\/+$/, "");
401
- state.worker.mcpServerUrl = `${state.worker.url}/mcp`;
402
- delete state.worker.deployHash;
403
- state.worker.updatedAt = new Date().toISOString();
404
- saveState(state);
405
-
406
- const health = await retryHealth(state.worker.url, expectedVersion, 8);
407
- if (!health.ok) {
408
- throw new Error(`Worker deployment did not become healthy at the expected version: ${health.error}`);
409
- }
410
- state.worker.deployHash = desiredHash;
411
- saveState(state);
412
- logger.success("Worker ready", { url: state.worker.url, version: health.version });
413
- return state.worker;
414
- }
415
-
416
-
417
- function workerDeploymentFingerprint(state) {
418
- const keyMaterial = [
419
- String(state.worker.accountAdminSecret || ""),
420
- String(state.worker.daemonSecret || ""),
421
- String(state.worker.oauthTokenVersion || ""),
422
- ].join("\0");
423
- const fingerprint = createHmac("sha256", keyMaterial);
424
- fingerprint.update("mbm-worker-deploy-v3");
425
- fingerprint.update(String(state.worker.name || ""));
426
- for (const file of workerDeployHashFiles()) {
427
- fingerprint.update(path.relative(packageRoot, file));
428
- fingerprint.update(workerHashContent(file));
429
- }
430
- return fingerprint.digest("hex");
431
- }
432
-
433
- function workerHashContent(file) {
434
- return readFileSync(file, "utf8");
435
- }
436
-
437
- function workerDeployHashFiles() {
438
- const files = [];
439
- for (const item of ["src/worker", "src/shared", "wrangler.jsonc", "tsconfig.json"]) {
440
- collectHashFiles(resolve(packageRoot, item), files);
441
- }
442
- return files.sort();
373
+ return ensureWorkerDeployment(state, args, { logger });
443
374
  }
444
375
 
445
- function collectHashFiles(target, out) {
446
- if (!existsSync(target)) return;
447
- const info = statSync(target);
448
- if (info.isFile()) {
449
- if (/\.(ts|js|mjs|json|jsonc|yaml|yml|lock)$/.test(target)) out.push(target);
450
- return;
451
- }
452
- if (!info.isDirectory()) return;
453
- for (const entry of readdirSync(target, { withFileTypes: true })) {
454
- if (entry.name === "node_modules" || entry.name === ".wrangler" || entry.name.endsWith(".d.ts")) continue;
455
- collectHashFiles(resolve(target, entry.name), out);
456
- }
457
- }
458
-
459
- async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()) {
460
- if (!workerUrl) return { ok: false, error: "missing_worker_url" };
461
- try {
462
- const response = await fetch(`${String(workerUrl).replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(5000) });
463
- if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
464
- const body = await response.json().catch(() => null);
465
- if (body?.ok !== true || body?.server !== appName) return { ok: false, error: "unexpected_health_response" };
466
- if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}` };
467
- return { ok: true, version: body.version };
468
- } catch (error) {
469
- return { ok: false, error: workerHealthError(error) };
470
- }
471
- }
472
-
473
- export function workerHealthUserReason(value) {
474
- const reason = String(value || "");
475
- if (reason.startsWith("version_mismatch:")) return "deployed version does not match the local package";
476
- if (/^HTTP \d+$/.test(reason)) return "health endpoint returned an HTTP error";
477
- if (reason === "unexpected_health_response") return "health endpoint returned an unexpected response";
478
- if (reason === "timeout") return "health check timed out";
479
- if (reason === "tls_error") return "TLS validation failed";
480
- if (reason === "network_error") return "network request failed";
481
- if (reason === "missing_worker_url") return "Worker URL is missing";
482
- return "health check failed";
483
- }
484
-
485
- function workerHealthError(error) {
486
- const message = String(error?.message || error || "");
487
- if (/timeout|aborted/i.test(message)) return "timeout";
488
- if (/certificate|TLS|SSL/i.test(message)) return "tls_error";
489
- if (/fetch failed|network|ECONN|ENOTFOUND|EAI_AGAIN/i.test(message)) return "network_error";
490
- return "request_failed";
491
- }
492
-
493
- async function retryHealth(workerUrl, expectedVersion, attempts) {
494
- let last = { ok: false, error: "not_checked" };
495
- for (let i = 0; i < attempts; i += 1) {
496
- last = await workerHealth(workerUrl, expectedVersion);
497
- if (last.ok) return last;
498
- await sleep(1000 + i * 500);
499
- }
500
- return last;
501
- }
502
-
503
- function extractWorkerUrl(text = "") {
504
- const matches = [...String(text).matchAll(/https:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/g)];
505
- if (matches.length) return matches.at(-1)[0].replace(/[),.]+$/, "");
506
- const anyHttps = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)];
507
- return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
508
- }
509
376
 
510
377
  function printStartJson(state, { requestedChangesApplied = true, notice = "", initialOwner = null } = {}) {
511
378
  createLogger({ component: "ready" }).json({
@@ -566,7 +433,7 @@ async function statusCommand(args) {
566
433
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
567
434
  const state = loadState(workspace, { stateDir: args.stateDir });
568
435
  state.policy = resolvePolicy({}, state.policy);
569
- const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
436
+ const health = state.worker?.url ? await workerHealth(state.worker.url, currentPackageVersion(), { expectedWorkerName: state.worker.name }) : { ok: false, error: "no worker url" };
570
437
  const payload = {
571
438
  ...redactState(state),
572
439
  workerHealth: health,
@@ -597,7 +464,7 @@ async function doctorCommand(args) {
597
464
  checks.push({ name: "full-policy-contract", ok: false, detail: sanitizeLines(error?.message || error) });
598
465
  }
599
466
  }
600
- const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
467
+ const health = state.worker?.url ? await workerHealth(state.worker.url, currentPackageVersion(), { expectedWorkerName: state.worker.name }) : { ok: false, error: "no worker url" };
601
468
  checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
602
469
  const diagnosticRuntime = new LocalRuntime({
603
470
  workspace,
@@ -656,7 +523,7 @@ async function rotateSecretsCommand(args) {
656
523
  if (daemonIdentity?.current) {
657
524
  throw new Error(`refusing to rotate secrets while the daemon is active (pid ${daemonOwner.pid}); stop the foreground daemon and retry`);
658
525
  }
659
- ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
526
+ ensureWorkerSecrets(state, { rotateSecrets: true });
660
527
  saveState(state);
661
528
  console.log("Rotated account administration, daemon, and global token-version secrets.");
662
529
  console.log("All account access tokens are invalid. Run machine-mcp to redeploy, then reconnect clients.");
@@ -963,8 +830,8 @@ Commands:
963
830
 
964
831
  Start options:
965
832
  --workspace PATH Use and remember this workspace path
966
- --worker-name NAME Worker name (default: mbm-<workspace-hash>)
967
- --force-worker Force wrangler deploy even if hash and health match
833
+ --worker-name NAME Worker name (default: mbm-<workspace-hash>); changing an existing name requires --force-worker
834
+ --force-worker Force same-name deployment, or explicitly allow an intentional --worker-name replacement
968
835
  --rotate-secrets Rotate secrets before deploying
969
836
  --daemon-only Skip deploy and only connect daemon from existing state
970
837
  --no-autostart Do not install login autostart during start
@@ -1,21 +1,39 @@
1
1
  import { HttpsProxyAgent } from "https-proxy-agent";
2
2
  import { getProxyForUrl } from "proxy-from-env";
3
3
 
4
+ const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
5
+
4
6
  export function proxyAgentForWebSocket(webSocketUrl, proxyResolver = getProxyForUrl) {
5
7
  const target = new URL(String(webSocketUrl));
6
8
  if (target.protocol !== "ws:" && target.protocol !== "wss:") throw new Error("relay WebSocket URL must use ws or wss");
7
- const proxyLookupUrl = new URL(target);
8
- proxyLookupUrl.protocol = target.protocol === "wss:" ? "https:" : "http:";
9
- const proxyValue = String(proxyResolver(proxyLookupUrl.href) || "").trim();
9
+ const lookupUrl = new URL(target);
10
+ lookupUrl.protocol = target.protocol === "wss:" ? "https:" : "http:";
11
+ return proxyAgentForLookup(lookupUrl, proxyResolver, {
12
+ errorCode: "relay_proxy_configuration",
13
+ subject: "relay proxy",
14
+ });
15
+ }
16
+
17
+ export function proxyAgentForHttp(httpUrl, proxyResolver = getProxyForUrl) {
18
+ const target = new URL(String(httpUrl));
19
+ if (!HTTP_PROTOCOLS.has(target.protocol)) throw new Error("HTTP request URL must use http or https");
20
+ return proxyAgentForLookup(target, proxyResolver, {
21
+ errorCode: "http_proxy_configuration",
22
+ subject: "HTTP proxy",
23
+ });
24
+ }
25
+
26
+ function proxyAgentForLookup(lookupUrl, proxyResolver, context) {
27
+ const proxyValue = String(proxyResolver(lookupUrl.href) || "").trim();
10
28
  if (!proxyValue) return { agent: null, mode: "direct" };
11
29
  let proxyUrl;
12
30
  try {
13
31
  proxyUrl = new URL(proxyValue);
14
32
  } catch {
15
- throw proxyConfigurationError("relay proxy configuration is not a valid URL");
33
+ throw proxyConfigurationError(`${context.subject} configuration is not a valid URL`, context.errorCode);
16
34
  }
17
- if (!new Set(["http:", "https:"]).has(proxyUrl.protocol)) {
18
- throw proxyConfigurationError("relay proxy must use HTTP or HTTPS");
35
+ if (!HTTP_PROTOCOLS.has(proxyUrl.protocol)) {
36
+ throw proxyConfigurationError(`${context.subject} must use HTTP or HTTPS`, context.errorCode);
19
37
  }
20
38
  try {
21
39
  return {
@@ -23,12 +41,12 @@ export function proxyAgentForWebSocket(webSocketUrl, proxyResolver = getProxyFor
23
41
  mode: "proxy",
24
42
  };
25
43
  } catch {
26
- throw proxyConfigurationError("relay proxy configuration could not be initialized");
44
+ throw proxyConfigurationError(`${context.subject} configuration could not be initialized`, context.errorCode);
27
45
  }
28
46
  }
29
47
 
30
- function proxyConfigurationError(message) {
48
+ function proxyConfigurationError(message, code) {
31
49
  const error = new Error(message);
32
- error.code = "relay_proxy_configuration";
50
+ error.code = code;
33
51
  return error;
34
52
  }
@@ -28,11 +28,15 @@ export function knownWorkerNames(stateRoot) {
28
28
  throw unreadableWorkerState(entry.name);
29
29
  }
30
30
  if (!state || typeof state !== "object" || Array.isArray(state)) throw unreadableWorkerState(entry.name);
31
- const name = String(state?.worker?.name || "");
32
- if (name && !WORKER_NAME.test(name)) {
33
- throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
31
+ const currentName = String(state?.worker?.name || "");
32
+ const previousNames = Array.isArray(state?.worker?.previousNames) ? state.worker.previousNames : [];
33
+ for (const name of [currentName, ...previousNames]) {
34
+ if (!name) continue;
35
+ if (typeof name !== "string" || !WORKER_NAME.test(name)) {
36
+ throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
37
+ }
38
+ names.add(name);
34
39
  }
35
- if (name) names.add(name);
36
40
  }
37
41
  return [...names];
38
42
  }
@@ -594,7 +594,26 @@ export function ensureWorkerSecrets(state, options = {}) {
594
594
  if (!state.worker.accountAdminSecret || options.rotateSecrets) state.worker.accountAdminSecret = randomToken("account_admin");
595
595
  if (!state.worker.daemonSecret || options.rotateSecrets) state.worker.daemonSecret = randomToken("daemon_secret");
596
596
  if (!state.worker.oauthTokenVersion || options.rotateSecrets) state.worker.oauthTokenVersion = randomToken("token_version");
597
- if (!state.worker.name || options.workerName) state.worker.name = options.workerName || defaultWorkerName(state.workspace.hash);
597
+
598
+ const requestedName = options.workerName || "";
599
+ if (!state.worker.name) {
600
+ state.worker.name = requestedName || defaultWorkerName(state.workspace.hash);
601
+ return;
602
+ }
603
+ if (!requestedName || requestedName === state.worker.name) return;
604
+ if (!options.allowWorkerRename) {
605
+ throw new Error(`this workspace already uses Worker ${state.worker.name}; changing --worker-name to ${requestedName} would create another Worker. Re-run with --force-worker only when that replacement is intentional`);
606
+ }
607
+
608
+ const previous = String(state.worker.name);
609
+ const previousNames = Array.isArray(state.worker.previousNames) ? state.worker.previousNames : [];
610
+ state.worker.previousNames = [...new Set([...previousNames, previous])].slice(-32);
611
+ state.worker.name = requestedName;
612
+ delete state.worker.url;
613
+ delete state.worker.mcpServerUrl;
614
+ delete state.worker.deployHash;
615
+ delete state.worker.deployedVersion;
616
+ delete state.worker.updatedAt;
598
617
  }
599
618
 
600
619
  function defaultWorkerName(hash) {
@@ -0,0 +1,170 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import path, { resolve } from "node:path";
4
+ import { runWrangler } from "./shell.mjs";
5
+ import { packageRoot, saveState } from "./state.mjs";
6
+ import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
7
+ import {
8
+ retryWorkerHealth,
9
+ workerHealthRequiresRedeploy,
10
+ workerHealthUserReason,
11
+ } from "./worker-health.mjs";
12
+
13
+ export async function ensureWorkerDeployment(state, args = {}, options = {}) {
14
+ const logger = options.logger || console;
15
+ const expectedVersion = options.expectedVersion || currentPackageVersion(options.packageRoot || packageRoot);
16
+ const desiredHash = workerDeploymentFingerprint(state, { packageRoot: options.packageRoot || packageRoot });
17
+ const runWranglerFn = options.runWrangler || runWrangler;
18
+ const saveStateFn = options.saveState || saveState;
19
+ const retryHealthFn = options.retryHealth || retryWorkerHealth;
20
+ const withSecretsFileFn = options.withSecretsFile || withWorkerSecretsFile;
21
+ const healthOptions = { expectedWorkerName: state.worker.name, ...(options.healthOptions || {}) };
22
+ const complete = hasCompleteWorkerState(state.worker);
23
+
24
+ if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
25
+ const health = await retryHealthFn(state.worker.url, expectedVersion, options.existingHealthAttempts || 2, healthOptions);
26
+ if (health.ok) {
27
+ logger.success?.("Worker unchanged and healthy", { url: state.worker.url });
28
+ logger.debug?.("Worker health route", { network_route: health.networkRoute || "unknown" });
29
+ return state.worker;
30
+ }
31
+ if (!workerHealthRequiresRedeploy(health.error)) {
32
+ logger.debug?.("Worker health check detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
33
+ throw workerVerificationError(health.error, { deploymentSucceeded: false });
34
+ }
35
+ logger.warn?.("Recorded Worker is stale; redeploying the same Worker", { reason: workerHealthUserReason(health.error) });
36
+ logger.debug?.("Worker health check detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
37
+ }
38
+
39
+ logger.info?.("Checking Cloudflare Wrangler login");
40
+ const whoami = await runWranglerFn(["whoami"], { capture: true, allowFailure: true });
41
+ if (whoami.code !== 0) {
42
+ logger.info?.("Wrangler is not logged in; opening Cloudflare login");
43
+ await runWranglerFn(["login"]);
44
+ }
45
+
46
+ logger.info?.("Deploying Cloudflare Worker", { name: state.worker.name });
47
+ const deploy = await withSecretsFileFn(state, secretFile => runWranglerFn([
48
+ "deploy",
49
+ "--name", state.worker.name,
50
+ "--minify",
51
+ "--keep-vars",
52
+ "--secrets-file", secretFile,
53
+ ], { capture: true }));
54
+
55
+ const detectedUrl = extractWorkerUrl(deploy.stdout) || extractWorkerUrl(deploy.stderr);
56
+ const recordedUrl = workerUrlMatchesName(state.worker.url, state.worker.name) ? state.worker.url : "";
57
+ const workerUrl = detectedUrl || recordedUrl;
58
+ if (!workerUrl) {
59
+ throw new Error("Worker upload returned success, but Wrangler output contained no workers.dev URL and no matching recorded URL exists. The deployment fingerprint was not saved; rerun with --verbose and inspect the Wrangler output before retrying.");
60
+ }
61
+
62
+ state.worker.url = workerUrl.replace(/\/+$/, "");
63
+ state.worker.mcpServerUrl = `${state.worker.url}/mcp`;
64
+ state.worker.deployHash = desiredHash;
65
+ state.worker.deployedVersion = expectedVersion;
66
+ state.worker.updatedAt = new Date().toISOString();
67
+ saveStateFn(state);
68
+
69
+ const health = await retryHealthFn(state.worker.url, expectedVersion, options.deploymentHealthAttempts || 8, healthOptions);
70
+ if (!health.ok) {
71
+ logger.debug?.("Worker post-deployment health detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
72
+ throw workerVerificationError(health.error, { deploymentSucceeded: true });
73
+ }
74
+ logger.success?.("Worker ready", { url: state.worker.url, version: health.version });
75
+ logger.debug?.("Worker health route", { network_route: health.networkRoute || "unknown" });
76
+ return state.worker;
77
+ }
78
+
79
+ export function workerDeploymentFingerprint(state, options = {}) {
80
+ const root = resolve(options.packageRoot || packageRoot);
81
+ const keyMaterial = [
82
+ String(state.worker.accountAdminSecret || ""),
83
+ String(state.worker.daemonSecret || ""),
84
+ String(state.worker.oauthTokenVersion || ""),
85
+ ].join("\0");
86
+ const fingerprint = createHmac("sha256", keyMaterial);
87
+ fingerprint.update("mbm-worker-deploy-v3");
88
+ fingerprint.update(String(state.worker.name || ""));
89
+ for (const file of workerDeployHashFiles(root)) {
90
+ fingerprint.update(path.relative(root, file));
91
+ fingerprint.update(readFileSync(file, "utf8"));
92
+ }
93
+ return fingerprint.digest("hex");
94
+ }
95
+
96
+ export function extractWorkerUrl(text = "") {
97
+ const matches = [...String(text).matchAll(/https:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/g)];
98
+ if (matches.length) return matches.at(-1)[0].replace(/[),.]+$/, "");
99
+ const anyHttps = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)];
100
+ return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
101
+ }
102
+
103
+ export function workerUrlMatchesName(workerUrl, workerName) {
104
+ if (!workerUrl || !workerName) return false;
105
+ try {
106
+ const url = new URL(String(workerUrl));
107
+ return url.protocol === "https:"
108
+ && url.hostname.endsWith(".workers.dev")
109
+ && url.hostname.startsWith(`${String(workerName)}.`);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function hasCompleteWorkerState(worker = {}) {
116
+ return Boolean(worker.url && worker.mcpServerUrl && worker.accountAdminSecret && worker.daemonSecret && worker.oauthTokenVersion && worker.name);
117
+ }
118
+
119
+ function workerDeployHashFiles(root) {
120
+ const files = [];
121
+ for (const item of ["src/worker", "src/shared", "wrangler.jsonc", "tsconfig.json"]) {
122
+ collectHashFiles(resolve(root, item), files);
123
+ }
124
+ return files.sort();
125
+ }
126
+
127
+ function collectHashFiles(target, out) {
128
+ if (!existsSync(target)) return;
129
+ const info = statSync(target);
130
+ if (info.isFile()) {
131
+ if (/\.(ts|js|mjs|json|jsonc|yaml|yml|lock)$/.test(target)) out.push(target);
132
+ return;
133
+ }
134
+ if (!info.isDirectory()) return;
135
+ for (const entry of readdirSync(target, { withFileTypes: true })) {
136
+ if (entry.name === "node_modules" || entry.name === ".wrangler" || entry.name.endsWith(".d.ts")) continue;
137
+ collectHashFiles(resolve(target, entry.name), out);
138
+ }
139
+ }
140
+
141
+ function workerVerificationError(reason, { deploymentSucceeded }) {
142
+ const readable = workerHealthUserReason(reason);
143
+ const guidance = workerVerificationGuidance(reason);
144
+ const message = deploymentSucceeded
145
+ ? `Cloudflare reported the Worker deployment succeeded, but ${readable}. The deployment fingerprint was saved, so retrying will verify the same Worker instead of deploying again. ${guidance}`
146
+ : `The recorded Worker could not be verified because ${readable}. No deployment was attempted. ${guidance}`;
147
+ const error = new Error(message);
148
+ error.code = "worker_health_unverified";
149
+ error.healthError = reason;
150
+ error.deploymentSucceeded = deploymentSucceeded;
151
+ return error;
152
+ }
153
+
154
+ function workerVerificationGuidance(reason) {
155
+ if (reason === "proxy_configuration") {
156
+ return "Check HTTPS_PROXY, HTTP_PROXY, and NO_PROXY, then run machine-mcp doctor.";
157
+ }
158
+ if (["timeout", "tls_error", "network_error", "request_failed"].includes(reason)) {
159
+ return "Check network/TLS access and HTTPS_PROXY, HTTP_PROXY, and NO_PROXY, then run machine-mcp doctor.";
160
+ }
161
+ if (/^HTTP 5\d\d$/.test(String(reason || ""))) {
162
+ return "Retry after the service recovers, or run machine-mcp doctor for the current health result.";
163
+ }
164
+ return "Run machine-mcp doctor and inspect the health endpoint before forcing another deployment.";
165
+ }
166
+
167
+ function currentPackageVersion(root) {
168
+ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
169
+ return String(pkg.version);
170
+ }
@@ -0,0 +1,246 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { appName } from "./state.mjs";
4
+ import { proxyAgentForHttp } from "./network-proxy.mjs";
5
+
6
+ const MAX_HEALTH_BODY_BYTES = 64 * 1024;
7
+ const DEFAULT_HEALTH_TIMEOUT_MS = 5_000;
8
+ const NON_RETRYABLE_HEALTH_ERRORS = new Set(["missing_worker_url", "invalid_worker_url", "proxy_configuration"]);
9
+ const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10
+ const WORKERS_DEV_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.workers\.dev$/;
11
+
12
+ export async function workerHealth(workerUrl, expectedVersion, options = {}) {
13
+ if (!workerUrl) return { ok: false, error: "missing_worker_url" };
14
+ let healthUrl;
15
+ try {
16
+ healthUrl = workerHealthUrl(workerUrl, options.expectedWorkerName);
17
+ } catch {
18
+ return { ok: false, error: "invalid_worker_url" };
19
+ }
20
+
21
+ try {
22
+ const probe = typeof options.probe === "function" ? options.probe : requestAllowedWorkerHealthJson;
23
+ const result = await probe(healthUrl, {
24
+ timeoutMs: options.timeoutMs,
25
+ proxyResolver: options.proxyResolver,
26
+ proxyAgentForUrl: options.proxyAgentForUrl,
27
+ });
28
+ const networkRoute = result.networkRoute || "direct";
29
+ if (result.statusCode !== 200) return { ok: false, error: `HTTP ${result.statusCode}`, networkRoute };
30
+ const body = result.body;
31
+ if (body?.ok !== true || body?.server !== appName) return { ok: false, error: "unexpected_health_response", networkRoute };
32
+ if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}`, networkRoute };
33
+ return { ok: true, version: body.version, networkRoute };
34
+ } catch (error) {
35
+ return {
36
+ ok: false,
37
+ error: workerHealthError(error),
38
+ networkRoute: error?.networkRoute || (hasErrorCode(error, "http_proxy_configuration") ? "invalid-proxy-configuration" : "unknown"),
39
+ };
40
+ }
41
+ }
42
+
43
+ export async function retryWorkerHealth(workerUrl, expectedVersion, attempts, options = {}) {
44
+ const count = Math.max(1, Number.parseInt(String(attempts), 10) || 1);
45
+ const wait = typeof options.wait === "function" ? options.wait : sleep;
46
+ let last = { ok: false, error: "not_checked" };
47
+ for (let index = 0; index < count; index += 1) {
48
+ last = await workerHealth(workerUrl, expectedVersion, options);
49
+ if (last.ok) return last;
50
+ if (!isRetryableWorkerHealthError(last.error)) return last;
51
+ if (index + 1 < count) await wait(1_000 + index * 500);
52
+ }
53
+ return last;
54
+ }
55
+
56
+ export function workerHealthUrl(workerUrl, expectedWorkerName = "") {
57
+ const base = new URL(`${String(workerUrl).replace(/\/+$/, "")}/`);
58
+ const hostname = base.hostname.toLowerCase();
59
+ const expectedName = String(expectedWorkerName || "").toLowerCase();
60
+ if (base.protocol !== "https:" || base.port || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
61
+ throw new Error("Worker URL must be an HTTPS workers.dev origin");
62
+ }
63
+ if (!WORKERS_DEV_HOST.test(hostname)) throw new Error("Worker URL must use a workers.dev hostname");
64
+ if (expectedName && (!WORKER_NAME.test(expectedName) || hostname.split(".")[0] !== expectedName)) {
65
+ throw new Error("Worker URL hostname does not match the recorded Worker name");
66
+ }
67
+ return `https://${hostname}/healthz`;
68
+ }
69
+
70
+ export function isRetryableWorkerHealthError(value) {
71
+ return !NON_RETRYABLE_HEALTH_ERRORS.has(String(value || ""));
72
+ }
73
+
74
+ export function workerHealthRequiresRedeploy(value) {
75
+ const reason = String(value || "");
76
+ return reason.startsWith("version_mismatch:")
77
+ || reason === "unexpected_health_response"
78
+ || reason === "HTTP 404"
79
+ || reason === "HTTP 410";
80
+ }
81
+
82
+ export function workerHealthUserReason(value) {
83
+ const reason = String(value || "");
84
+ if (reason.startsWith("version_mismatch:")) return "deployed version does not match the local package";
85
+ if (/^HTTP \d+$/.test(reason)) return "health endpoint returned an HTTP error";
86
+ if (reason === "unexpected_health_response") return "health endpoint returned an unexpected response";
87
+ if (reason === "timeout") return "health check timed out";
88
+ if (reason === "tls_error") return "TLS validation failed";
89
+ if (reason === "network_error") return "network request failed";
90
+ if (reason === "proxy_configuration") return "HTTP proxy configuration is invalid";
91
+ if (reason === "missing_worker_url") return "Worker URL is missing";
92
+ if (reason === "invalid_worker_url") return "Worker URL is invalid";
93
+ return "health check failed";
94
+ }
95
+
96
+ export function workerHealthError(error) {
97
+ if (hasErrorCode(error, "http_proxy_configuration")) return "proxy_configuration";
98
+ if (hasErrorCode(error, "ETIMEDOUT") || hasErrorCode(error, "ABORT_ERR") || /timeout|timed out|aborted/i.test(errorMessages(error))) return "timeout";
99
+ if (hasTlsError(error)) return "tls_error";
100
+ if (hasNetworkError(error)) return "network_error";
101
+ return "request_failed";
102
+ }
103
+
104
+ export function requestWorkerHealthJson(url, options = {}) {
105
+ return requestJson(new URL(String(url)), {
106
+ timeoutMs: boundedTimeout(options.timeoutMs),
107
+ proxyResolver: options.proxyResolver,
108
+ proxyAgentForUrl: options.proxyAgentForUrl,
109
+ });
110
+ }
111
+
112
+ function requestAllowedWorkerHealthJson(url, options = {}) {
113
+ const target = new URL(String(url));
114
+ if (target.protocol !== "https:" || target.port || target.pathname !== "/healthz" || target.search || target.hash || !WORKERS_DEV_HOST.test(target.hostname)) {
115
+ const error = new Error("health request target is not an allowed workers.dev endpoint");
116
+ error.code = "ERR_INVALID_WORKER_HEALTH_URL";
117
+ throw error;
118
+ }
119
+ return requestJson(target, {
120
+ timeoutMs: boundedTimeout(options.timeoutMs),
121
+ proxyResolver: options.proxyResolver,
122
+ proxyAgentForUrl: options.proxyAgentForUrl,
123
+ });
124
+ }
125
+
126
+ function requestJson(target, options) {
127
+ return new Promise((resolvePromise, rejectPromise) => {
128
+ const selectProxy = typeof options.proxyAgentForUrl === "function" ? options.proxyAgentForUrl : proxyAgentForHttp;
129
+ let proxy;
130
+ try {
131
+ proxy = selectProxy(target.href, options.proxyResolver);
132
+ } catch (error) {
133
+ rejectPromise(withNetworkRoute(error, "invalid-proxy-configuration"));
134
+ return;
135
+ }
136
+ const networkRoute = proxy?.agent ? "proxy" : "direct";
137
+ const transport = target.protocol === "https:" ? https : http;
138
+ const request = transport.request({
139
+ protocol: target.protocol,
140
+ hostname: target.hostname,
141
+ port: target.port || undefined,
142
+ method: "GET",
143
+ path: `${target.pathname}${target.search}`,
144
+ headers: {
145
+ Accept: "application/json",
146
+ "User-Agent": "machine-bridge-mcp-health",
147
+ },
148
+ ...(proxy?.agent ? { agent: proxy.agent } : {}),
149
+ });
150
+ let settled = false;
151
+ const finish = (callback) => {
152
+ if (settled) return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ callback();
156
+ };
157
+ const rejectWithRoute = (error) => finish(() => rejectPromise(withNetworkRoute(error, networkRoute)));
158
+ const timer = setTimeout(() => {
159
+ const error = new Error(`health request timed out after ${options.timeoutMs}ms`);
160
+ error.code = "ETIMEDOUT";
161
+ request.destroy(error);
162
+ }, options.timeoutMs);
163
+ timer.unref?.();
164
+
165
+ request.on("response", (response) => {
166
+ const statusCode = Number(response.statusCode || 0);
167
+ const chunks = [];
168
+ let bytes = 0;
169
+ response.on("data", (chunk) => {
170
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
171
+ bytes += buffer.length;
172
+ if (bytes > MAX_HEALTH_BODY_BYTES) {
173
+ const error = new Error("health response exceeded the size limit");
174
+ error.code = "ERR_RESPONSE_TOO_LARGE";
175
+ response.destroy(error);
176
+ return;
177
+ }
178
+ chunks.push(buffer);
179
+ });
180
+ response.on("error", rejectWithRoute);
181
+ response.on("end", () => {
182
+ if (settled) return;
183
+ let body = null;
184
+ try {
185
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
186
+ } catch {
187
+ // Invalid JSON is reported through the normal unexpected-health-response classification.
188
+ }
189
+ finish(() => resolvePromise({ statusCode, body, networkRoute }));
190
+ });
191
+ });
192
+ request.on("error", rejectWithRoute);
193
+ request.end();
194
+ });
195
+ }
196
+
197
+ function withNetworkRoute(error, networkRoute) {
198
+ const value = error instanceof Error ? error : new Error(String(error));
199
+ if (!value.networkRoute) value.networkRoute = networkRoute;
200
+ return value;
201
+ }
202
+
203
+ function hasTlsError(error) {
204
+ const codes = errorCodes(error);
205
+ if (codes.some((code) => code.startsWith("ERR_TLS_") || code.startsWith("CERT_") || code.includes("CERTIFICATE") || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || code === "DEPTH_ZERO_SELF_SIGNED_CERT")) return true;
206
+ return /certificate|\bTLS\b|\bSSL\b/i.test(errorMessages(error));
207
+ }
208
+
209
+ function hasNetworkError(error) {
210
+ const networkCodes = new Set([
211
+ "ECONNABORTED", "ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETDOWN", "ENETRESET",
212
+ "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE", "UND_ERR_CONNECT_TIMEOUT",
213
+ ]);
214
+ if (errorCodes(error).some((code) => networkCodes.has(code))) return true;
215
+ return /fetch failed|network|socket hang up|connection reset|connection refused/i.test(errorMessages(error));
216
+ }
217
+
218
+ function hasErrorCode(error, expected) {
219
+ return errorCodes(error).includes(expected);
220
+ }
221
+
222
+ function errorCodes(error) {
223
+ const codes = [];
224
+ for (let current = error; current && typeof current === "object"; current = current.cause) {
225
+ if (typeof current.code === "string") codes.push(current.code);
226
+ }
227
+ return codes;
228
+ }
229
+
230
+ function errorMessages(error) {
231
+ const messages = [];
232
+ for (let current = error; current; current = current?.cause) {
233
+ messages.push(String(current?.message || current));
234
+ if (typeof current !== "object") break;
235
+ }
236
+ return messages.join(" ");
237
+ }
238
+
239
+ function boundedTimeout(value) {
240
+ const numeric = Number(value);
241
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(60_000, Math.floor(numeric)) : DEFAULT_HEALTH_TIMEOUT_MS;
242
+ }
243
+
244
+ function sleep(ms) {
245
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
246
+ }
@@ -24,7 +24,7 @@ import {
24
24
  } from "./http";
25
25
 
26
26
  const SERVER_NAME = String(serverMetadata.name);
27
- const SERVER_VERSION = "1.1.0";
27
+ const SERVER_VERSION = "1.1.1";
28
28
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
29
29
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
30
30
  const JSONRPC_VERSION = "2.0";