machine-bridge-mcp 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 - 2026-07-11
4
+
5
+ ### Relay reliability and protocol correctness
6
+
7
+ - Add a deadline for WebSocket connection establishment so a transport stuck in `CONNECTING` cannot freeze automatic reconnection indefinitely. Sustained-outage reminders now run on an independent exponential-backoff timer capped at 15 minutes instead of appearing only when another reconnect event happens.
8
+ - Handle Worker `{type:"error"}` messages explicitly. A daemon hello timeout is classified as transient and retried; unknown protocol errors, duplicate hello messages, identity/version mismatch, and authentication rejection terminate with actionable guidance instead of becoming an `unknown websocket message` warning.
9
+ - Validate daemon WebSocket JSON as a non-array object before field access. Invalid JSON closes with code 1007, non-object/unknown/duplicate protocol messages close with code 1002, and active daemon replacement semantics remain unchanged.
10
+
11
+ ### Logging, tests, and documentation
12
+
13
+ - Replace default outage/recovery JSON field dumps with readable duration, attempt, cause, automatic-recovery, and long-outage action text. Exact seconds, error classes, retry delays, and raw transport details remain debug-only. Advance the autostart log schema so historical 0.8.1-format lines are separated into the bounded owner-only legacy snapshot. Treat a service-style daemon-only start that finds the workspace daemon already running as a silent idempotent success, preventing repeated lock warnings and duplicate readiness output from accumulating in service logs.
14
+ - Add deterministic tests for stalled connection attempts, autonomous reminder backoff, retryable versus fatal relay error messages, close-reason classification, and runtime control-message routing. Extend live Worker integration coverage to invalid JSON, non-object messages, duplicate hello, and unknown authenticated messages. Enable TypeScript unused-local and unused-parameter checks to prevent dead Worker code from accumulating.
15
+ - Update architecture, operations, logging, testing, and README guidance to match the implemented state machine and operator-facing behavior. Add a repository automation contract separating code/test/commit/push work from npm publication, global CLI installation, Worker deployment, credential rotation, and daemon/service operations; the owner performs those live release steps explicitly. Improve release-check diagnostics so a missing local or remote version tag is reported distinctly from a tag pointing at the wrong commit, with the required `release:publish` step named directly.
16
+
3
17
  ## 0.8.1 - 2026-07-11
4
18
 
5
19
  ### Fixed
package/CONTRIBUTING.md CHANGED
@@ -4,15 +4,23 @@ This repository treats every Git-tracked or nonignored repository file as releas
4
4
 
5
5
  ## Required for every release-relevant change
6
6
 
7
- Before a change is merged to `main`:
7
+ Before the reviewed code is pushed to `main`:
8
8
 
9
9
  1. bump `package.json` to a version newer than the latest reachable `v*` tag;
10
10
  2. add the matching dated section to `CHANGELOG.md`;
11
11
  3. run `npm run check`, `npm audit`, `npm audit --omit=dev`, and `npm run worker:dry-run`;
12
12
  4. inspect the complete diff and the npm package manifest;
13
- 5. push the reviewed commit to GitHub;
14
- 6. create the matching Git tag and GitHub Release;
15
- 7. publish the same version to npm.
13
+ 5. commit and push the reviewed change to GitHub.
14
+
15
+ The release operator then creates the matching Git tag and GitHub Release and publishes the same version to npm. These release actions are not implicit coding-agent tasks. Unless the user explicitly requests them in the current task, automation must not publish or alter npm versions, create tags or releases, install the CLI globally, deploy the Worker, rotate credentials, mutate live deployment state, or start/stop/install/remove the daemon or autostart service. See [AGENTS.md](AGENTS.md) for the repository automation contract.
16
+
17
+ After npm publication, the standard machine update is:
18
+
19
+ ```bash
20
+ npm install -g --allow-scripts=esbuild,workerd,sharp machine-bridge-mcp@latest && machine-mcp
21
+ ```
22
+
23
+ The npm command updates the global CLI. Normal `machine-mcp` startup checks the Worker deployment hash, expected version, and health, redeploys when necessary, and reconciles the daemon/autostart flow.
16
24
 
17
25
  `npm run release-impact:check` enforces the version and changelog parts. It fails when release-relevant files changed after the latest version tag but the package version was not advanced.
18
26
 
package/README.md CHANGED
@@ -335,7 +335,7 @@ Default state roots:
335
335
 
336
336
  State/config writes use owner-only temporary files, flushes, and atomic rename. Malformed state is retained as a bounded corrupt backup before reconstruction. 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 validates markers, canonical paths, active locks, workspace/source exclusions, and known contents before recursive deletion.
337
337
 
338
- Default foreground logs report authenticated relay readiness, persistent degradation, and recovery summaries rather than raw WebSocket callbacks. Brief self-healing disconnects and close codes/reasons are debug-only. 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).
338
+ 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).
339
339
 
340
340
  ## Development and verification
341
341
 
@@ -177,11 +177,11 @@ Managed jobs use the same argv/environment primitives but a different lifecycle.
177
177
 
178
178
  ## Daemon reconnect and replacement
179
179
 
180
- The local `RelayConnection` treats transport open, authenticated readiness, and outage recovery as separate states. It sends `hello` after WebSocket open and reports readiness only after `hello_ack`. A missing acknowledgement reaches a local handshake deadline and the candidate is terminated. Once ready, application heartbeats require inbound activity; a silent half-open socket is terminated and reconnected.
180
+ The local `RelayConnection` 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.
181
181
 
182
182
  Reconnect uses bounded exponential backoff with jitter. Brief self-healing interruptions are debug-only. An unresolved outage is promoted to a rate-limited warning after a grace period, and recovery produces one summary. Raw close codes and reason strings remain debug-only.
183
183
 
184
- The Worker also treats a new connection as a bounded candidate until it authenticates and completes `hello`; a Durable Object alarm enforces the deadline across hibernation. Only a completed candidate replaces the old socket. Pending calls retain their originating socket reference. A stale socket cannot complete or cancel replacement-socket calls. Closing a socket rejects only calls assigned to it.
184
+ The Worker also treats a new connection as a bounded candidate until it authenticates and completes `hello`; a Durable Object alarm enforces the deadline across hibernation. Only a completed candidate replaces the old socket. Daemon messages must be valid JSON objects, duplicate `hello` and unknown message types are rejected with explicit protocol errors, and protocol violations close with standard WebSocket status codes. Pending calls retain their originating socket reference. A stale socket cannot complete or cancel replacement-socket calls. Closing a socket rejects only calls assigned to it.
185
185
 
186
186
  ## Persistence
187
187
 
@@ -13,6 +13,12 @@ This document records project-wide decisions that must survive individual fixes,
13
13
 
14
14
  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.
15
15
 
16
+ ## Change and release-operation ownership
17
+
18
+ Repository changes and live release operations are separate responsibilities. Unless the current task explicitly authorizes a broader scope, coding automation may edit source, tests, documentation, package metadata, and changelog entries; run repository-local checks; commit; and push to GitHub. It must not publish or alter npm releases, create tags or GitHub Releases, install the package globally, deploy or reconfigure a Cloudflare Worker, rotate credentials, mutate live deployment state, or start/stop/install/remove the daemon or autostart service.
19
+
20
+ The normal handoff is: the repository owner publishes the reviewed npm version, then runs `npm install -g --allow-scripts=esbuild,workerd,sharp machine-bridge-mcp@latest && machine-mcp`. The npm command updates the global CLI. The subsequent normal startup validates the Worker deployment hash, expected version, and health, redeploys when necessary, and reconciles the daemon/autostart flow. Live operations require explicit authorization even when they appear to be the obvious next release step.
21
+
16
22
  ## Architectural boundaries
17
23
 
18
24
  The preferred dependency direction is:
package/docs/LOGGING.md CHANGED
@@ -41,10 +41,12 @@ Brief network interruptions are expected on laptop network changes, Worker deplo
41
41
 
42
42
  - the raw close code, close reason, error class, connected duration, and retry delay are debug-only;
43
43
  - a brief interruption that reconnects within the grace period produces no `info` or `warn` line;
44
- - an outage that remains unresolved for 10 seconds produces one rate-limited warning stating that automatic reconnection is in progress;
45
- - relay identity/version mismatch and authentication rejection are non-transient: they produce an immediate actionable error and terminate that daemon instead of entering the reconnect loop;
46
- - repeated warnings are rate-limited;
47
- - recovery after a visible outage produces one information summary with outage duration and attempt count;
44
+ - an outage that remains unresolved for 10 seconds produces a user-readable warning stating the duration, reconnect attempt count, classified cause, and automatic recovery behavior;
45
+ - while the outage remains unresolved, reminders are scheduled independently of reconnect callbacks and use exponential backoff capped at 15 minutes;
46
+ - a WebSocket that remains in `CONNECTING` beyond its deadline is terminated so one stalled network attempt cannot freeze the reconnect loop;
47
+ - relay identity/version mismatch, authentication rejection, and unexpected protocol messages are non-transient: they produce an immediate actionable error and terminate that daemon instead of entering the reconnect loop;
48
+ - a Worker `daemon_hello_timeout` error is transient and follows the normal reconnect path rather than being misclassified as authentication rejection;
49
+ - recovery after a visible outage produces one information summary with a human-readable duration and attempt count; exact seconds and error classes remain debug-only;
48
50
  - an authenticated replacement is a distinct warning and permanently stops the older daemon;
49
51
  - failure to receive `hello_ack` within the handshake deadline terminates the candidate socket and retries;
50
52
  - lack of inbound heartbeat activity terminates a half-open socket and reconnects.
@@ -55,11 +57,11 @@ Examples:
55
57
 
56
58
  ```text
57
59
  [info] daemon: remote relay connected
58
- [warn] daemon: remote relay is unavailable; automatic reconnection is still in progress {"outage_seconds":12,"attempts":3,"cause":"connection interrupted"}
59
- [info] daemon: remote relay connection restored {"outage_seconds":18,"attempts":4}
60
+ [warn] daemon: remote relay unavailable for 12 seconds; reconnecting automatically (3 reconnect attempts; connection interrupted).
61
+ [info] daemon: remote relay connection restored after 18 seconds (4 reconnect attempts)
60
62
  ```
61
63
 
62
- With `--verbose`, the same incident may additionally include bounded transport diagnostics.
64
+ With `--verbose`, the same incident additionally includes bounded structured fields such as exact seconds, coarse error class, retry delay, and transport close diagnostics.
63
65
 
64
66
  ## Tool events
65
67
 
@@ -26,19 +26,19 @@ A successful diagnostic result applies only to that probe. An MCP host can still
26
26
 
27
27
  ### Relay interruption messages
28
28
 
29
- A brief relay interruption is retried automatically and is visible only with `--verbose`. Default logs do not print raw WebSocket values such as `code=1006` with an empty reason. If a transient outage persists for 10 seconds, one warning is emitted and repeated warnings are rate-limited; a later recovery produces one summary with outage duration and attempt count. Identity/version mismatch and authentication rejection are not retried as ordinary network faults: the daemon emits an immediate actionable error and exits, requiring upgrade/redeployment or credential repair.
29
+ A brief relay interruption is retried automatically and is visible only with `--verbose`. Default logs do not print raw WebSocket values such as `code=1006` with an empty reason. If a transient outage persists for 10 seconds, the daemon emits a readable duration/cause/reconnect summary; later reminders use autonomous exponential backoff capped at 15 minutes, and recovery produces one readable summary. Each transport connection attempt also has a deadline, so a socket stuck in `CONNECTING` cannot freeze retries. Identity/version mismatch, authentication rejection, and unexpected protocol messages are not retried as ordinary network faults: the daemon emits an immediate actionable error and exits, requiring upgrade/redeployment or credential repair. A Worker-side daemon handshake timeout remains retryable.
30
30
 
31
31
  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.
32
32
 
33
33
  ## Logs
34
34
 
35
- Remote autostart definitions prefer a stable PATH alias that resolves to the currently running Node executable and persist a sanitized absolute-only service `PATH` containing the Node/CLI directories, the installer's absolute PATH entries, and platform defaults. This avoids versioned Homebrew-style paths becoming invalid after upgrades and prevents launchd/systemd from falling back to a minimal system-only PATH. Re-run `machine-mcp service install` after changing Node installation families or PATH layout. Autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup. On a logging-schema upgrade, bounded prior content is moved into `daemon.out.legacy.log` and `daemon.err.legacy.log`; use the active filenames when diagnosing current behavior.
35
+ Remote autostart definitions prefer a stable PATH alias that resolves to the currently running Node executable and persist a sanitized absolute-only service `PATH` containing the Node/CLI directories, the installer's absolute PATH entries, and platform defaults. This avoids versioned Homebrew-style paths becoming invalid after upgrades and prevents launchd/systemd from falling back to a minimal system-only PATH. Re-run `machine-mcp service install` after changing Node installation families or PATH layout. A service-style `--daemon-only` start that finds the same workspace daemon already running is an idempotent no-op: it exits successfully without repeating warnings or readiness output; explicit policy/secret/change requests still report that changes were not applied. Autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup. On a logging-schema upgrade, bounded prior content is moved into `daemon.out.legacy.log` and `daemon.err.legacy.log`; use the active filenames when diagnosing current behavior.
36
36
 
37
37
  Logging is level-based:
38
38
 
39
39
  ```text
40
40
  error unrecoverable local/transport/service failures
41
- warn relay disconnect/send failures, malformed relay events, supersession and service problems
41
+ warn persistent retryable relay degradation, supersession, and service problems
42
42
  info startup/deploy/connect transitions
43
43
  debug all per-tool starts/successes/failures/cancellations/timing, correlation and reconnect details
44
44
  ```
package/docs/TESTING.md CHANGED
@@ -13,7 +13,8 @@ The repository requires Node.js 26 and npm 12. `.node-version`, `.nvmrc`, `packa
13
13
  The suite includes:
14
14
 
15
15
  - release-impact enforcement requiring a new package version and CHANGELOG section for release-relevant changes;
16
- - generated Cloudflare Worker types and strict TypeScript checking;
16
+ - release-state diagnostics distinguishing missing local/remote version tags from tags that point to the wrong commit, including the required publication order;
17
+ - generated Cloudflare Worker types and strict TypeScript checking, including unused-local and unused-parameter rejection;
17
18
  - syntax validation for every shipped JavaScript entry point;
18
19
  - shared tool-catalog schema, annotation, and profile-inventory checks;
19
20
  - canonical path and symbolic-link escape tests;
@@ -37,11 +38,11 @@ The suite includes:
37
38
  - guarded state-root removal, schema migration, policy-origin persistence, and legacy implicit-default migration;
38
39
  - no filename-based sensitive-file denial under unrestricted policy;
39
40
  - log redaction, control-character handling, message/field bounds, suppression of both successful and failed per-tool events outside debug, service warning-level configuration, and idempotent bounded migration of legacy log formats;
40
- - deterministic relay connection lifecycle coverage for transport construction/error, pre-handshake `welcome` validation, authenticated `hello_ack` readiness, identity/version mismatch, handshake and heartbeat timeout, brief-outage suppression, sustained-outage escalation, recovery summaries, and supersession;
41
+ - deterministic relay connection lifecycle coverage for transport construction/error/deadline, pre-handshake `welcome` validation, authenticated `hello_ack` readiness, identity/version mismatch, retryable Worker handshake errors, fatal protocol errors, autonomous outage-reminder backoff, handshake and heartbeat timeout, brief-outage suppression, sustained-outage escalation, recovery summaries, and supersession;
41
42
  - shared no-follow bounded-file reads for normal files, over-limit data, directories, and symbolic links;
42
43
  - CLI parsing, policy profiles, and client configuration boundaries;
43
44
  - live stdio MCP initialization, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
44
- - live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, dynamic tool advertisement, rich content, daemon replacement, and cancellation.
45
+ - live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure.
45
46
 
46
47
  ## Additional release checks
47
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Cross-client MCP bridge for canonical local automation, files, Git, SSH resources, managed jobs, and processes over stdio or an OAuth-protected remote relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "prepublishOnly": "npm run check && npm run version:check && npm run release:check",
44
44
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
45
45
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
46
- "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs",
47
- "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
46
+ "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/release-state.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/release-state-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs",
47
+ "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
48
48
  "worker:dry-run": "wrangler deploy --dry-run",
49
49
  "worker:integration-test": "node tests/worker-integration-test.mjs",
50
50
  "release:check": "node scripts/github-release.mjs --check",
@@ -65,7 +65,8 @@
65
65
  "relay:test": "node tests/relay-connection-test.mjs",
66
66
  "install:test": "node tests/install-smoke-test.mjs",
67
67
  "secure-file:test": "node tests/secure-file-test.mjs",
68
- "architecture:test": "node tests/architecture-test.mjs"
68
+ "architecture:test": "node tests/architecture-test.mjs",
69
+ "release-state:test": "node tests/release-state-test.mjs"
69
70
  },
70
71
  "dependencies": {
71
72
  "wrangler": "4.110.0",
package/src/local/cli.mjs CHANGED
@@ -337,6 +337,10 @@ async function prepareStartMode(args, state, logger) {
337
337
 
338
338
  function reportExistingDaemon(args, state, owner, logger) {
339
339
  const pid = owner?.pid ? `pid ${owner.pid}` : "unknown pid";
340
+ if (isIdempotentDaemonOnlyStart(args)) {
341
+ logger.debug?.("local daemon already running; daemon-only start completed as an idempotent no-op", { owner_pid_known: Boolean(owner?.pid) });
342
+ return;
343
+ }
340
344
  logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
341
345
  if (args.json) {
342
346
  printStartJson(state, {
@@ -354,6 +358,24 @@ function reportExistingDaemon(args, state, owner, logger) {
354
358
  });
355
359
  }
356
360
 
361
+ function isIdempotentDaemonOnlyStart(args) {
362
+ if (!args.daemonOnly || args.json) return false;
363
+ return !Boolean(
364
+ args.profile
365
+ || args.execMode
366
+ || args.rotateSecrets
367
+ || args.forceWorker
368
+ || args.workerName
369
+ || args.noWrite
370
+ || args.noExec
371
+ || args.fullEnv
372
+ || args.unrestrictedPaths
373
+ || args.absolutePaths
374
+ || args.printMcpCredentials
375
+ || args.printCredentials
376
+ );
377
+ }
378
+
357
379
  async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
358
380
  let runtime = null;
359
381
  try {
@@ -3,10 +3,13 @@ import { classifyOperationalError } from "./log.mjs";
3
3
 
4
4
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
5
5
  const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75_000;
6
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
6
7
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
7
8
  const DEFAULT_OUTAGE_WARN_AFTER_MS = 10_000;
8
9
  const DEFAULT_OUTAGE_WARN_REPEAT_MS = 60_000;
10
+ const DEFAULT_OUTAGE_WARN_MAX_REPEAT_MS = 15 * 60_000;
9
11
  const MAX_CLOSE_REASON_CHARS = 128;
12
+ const MAX_PROTOCOL_ERROR_CODE_CHARS = 64;
10
13
 
11
14
  const DEFAULT_SCHEDULER = Object.freeze({
12
15
  setTimeout: (callback, delay) => setTimeout(callback, delay),
@@ -35,9 +38,14 @@ export class RelayConnection {
35
38
  this.maxPayload = boundedPositiveInteger(options.maxPayload, 8 * 1024 * 1024);
36
39
  this.heartbeatIntervalMs = boundedPositiveInteger(options.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS);
37
40
  this.heartbeatTimeoutMs = boundedPositiveInteger(options.heartbeatTimeoutMs, DEFAULT_HEARTBEAT_TIMEOUT_MS);
41
+ this.connectTimeoutMs = boundedPositiveInteger(options.connectTimeoutMs, DEFAULT_CONNECT_TIMEOUT_MS);
38
42
  this.handshakeTimeoutMs = boundedPositiveInteger(options.handshakeTimeoutMs, DEFAULT_HANDSHAKE_TIMEOUT_MS);
39
43
  this.outageWarnAfterMs = boundedPositiveInteger(options.outageWarnAfterMs, DEFAULT_OUTAGE_WARN_AFTER_MS);
40
44
  this.outageWarnRepeatMs = boundedPositiveInteger(options.outageWarnRepeatMs, DEFAULT_OUTAGE_WARN_REPEAT_MS);
45
+ this.outageWarnMaxRepeatMs = Math.max(
46
+ this.outageWarnRepeatMs,
47
+ boundedPositiveInteger(options.outageWarnMaxRepeatMs, DEFAULT_OUTAGE_WARN_MAX_REPEAT_MS),
48
+ );
41
49
 
42
50
  this.closed = true;
43
51
  this.socket = null;
@@ -47,12 +55,14 @@ export class RelayConnection {
47
55
  this.lastInboundAt = 0;
48
56
  this.reconnectAttempt = 0;
49
57
  this.reconnectTimer = null;
58
+ this.connectTimer = null;
50
59
  this.heartbeatTimer = null;
51
60
  this.handshakeTimer = null;
52
61
  this.outageWarnTimer = null;
53
62
  this.outageStartedAt = 0;
54
63
  this.outageAttempts = 0;
55
64
  this.outageNoticeEmitted = false;
65
+ this.outageWarningCount = 0;
56
66
  this.lastOutageWarnAt = 0;
57
67
  this.lastCloseCategory = "connection_interrupted";
58
68
  this.lastTransportErrorClass = "";
@@ -77,6 +87,7 @@ export class RelayConnection {
77
87
  this.closed = true;
78
88
  this.ready = false;
79
89
  this.clearTimer("heartbeatTimer", "clearInterval");
90
+ this.clearTimer("connectTimer", "clearTimeout");
80
91
  this.clearTimer("handshakeTimer", "clearTimeout");
81
92
  this.clearTimer("reconnectTimer", "clearTimeout");
82
93
  this.clearTimer("outageWarnTimer", "clearTimeout");
@@ -128,7 +139,8 @@ export class RelayConnection {
128
139
  } else if (this.outageStartedAt > 0) {
129
140
  const outageMs = Math.max(0, this.connectedAt - this.outageStartedAt);
130
141
  if (this.outageNoticeEmitted) {
131
- this.logger.info?.("remote relay connection restored", {
142
+ this.logger.info?.(`remote relay connection restored after ${formatDuration(outageMs)} (${formatAttempts(this.outageAttempts)})`);
143
+ this.logger.debug?.("remote relay outage recovery details", {
132
144
  outage_seconds: roundSeconds(outageMs),
133
145
  attempts: this.outageAttempts,
134
146
  });
@@ -150,6 +162,20 @@ export class RelayConnection {
150
162
  return true;
151
163
  }
152
164
 
165
+ handleServerError(message = {}) {
166
+ const errorCode = sanitizeProtocolErrorCode(message?.error);
167
+ this.logger.debug?.("remote relay reported a protocol error", { error_code: errorCode });
168
+ if (errorCode === "daemon_hello_timeout" && !this.ready) {
169
+ const socket = this.socket;
170
+ if (this.closed || !socket) return true;
171
+ this.pendingCloseCategory = "relay_handshake_timeout";
172
+ terminateSocket(socket);
173
+ return true;
174
+ }
175
+ this.failPermanently("relay_protocol_error");
176
+ return true;
177
+ }
178
+
153
179
  connect() {
154
180
  if (this.closed || this.socket) return;
155
181
  const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
@@ -167,8 +193,17 @@ export class RelayConnection {
167
193
  return;
168
194
  }
169
195
  this.socket = socket;
196
+ this.clearTimer("connectTimer", "clearTimeout");
197
+ this.connectTimer = this.scheduler.setTimeout(() => {
198
+ if (this.socket !== socket || this.closed || this.isSocketOpen(socket)) return;
199
+ this.logger.debug?.("remote relay transport connection timed out", { timeout_ms: this.connectTimeoutMs });
200
+ this.pendingCloseCategory = "relay_connect_timeout";
201
+ terminateSocket(socket);
202
+ }, this.connectTimeoutMs);
203
+ this.connectTimer?.unref?.();
170
204
 
171
205
  socket.on("open", () => {
206
+ this.clearTimer("connectTimer", "clearTimeout");
172
207
  if (this.socket !== socket || this.closed) {
173
208
  try { socket.close(1000, "stale daemon connection"); } catch {}
174
209
  return;
@@ -204,6 +239,7 @@ export class RelayConnection {
204
239
  const wasReady = this.ready;
205
240
  this.socket = null;
206
241
  this.ready = false;
242
+ this.clearTimer("connectTimer", "clearTimeout");
207
243
  this.clearTimer("heartbeatTimer", "clearInterval");
208
244
  this.clearTimer("handshakeTimer", "clearTimeout");
209
245
  const reasonText = sanitizeCloseReason(reason);
@@ -265,6 +301,7 @@ export class RelayConnection {
265
301
  this.closed = true;
266
302
  this.ready = false;
267
303
  this.socket = null;
304
+ this.clearTimer("connectTimer", "clearTimeout");
268
305
  this.clearTimer("heartbeatTimer", "clearInterval");
269
306
  this.clearTimer("handshakeTimer", "clearTimeout");
270
307
  this.clearTimer("reconnectTimer", "clearTimeout");
@@ -286,7 +323,8 @@ export class RelayConnection {
286
323
  reject(error);
287
324
  return;
288
325
  }
289
- this.logger.error?.(message, { cause: relayCloseUserCause(category) });
326
+ this.logger.error?.(message);
327
+ this.logger.debug?.("remote relay fatal details", { category, cause: relayCloseUserCause(category) });
290
328
  queueMicrotask(() => {
291
329
  try { this.onFatal(error); } catch (callbackError) {
292
330
  this.logger.error?.("relay fatal callback failed", { error_class: classifyOperationalError(callbackError) });
@@ -299,7 +337,6 @@ export class RelayConnection {
299
337
  this.recordOutage(category);
300
338
  const delay = this.reconnectDelay(this.reconnectAttempt++);
301
339
  this.scheduleOutageWarning();
302
- this.maybeRepeatOutageWarning();
303
340
  this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay, attempt: this.outageAttempts });
304
341
  this.reconnectTimer = this.scheduler.setTimeout(() => {
305
342
  this.reconnectTimer = null;
@@ -352,31 +389,39 @@ export class RelayConnection {
352
389
  }
353
390
 
354
391
  scheduleOutageWarning() {
355
- if (this.outageNoticeEmitted || this.outageWarnTimer || this.outageStartedAt === 0) return;
356
- const elapsed = Math.max(0, this.now() - this.outageStartedAt);
357
- const delay = Math.max(0, this.outageWarnAfterMs - elapsed);
392
+ if (this.outageWarnTimer || this.outageStartedAt === 0 || this.closed || this.ready) return;
393
+ const dueAt = this.outageNoticeEmitted
394
+ ? this.lastOutageWarnAt + this.nextOutageWarningDelay()
395
+ : this.outageStartedAt + this.outageWarnAfterMs;
396
+ const delay = Math.max(0, dueAt - this.now());
358
397
  this.outageWarnTimer = this.scheduler.setTimeout(() => {
359
398
  this.outageWarnTimer = null;
360
399
  if (this.closed || this.ready || this.outageStartedAt === 0) return;
361
400
  this.emitOutageWarning();
401
+ this.scheduleOutageWarning();
362
402
  }, delay);
363
403
  this.outageWarnTimer?.unref?.();
364
404
  }
365
405
 
366
- maybeRepeatOutageWarning() {
367
- if (!this.outageNoticeEmitted || this.lastOutageWarnAt === 0) return;
368
- if (this.now() - this.lastOutageWarnAt < this.outageWarnRepeatMs) return;
369
- this.emitOutageWarning();
406
+ nextOutageWarningDelay() {
407
+ const exponent = Math.max(0, Math.min(this.outageWarningCount - 1, 20));
408
+ return Math.min(this.outageWarnRepeatMs * (2 ** exponent), this.outageWarnMaxRepeatMs);
370
409
  }
371
410
 
372
411
  emitOutageWarning() {
373
412
  const outageMs = Math.max(0, this.now() - this.outageStartedAt);
374
413
  this.outageNoticeEmitted = true;
414
+ this.outageWarningCount += 1;
375
415
  this.lastOutageWarnAt = this.now();
376
- this.logger.warn?.(relayOutageWarningMessage(), {
416
+ const cause = relayCloseUserCause(this.lastCloseCategory);
417
+ const action = outageMs >= 5 * 60_000
418
+ ? " If this persists, check internet access and the deployed Worker."
419
+ : "";
420
+ this.logger.warn?.(`remote relay unavailable for ${formatDuration(outageMs)}; reconnecting automatically (${formatAttempts(this.outageAttempts)}; ${cause}).${action}`);
421
+ this.logger.debug?.("remote relay outage details", {
377
422
  outage_seconds: roundSeconds(outageMs),
378
423
  attempts: this.outageAttempts,
379
- cause: relayCloseUserCause(this.lastCloseCategory),
424
+ cause,
380
425
  ...(this.lastTransportErrorClass ? { error_class: this.lastTransportErrorClass } : {}),
381
426
  });
382
427
  }
@@ -386,6 +431,7 @@ export class RelayConnection {
386
431
  this.outageStartedAt = 0;
387
432
  this.outageAttempts = 0;
388
433
  this.outageNoticeEmitted = false;
434
+ this.outageWarningCount = 0;
389
435
  this.lastOutageWarnAt = 0;
390
436
  this.lastCloseCategory = "connection_interrupted";
391
437
  this.lastTransportErrorClass = "";
@@ -406,10 +452,15 @@ export class RelayConnection {
406
452
 
407
453
  export function relayCloseCategory(code, reason = "") {
408
454
  const numeric = Number(code);
409
- if (isSupersededClose(numeric, reason)) return "superseded";
455
+ const reasonText = String(reason || "");
456
+ if (isSupersededClose(numeric, reasonText)) return "superseded";
457
+ if (numeric === 1008 && reasonText === "daemon hello timeout") return "relay_handshake_timeout";
458
+ if (numeric === 1008 && ["stale daemon candidate", "expired daemon candidate"].includes(reasonText)) return "relay_restarting_or_unavailable";
459
+ if (numeric === 1008 && ["daemon hello required", "missing daemon attachment", "invalid daemon candidate timestamp"].includes(reasonText)) return "relay_protocol_error";
410
460
  if (numeric === 1000) return "normal_close";
411
461
  if (numeric === 1001 || numeric === 1012 || numeric === 1013) return "relay_restarting_or_unavailable";
412
462
  if (numeric === 1006) return "connection_interrupted";
463
+ if (numeric === 1002) return "relay_protocol_error";
413
464
  if (numeric === 1007) return "invalid_transport_payload";
414
465
  if (numeric === 1008) return "relay_policy_rejected";
415
466
  if (numeric === 1009) return "message_too_large";
@@ -421,13 +472,12 @@ function relayFatalMessage(category) {
421
472
  if (category === "relay_protocol_mismatch") {
422
473
  return "remote relay identity or version does not match this daemon; upgrade and redeploy both components";
423
474
  }
475
+ if (category === "relay_protocol_error") {
476
+ return "remote relay protocol error; upgrade and redeploy both components, then restart the daemon";
477
+ }
424
478
  return "remote relay rejected the daemon connection; verify credentials or redeploy the Worker";
425
479
  }
426
480
 
427
- function relayOutageWarningMessage() {
428
- return "remote relay is unavailable; automatic reconnection is still in progress";
429
- }
430
-
431
481
  function relayCloseUserCause(category) {
432
482
  const causes = {
433
483
  connection_interrupted: "connection interrupted",
@@ -436,9 +486,11 @@ function relayCloseUserCause(category) {
436
486
  relay_internal_error: "relay internal error",
437
487
  relay_protocol_mismatch: "relay identity or version mismatch",
438
488
  relay_authentication_failed: "relay authentication failed",
489
+ relay_connect_timeout: "relay connection attempt timed out",
439
490
  relay_handshake_timeout: "relay authentication acknowledgement timed out",
440
491
  relay_heartbeat_timeout: "relay stopped responding",
441
492
  relay_transport_error: "relay transport error",
493
+ relay_protocol_error: "relay protocol error",
442
494
  invalid_transport_payload: "invalid transport payload",
443
495
  message_too_large: "message exceeded the relay limit",
444
496
  normal_close: "connection closed",
@@ -512,6 +564,37 @@ function boundedPositiveInteger(value, fallback) {
512
564
  return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
513
565
  }
514
566
 
567
+ function sanitizeProtocolErrorCode(value) {
568
+ const code = String(value || "unknown_error").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_PROTOCOL_ERROR_CODE_CHARS);
569
+ return code || "unknown_error";
570
+ }
571
+
572
+ function formatAttempts(value) {
573
+ const attempts = Math.max(1, Math.floor(Number(value) || 1));
574
+ return `${attempts} reconnect attempt${attempts === 1 ? "" : "s"}`;
575
+ }
576
+
577
+ function formatDuration(milliseconds) {
578
+ let seconds = Math.max(1, Math.round(Number(milliseconds) / 1000));
579
+ const units = [
580
+ ["day", 86_400],
581
+ ["hour", 3_600],
582
+ ["minute", 60],
583
+ ["second", 1],
584
+ ];
585
+ const parts = [];
586
+ for (const [label, size] of units) {
587
+ if (seconds < size && parts.length === 0) continue;
588
+ const amount = Math.floor(seconds / size);
589
+ if (amount > 0) {
590
+ parts.push(`${amount} ${label}${amount === 1 ? "" : "s"}`);
591
+ seconds -= amount * size;
592
+ }
593
+ if (parts.length === 2) break;
594
+ }
595
+ return parts.join(" ") || "1 second";
596
+ }
597
+
515
598
  function roundSeconds(milliseconds) {
516
599
  return Math.max(1, Math.round(milliseconds / 1000));
517
600
  }
@@ -176,12 +176,16 @@ export class LocalRuntime {
176
176
  async handleMessage(raw) {
177
177
  let message;
178
178
  try { message = JSON.parse(raw); } catch {
179
- this.logger.warn?.("invalid websocket JSON");
179
+ this.handleRelayProtocolViolation("invalid_server_json");
180
+ return;
181
+ }
182
+ if (!isPlainRecord(message)) {
183
+ this.handleRelayProtocolViolation("invalid_server_message");
180
184
  return;
181
185
  }
182
186
  if (this.handleRelayControlMessage(message)) return;
183
187
  if (message.type !== "tool_call") {
184
- this.logger.warn?.("unknown websocket message", { type: String(message.type || "") });
188
+ this.handleRelayProtocolViolation("unexpected_server_message_type");
185
189
  return;
186
190
  }
187
191
  await this.handleRelayToolCall(message);
@@ -197,6 +201,10 @@ export class LocalRuntime {
197
201
  return true;
198
202
  }
199
203
  if (message.type === "pong") return true;
204
+ if (message.type === "error") {
205
+ this.relay?.handleServerError(message);
206
+ return true;
207
+ }
200
208
  if (message.type === "cancel_call") {
201
209
  if (typeof message.id === "string") this.cancelCall(message.id, "remote cancellation");
202
210
  return true;
@@ -204,6 +212,14 @@ export class LocalRuntime {
204
212
  return false;
205
213
  }
206
214
 
215
+ handleRelayProtocolViolation(errorCode) {
216
+ if (this.relay) {
217
+ this.relay.handleServerError({ type: "error", error: errorCode });
218
+ return;
219
+ }
220
+ this.logger.error?.("remote relay protocol error; upgrade and redeploy both components, then restart the daemon");
221
+ }
222
+
207
223
  async handleRelayToolCall(message) {
208
224
  const envelope = normalizeRelayToolCall(message);
209
225
  if (!envelope.ok) {
@@ -1154,7 +1170,7 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
1154
1170
  function handleRelayData(runtime, data) {
1155
1171
  const raw = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
1156
1172
  if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
1157
- runtime.logger.warn?.("oversized websocket message rejected");
1173
+ runtime.handleRelayProtocolViolation("server_message_too_large");
1158
1174
  return;
1159
1175
  }
1160
1176
  return runtime.handleMessage(raw);
@@ -9,7 +9,7 @@ import { readBoundedRegularFileSync } from "./secure-file.mjs";
9
9
  const LABEL = "dev.machine-bridge-mcp.daemon";
10
10
  const WINDOWS_TASK = "MachineBridgeMCP";
11
11
  const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
12
- const AUTOSTART_LOG_SCHEMA_VERSION = 2;
12
+ const AUTOSTART_LOG_SCHEMA_VERSION = 3;
13
13
 
14
14
  function serviceRun(command, args) {
15
15
  return run(command, args, {
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
3
3
  import serverMetadata from "../shared/server-metadata.json";
4
4
 
5
5
  const SERVER_NAME = String(serverMetadata.name);
6
- const SERVER_VERSION = "0.8.1";
6
+ const SERVER_VERSION = "0.8.2";
7
7
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
8
8
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
9
9
  const JSONRPC_VERSION = "2.0";
@@ -217,13 +217,18 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
217
217
  try { ws.close(1007, "invalid UTF-8"); } catch {}
218
218
  return;
219
219
  }
220
- let body: Record<string, unknown>;
220
+ let parsed: unknown;
221
221
  try {
222
- body = JSON.parse(text) as Record<string, unknown>;
222
+ parsed = JSON.parse(text);
223
223
  } catch {
224
- ws.send(JSON.stringify({ type: "error", error: "invalid_json" }));
224
+ rejectDaemonMessage(ws, "invalid_json", 1007, "invalid JSON");
225
+ return;
226
+ }
227
+ if (!isObjectRecord(parsed)) {
228
+ rejectDaemonMessage(ws, "invalid_message", 1002, "daemon message must be an object");
225
229
  return;
226
230
  }
231
+ const body = parsed;
227
232
 
228
233
  const socketAttachment = this.socketAttachment(ws);
229
234
  if (!socketAttachment) {
@@ -237,6 +242,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
237
242
  }
238
243
 
239
244
  if (body.type === "hello") {
245
+ if (socketAttachment.role === "daemon") {
246
+ rejectDaemonMessage(ws, "duplicate_hello", 1002, "duplicate daemon hello");
247
+ return;
248
+ }
240
249
  const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
241
250
  if (socketAttachment.role === "candidate") {
242
251
  if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
@@ -280,7 +289,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
280
289
  }
281
290
 
282
291
  if (body.type !== "tool_result" || typeof body.id !== "string") {
283
- ws.send(JSON.stringify({ type: "error", error: "unknown_message_type" }));
292
+ rejectDaemonMessage(ws, "unknown_message_type", 1002, "unknown daemon message type");
284
293
  return;
285
294
  }
286
295
 
@@ -912,6 +921,15 @@ export default {
912
921
  },
913
922
  } satisfies ExportedHandler<BridgeEnv>;
914
923
 
924
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
925
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
926
+ }
927
+
928
+ function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
929
+ try { ws.send(JSON.stringify({ type: "error", error })); } catch {}
930
+ try { ws.close(closeCode, closeReason); } catch {}
931
+ }
932
+
915
933
  function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
916
934
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
917
935
  const candidate = value as Record<string, unknown>;
package/tsconfig.json CHANGED
@@ -9,6 +9,8 @@
9
9
  ],
10
10
  "strict": true,
11
11
  "noEmit": true,
12
+ "noUnusedLocals": true,
13
+ "noUnusedParameters": true,
12
14
  "skipLibCheck": true,
13
15
  "forceConsistentCasingInFileNames": true,
14
16
  "resolveJsonModule": true