machine-bridge-mcp 0.8.0 → 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 +26 -0
- package/CONTRIBUTING.md +12 -4
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/ENGINEERING.md +7 -0
- package/docs/LOGGING.md +11 -7
- package/docs/OPERATIONS.md +3 -3
- package/docs/TESTING.md +5 -4
- package/package.json +5 -4
- package/src/local/cli.mjs +22 -0
- package/src/local/relay-connection.mjs +122 -17
- package/src/local/runtime.mjs +23 -3
- package/src/local/service.mjs +43 -7
- package/src/worker/index.ts +23 -5
- package/tsconfig.json +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.8.1 - 2026-07-11
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Recognize and validate the Worker's pre-handshake `welcome` control message instead of reporting it as an unknown WebSocket warning. A valid welcome remains debug-only and does not imply authenticated readiness; identity or version mismatch still fails immediately.
|
|
22
|
+
- Add a versioned autostart-log schema migration. On the first daemon start after this logging-format change, bounded prior logs are copied to owner-only `daemon.*.legacy.log` snapshots and the active logs are cleared, preventing historical raw close-code lines from appearing to be current behavior.
|
|
23
|
+
|
|
24
|
+
### Tests and documentation
|
|
25
|
+
|
|
26
|
+
- Add relay/runtime regression coverage for valid welcome handling and welcome metadata validation, plus service tests for one-time bounded legacy-log migration and schema-marker idempotence.
|
|
27
|
+
- Record the protocol producer/consumer contract rule and document how current and legacy daemon logs are separated.
|
|
28
|
+
|
|
3
29
|
## 0.8.0 - 2026-07-11
|
|
4
30
|
|
|
5
31
|
### Architecture
|
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:
|
|
@@ -32,6 +38,7 @@ Rules:
|
|
|
32
38
|
- Transport lifecycle, domain execution, persistence, and presentation are separate modules.
|
|
33
39
|
- Pure classification and normalization functions are exported and tested directly when practical.
|
|
34
40
|
- Adapters may translate data but should not duplicate policy or schemas.
|
|
41
|
+
- Every protocol control message emitted by one side must be explicitly accepted, rejected, or version-gated by the other side, with an end-to-end contract test covering the message name and semantics.
|
|
35
42
|
- State transitions are explicit; readiness is not inferred from a lower-level event. For example, an open WebSocket is not an authenticated relay until `hello_ack` is received.
|
|
36
43
|
- Every externally controlled input is bounded before expensive allocation, traversal, parsing, storage, or execution.
|
|
37
44
|
- Repository text must not contain invisible ASCII controls other than tab, CR, and LF; architecture tests enforce this even when JavaScript syntax remains valid.
|
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
|
|
|
@@ -97,6 +99,8 @@ logs/daemon.err.log
|
|
|
97
99
|
|
|
98
100
|
Existing files are opened without following symbolic links where supported and tail-trimmed on UTF-8/line boundaries before startup. Background services use `warn`, so ordinary tool traffic and brief relay interruptions do not cause sustained growth.
|
|
99
101
|
|
|
102
|
+
The log format has an explicit schema marker. When the schema changes, the daemon copies a bounded tail of each prior active log to an owner-only `daemon.out.legacy.log` or `daemon.err.legacy.log` snapshot, clears the active file, and advances the marker. The migration is idempotent and keeps historical transport-format lines visibly separate from current behavior.
|
|
103
|
+
|
|
100
104
|
Each managed job has owner-only runner diagnostic logs. Child-step output is retained only in bounded, redacted job results according to `capture_output`; it is not copied into daemon or runner operational logs.
|
|
101
105
|
|
|
102
106
|
## MCP host boundary
|
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.
|
|
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;
|
|
@@ -36,12 +37,12 @@ The suite includes:
|
|
|
36
37
|
- daemon/startup locking and state corruption recovery;
|
|
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
|
-
- log redaction, control-character handling, message/field bounds, suppression of both successful and failed per-tool events outside debug,
|
|
40
|
-
- deterministic relay connection lifecycle coverage for transport construction/error, authenticated `hello_ack` readiness, identity/version mismatch, handshake and heartbeat timeout, brief-outage suppression, sustained-outage escalation, recovery summaries, and supersession;
|
|
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;
|
|
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");
|
|
@@ -94,6 +105,19 @@ export class RelayConnection {
|
|
|
94
105
|
return this.sendOnSocket(this.socket, value);
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
observeWelcome(message = {}) {
|
|
109
|
+
const socket = this.socket;
|
|
110
|
+
if (this.closed || this.ready || !this.isSocketOpen(socket)) return false;
|
|
111
|
+
const mismatch = welcomeMismatch(message, this.expectedServer, this.expectedVersion);
|
|
112
|
+
if (mismatch) {
|
|
113
|
+
this.logger.debug?.("remote relay welcome rejected", { reason: mismatch });
|
|
114
|
+
this.failPermanently("relay_protocol_mismatch");
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
this.logger.debug?.("remote relay welcome received");
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
acknowledge(message = {}) {
|
|
98
122
|
const socket = this.socket;
|
|
99
123
|
if (this.closed || this.ready || !this.isSocketOpen(socket)) return false;
|
|
@@ -115,7 +139,8 @@ export class RelayConnection {
|
|
|
115
139
|
} else if (this.outageStartedAt > 0) {
|
|
116
140
|
const outageMs = Math.max(0, this.connectedAt - this.outageStartedAt);
|
|
117
141
|
if (this.outageNoticeEmitted) {
|
|
118
|
-
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", {
|
|
119
144
|
outage_seconds: roundSeconds(outageMs),
|
|
120
145
|
attempts: this.outageAttempts,
|
|
121
146
|
});
|
|
@@ -137,6 +162,20 @@ export class RelayConnection {
|
|
|
137
162
|
return true;
|
|
138
163
|
}
|
|
139
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
|
+
|
|
140
179
|
connect() {
|
|
141
180
|
if (this.closed || this.socket) return;
|
|
142
181
|
const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
|
|
@@ -154,8 +193,17 @@ export class RelayConnection {
|
|
|
154
193
|
return;
|
|
155
194
|
}
|
|
156
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?.();
|
|
157
204
|
|
|
158
205
|
socket.on("open", () => {
|
|
206
|
+
this.clearTimer("connectTimer", "clearTimeout");
|
|
159
207
|
if (this.socket !== socket || this.closed) {
|
|
160
208
|
try { socket.close(1000, "stale daemon connection"); } catch {}
|
|
161
209
|
return;
|
|
@@ -191,6 +239,7 @@ export class RelayConnection {
|
|
|
191
239
|
const wasReady = this.ready;
|
|
192
240
|
this.socket = null;
|
|
193
241
|
this.ready = false;
|
|
242
|
+
this.clearTimer("connectTimer", "clearTimeout");
|
|
194
243
|
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
195
244
|
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
196
245
|
const reasonText = sanitizeCloseReason(reason);
|
|
@@ -252,6 +301,7 @@ export class RelayConnection {
|
|
|
252
301
|
this.closed = true;
|
|
253
302
|
this.ready = false;
|
|
254
303
|
this.socket = null;
|
|
304
|
+
this.clearTimer("connectTimer", "clearTimeout");
|
|
255
305
|
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
256
306
|
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
257
307
|
this.clearTimer("reconnectTimer", "clearTimeout");
|
|
@@ -273,7 +323,8 @@ export class RelayConnection {
|
|
|
273
323
|
reject(error);
|
|
274
324
|
return;
|
|
275
325
|
}
|
|
276
|
-
this.logger.error?.(message
|
|
326
|
+
this.logger.error?.(message);
|
|
327
|
+
this.logger.debug?.("remote relay fatal details", { category, cause: relayCloseUserCause(category) });
|
|
277
328
|
queueMicrotask(() => {
|
|
278
329
|
try { this.onFatal(error); } catch (callbackError) {
|
|
279
330
|
this.logger.error?.("relay fatal callback failed", { error_class: classifyOperationalError(callbackError) });
|
|
@@ -286,7 +337,6 @@ export class RelayConnection {
|
|
|
286
337
|
this.recordOutage(category);
|
|
287
338
|
const delay = this.reconnectDelay(this.reconnectAttempt++);
|
|
288
339
|
this.scheduleOutageWarning();
|
|
289
|
-
this.maybeRepeatOutageWarning();
|
|
290
340
|
this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay, attempt: this.outageAttempts });
|
|
291
341
|
this.reconnectTimer = this.scheduler.setTimeout(() => {
|
|
292
342
|
this.reconnectTimer = null;
|
|
@@ -339,31 +389,39 @@ export class RelayConnection {
|
|
|
339
389
|
}
|
|
340
390
|
|
|
341
391
|
scheduleOutageWarning() {
|
|
342
|
-
if (this.
|
|
343
|
-
const
|
|
344
|
-
|
|
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());
|
|
345
397
|
this.outageWarnTimer = this.scheduler.setTimeout(() => {
|
|
346
398
|
this.outageWarnTimer = null;
|
|
347
399
|
if (this.closed || this.ready || this.outageStartedAt === 0) return;
|
|
348
400
|
this.emitOutageWarning();
|
|
401
|
+
this.scheduleOutageWarning();
|
|
349
402
|
}, delay);
|
|
350
403
|
this.outageWarnTimer?.unref?.();
|
|
351
404
|
}
|
|
352
405
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
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);
|
|
357
409
|
}
|
|
358
410
|
|
|
359
411
|
emitOutageWarning() {
|
|
360
412
|
const outageMs = Math.max(0, this.now() - this.outageStartedAt);
|
|
361
413
|
this.outageNoticeEmitted = true;
|
|
414
|
+
this.outageWarningCount += 1;
|
|
362
415
|
this.lastOutageWarnAt = this.now();
|
|
363
|
-
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", {
|
|
364
422
|
outage_seconds: roundSeconds(outageMs),
|
|
365
423
|
attempts: this.outageAttempts,
|
|
366
|
-
cause
|
|
424
|
+
cause,
|
|
367
425
|
...(this.lastTransportErrorClass ? { error_class: this.lastTransportErrorClass } : {}),
|
|
368
426
|
});
|
|
369
427
|
}
|
|
@@ -373,6 +431,7 @@ export class RelayConnection {
|
|
|
373
431
|
this.outageStartedAt = 0;
|
|
374
432
|
this.outageAttempts = 0;
|
|
375
433
|
this.outageNoticeEmitted = false;
|
|
434
|
+
this.outageWarningCount = 0;
|
|
376
435
|
this.lastOutageWarnAt = 0;
|
|
377
436
|
this.lastCloseCategory = "connection_interrupted";
|
|
378
437
|
this.lastTransportErrorClass = "";
|
|
@@ -393,10 +452,15 @@ export class RelayConnection {
|
|
|
393
452
|
|
|
394
453
|
export function relayCloseCategory(code, reason = "") {
|
|
395
454
|
const numeric = Number(code);
|
|
396
|
-
|
|
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";
|
|
397
460
|
if (numeric === 1000) return "normal_close";
|
|
398
461
|
if (numeric === 1001 || numeric === 1012 || numeric === 1013) return "relay_restarting_or_unavailable";
|
|
399
462
|
if (numeric === 1006) return "connection_interrupted";
|
|
463
|
+
if (numeric === 1002) return "relay_protocol_error";
|
|
400
464
|
if (numeric === 1007) return "invalid_transport_payload";
|
|
401
465
|
if (numeric === 1008) return "relay_policy_rejected";
|
|
402
466
|
if (numeric === 1009) return "message_too_large";
|
|
@@ -408,13 +472,12 @@ function relayFatalMessage(category) {
|
|
|
408
472
|
if (category === "relay_protocol_mismatch") {
|
|
409
473
|
return "remote relay identity or version does not match this daemon; upgrade and redeploy both components";
|
|
410
474
|
}
|
|
475
|
+
if (category === "relay_protocol_error") {
|
|
476
|
+
return "remote relay protocol error; upgrade and redeploy both components, then restart the daemon";
|
|
477
|
+
}
|
|
411
478
|
return "remote relay rejected the daemon connection; verify credentials or redeploy the Worker";
|
|
412
479
|
}
|
|
413
480
|
|
|
414
|
-
function relayOutageWarningMessage() {
|
|
415
|
-
return "remote relay is unavailable; automatic reconnection is still in progress";
|
|
416
|
-
}
|
|
417
|
-
|
|
418
481
|
function relayCloseUserCause(category) {
|
|
419
482
|
const causes = {
|
|
420
483
|
connection_interrupted: "connection interrupted",
|
|
@@ -423,9 +486,11 @@ function relayCloseUserCause(category) {
|
|
|
423
486
|
relay_internal_error: "relay internal error",
|
|
424
487
|
relay_protocol_mismatch: "relay identity or version mismatch",
|
|
425
488
|
relay_authentication_failed: "relay authentication failed",
|
|
489
|
+
relay_connect_timeout: "relay connection attempt timed out",
|
|
426
490
|
relay_handshake_timeout: "relay authentication acknowledgement timed out",
|
|
427
491
|
relay_heartbeat_timeout: "relay stopped responding",
|
|
428
492
|
relay_transport_error: "relay transport error",
|
|
493
|
+
relay_protocol_error: "relay protocol error",
|
|
429
494
|
invalid_transport_payload: "invalid transport payload",
|
|
430
495
|
message_too_large: "message exceeded the relay limit",
|
|
431
496
|
normal_close: "connection closed",
|
|
@@ -435,6 +500,15 @@ function relayCloseUserCause(category) {
|
|
|
435
500
|
return causes[String(category || "")] || "connection interrupted";
|
|
436
501
|
}
|
|
437
502
|
|
|
503
|
+
export function welcomeMismatch(message, expectedServer = "", expectedVersion = "") {
|
|
504
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return "invalid_welcome";
|
|
505
|
+
if (message.type !== "welcome") return "unexpected_welcome_type";
|
|
506
|
+
if (expectedServer && message.server !== expectedServer) return "server_identity_mismatch";
|
|
507
|
+
if (expectedVersion && message.version !== expectedVersion) return "server_version_mismatch";
|
|
508
|
+
if (typeof message.server !== "string" || !message.server || typeof message.version !== "string" || !message.version) return "incomplete_welcome";
|
|
509
|
+
return "";
|
|
510
|
+
}
|
|
511
|
+
|
|
438
512
|
export function acknowledgementMismatch(message, expectedServer = "", expectedVersion = "") {
|
|
439
513
|
if (!message || typeof message !== "object" || Array.isArray(message)) return "invalid_acknowledgement";
|
|
440
514
|
if (message.type !== "hello_ack") return "unexpected_acknowledgement_type";
|
|
@@ -490,6 +564,37 @@ function boundedPositiveInteger(value, fallback) {
|
|
|
490
564
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
491
565
|
}
|
|
492
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
|
+
|
|
493
598
|
function roundSeconds(milliseconds) {
|
|
494
599
|
return Math.max(1, Math.round(milliseconds / 1000));
|
|
495
600
|
}
|
package/src/local/runtime.mjs
CHANGED
|
@@ -176,23 +176,35 @@ 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);
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
handleRelayControlMessage(message) {
|
|
195
|
+
if (message.type === "welcome") {
|
|
196
|
+
this.relay?.observeWelcome(message);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
191
199
|
if (message.type === "hello_ack") {
|
|
192
200
|
this.relay?.acknowledge(message);
|
|
193
201
|
return true;
|
|
194
202
|
}
|
|
195
203
|
if (message.type === "pong") return true;
|
|
204
|
+
if (message.type === "error") {
|
|
205
|
+
this.relay?.handleServerError(message);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
196
208
|
if (message.type === "cancel_call") {
|
|
197
209
|
if (typeof message.id === "string") this.cancelCall(message.id, "remote cancellation");
|
|
198
210
|
return true;
|
|
@@ -200,6 +212,14 @@ export class LocalRuntime {
|
|
|
200
212
|
return false;
|
|
201
213
|
}
|
|
202
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
|
+
|
|
203
223
|
async handleRelayToolCall(message) {
|
|
204
224
|
const envelope = normalizeRelayToolCall(message);
|
|
205
225
|
if (!envelope.ok) {
|
|
@@ -1150,7 +1170,7 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
|
|
|
1150
1170
|
function handleRelayData(runtime, data) {
|
|
1151
1171
|
const raw = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
1152
1172
|
if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
|
1153
|
-
runtime.
|
|
1173
|
+
runtime.handleRelayProtocolViolation("server_message_too_large");
|
|
1154
1174
|
return;
|
|
1155
1175
|
}
|
|
1156
1176
|
return runtime.handleMessage(raw);
|
package/src/local/service.mjs
CHANGED
|
@@ -4,10 +4,12 @@ import path from "node:path";
|
|
|
4
4
|
import { run } from "./shell.mjs";
|
|
5
5
|
import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
|
|
6
6
|
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
7
|
+
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
7
8
|
|
|
8
9
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
9
10
|
const WINDOWS_TASK = "MachineBridgeMCP";
|
|
10
11
|
const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
|
12
|
+
const AUTOSTART_LOG_SCHEMA_VERSION = 3;
|
|
11
13
|
|
|
12
14
|
function serviceRun(command, args) {
|
|
13
15
|
return run(command, args, {
|
|
@@ -53,33 +55,67 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
53
55
|
const root = expandHome(stateRoot);
|
|
54
56
|
const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
|
|
55
57
|
const keepBytes = Math.min(maxBytes, Number.isFinite(Number(options.keepBytes)) ? Math.max(1024, Number(options.keepBytes)) : 1024 * 1024);
|
|
58
|
+
const schemaVersion = String(Number.isInteger(Number(options.schemaVersion)) && Number(options.schemaVersion) > 0
|
|
59
|
+
? Number(options.schemaVersion)
|
|
60
|
+
: AUTOSTART_LOG_SCHEMA_VERSION);
|
|
56
61
|
const logs = path.join(root, "logs");
|
|
62
|
+
const schemaFile = path.join(logs, ".log-schema");
|
|
63
|
+
const migrate = readLogSchema(schemaFile) !== schemaVersion;
|
|
64
|
+
let migrationComplete = true;
|
|
65
|
+
|
|
57
66
|
for (const name of ["daemon.out.log", "daemon.err.log"]) {
|
|
58
67
|
const file = path.join(logs, name);
|
|
59
68
|
let fd;
|
|
60
69
|
try {
|
|
61
70
|
if (!existsSync(file)) continue;
|
|
62
71
|
const before = lstatSync(file);
|
|
63
|
-
if (before.isSymbolicLink() || !before.isFile())
|
|
72
|
+
if (before.isSymbolicLink() || !before.isFile()) {
|
|
73
|
+
if (migrate) migrationComplete = false;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
64
76
|
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
65
77
|
fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
|
|
66
78
|
const info = fstatSync(fd);
|
|
67
|
-
if (!info.isFile())
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
79
|
+
if (!info.isFile()) {
|
|
80
|
+
if (migrate) migrationComplete = false;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (migrate && info.size > 0) {
|
|
84
|
+
const legacy = readLogTail(fd, info.size, maxBytes);
|
|
85
|
+
if (legacy.length) writePrivateServiceFile(path.join(logs, legacyLogName(name)), legacy);
|
|
86
|
+
ftruncateSync(fd, 0);
|
|
87
|
+
} else if (info.size > maxBytes) {
|
|
88
|
+
const tail = readLogTail(fd, info.size, keepBytes);
|
|
73
89
|
ftruncateSync(fd, 0);
|
|
74
90
|
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
75
91
|
}
|
|
76
92
|
try { chmodSync(file, 0o600); } catch {}
|
|
77
93
|
} catch {
|
|
94
|
+
if (migrate) migrationComplete = false;
|
|
78
95
|
// Operational log maintenance is best effort and must not stop startup.
|
|
79
96
|
} finally {
|
|
80
97
|
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
81
98
|
}
|
|
82
99
|
}
|
|
100
|
+
|
|
101
|
+
if (migrate && migrationComplete) {
|
|
102
|
+
try { writePrivateServiceFile(schemaFile, `${schemaVersion}\n`); } catch {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readLogSchema(file) {
|
|
107
|
+
try { return readBoundedRegularFileSync(file, 64).toString("utf8").trim(); } catch { return ""; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readLogTail(fd, size, limit) {
|
|
111
|
+
const length = Math.min(limit, size);
|
|
112
|
+
const buffer = Buffer.alloc(length);
|
|
113
|
+
readSync(fd, buffer, 0, length, Math.max(0, size - length));
|
|
114
|
+
return lineSafeTail(buffer);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function legacyLogName(name) {
|
|
118
|
+
return name.endsWith(".log") ? `${name.slice(0, -4)}.legacy.log` : `${name}.legacy`;
|
|
83
119
|
}
|
|
84
120
|
|
|
85
121
|
function lineSafeTail(buffer) {
|
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>;
|