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 +14 -0
- package/CONTRIBUTING.md +12 -4
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/ENGINEERING.md +6 -0
- package/docs/LOGGING.md +9 -7
- package/docs/OPERATIONS.md +3 -3
- package/docs/TESTING.md +4 -3
- package/package.json +5 -4
- package/src/local/cli.mjs +22 -0
- package/src/local/relay-connection.mjs +100 -17
- package/src/local/runtime.mjs +19 -3
- package/src/local/service.mjs +1 -1
- package/src/worker/index.ts +23 -5
- package/tsconfig.json +2 -0
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
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/docs/ENGINEERING.md
CHANGED
|
@@ -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
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
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
|
|
59
|
-
[info] daemon: remote relay connection restored
|
|
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
|
|
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
|
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
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?.(
|
|
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
|
|
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.
|
|
356
|
-
const
|
|
357
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/local/runtime.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
1173
|
+
runtime.handleRelayProtocolViolation("server_message_too_large");
|
|
1158
1174
|
return;
|
|
1159
1175
|
}
|
|
1160
1176
|
return runtime.handleMessage(raw);
|
package/src/local/service.mjs
CHANGED
|
@@ -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 =
|
|
12
|
+
const AUTOSTART_LOG_SCHEMA_VERSION = 3;
|
|
13
13
|
|
|
14
14
|
function serviceRun(command, args) {
|
|
15
15
|
return run(command, args, {
|
package/src/worker/index.ts
CHANGED
|
@@ -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.
|
|
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
|
|
220
|
+
let parsed: unknown;
|
|
221
221
|
try {
|
|
222
|
-
|
|
222
|
+
parsed = JSON.parse(text);
|
|
223
223
|
} catch {
|
|
224
|
-
ws
|
|
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
|
|
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>;
|