machine-bridge-mcp 1.1.4 → 1.1.5
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 +10 -0
- package/README.md +4 -2
- package/SECURITY.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +3 -1
- package/docs/AUDIT.md +14 -0
- package/docs/LOGGING.md +2 -0
- package/docs/OPERATIONS.md +7 -3
- package/docs/TESTING.md +3 -2
- package/package.json +6 -5
- package/src/local/cli-account-admin.mjs +1 -1
- package/src/local/cli.mjs +12 -3
- package/src/local/service-environment.mjs +129 -0
- package/src/local/service.mjs +21 -52
- package/src/local/state.mjs +1 -1
- package/src/local/windows-service.mjs +248 -0
- package/src/worker/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.5 - 2026-07-16
|
|
4
|
+
|
|
5
|
+
### Windows autostart and persisted network environment
|
|
6
|
+
|
|
7
|
+
- Replace the oversized Windows Scheduled Task `/TR` invocation with a short private launcher. The launcher contains the full quoted Node/CLI/workspace argv, routes output to the normal service logs, exits on success, and restarts nonzero daemon exits after five seconds. Register it for current-user logon at `LIMITED` run level, and reject a custom state path when even the short action exceeds Task Scheduler's 262-character boundary.
|
|
8
|
+
- Query Windows task state and last result through fixed PowerShell object properties instead of localized `schtasks` text. Creation, start, stop, and removal are verified against observed state; an installed `Ready` task is no longer reported as active, and localized nonzero stop/delete output cannot create a false failure after the requested state is reached. Default reboot recovery occurs after that Windows user signs in, not before login or as `SYSTEM`.
|
|
9
|
+
- Persist an owner-only allowlist of proxy and custom-CA environment variables for autostart daemons. Session-only PowerShell proxy settings now survive logon/reboot, environment-free later starts do not erase them, explicit case-insensitive replacements remove stale variants, and status/logging expose only configured key names. Unrelated environment secrets, oversized values, and control characters are rejected.
|
|
10
|
+
- Extend Worker idempotency coverage through a real disk-state reload to prove that a successful Wrangler upload followed by health timeout is not repeated by a new process. Clarify that a different canonical `--workspace` intentionally selects a different profile and Worker, and add terminal guidance to rotate any one-time account password exposed in shared output.
|
|
11
|
+
- Refresh the exact `ws` and Wrangler pins. The `ws` patch tightens fragment buffering limits, while Wrangler advances its bundled `workerd`; update the reviewed install-script allowlist to the exact new `workerd` version and retain zero high-severity or production audit findings.
|
|
12
|
+
|
|
3
13
|
## 1.1.4 - 2026-07-15
|
|
4
14
|
|
|
5
15
|
### File integrity and contract corrections
|
package/README.md
CHANGED
|
@@ -466,7 +466,7 @@ machine-mcp job submit|inspect|approve|list|read|cancel
|
|
|
466
466
|
machine-mcp uninstall [--keep-worker] [--yes]
|
|
467
467
|
```
|
|
468
468
|
|
|
469
|
-
Each canonical workspace has an independent profile, Worker name, credential set, state file, startup lock, and daemon lock.
|
|
469
|
+
Each canonical workspace has an independent profile, Worker name, credential set, state file, startup lock, and daemon lock. Supplying a genuinely different `--workspace` therefore selects or creates a different Worker; junctions and symbolic links are canonicalized before the workspace identity is hashed. Use `machine-mcp workspace show` and `machine-mcp status` before treating two Worker names as duplicates.
|
|
470
470
|
|
|
471
471
|
## Autostart
|
|
472
472
|
|
|
@@ -476,7 +476,9 @@ Remote mode supports:
|
|
|
476
476
|
- Linux `systemd --user`, with best-effort lingering;
|
|
477
477
|
- Windows Scheduled Task at logon.
|
|
478
478
|
|
|
479
|
-
The service definition contains neither credentials nor a duplicate policy. It loads the selected policy from owner-only local state and uses the documented `full` default if policy state is absent. launchd/systemd definitions persist a sanitized absolute-only PATH captured during installation, including the stable Node/CLI directories, so background `full` mode does not lose Homebrew or other developer command locations.
|
|
479
|
+
The service definition contains neither Worker/account credentials nor a duplicate policy. It loads the selected policy from owner-only local state and uses the documented `full` default if policy state is absent. launchd/systemd definitions persist a sanitized absolute-only PATH captured during installation, including the stable Node/CLI directories, so background `full` mode does not lose Homebrew or other developer command locations. A private, allowlisted service-environment snapshot preserves proxy and custom-CA variables needed by a background daemon; status reports only configured key names, never values. Re-run `machine-mcp service install` after changing those variables. Custom Windows state paths used for autostart must also remain within the Task Scheduler path limit and must not contain a literal `%`.
|
|
480
|
+
|
|
481
|
+
Windows uses a short private launcher instead of putting the full Node command in Task Scheduler's bounded `/TR` field. The launcher routes output to the normal service logs and restarts a nonzero daemon exit after a delay. The task runs with least privilege when that Windows user signs in. Thus a reboot followed by normal user sign-in needs no command, but the default installation deliberately does not claim pre-login availability and does not store a Windows password or run as `SYSTEM`. Background services run at log level `warn` with JSON output: relay, protocol, and service problems are retained as bounded structured events, while all per-tool success/failure/cancellation/timing events remain debug-only. Logs are owner-only where supported and bounded by tail trimming.
|
|
480
482
|
|
|
481
483
|
## Secret rotation
|
|
482
484
|
|
package/SECURITY.md
CHANGED
|
@@ -69,7 +69,7 @@ Repository and user instruction files remain untrusted content from the model's
|
|
|
69
69
|
|
|
70
70
|
Automatic `package.*` commands expose only validated script names and fixed package-manager argv; they do not expose script bodies. Executing one still runs repository-controlled package-script code with local-user authority and is not a sandbox or trust upgrade.
|
|
71
71
|
|
|
72
|
-
Relay proxy selection honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. Only HTTP(S) proxy URLs are accepted. Proxy endpoints, credentials, and authorization headers are not returned through MCP or written to operational logs; status exposes only coarse route state.
|
|
72
|
+
Relay proxy selection honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. Only HTTP(S) proxy URLs are accepted. Autostart installation persists an allowlist of proxy and custom-CA environment values in `service-environment.json` so a background daemon can reproduce the foreground network route; a proxy URL may itself contain credentials, so this file is sensitive local state and must remain protected with the rest of the state root. Proxy endpoints, credentials, certificate paths, and authorization headers are not returned through MCP or written to operational logs; service status exposes only configured environment key names and relay status exposes only coarse route state.
|
|
73
73
|
|
|
74
74
|
Skill loading is non-executing. `load_local_skill` returns an entrypoint and bounded file inventory; scripts remain inert until a separate process or command tool is called. Symlinked skill directories are followed only after canonical path-policy validation, while symbolic-link `SKILL.md` entrypoints are rejected. Traversal, cycles, content, summaries, and inventory are bounded.
|
|
75
75
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Machine Bridge Browser",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.5",
|
|
5
5
|
"description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"action": {
|
|
31
31
|
"default_title": "Machine Bridge Browser"
|
|
32
32
|
},
|
|
33
|
-
"version_name": "1.1.
|
|
33
|
+
"version_name": "1.1.5"
|
|
34
34
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -116,7 +116,9 @@ The daemon attachment deliberately omits workspace path/name/hash and process ID
|
|
|
116
116
|
|
|
117
117
|
### Autostart layer
|
|
118
118
|
|
|
119
|
-
The service layer emits launchd, systemd-user, or Windows Scheduled Task definitions.
|
|
119
|
+
The service layer emits launchd, systemd-user, or Windows Scheduled Task definitions. Worker/account credentials are not embedded in service definitions; the daemon loads owner-only state. The exact policy is stored in owner-only state. An allowlisted `service-environment.json` separately persists only network-proxy and custom-CA variables needed by a background daemon, because shell-session environment is not inherited reliably by login managers; values are loaded only for `--daemon-only`, never logged, and status exposes key names only. launchd/systemd definitions contain the workspace/state-root selectors, `warn` plus JSON log settings, and a sanitized absolute-only PATH captured at installation so background `full` mode can resolve the same developer tools without accepting relative PATH entries.
|
|
120
|
+
|
|
121
|
+
The Windows adapter owns a short private restart launcher because Task Scheduler's `/TR` action is substantially smaller than the Windows process command-line limit and the full installed Node/CLI/workspace argv can exceed it. The scheduled action therefore contains only the launcher path. The launcher performs the full quoted invocation, redirects to service logs, and restarts only nonzero exits. A fixed PowerShell object query supplies language-independent `Ready`/`Running` state; provider installation is not equated with process activity. The trigger is least-privilege current-user logon, not boot-time `SYSTEM` execution.
|
|
120
122
|
|
|
121
123
|
The platform adapters normalize launchd, systemd, and Windows Scheduled Task operations to one `{ok, provider}` result contract. Removal is not a provider-specific sequence: `service-lifecycle.mjs` first stops the provider, then every verified workspace daemon in scope, and only then removes the definition. A failed stop or unverifiable process prevents definition/state deletion.
|
|
122
124
|
|
package/docs/AUDIT.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Engineering and security audit
|
|
2
2
|
|
|
3
|
+
## 2026-07-16 Windows autostart, restart, and workspace-identity incident
|
|
4
|
+
|
|
5
|
+
The reported transcript combined two distinct questions that the previous Worker fix did not answer. The first ordinary `machine-mcp` invocation addressed the remembered workspace, while the next command explicitly supplied another `--workspace`. Machine Bridge intentionally assigns one profile and one Worker to each canonical workspace, so that sequence is not by itself evidence that a retry duplicated the same resource. The existing deployment code did persist successful Wrangler evidence before health verification, but its regression reused one in-memory object. The new test performs an actual atomic state write, reconstructs state from disk as a new process would, repeats an ambiguous timeout, and proves Wrangler deploy is still called exactly once. Documentation now requires comparing canonical workspace, profile, Worker name, and URL before deleting any apparent duplicate.
|
|
6
|
+
|
|
7
|
+
The Windows autostart warning was a separate confirmed defect. The adapter placed the complete Node executable, globally installed CLI entrypoint, workspace, state root, and logging arguments in `schtasks /TR`. Representative installed paths exceed Task Scheduler's 262-character action-path boundary, so task creation could fail consistently even though foreground relay startup succeeded. The generic startup warning discarded the provider reason, and `service status` compounded diagnosis by equating a successful task query with a running task. Stop and removal also depended on English fragments in `schtasks` output, which is invalid on non-English Windows.
|
|
8
|
+
|
|
9
|
+
Windows now stores the long invocation in a private state-root launcher and registers only that short launcher path. The launcher uses bounded fixed arguments, redirects to the standard service logs, exits after a successful daemon exit, and restarts a nonzero exit after five seconds. Registration uses the current user and `LIMITED` run level. Creation, start, stop, and removal are accepted only after fixed PowerShell object queries observe the actual Task Scheduler state and last result, so localized text is not a control signal and an installed `Ready` task is no longer reported as active. Tests reproduce an inline action above 262 characters, verify the replacement action is below the limit, exercise running/completed states, and inject localized nonzero command output.
|
|
10
|
+
|
|
11
|
+
A third lifecycle gap explained why a foreground connection repaired by PowerShell `$env:` variables could fail after reboot. Login managers do not preserve that terminal's process environment. Autostart installation now snapshots only an explicit proxy/custom-CA allowlist into private local state; `--daemon-only` fills missing runtime variables from that snapshot. A later environment-free startup preserves the snapshot, case-insensitive replacements remove stale Windows variants, explicit empty values can clear settings, and status/logging reveal only key names. Tests prove unrelated secret variables are excluded and values are bounded and control-character safe. A proxy URL may contain credentials, so the snapshot is treated as sensitive state rather than a service-definition or log field.
|
|
12
|
+
|
|
13
|
+
The resulting reboot contract is intentionally narrower than “available before anyone logs in.” The Windows task is `ONLOGON`: after the configured user signs in, no terminal action is required and the launcher self-recovers nonzero daemon exits. Pre-login operation would require a materially different Windows service account, stored-credential, or `SYSTEM` boundary and is not silently introduced. The audit also identified the exposed one-time owner password in the supplied screenshot as compromised operational output; CLI guidance now warns against sharing credential-bearing terminal output, but the affected account must be rotated by the operator because repository changes cannot revoke a displayed live credential.
|
|
14
|
+
|
|
15
|
+
Dependency review found no npm audit findings in either the complete or production graph. Two exact patch pins were stale: `ws` contained a fragment-limit correction and Wrangler carried a newer `workerd` plus unrelated tooling changes. Both pins were updated, the exact install-script allowlist was advanced to the reviewed `workerd` build, and the full packaging/install/Worker suites remain the acceptance boundary rather than treating “latest” as sufficient evidence.
|
|
16
|
+
|
|
3
17
|
## 2026-07-15 Windows Worker deployment convergence incident
|
|
4
18
|
|
|
5
19
|
A Windows report showed Wrangler creating a Worker successfully, followed by `Worker deployment did not become healthy at the expected version: timeout`. Re-running startup, and then changing the name to a suffixed `-r2` variant, left multiple Workers in the Cloudflare account. The causal defect was not Worker creation itself: Wrangler and the long-lived relay already followed standard proxy routing, while the independent health verifier used a direct global `fetch`. On networks where outbound HTTPS required an environment proxy, deployment could succeed and health verification could time out.
|
package/docs/LOGGING.md
CHANGED
|
@@ -39,6 +39,8 @@ Wrangler upload and `/healthz` verification are logged as separate state transit
|
|
|
39
39
|
|
|
40
40
|
Health routing records only `direct` or `proxy` at debug level. Proxy URLs, credentials, request headers, Worker secrets, and raw response bodies are never fields. Repeated per-attempt health failures remain debug-only; the terminal startup error contains one user-readable classification and corrective commands.
|
|
41
41
|
|
|
42
|
+
Autostart installation and daemon startup may report the names of allowlisted proxy/custom-CA environment variables that were saved or loaded. Their values, source environment, proxy endpoints, embedded credentials, and certificate paths are prohibited log fields. Windows task failures use stable reasons such as `task_create_failed` or `task_status_unavailable`; localized command output is retained only in an explicitly requested service-command result, not repeated in default startup warnings.
|
|
43
|
+
|
|
42
44
|
## Relay connection event policy
|
|
43
45
|
|
|
44
46
|
A TCP/WebSocket `open` event is only transport availability. The daemon is reported as connected only after the Worker returns `hello_ack` for the authenticated `hello` message.
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -16,7 +16,7 @@ Wrangler upload and public health verification are two separate observations. On
|
|
|
16
16
|
|
|
17
17
|
Automatic redeployment is limited to bounded health evidence that the recorded endpoint is genuinely stale: a persistent package-version mismatch, an unexpected Machine Bridge identity, or a persistent `404`/`410`. Unreachability is not proof of absence. `--force-worker` remains the explicit override when an operator deliberately wants an upload despite matching state.
|
|
18
18
|
|
|
19
|
-
Each workspace has one stable Worker name. A command that supplies a different `--worker-name` after initialization is rejected unless `--force-worker` is present. A name such as `mbm-example-r2` is a separate Cloudflare Worker, not a revision of `mbm-example`.
|
|
19
|
+
Each canonical workspace has one stable Worker name. A command that supplies a different `--worker-name` after initialization is rejected unless `--force-worker` is present. A name such as `mbm-example-r2` is a separate Cloudflare Worker, not a revision of `mbm-example`. A different `--workspace` is also a separate identity by design; Windows junctions and symbolic links are resolved before hashing. A transcript that runs `machine-mcp` and later runs `machine-mcp --workspace OTHER_PATH` does not by itself prove a same-workspace duplicate. Compare `machine-mcp workspace show`, `machine-mcp status`, the canonical workspace path, and the recorded Worker URL first. If an older release already created duplicates, remove only the confirmed unused Workers in the Cloudflare dashboard or with Wrangler. Source upgrades do not delete ambiguous live deployments automatically.
|
|
20
20
|
|
|
21
21
|
For a health timeout on Windows or a managed network:
|
|
22
22
|
|
|
@@ -26,7 +26,7 @@ machine-mcp doctor
|
|
|
26
26
|
machine-mcp --verbose
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Run these commands from the same environment used for startup so `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` are identical. Debug logs expose only the selected route and classified error; they never print a proxy endpoint or credentials.
|
|
29
|
+
Run these commands from the same environment used for startup so `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` are identical. After the foreground connection succeeds, run `machine-mcp service install` from that same PowerShell session. Installation stores only an allowlisted proxy/custom-CA environment snapshot in private local state so the logon task does not lose session-only `$env:` settings after reboot. A later start with no proxy variables does not erase the saved snapshot; set a variable explicitly to an empty value and reinstall to clear it. `machine-mcp service status` reports only the saved key names. Debug logs expose only the selected route and classified error; they never print a proxy endpoint or credentials.
|
|
30
30
|
|
|
31
31
|
### Blocking-layer decision table
|
|
32
32
|
|
|
@@ -149,7 +149,11 @@ Uninstall acquires a state-root `maintenance.lock` that blocks new profile/state
|
|
|
149
149
|
Stable errors include `policy_denied`, `invalid_request`, `timeout`, `cancelled`, `network_error`, `unavailable`, `limit_exceeded`, and `integrity_error`, with retryability metadata. Diagnose by code first; free-form messages are guidance, not an API contract.
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
Windows
|
|
152
|
+
Windows Task Scheduler limits the `/TR` action text, so the platform adapter writes a short private `service-launcher.cmd` under the state root rather than embedding the full Node, package, workspace, state, and logging argv in the task action. The launcher uses CRT-compatible argument quoting, sends stdout/stderr to the normal service logs, exits on a successful daemon exit, and restarts a nonzero exit after five seconds. Task creation is accepted only after a separate state query observes the task. Start, stop, and removal likewise query the Task Scheduler state through fixed PowerShell object properties instead of parsing localized `schtasks` messages; an installed `Ready` task is not mislabeled as currently running.
|
|
153
|
+
|
|
154
|
+
The Windows trigger is current-user `ONLOGON` with `LIMITED` run level. After a reboot, signing in to that user is sufficient; no terminal command is required. Pre-login operation is intentionally not provided by the default design because it would require a different service-account/credential boundary. 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. A private allowlisted `service-environment.json` preserves `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, matching lowercase forms, optional `ALL_PROXY`, Node proxy selection, and custom-CA path variables. Existing saved values survive an environment-free reinstall, while explicitly supplied values replace case-insensitive prior variants. Values may include proxy credentials, so the file stays in owner-only state and is never returned or logged; status exposes key names only. Re-run `machine-mcp service install` after changing Node installation families, PATH layout, proxy, or CA configuration. Custom Windows state paths used for autostart must also remain within the Task Scheduler path limit and must not contain a literal `%`.
|
|
155
|
+
|
|
156
|
+
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`. Installed services pass `--log-level warn --log-format json`, so each active line is a bounded JSON event suitable for ingestion. Files are owner-only where supported and tail-trimmed before daemon startup. If the log schema marker does not match the current format, the active files are cleared before startup and the current marker is written. Runtime code reads and maintains only the active filenames.
|
|
153
157
|
|
|
154
158
|
Logging is level-based:
|
|
155
159
|
|
package/docs/TESTING.md
CHANGED
|
@@ -22,10 +22,11 @@ The suite includes:
|
|
|
22
22
|
- default working-agreement injection without user files, bounded automatic project metadata, script-body non-disclosure, user-global opt-out, repository opt-out rejection, global `model_instructions_file` injection in stdio/remote initialization, hierarchical precedence, `.agents/skills` and `.codex/skills` compatibility discovery, live project/skill rescanning and fingerprints, automatic task ranking/loading with English inflection normalization, bounded Chinese/English intent aliases, capability-name weighting, and selected-skill instruction loading; automatic `package.*` commands with English/Chinese workflow-intent matching and Windows command-shim execution, explicit command override/removal, direct argv handling, timeout ceilings, runtime-keyed routing-telemetry privacy, and execution-profile denial;
|
|
23
23
|
- concurrent complete-before-visible lock claims, atomic replacement under active readers, malformed-lock grace, snapshot/token-safe reclamation, absolute-age expiry, PID-reuse detection, bounded startup-lock waiting, and wall-clock rollback injection proving duration deadlines remain monotonic;
|
|
24
24
|
- foreground takeover of active and orphaned background daemons with current service-lock metadata, foreground-process protection, bounded final lock-handoff retry, actual-PID exit waiting, POSIX non-escalating timeout behavior, Windows verified-daemon stop semantics, daemon lock mode/version/process-start metadata, launchd service-target semantics, and silent idempotent duplicate service starts; daemon fixture subprocesses intentionally do not inherit V8 coverage because their purpose is ownership timing rather than code measurement;
|
|
25
|
-
- fail-closed service lifecycle ordering for provider-stop, all-workspace daemon-stop, and definition removal, including platform/daemon/removal failure injection and normalized macOS/systemd/Windows results;
|
|
25
|
+
- fail-closed service lifecycle ordering for provider-stop, all-workspace daemon-stop, and definition removal, including platform/daemon/removal failure injection and normalized macOS/systemd/Windows results; Windows coverage reproduces an inline `/TR` command above 262 characters, proves the short launcher action remains bounded, verifies least-privilege logon registration, restart/log routing, language-independent `Ready`/`Running` observation, and state-observed stop/removal despite localized nonzero command output;
|
|
26
|
+
- private service-environment capture/load coverage for exact allowlisting, value bounds and control-character rejection, non-proxy secret exclusion, runtime-value precedence, Windows case-insensitive replacement, explicit empty-value clearing, and preservation across a later environment-free startup;
|
|
26
27
|
- machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, broker result redaction, pre-registered runtime-handshake listeners, bounded socket/HTTP waits, frozen-wall-clock browser deadline coverage, installed-application discovery caching, and name-based task matching;
|
|
27
28
|
- Worker health direct/proxy routing through a real local HTTP CONNECT proxy, `NO_PROXY` bypass, exact `workers.dev` origin/name validation, redirect rejection, body/deadline bounds, error classification, bounded propagation retry, and invalid proxy fail-fast behavior without endpoint or credential disclosure;
|
|
28
|
-
- Worker deployment ambiguity/idempotency: a successful Wrangler result followed by health timeout persists the fingerprint, a second start performs no upload, definitive stale-version evidence redeploys under the same name, accidental name changes are rejected, forced replacements clear current endpoint state, and prior names remain in uninstall inventory;
|
|
29
|
+
- Worker deployment ambiguity/idempotency: a successful Wrangler result followed by health timeout persists the fingerprint, a second start performs no upload, an actual process restart and disk-state reload still performs no upload, definitive stale-version evidence redeploys under the same name, accidental name changes are rejected, forced replacements clear current endpoint state, and prior names remain in uninstall inventory;
|
|
29
30
|
- relay environment-proxy direct/bypass/agent selection, unsupported proxy rejection, fail-fast invalid configuration, and route observability without endpoint or credential disclosure;
|
|
30
31
|
- canonical path and symbolic-link escape tests;
|
|
31
32
|
- relative-path privacy and error-path redaction tests;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"worker:types": "node scripts/generate-worker-types.mjs",
|
|
44
44
|
"typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
|
|
45
45
|
"syntax": "node scripts/syntax-check.mjs",
|
|
46
|
-
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run worker-deployment:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run install:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test && npm run oauth-browser:test",
|
|
46
|
+
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run worker-deployment:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-environment:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run install:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test && npm run oauth-browser:test",
|
|
47
47
|
"worker:dry-run": "wrangler deploy --dry-run",
|
|
48
48
|
"worker:integration-test": "node tests/worker-integration-test.mjs",
|
|
49
49
|
"release:check": "node scripts/github-release.mjs --check",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
"app-automation:test": "node tests/app-automation-test.mjs",
|
|
80
80
|
"process-lock:test": "node tests/process-lock-test.mjs",
|
|
81
81
|
"service-lifecycle:test": "node tests/service-lifecycle-test.mjs",
|
|
82
|
+
"service-environment:test": "node tests/service-environment-test.mjs",
|
|
82
83
|
"app-automation:live-test": "node tests/app-automation-test.mjs --live-macos",
|
|
83
84
|
"browser-command:test": "node tests/browser-command-test.mjs",
|
|
84
85
|
"browser-devtools-input:test": "node tests/browser-devtools-input-test.mjs",
|
|
@@ -113,8 +114,8 @@
|
|
|
113
114
|
"dependencies": {
|
|
114
115
|
"https-proxy-agent": "9.1.0",
|
|
115
116
|
"proxy-from-env": "2.1.0",
|
|
116
|
-
"wrangler": "4.
|
|
117
|
-
"ws": "8.21.
|
|
117
|
+
"wrangler": "4.111.0",
|
|
118
|
+
"ws": "8.21.1"
|
|
118
119
|
},
|
|
119
120
|
"devDependencies": {
|
|
120
121
|
"@types/node": "26.1.1",
|
|
@@ -152,7 +153,7 @@
|
|
|
152
153
|
"allowScripts": {
|
|
153
154
|
"esbuild@0.28.1": true,
|
|
154
155
|
"sharp@0.34.5": true,
|
|
155
|
-
"workerd@1.
|
|
156
|
+
"workerd@1.20260710.1": true,
|
|
156
157
|
"fsevents": false
|
|
157
158
|
},
|
|
158
159
|
"packageManager": "npm@12.0.1",
|
|
@@ -73,7 +73,7 @@ function printAccountResult(action, result) {
|
|
|
73
73
|
if (result.account) console.log(`${result.account.name}: ${result.account.role}; ${result.account.active ? "active" : "disabled"}`);
|
|
74
74
|
if (result.password) {
|
|
75
75
|
process.stdout.write(`Password: ${result.password}\n`);
|
|
76
|
-
console.log("Save this password now; it is not stored locally or shown again.");
|
|
76
|
+
console.log("Save this password now; it is not stored locally or shown again. Do not share this terminal output.");
|
|
77
77
|
}
|
|
78
78
|
if (result.removed) console.log("Account removed.");
|
|
79
79
|
}
|
package/src/local/cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { classifyOperationalError, createLogger, sanitizeLogText } from "./log.m
|
|
|
18
18
|
import { runExecutable, runWrangler } from "./shell.mjs";
|
|
19
19
|
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
20
20
|
import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
|
|
21
|
+
import { loadServiceEnvironment, serviceEnvironmentSummary } from "./service-environment.mjs";
|
|
21
22
|
import { ensureWorkerDeployment } from "./worker-deployment.mjs";
|
|
22
23
|
import { workerHealth } from "./worker-health.mjs";
|
|
23
24
|
export { workerHealthUserReason } from "./worker-health.mjs";
|
|
@@ -168,6 +169,10 @@ async function startCommand(args) {
|
|
|
168
169
|
const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "cli" });
|
|
169
170
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
|
|
170
171
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
172
|
+
if (args.daemonOnly) {
|
|
173
|
+
const serviceEnvironment = loadServiceEnvironment(state.paths.stateRoot);
|
|
174
|
+
logger.debug?.("Loaded persisted service network environment", { keys: serviceEnvironment.keys });
|
|
175
|
+
}
|
|
171
176
|
const startupLock = await acquireStartupLockWithWait(state, { operation: "start", logger });
|
|
172
177
|
|
|
173
178
|
try {
|
|
@@ -395,7 +400,7 @@ function printMcpConnection(state, { quiet = false, verbose = false, initialOwne
|
|
|
395
400
|
logger.success("Remote MCP bridge is ready");
|
|
396
401
|
logger.plain(` MCP Server URL: ${state.worker.mcpServerUrl}`);
|
|
397
402
|
if (initialOwner) {
|
|
398
|
-
logger.warn("Initial owner account created; save the password now because it is not stored locally or shown again.");
|
|
403
|
+
logger.warn("Initial owner account created; save the password now because it is not stored locally or shown again. Do not share this terminal output; rotate the password immediately if it has been exposed.");
|
|
399
404
|
logger.plain(` Account: ${initialOwner.name}`);
|
|
400
405
|
logger.plain(` Password: ${initialOwner.password}`);
|
|
401
406
|
} else {
|
|
@@ -544,8 +549,9 @@ async function serviceCommand(args) {
|
|
|
544
549
|
...status,
|
|
545
550
|
workspace: state?.workspace?.path || null,
|
|
546
551
|
workspace_daemon: workspaceDaemon,
|
|
552
|
+
service_environment: serviceEnvironmentSummary(stateRoot),
|
|
547
553
|
effective_active: Boolean(status.active || workspaceDaemon?.alive),
|
|
548
|
-
orphaned_workspace_daemon: Boolean(
|
|
554
|
+
orphaned_workspace_daemon: Boolean(status.active === false && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
|
|
549
555
|
}, null, 2));
|
|
550
556
|
return;
|
|
551
557
|
}
|
|
@@ -620,7 +626,10 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, l
|
|
|
620
626
|
const { installAutostart } = await import("./service.mjs");
|
|
621
627
|
const result = await installAutostart({ workspace, stateRoot, entryScript, logger: structuredLogger(true) });
|
|
622
628
|
if (result?.ok) logger.info("Autostart installed for future logins", { provider: result.provider });
|
|
623
|
-
else logger.warn("Autostart installation reported a problem; run `machine-mcp service status` for details"
|
|
629
|
+
else logger.warn("Autostart installation reported a problem; run `machine-mcp service status` for details", {
|
|
630
|
+
provider: result?.provider || "unknown",
|
|
631
|
+
reason: result?.reason || "installation_failed",
|
|
632
|
+
});
|
|
624
633
|
} catch (error) {
|
|
625
634
|
logger.warn("Autostart installation skipped", { error_class: classifyOperationalError(error) });
|
|
626
635
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
4
|
+
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
5
|
+
|
|
6
|
+
const SERVICE_ENVIRONMENT_SCHEMA = 1;
|
|
7
|
+
const MAX_SERVICE_ENVIRONMENT_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_ENVIRONMENT_VALUE_BYTES = 16 * 1024;
|
|
9
|
+
const SERVICE_ENVIRONMENT_FILE = "service-environment.json";
|
|
10
|
+
|
|
11
|
+
export const SERVICE_NETWORK_ENVIRONMENT_KEYS = Object.freeze([
|
|
12
|
+
"HTTP_PROXY",
|
|
13
|
+
"HTTPS_PROXY",
|
|
14
|
+
"NO_PROXY",
|
|
15
|
+
"ALL_PROXY",
|
|
16
|
+
"http_proxy",
|
|
17
|
+
"https_proxy",
|
|
18
|
+
"no_proxy",
|
|
19
|
+
"all_proxy",
|
|
20
|
+
"NODE_USE_ENV_PROXY",
|
|
21
|
+
"NODE_EXTRA_CA_CERTS",
|
|
22
|
+
"SSL_CERT_FILE",
|
|
23
|
+
"SSL_CERT_DIR",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export function serviceEnvironmentPath(stateRoot) {
|
|
27
|
+
return path.join(path.resolve(String(stateRoot)), SERVICE_ENVIRONMENT_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function captureServiceEnvironment(environment = process.env) {
|
|
31
|
+
const captured = {};
|
|
32
|
+
for (const key of SERVICE_NETWORK_ENVIRONMENT_KEYS) {
|
|
33
|
+
if (!Object.prototype.hasOwnProperty.call(environment, key)) continue;
|
|
34
|
+
const value = environment[key];
|
|
35
|
+
if (value === undefined || value === null) continue;
|
|
36
|
+
captured[key] = validateEnvironmentValue(key, value);
|
|
37
|
+
}
|
|
38
|
+
return captured;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeServiceEnvironment(stateRoot, environment = process.env) {
|
|
42
|
+
const file = serviceEnvironmentPath(stateRoot);
|
|
43
|
+
const previous = existsSync(file) ? parseServiceEnvironment(file).environment : {};
|
|
44
|
+
const captured = captureServiceEnvironment(environment);
|
|
45
|
+
const merged = { ...previous };
|
|
46
|
+
for (const [key, value] of Object.entries(captured)) {
|
|
47
|
+
for (const existing of Object.keys(merged)) {
|
|
48
|
+
if (existing.toLowerCase() === key.toLowerCase()) delete merged[existing];
|
|
49
|
+
}
|
|
50
|
+
merged[key] = value;
|
|
51
|
+
}
|
|
52
|
+
const payload = {
|
|
53
|
+
schemaVersion: SERVICE_ENVIRONMENT_SCHEMA,
|
|
54
|
+
environment: merged,
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
const content = `${JSON.stringify(payload, null, 2)}\n`;
|
|
58
|
+
if (Buffer.byteLength(content) > MAX_SERVICE_ENVIRONMENT_BYTES) {
|
|
59
|
+
throw new Error("service network environment exceeds the persistence limit");
|
|
60
|
+
}
|
|
61
|
+
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
62
|
+
return { path: file, keys: Object.keys(payload.environment).sort() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadServiceEnvironment(stateRoot, targetEnvironment = process.env, options = {}) {
|
|
66
|
+
const file = serviceEnvironmentPath(stateRoot);
|
|
67
|
+
if (!existsSync(file)) return { path: file, keys: [], loaded: false };
|
|
68
|
+
const payload = parseServiceEnvironment(file);
|
|
69
|
+
const platform = String(options.platform || process.platform);
|
|
70
|
+
const loaded = [];
|
|
71
|
+
for (const [key, rawValue] of Object.entries(payload.environment)) {
|
|
72
|
+
if (!SERVICE_NETWORK_ENVIRONMENT_KEYS.includes(key)) continue;
|
|
73
|
+
if (hasEnvironmentKey(targetEnvironment, key, platform)) continue;
|
|
74
|
+
targetEnvironment[key] = validateEnvironmentValue(key, rawValue);
|
|
75
|
+
loaded.push(key);
|
|
76
|
+
}
|
|
77
|
+
return { path: file, keys: loaded.sort(), loaded: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function serviceEnvironmentSummary(stateRoot) {
|
|
81
|
+
const file = serviceEnvironmentPath(stateRoot);
|
|
82
|
+
if (!existsSync(file)) return { configured: false, keys: [] };
|
|
83
|
+
const payload = parseServiceEnvironment(file);
|
|
84
|
+
return { configured: true, keys: Object.keys(payload.environment).sort() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseServiceEnvironment(file) {
|
|
88
|
+
const content = readBoundedRegularFileSync(file, MAX_SERVICE_ENVIRONMENT_BYTES, "service environment file");
|
|
89
|
+
let payload;
|
|
90
|
+
try {
|
|
91
|
+
payload = JSON.parse(content.toString("utf8"));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error("service environment file is not valid JSON", { cause: error });
|
|
94
|
+
}
|
|
95
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
96
|
+
throw new Error("service environment file must contain an object");
|
|
97
|
+
}
|
|
98
|
+
if (payload.schemaVersion !== SERVICE_ENVIRONMENT_SCHEMA) {
|
|
99
|
+
throw new Error("service environment schema is obsolete; reinstall autostart");
|
|
100
|
+
}
|
|
101
|
+
if (!payload.environment || typeof payload.environment !== "object" || Array.isArray(payload.environment)) {
|
|
102
|
+
throw new Error("service environment file has an invalid environment map");
|
|
103
|
+
}
|
|
104
|
+
for (const [key, value] of Object.entries(payload.environment)) {
|
|
105
|
+
if (!SERVICE_NETWORK_ENVIRONMENT_KEYS.includes(key)) {
|
|
106
|
+
throw new Error("service environment file contains an unsupported key");
|
|
107
|
+
}
|
|
108
|
+
validateEnvironmentValue(key, value);
|
|
109
|
+
}
|
|
110
|
+
return payload;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateEnvironmentValue(key, value) {
|
|
114
|
+
const text = String(value);
|
|
115
|
+
if (text.includes("\0") || /[\r\n]/.test(text)) {
|
|
116
|
+
throw new Error(`service environment value ${key} contains a prohibited control character`);
|
|
117
|
+
}
|
|
118
|
+
if (Buffer.byteLength(text) > MAX_ENVIRONMENT_VALUE_BYTES) {
|
|
119
|
+
throw new Error(`service environment value ${key} exceeds the size limit`);
|
|
120
|
+
}
|
|
121
|
+
return text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasEnvironmentKey(environment, key, platform) {
|
|
125
|
+
if (Object.prototype.hasOwnProperty.call(environment, key)) return true;
|
|
126
|
+
if (platform !== "win32") return false;
|
|
127
|
+
const wanted = key.toLowerCase();
|
|
128
|
+
return Object.keys(environment).some(existing => existing.toLowerCase() === wanted);
|
|
129
|
+
}
|
package/src/local/service.mjs
CHANGED
|
@@ -6,9 +6,17 @@ import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
|
|
|
6
6
|
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
7
7
|
import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
8
8
|
import { waitForInactiveStatus } from "./service-convergence.mjs";
|
|
9
|
+
import { writeServiceEnvironment } from "./service-environment.mjs";
|
|
10
|
+
import {
|
|
11
|
+
installWindowsTask,
|
|
12
|
+
startWindowsTask,
|
|
13
|
+
statusWindowsTask,
|
|
14
|
+
stopWindowsTask,
|
|
15
|
+
uninstallWindowsTask,
|
|
16
|
+
} from "./windows-service.mjs";
|
|
17
|
+
export { windowsCommandLineArgument } from "./windows-service.mjs";
|
|
9
18
|
|
|
10
19
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
11
|
-
const WINDOWS_TASK = "MachineBridgeMCP";
|
|
12
20
|
const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
|
13
21
|
const AUTOSTART_LOG_SCHEMA_VERSION = 3;
|
|
14
22
|
|
|
@@ -26,41 +34,38 @@ export function runServiceCommand(command, args, execute = runExecutable) {
|
|
|
26
34
|
|
|
27
35
|
export async function installAutostart({ workspace, stateRoot, entryScript, logger = console }) {
|
|
28
36
|
const spec = serviceSpec({ workspace, stateRoot, entryScript });
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
const serviceEnvironment = writeServiceEnvironment(spec.stateRoot);
|
|
38
|
+
let result;
|
|
39
|
+
if (process.platform === "darwin") result = await installLaunchd(spec, logger);
|
|
40
|
+
else if (process.platform === "win32") result = await installWindowsTask(spec, logger, { run: serviceRun });
|
|
41
|
+
else result = await installSystemd(spec, logger);
|
|
42
|
+
return { ...result, service_environment: serviceEnvironment };
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
export async function uninstallAutostart({ stateRoot, logger = console } = {}) {
|
|
35
46
|
if (process.platform === "darwin") return uninstallLaunchd(logger);
|
|
36
|
-
if (process.platform === "win32") return uninstallWindowsTask(logger);
|
|
47
|
+
if (process.platform === "win32") return uninstallWindowsTask(logger, { run: serviceRun, stateRoot });
|
|
37
48
|
return uninstallSystemd(logger);
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
export async function autostartStatus({ logger = console } = {}) {
|
|
41
52
|
if (process.platform === "darwin") return statusLaunchd();
|
|
42
|
-
if (process.platform === "win32") return statusWindowsTask();
|
|
53
|
+
if (process.platform === "win32") return statusWindowsTask({ run: serviceRun });
|
|
43
54
|
return statusSystemd();
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
export async function startAutostart({ logger = console } = {}) {
|
|
47
58
|
if (process.platform === "darwin") return startLaunchd(logger);
|
|
48
|
-
if (process.platform === "win32") {
|
|
49
|
-
return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]));
|
|
50
|
-
}
|
|
59
|
+
if (process.platform === "win32") return startWindowsTask(logger, { run: serviceRun });
|
|
51
60
|
return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]));
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export async function stopAutostart({ logger = console } = {}) {
|
|
55
64
|
if (process.platform === "darwin") return stopLaunchd(logger);
|
|
56
|
-
if (process.platform === "win32") {
|
|
57
|
-
return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]), { allowAlreadyStopped: true });
|
|
58
|
-
}
|
|
65
|
+
if (process.platform === "win32") return stopWindowsTask(logger, { run: serviceRun });
|
|
59
66
|
return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]), { allowAlreadyStopped: true });
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
64
69
|
export function normalizeServiceCommandResult(provider, result, { allowAlreadyStopped = false } = {}) {
|
|
65
70
|
const detail = `${result?.stdout || ""}
|
|
66
71
|
${result?.stderr || ""}`;
|
|
@@ -136,7 +141,7 @@ function serviceSpec({ workspace, stateRoot, entryScript }) {
|
|
|
136
141
|
ensureOwnerOnlyDir(root);
|
|
137
142
|
ensureOwnerOnlyDir(logs);
|
|
138
143
|
for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) ensurePrivateLogFile(file);
|
|
139
|
-
|
|
144
|
+
const spec = {
|
|
140
145
|
workspace,
|
|
141
146
|
stateRoot: root,
|
|
142
147
|
entryScript: resolvedEntryScript,
|
|
@@ -145,6 +150,7 @@ function serviceSpec({ workspace, stateRoot, entryScript }) {
|
|
|
145
150
|
stdout: path.join(logs, "daemon.out.log"),
|
|
146
151
|
stderr: path.join(logs, "daemon.err.log"),
|
|
147
152
|
};
|
|
153
|
+
return { ...spec, daemonArgs: daemonArgs(spec) };
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
export function stableNodeExecutable(options = {}) {
|
|
@@ -413,43 +419,6 @@ WantedBy=default.target
|
|
|
413
419
|
`;
|
|
414
420
|
}
|
|
415
421
|
|
|
416
|
-
async function installWindowsTask(spec, logger) {
|
|
417
|
-
const command = windowsCommand(spec);
|
|
418
|
-
const result = await serviceRun("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/TR", command, "/F"]);
|
|
419
|
-
const ok = result.code === 0;
|
|
420
|
-
if (ok) logger.info?.("Windows Scheduled Task installed for logon");
|
|
421
|
-
else logger.warn?.("Windows Scheduled Task installation failed");
|
|
422
|
-
return { ok, provider: "schtasks", task: WINDOWS_TASK, result };
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
async function uninstallWindowsTask(logger) {
|
|
426
|
-
const end = await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
|
|
427
|
-
const result = await serviceRun("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
|
|
428
|
-
const missing = /cannot find|not exist|not found/i.test(`${result.stdout}
|
|
429
|
-
${result.stderr}`);
|
|
430
|
-
const ok = result.code === 0 || missing;
|
|
431
|
-
if (ok) logger.info?.("Windows Scheduled Task removed");
|
|
432
|
-
else logger.warn?.("Windows Scheduled Task removal failed");
|
|
433
|
-
return { ok, provider: "schtasks", task: WINDOWS_TASK, end, result };
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
async function statusWindowsTask() {
|
|
437
|
-
const result = await serviceRun("schtasks", ["/Query", "/TN", WINDOWS_TASK, "/FO", "LIST"]);
|
|
438
|
-
return { ok: result.code === 0, provider: "schtasks", installed: result.code === 0, task: WINDOWS_TASK, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function windowsCommand(spec) {
|
|
442
|
-
return [spec.node, ...daemonArgs(spec)].map(windowsCommandLineArgument).join(" ");
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
export function windowsCommandLineArgument(value) {
|
|
446
|
-
const text = String(value);
|
|
447
|
-
if (text.includes("\0")) throw new Error("Windows command-line argument contains a NUL byte");
|
|
448
|
-
const escapedQuotes = text.replace(/(\\*)"/g, (_match, slashes) => `${slashes}${slashes}\\"`);
|
|
449
|
-
const escapedTrailingSlashes = escapedQuotes.replace(/(\\+)$/, (slashes) => `${slashes}${slashes}`);
|
|
450
|
-
return `"${escapedTrailingSlashes}"`;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
422
|
export function systemdQuote(value) {
|
|
454
423
|
const escaped = String(value)
|
|
455
424
|
.replaceAll("\\", "\\\\")
|
package/src/local/state.mjs
CHANGED
|
@@ -497,7 +497,7 @@ function assertValidStateMarker(marker) {
|
|
|
497
497
|
}
|
|
498
498
|
|
|
499
499
|
function hasOnlyStateEntries(entries) {
|
|
500
|
-
const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "maintenance.lock", "profiles", "logs"]);
|
|
500
|
+
const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "maintenance.lock", "profiles", "logs", "service-environment.json", "service-launcher.cmd"]);
|
|
501
501
|
return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+(?:-[a-f0-9]{8})?$/.test(entry));
|
|
502
502
|
}
|
|
503
503
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
3
|
+
import { waitForInactiveStatus } from "./service-convergence.mjs";
|
|
4
|
+
|
|
5
|
+
export const WINDOWS_TASK = "MachineBridgeMCP";
|
|
6
|
+
const WINDOWS_LAUNCHER = "service-launcher.cmd";
|
|
7
|
+
const WINDOWS_TASK_COMMAND_MAX_CHARS = 262;
|
|
8
|
+
const WINDOWS_STATUS_SCRIPT = [
|
|
9
|
+
"$task = Get-ScheduledTask -TaskName 'MachineBridgeMCP' -ErrorAction SilentlyContinue;",
|
|
10
|
+
"if ($null -eq $task) { exit 3 };",
|
|
11
|
+
"$info = Get-ScheduledTaskInfo -TaskName 'MachineBridgeMCP' -ErrorAction Stop;",
|
|
12
|
+
"$payload = [ordered]@{ state = $task.State.ToString(); last_result = [Int64]$info.LastTaskResult; last_run_time = $info.LastRunTime.ToUniversalTime().ToString('o') };",
|
|
13
|
+
"[Console]::Out.Write(($payload | ConvertTo-Json -Compress))",
|
|
14
|
+
].join(" ");
|
|
15
|
+
|
|
16
|
+
export async function installWindowsTask(spec, logger = console, options = {}) {
|
|
17
|
+
const run = requiredRun(options.run);
|
|
18
|
+
const taskAction = windowsTaskAction(windowsLauncherPath(spec.stateRoot));
|
|
19
|
+
const launcher = writeWindowsLauncher(spec);
|
|
20
|
+
const create = await run("schtasks", [
|
|
21
|
+
"/Create",
|
|
22
|
+
"/TN", WINDOWS_TASK,
|
|
23
|
+
"/SC", "ONLOGON",
|
|
24
|
+
"/TR", taskAction,
|
|
25
|
+
"/RL", "LIMITED",
|
|
26
|
+
"/F",
|
|
27
|
+
]);
|
|
28
|
+
const status = await waitForWindowsStatus(value => value.installed === true, { ...options, run });
|
|
29
|
+
const ok = create?.code === 0 && status.installed === true;
|
|
30
|
+
if (ok) logger.info?.("Windows Scheduled Task installed for user logon");
|
|
31
|
+
else logger.warn?.("Windows Scheduled Task installation failed", { reason: windowsServiceFailureReason(create, status) });
|
|
32
|
+
return {
|
|
33
|
+
ok,
|
|
34
|
+
provider: "schtasks",
|
|
35
|
+
task: WINDOWS_TASK,
|
|
36
|
+
trigger: "user_logon",
|
|
37
|
+
launcher: launcher.path,
|
|
38
|
+
launcher_restarts_on_failure: true,
|
|
39
|
+
create,
|
|
40
|
+
status,
|
|
41
|
+
reason: ok ? "installed" : windowsServiceFailureReason(create, status),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function startWindowsTask(logger = console, options = {}) {
|
|
46
|
+
const run = requiredRun(options.run);
|
|
47
|
+
const before = await statusWindowsTask({ ...options, run });
|
|
48
|
+
if (before.installed === null) return { ok: false, provider: "schtasks", task: WINDOWS_TASK, reason: "task_status_unavailable", status: before };
|
|
49
|
+
if (before.installed === false) return { ok: false, provider: "schtasks", task: WINDOWS_TASK, reason: "not_installed", status: before };
|
|
50
|
+
const command = await run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
|
|
51
|
+
const after = await waitForWindowsStatus(
|
|
52
|
+
status => status.active === true || completedSince(before, status),
|
|
53
|
+
{ ...options, run },
|
|
54
|
+
);
|
|
55
|
+
const completed = completedSince(before, after);
|
|
56
|
+
const ok = after.active === true || completed;
|
|
57
|
+
if (ok) logger.info?.("Windows Scheduled Task started");
|
|
58
|
+
else logger.warn?.("Windows Scheduled Task did not reach a running or successful completed state");
|
|
59
|
+
return {
|
|
60
|
+
ok,
|
|
61
|
+
provider: "schtasks",
|
|
62
|
+
task: WINDOWS_TASK,
|
|
63
|
+
command,
|
|
64
|
+
status: after,
|
|
65
|
+
reason: after.active === true ? "started" : completed ? "completed" : "start_not_observed",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function stopWindowsTask(logger = console, options = {}) {
|
|
70
|
+
const run = requiredRun(options.run);
|
|
71
|
+
const before = await statusWindowsTask({ ...options, run });
|
|
72
|
+
if (before.installed === null) return { ok: false, provider: "schtasks", task: WINDOWS_TASK, reason: "task_status_unavailable", status: before };
|
|
73
|
+
if (before.installed === false || before.active === false) {
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
provider: "schtasks",
|
|
77
|
+
task: WINDOWS_TASK,
|
|
78
|
+
installed: before.installed,
|
|
79
|
+
active_before: false,
|
|
80
|
+
active: false,
|
|
81
|
+
already_stopped: true,
|
|
82
|
+
reason: before.installed ? "already_stopped" : "not_installed",
|
|
83
|
+
status: before,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const command = await run("schtasks", ["/End", "/TN", WINDOWS_TASK]);
|
|
87
|
+
const after = await waitForInactiveStatus(
|
|
88
|
+
() => statusWindowsTask({ ...options, run }),
|
|
89
|
+
windowsStatusWaitOptions(options),
|
|
90
|
+
);
|
|
91
|
+
const ok = after?.active === false;
|
|
92
|
+
if (ok) logger.info?.("Windows Scheduled Task stopped");
|
|
93
|
+
else logger.warn?.("Windows Scheduled Task is still active after the stop request");
|
|
94
|
+
return {
|
|
95
|
+
ok,
|
|
96
|
+
provider: "schtasks",
|
|
97
|
+
task: WINDOWS_TASK,
|
|
98
|
+
installed: after?.installed !== false,
|
|
99
|
+
active_before: true,
|
|
100
|
+
active: after?.active !== false,
|
|
101
|
+
already_stopped: false,
|
|
102
|
+
command,
|
|
103
|
+
status: after,
|
|
104
|
+
reason: ok ? "stopped" : "stop_not_observed",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function uninstallWindowsTask(logger = console, options = {}) {
|
|
109
|
+
const run = requiredRun(options.run);
|
|
110
|
+
const stopped = await stopWindowsTask(logger, { ...options, run });
|
|
111
|
+
if (!stopped.ok) return { ok: false, provider: "schtasks", task: WINDOWS_TASK, stop: stopped, reason: "stop_failed" };
|
|
112
|
+
const command = await run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
|
|
113
|
+
const status = await waitForWindowsStatus(
|
|
114
|
+
value => value.installed === false,
|
|
115
|
+
{ ...options, run },
|
|
116
|
+
);
|
|
117
|
+
const ok = status.installed === false;
|
|
118
|
+
if (ok) logger.info?.("Windows Scheduled Task removed");
|
|
119
|
+
else logger.warn?.("Windows Scheduled Task removal could not be verified");
|
|
120
|
+
return { ok, provider: "schtasks", task: WINDOWS_TASK, stop: stopped, command, status, reason: ok ? "removed" : "removal_not_observed" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function statusWindowsTask(options = {}) {
|
|
124
|
+
const run = requiredRun(options.run);
|
|
125
|
+
const powershell = String(options.powershell || "powershell.exe");
|
|
126
|
+
const result = await run(powershell, ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_STATUS_SCRIPT]);
|
|
127
|
+
if (result?.code === 3) {
|
|
128
|
+
return { ok: false, provider: "schtasks", task: WINDOWS_TASK, installed: false, active: false, state: "missing" };
|
|
129
|
+
}
|
|
130
|
+
if (result?.code !== 0) {
|
|
131
|
+
return { ok: false, provider: "schtasks", task: WINDOWS_TASK, installed: null, active: null, state: "unknown", query: result };
|
|
132
|
+
}
|
|
133
|
+
let payload;
|
|
134
|
+
try { payload = JSON.parse(String(result.stdout || "")); } catch {
|
|
135
|
+
return { ok: false, provider: "schtasks", task: WINDOWS_TASK, installed: null, active: null, state: "unknown", query: result };
|
|
136
|
+
}
|
|
137
|
+
const state = String(payload?.state || "").trim().toLowerCase();
|
|
138
|
+
const lastResult = Number(payload?.last_result);
|
|
139
|
+
const lastRunTime = typeof payload?.last_run_time === "string" ? payload.last_run_time : null;
|
|
140
|
+
const active = state === "running";
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
provider: "schtasks",
|
|
144
|
+
task: WINDOWS_TASK,
|
|
145
|
+
installed: true,
|
|
146
|
+
active,
|
|
147
|
+
state: state || "unknown",
|
|
148
|
+
last_result: Number.isFinite(lastResult) ? lastResult : null,
|
|
149
|
+
last_run_time: lastRunTime,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function windowsLauncherPath(stateRoot) {
|
|
154
|
+
return path.join(path.resolve(String(stateRoot)), WINDOWS_LAUNCHER);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function writeWindowsLauncher(spec) {
|
|
158
|
+
const launcherPath = windowsLauncherPath(spec.stateRoot);
|
|
159
|
+
const content = windowsLauncherContent(spec);
|
|
160
|
+
replaceFileAtomicallySync(launcherPath, content, { mode: 0o600 });
|
|
161
|
+
return { path: launcherPath, content };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function windowsLauncherContent(spec) {
|
|
165
|
+
const command = [spec.node, ...(spec.daemonArgs || [])].map(windowsBatchArgument).join(" ");
|
|
166
|
+
const stdout = windowsBatchArgument(spec.stdout);
|
|
167
|
+
const stderr = windowsBatchArgument(spec.stderr);
|
|
168
|
+
return [
|
|
169
|
+
"@echo off",
|
|
170
|
+
"setlocal DisableDelayedExpansion",
|
|
171
|
+
":restart",
|
|
172
|
+
`${command} 1>>${stdout} 2>>${stderr}`,
|
|
173
|
+
'set "mbm_exit=%ERRORLEVEL%"',
|
|
174
|
+
'if "%mbm_exit%"=="0" exit /b 0',
|
|
175
|
+
'"%SystemRoot%\\System32\\timeout.exe" /t 5 /nobreak >nul 2>&1',
|
|
176
|
+
"goto restart",
|
|
177
|
+
"",
|
|
178
|
+
].join("\r\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function windowsTaskAction(launcherPath) {
|
|
182
|
+
const action = String(launcherPath);
|
|
183
|
+
if (action.includes("\0") || /[\r\n]/.test(action)) throw new Error("Windows autostart launcher path contains a prohibited control character");
|
|
184
|
+
if (!path.isAbsolute(action) && !path.win32.isAbsolute(action)) throw new Error("Windows autostart launcher path must be absolute");
|
|
185
|
+
if (action.includes("%")) throw new Error("Windows autostart launcher path must not contain a percent sign because Task Scheduler may expand it as an environment variable");
|
|
186
|
+
if (action.length > WINDOWS_TASK_COMMAND_MAX_CHARS) {
|
|
187
|
+
throw new Error(`Windows autostart action exceeds the ${WINDOWS_TASK_COMMAND_MAX_CHARS}-character Task Scheduler limit; use the default state directory or a shorter --state-dir`);
|
|
188
|
+
}
|
|
189
|
+
return action;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function windowsCommandLineArgument(value) {
|
|
193
|
+
const text = String(value);
|
|
194
|
+
if (text.includes("\0")) throw new Error("Windows command-line argument contains a NUL byte");
|
|
195
|
+
if (/[\r\n]/.test(text)) throw new Error("Windows command-line argument contains a line break");
|
|
196
|
+
const escapedQuotes = text.replace(/(\\*)"/g, (_match, slashes) => `${slashes}${slashes}\\"`);
|
|
197
|
+
const escapedTrailingSlashes = escapedQuotes.replace(/(\\+)$/, slashes => `${slashes}${slashes}`);
|
|
198
|
+
return `"${escapedTrailingSlashes}"`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function windowsBatchArgument(value) {
|
|
202
|
+
return windowsCommandLineArgument(value).replaceAll("%", "%%");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function completedSince(before, after) {
|
|
206
|
+
return after?.installed === true
|
|
207
|
+
&& after.active === false
|
|
208
|
+
&& after.last_result === 0
|
|
209
|
+
&& Boolean(after.last_run_time)
|
|
210
|
+
&& after.last_run_time !== before?.last_run_time;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function windowsServiceFailureReason(command, status) {
|
|
214
|
+
if (command?.code !== 0) return "task_create_failed";
|
|
215
|
+
if (status?.installed === false) return "task_not_found_after_create";
|
|
216
|
+
if (status?.installed === null) return "task_status_unavailable";
|
|
217
|
+
return "task_installation_unverified";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function waitForWindowsStatus(predicate, options) {
|
|
221
|
+
const attempts = boundedPositiveInteger(options.statusAttempts, 10);
|
|
222
|
+
const delayMs = boundedPositiveInteger(options.statusDelayMs, 100);
|
|
223
|
+
const sleep = typeof options.sleep === "function" ? options.sleep : milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
224
|
+
let status = await statusWindowsTask(options);
|
|
225
|
+
for (let index = 1; index < attempts && !predicate(status); index += 1) {
|
|
226
|
+
await sleep(delayMs);
|
|
227
|
+
status = await statusWindowsTask(options);
|
|
228
|
+
}
|
|
229
|
+
return status;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function windowsStatusWaitOptions(options) {
|
|
233
|
+
return {
|
|
234
|
+
attempts: boundedPositiveInteger(options.statusAttempts, 10),
|
|
235
|
+
delayMs: boundedPositiveInteger(options.statusDelayMs, 100),
|
|
236
|
+
sleep: typeof options.sleep === "function" ? options.sleep : undefined,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function requiredRun(run) {
|
|
241
|
+
if (typeof run !== "function") throw new Error("Windows service command runner is required");
|
|
242
|
+
return run;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function boundedPositiveInteger(value, fallback) {
|
|
246
|
+
const number = Number(value);
|
|
247
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
248
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "./http";
|
|
25
25
|
|
|
26
26
|
const SERVER_NAME = String(serverMetadata.name);
|
|
27
|
-
const SERVER_VERSION = "1.1.
|
|
27
|
+
const SERVER_VERSION = "1.1.5";
|
|
28
28
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
29
29
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
30
30
|
const JSONRPC_VERSION = "2.0";
|