machine-bridge-mcp 0.3.3 → 0.4.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 +61 -0
- package/README.md +158 -171
- package/SECURITY.md +92 -40
- package/docs/ARCHITECTURE.md +126 -59
- package/docs/CLIENTS.md +125 -0
- package/docs/OPERATIONS.md +83 -0
- package/docs/RELEASING.md +62 -0
- package/docs/TESTING.md +51 -0
- package/package.json +18 -8
- package/src/local/cli.mjs +152 -51
- package/src/local/daemon.mjs +620 -306
- package/src/local/patch.mjs +140 -0
- package/src/local/process-sessions.mjs +352 -0
- package/src/local/service.mjs +5 -16
- package/src/local/shell.mjs +22 -5
- package/src/local/state.mjs +1 -1
- package/src/local/stdio.mjs +194 -0
- package/src/local/tools.mjs +96 -0
- package/src/shared/tool-catalog.json +638 -0
- package/src/worker/index.ts +151 -162
- package/tsconfig.json +4 -2
- package/src/local/self-test.mjs +0 -227
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Operations
|
|
2
|
+
|
|
3
|
+
## Health and diagnosis
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
machine-mcp status
|
|
7
|
+
machine-mcp doctor
|
|
8
|
+
machine-mcp service status
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`status` prints redacted profile state and verifies the deployed Worker version. `doctor` checks Node.js, the package-installed Wrangler binary, Cloudflare login, and Worker health. Public `/healthz` output contains only server identity and version; daemon details require an authenticated `server_info` call.
|
|
12
|
+
|
|
13
|
+
## Logs
|
|
14
|
+
|
|
15
|
+
Remote 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.
|
|
16
|
+
|
|
17
|
+
Structured operational events may include:
|
|
18
|
+
|
|
19
|
+
- component and severity;
|
|
20
|
+
- tool name;
|
|
21
|
+
- shortened random call identifier;
|
|
22
|
+
- duration and success/failure;
|
|
23
|
+
- coarse error class;
|
|
24
|
+
- connection/reconnect status.
|
|
25
|
+
|
|
26
|
+
They intentionally omit:
|
|
27
|
+
|
|
28
|
+
- file contents and image data;
|
|
29
|
+
- write, edit, or patch payloads;
|
|
30
|
+
- command argv, shell text, stdin, stdout, and stderr;
|
|
31
|
+
- MCP connection passwords, daemon secrets, authorization codes, and access tokens;
|
|
32
|
+
- full request bodies.
|
|
33
|
+
|
|
34
|
+
Unexpected Worker failures log endpoint path and error class rather than raw exception messages. Cloudflare observability is sampled and is not a complete audit log.
|
|
35
|
+
|
|
36
|
+
## Reconnect and replacement
|
|
37
|
+
|
|
38
|
+
The daemon sends heartbeats and reconnects with bounded exponential backoff and jitter. A new socket remains a candidate until it authenticates and sends a valid `hello`; only then does it replace the previous daemon.
|
|
39
|
+
|
|
40
|
+
Pending calls are bound to the socket that received them. Results from another socket are ignored. A lost or replaced socket rejects only its own pending calls and terminates locally tracked child process trees. Process sessions are in-memory and do not survive daemon restart or replacement.
|
|
41
|
+
|
|
42
|
+
## Limits
|
|
43
|
+
|
|
44
|
+
Defense-in-depth limits include:
|
|
45
|
+
|
|
46
|
+
- Worker MCP body: 8 MiB by default, hard cap 16 MiB;
|
|
47
|
+
- OAuth body: 64 KiB;
|
|
48
|
+
- daemon WebSocket message: 8 MiB;
|
|
49
|
+
- text writes and patch envelopes: 5 MiB;
|
|
50
|
+
- images: 4 MiB before base64 encoding;
|
|
51
|
+
- shell/argv envelope: 64 KiB;
|
|
52
|
+
- captured one-shot output: 512 KiB per stream by default;
|
|
53
|
+
- process-session retained output: 1 MiB per stream, with lossless base64 fallback for non-UTF-8 slices;
|
|
54
|
+
- process sessions: 8 retained per runtime;
|
|
55
|
+
- process stdin write: 64 KiB per call;
|
|
56
|
+
- local simultaneous tool calls: 16;
|
|
57
|
+
- Worker pending daemon calls: 32;
|
|
58
|
+
- command timeout: 1–600 seconds;
|
|
59
|
+
- process-session read wait: at most 30 seconds;
|
|
60
|
+
- direct directory result: 10,000 entries and 4 MiB of path metadata;
|
|
61
|
+
- recursive walk: 200,000 visited entries.
|
|
62
|
+
|
|
63
|
+
## Upgrade behavior
|
|
64
|
+
|
|
65
|
+
Version 0.4.1 changes the default for newly selected workspaces to the maximum-permission `full` profile. Existing state retains its saved policy. To intentionally migrate an older workspace to the new default:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
machine-mcp --workspace /path/to/project --profile full
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`full` enables writes, direct processes, process sessions, shell execution, unrestricted direct filesystem paths, absolute path output, and the complete parent environment. Use `agent`, `edit`, or `review` when that authority is unnecessary.
|
|
72
|
+
|
|
73
|
+
A remote policy change is saved locally, propagated in the daemon handshake, and persisted in the autostart definition.
|
|
74
|
+
|
|
75
|
+
## Incident response
|
|
76
|
+
|
|
77
|
+
After suspected credential or client compromise:
|
|
78
|
+
|
|
79
|
+
1. stop foreground and autostart daemons;
|
|
80
|
+
2. run `machine-mcp rotate-secrets --no-print-credentials`;
|
|
81
|
+
3. restart without broad flags and redeploy;
|
|
82
|
+
4. inspect Cloudflare account access, Worker configuration, local state permissions, and service logs;
|
|
83
|
+
5. remove the Worker and local state if continued remote access is unnecessary.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Release process
|
|
2
|
+
|
|
3
|
+
The release invariant is:
|
|
4
|
+
|
|
5
|
+
- `main` points to the release commit.
|
|
6
|
+
- `v<package version>` points to that same commit locally and on GitHub.
|
|
7
|
+
- A final GitHub Release exists for the tag.
|
|
8
|
+
- The GitHub Release contains the npm tarball generated from that commit.
|
|
9
|
+
- `package.json`, `package-lock.json`, and the Worker-reported version agree.
|
|
10
|
+
|
|
11
|
+
`npm publish` runs `release:check` through `prepublishOnly`, so npm publication is blocked until all GitHub state is synchronized.
|
|
12
|
+
|
|
13
|
+
## Prepare a version
|
|
14
|
+
|
|
15
|
+
1. Set the new version without creating an automatic npm tag:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm version <version> --no-git-tag-version
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. Add the matching `CHANGELOG.md` section.
|
|
22
|
+
3. Run `npm run check`, review the diff, and commit all release changes to `main`.
|
|
23
|
+
|
|
24
|
+
## Publish GitHub source and release
|
|
25
|
+
|
|
26
|
+
From a clean `main` worktree:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm run release:publish
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The command validates the project, fast-forwards `origin/main`, creates or verifies the annotated version tag, pushes it, builds the npm tarball, creates or updates the GitHub Release, uploads the tarball, and verifies the resulting state.
|
|
33
|
+
|
|
34
|
+
To verify without changing anything:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm run release:check
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To create GitHub Release records for existing remote version tags that lack releases:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm run release:backfill
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Publish npm
|
|
47
|
+
|
|
48
|
+
Only after `release:check` succeeds:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm publish --access public
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The npm lifecycle repeats the full project checks and the GitHub synchronization check before upload.
|
|
55
|
+
|
|
56
|
+
## Authentication requirements
|
|
57
|
+
|
|
58
|
+
- Git push access to `origin`.
|
|
59
|
+
- An authenticated GitHub CLI session with repository release permission.
|
|
60
|
+
- An npm account that owns the package or has maintainer permission.
|
|
61
|
+
|
|
62
|
+
GitHub Actions is intentionally not required by this release path. This avoids coupling releases to an OAuth token with the separate `workflow` scope while retaining fail-closed synchronization.
|
package/docs/TESTING.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Testing strategy
|
|
2
|
+
|
|
3
|
+
The project treats transport, authorization, local authority, and state removal as separate failure domains.
|
|
4
|
+
|
|
5
|
+
## Required suite
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm run check
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The suite includes:
|
|
12
|
+
|
|
13
|
+
- generated Cloudflare Worker types and strict TypeScript checking;
|
|
14
|
+
- syntax validation for every shipped JavaScript entry point;
|
|
15
|
+
- shared tool-catalog schema, annotation, and profile-inventory checks;
|
|
16
|
+
- canonical path and symbolic-link escape tests;
|
|
17
|
+
- relative-path privacy and error-path redaction tests;
|
|
18
|
+
- atomic create/update, optimistic hash, exact edit, and patch transaction tests;
|
|
19
|
+
- patch ambiguity, canonical collision, rollback, move, and delete tests;
|
|
20
|
+
- UTF-8 and binary handling;
|
|
21
|
+
- image content formatting;
|
|
22
|
+
- nested Git repository detection and helper suppression;
|
|
23
|
+
- author-email privacy in `git_log`;
|
|
24
|
+
- isolated command HOME/temp/cache behavior;
|
|
25
|
+
- one-shot timeout, descendant termination, cancellation, and process-session interaction;
|
|
26
|
+
- daemon/startup locking and state corruption recovery;
|
|
27
|
+
- guarded state-root removal and legacy-state migration;
|
|
28
|
+
- log redaction, control-character handling, and service-log trimming;
|
|
29
|
+
- CLI parsing, policy profiles, and client configuration boundaries;
|
|
30
|
+
- live stdio MCP initialization, discovery, calls, rich content, sessions, cancellation, and continued responsiveness;
|
|
31
|
+
- live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, dynamic tool advertisement, rich content, daemon replacement, and cancellation.
|
|
32
|
+
|
|
33
|
+
## Additional release checks
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm run worker:dry-run
|
|
37
|
+
npm audit --omit=dev --audit-level=high
|
|
38
|
+
npm pack --dry-run
|
|
39
|
+
npm run version:check
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
GitHub Actions executes the main suite on Linux, macOS, and Windows. Node 22 and 24 are covered on Linux; Node 22 is covered on macOS and Windows. A separate package-audit job runs production dependency auditing and a dry-run package build. Dependency and GitHub Actions updates are monitored by Dependabot.
|
|
43
|
+
|
|
44
|
+
## Test design rules
|
|
45
|
+
|
|
46
|
+
- Tests should exercise the public boundary rather than only helper functions when practical.
|
|
47
|
+
- Every permission-expanding feature needs a denial test.
|
|
48
|
+
- Every bounded resource needs an over-limit test.
|
|
49
|
+
- Every multi-stage mutation needs a no-partial-commit test.
|
|
50
|
+
- Every remote call correlation change needs daemon replacement and cancellation coverage.
|
|
51
|
+
- Logs and public metadata should be tested for absence of sensitive fields, not only presence of expected fields.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "Cross-client MCP bridge for policy-controlled local files, Git, images, patches, and processes over stdio or OAuth-protected remote relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"engines": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"mbm.cmd",
|
|
18
18
|
"bin",
|
|
19
19
|
"src/local",
|
|
20
|
+
"src/shared",
|
|
20
21
|
"src/worker/index.ts",
|
|
21
22
|
"scripts/sync-worker-version.mjs",
|
|
22
23
|
"wrangler.jsonc",
|
|
@@ -30,18 +31,23 @@
|
|
|
30
31
|
"scripts": {
|
|
31
32
|
"start": "node bin/machine-mcp.mjs start",
|
|
32
33
|
"doctor": "node bin/machine-mcp.mjs doctor",
|
|
33
|
-
"self-test": "node
|
|
34
|
+
"self-test": "node tests/local-self-test.mjs",
|
|
34
35
|
"version:sync": "node scripts/sync-worker-version.mjs",
|
|
35
36
|
"version:check": "node scripts/sync-worker-version.mjs --check",
|
|
36
37
|
"version": "node scripts/sync-worker-version.mjs && git add package.json package-lock.json src/worker/index.ts",
|
|
37
38
|
"prepack": "npm run version:check",
|
|
38
|
-
"prepublishOnly": "npm run check && npm run version:check",
|
|
39
|
+
"prepublishOnly": "npm run check && npm run version:check && npm run release:check",
|
|
39
40
|
"worker:types": "wrangler types src/worker/worker-configuration.d.ts",
|
|
40
41
|
"typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
|
|
41
|
-
"syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.mjs && node --check src/local/
|
|
42
|
-
"check": "npm run typecheck && npm run syntax && npm run self-test && npm run worker:integration-test",
|
|
42
|
+
"syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.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 scripts/sync-worker-version.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/daemon-self-test.mjs",
|
|
43
|
+
"check": "npm run typecheck && npm run syntax && npm run catalog:test && npm run self-test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
43
44
|
"worker:dry-run": "wrangler deploy --dry-run",
|
|
44
|
-
"worker:integration-test": "node
|
|
45
|
+
"worker:integration-test": "node tests/worker-integration-test.mjs",
|
|
46
|
+
"release:check": "node scripts/github-release.mjs --check",
|
|
47
|
+
"release:publish": "node scripts/github-release.mjs --publish",
|
|
48
|
+
"release:backfill": "node scripts/github-release.mjs --backfill",
|
|
49
|
+
"stdio:integration-test": "node tests/stdio-integration-test.mjs",
|
|
50
|
+
"catalog:test": "node tests/catalog-test.mjs"
|
|
45
51
|
},
|
|
46
52
|
"dependencies": {
|
|
47
53
|
"wrangler": "^4.110.0",
|
|
@@ -57,7 +63,11 @@
|
|
|
57
63
|
"cloudflare-workers",
|
|
58
64
|
"durable-objects",
|
|
59
65
|
"wrangler",
|
|
60
|
-
"local-machine"
|
|
66
|
+
"local-machine",
|
|
67
|
+
"stdio",
|
|
68
|
+
"coding-agent",
|
|
69
|
+
"oauth",
|
|
70
|
+
"cross-platform"
|
|
61
71
|
],
|
|
62
72
|
"repository": {
|
|
63
73
|
"type": "git",
|
package/src/local/cli.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import path, { resolve } from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
6
|
import { LocalDaemon } from "./daemon.mjs";
|
|
7
|
+
import { runStdioServer } from "./stdio.mjs";
|
|
8
|
+
import { normalizePolicy } from "./tools.mjs";
|
|
7
9
|
import { createLogger, sanitizeLogText } from "./log.mjs";
|
|
8
10
|
import { runWrangler } from "./shell.mjs";
|
|
9
11
|
import {
|
|
@@ -34,11 +36,11 @@ import {
|
|
|
34
36
|
const BOOLEAN_OPTIONS = new Set([
|
|
35
37
|
"help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
36
38
|
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
|
|
37
|
-
"printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths",
|
|
39
|
+
"printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
38
40
|
"yes", "keepWorker", "noApi", "api", "rotateApiKey",
|
|
39
41
|
]);
|
|
40
42
|
const VALUE_OPTIONS = new Set([
|
|
41
|
-
"workspace", "stateDir", "workerName", "apiPort", "apiHost", "apiKey", "apiModel", "port",
|
|
43
|
+
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "apiPort", "apiHost", "apiKey", "apiModel", "port",
|
|
42
44
|
]);
|
|
43
45
|
|
|
44
46
|
export async function main(argv = process.argv.slice(2)) {
|
|
@@ -52,6 +54,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
52
54
|
|
|
53
55
|
switch (command) {
|
|
54
56
|
case "start": return startCommand(args);
|
|
57
|
+
case "stdio": return stdioCommand(args);
|
|
58
|
+
case "client-config": return clientConfigCommand(args);
|
|
55
59
|
case "status": return statusCommand(args);
|
|
56
60
|
case "doctor": return doctorCommand(args);
|
|
57
61
|
case "workspace": return workspaceCommand(args);
|
|
@@ -70,9 +74,11 @@ const COMMAND_OPTIONS = {
|
|
|
70
74
|
start: new Set([
|
|
71
75
|
"workspace", "stateDir", "workerName", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
72
76
|
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials", "printCredentials",
|
|
73
|
-
"noWrite", "noExec", "fullEnv", "unrestrictedPaths",
|
|
77
|
+
"profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
74
78
|
"noApi", "api", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port",
|
|
75
79
|
]),
|
|
80
|
+
stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose"]),
|
|
81
|
+
"client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
|
|
76
82
|
status: new Set(["workspace", "stateDir"]),
|
|
77
83
|
doctor: new Set(["workspace", "stateDir"]),
|
|
78
84
|
"rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "quiet"]),
|
|
@@ -99,7 +105,7 @@ export function validatePositionals(command, args) {
|
|
|
99
105
|
const count = args._.length;
|
|
100
106
|
const conflict = Boolean(args.workspace) && count > (command === "workspace" || command === "service" || command === "autostart" ? 1 : 0);
|
|
101
107
|
if (conflict) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
102
|
-
if (["start", "status", "doctor", "rotate-secrets"].includes(command)) {
|
|
108
|
+
if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
|
|
103
109
|
if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
|
|
104
110
|
if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
105
111
|
return;
|
|
@@ -118,6 +124,10 @@ export function validatePositionals(command, args) {
|
|
|
118
124
|
if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
119
125
|
return;
|
|
120
126
|
}
|
|
127
|
+
if (command === "client-config") {
|
|
128
|
+
if (count > 1) throw new Error("client-config accepts at most one positional client name");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
121
131
|
if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
|
|
122
132
|
}
|
|
123
133
|
|
|
@@ -255,60 +265,65 @@ async function startCommand(args) {
|
|
|
255
265
|
const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
|
|
256
266
|
throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
|
|
257
267
|
}
|
|
268
|
+
|
|
258
269
|
try {
|
|
259
270
|
if (args.daemonOnly) {
|
|
260
271
|
const { trimAutostartLogs } = await import("./service.mjs");
|
|
261
272
|
trimAutostartLogs(state.paths.stateRoot);
|
|
273
|
+
} else {
|
|
274
|
+
// Stop an installed service before acquiring the runtime lock. If a
|
|
275
|
+
// foreground daemon owns the lock, no new policy or secret state is saved.
|
|
276
|
+
await stopAutostartBestEffort(Boolean(args.quiet));
|
|
262
277
|
}
|
|
263
|
-
const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
|
|
264
|
-
const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
|
|
265
|
-
|
|
266
|
-
const workerName = validateWorkerName(args.workerName);
|
|
267
|
-
ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
|
|
268
|
-
state.policy = {
|
|
269
|
-
allowWrite: args.noWrite ? false : true,
|
|
270
|
-
allowExec: args.noExec ? false : true,
|
|
271
|
-
unrestrictedPaths: Boolean(args.unrestrictedPaths),
|
|
272
|
-
minimalEnv: args.fullEnv ? false : true,
|
|
273
|
-
updatedAt: new Date().toISOString(),
|
|
274
|
-
};
|
|
275
|
-
saveState(state);
|
|
276
|
-
|
|
277
|
-
if (!args.daemonOnly) await ensureWorker(state, args);
|
|
278
|
-
else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
|
|
279
|
-
|
|
280
|
-
const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
|
|
281
|
-
const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
|
|
282
|
-
|
|
283
|
-
if (!args.daemonOnly && !args.noAutostart) {
|
|
284
|
-
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
|
|
288
278
|
|
|
289
279
|
const lock = acquireDaemonLock(state);
|
|
290
|
-
let daemon = null;
|
|
291
280
|
if (!lock.acquired) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
281
|
+
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
282
|
+
logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
|
|
283
|
+
if (args.json) printStartJson(state, {
|
|
284
|
+
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
285
|
+
requestedChangesApplied: false,
|
|
286
|
+
notice: "local daemon already running; requested changes were not applied",
|
|
287
|
+
});
|
|
288
|
+
else printMcpConnection(state, {
|
|
289
|
+
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
290
|
+
includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
|
|
291
|
+
quiet: Boolean(args.quiet),
|
|
292
|
+
});
|
|
302
293
|
return;
|
|
303
294
|
}
|
|
304
295
|
|
|
296
|
+
let daemon = null;
|
|
305
297
|
try {
|
|
298
|
+
const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
|
|
299
|
+
const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
|
|
300
|
+
const workerName = validateWorkerName(args.workerName);
|
|
301
|
+
ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
|
|
302
|
+
state.policy = resolvePolicy(args, state.policy);
|
|
303
|
+
state.policy.updatedAt = new Date().toISOString();
|
|
304
|
+
saveState(state);
|
|
305
|
+
|
|
306
|
+
if (!args.daemonOnly) await ensureWorker(state, args);
|
|
307
|
+
else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
|
|
308
|
+
|
|
309
|
+
const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
|
|
310
|
+
const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
|
|
311
|
+
|
|
312
|
+
if (!args.daemonOnly && !args.noAutostart) {
|
|
313
|
+
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1] });
|
|
314
|
+
}
|
|
315
|
+
|
|
306
316
|
daemon = new LocalDaemon({
|
|
307
317
|
workerUrl: state.worker.url,
|
|
308
318
|
secret: state.worker.daemonSecret,
|
|
309
319
|
workspace,
|
|
310
320
|
policy: state.policy,
|
|
311
321
|
logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
|
|
322
|
+
onSuperseded: () => {
|
|
323
|
+
logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
|
|
324
|
+
lock.release();
|
|
325
|
+
process.exit(0);
|
|
326
|
+
},
|
|
312
327
|
});
|
|
313
328
|
|
|
314
329
|
const waitForConnect = daemon.start();
|
|
@@ -332,6 +347,85 @@ async function startCommand(args) {
|
|
|
332
347
|
}
|
|
333
348
|
}
|
|
334
349
|
|
|
350
|
+
const POLICY_PROFILES = Object.freeze({
|
|
351
|
+
review: Object.freeze({ profile: "review", allowWrite: false, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
|
|
352
|
+
edit: Object.freeze({ profile: "edit", allowWrite: true, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
|
|
353
|
+
agent: Object.freeze({ profile: "agent", allowWrite: true, execMode: "direct", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
|
|
354
|
+
full: Object.freeze({ profile: "full", allowWrite: true, execMode: "shell", unrestrictedPaths: true, minimalEnv: false, exposeAbsolutePaths: true }),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
export function resolvePolicy(args = {}, stored = {}) {
|
|
358
|
+
const hasStored = stored && typeof stored === "object" && (
|
|
359
|
+
typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
|
|
360
|
+
);
|
|
361
|
+
const explicitKeys = ["profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
|
|
362
|
+
const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
|
|
363
|
+
let base;
|
|
364
|
+
if (args.profile !== undefined) {
|
|
365
|
+
const profile = String(args.profile).trim().toLowerCase();
|
|
366
|
+
if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
|
|
367
|
+
base = { ...POLICY_PROFILES[profile] };
|
|
368
|
+
} else if (hasStored) {
|
|
369
|
+
base = normalizePolicy(stored);
|
|
370
|
+
} else {
|
|
371
|
+
base = { ...POLICY_PROFILES.full };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!hasExplicit) return normalizePolicy(base);
|
|
375
|
+
if (args.execMode !== undefined) {
|
|
376
|
+
const execMode = String(args.execMode).trim().toLowerCase();
|
|
377
|
+
if (!["off", "direct", "shell"].includes(execMode)) throw new Error("--exec-mode must be off, direct, or shell");
|
|
378
|
+
base.execMode = execMode;
|
|
379
|
+
}
|
|
380
|
+
if (args.noWrite === true) base.allowWrite = false;
|
|
381
|
+
if (args.noWrite === false) base.allowWrite = true;
|
|
382
|
+
if (args.noExec === true) base.execMode = "off";
|
|
383
|
+
if (args.noExec === false && base.execMode === "off") base.execMode = "direct";
|
|
384
|
+
if (args.fullEnv === true) base.minimalEnv = false;
|
|
385
|
+
if (args.fullEnv === false) base.minimalEnv = true;
|
|
386
|
+
if (args.unrestrictedPaths === true) base.unrestrictedPaths = true;
|
|
387
|
+
if (args.unrestrictedPaths === false) base.unrestrictedPaths = false;
|
|
388
|
+
if (args.absolutePaths === true) base.exposeAbsolutePaths = true;
|
|
389
|
+
if (args.absolutePaths === false) base.exposeAbsolutePaths = false;
|
|
390
|
+
const overrideKeys = ["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
|
|
391
|
+
if (args.profile === undefined || overrideKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key))) base.profile = "custom";
|
|
392
|
+
return normalizePolicy(base);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function stdioCommand(args) {
|
|
396
|
+
assertNodeVersion();
|
|
397
|
+
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
398
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
399
|
+
const policy = resolvePolicy(args, state.policy);
|
|
400
|
+
await runStdioServer({ workspace, policy, verbose: Boolean(args.verbose) });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function clientConfigCommand(args) {
|
|
404
|
+
const workspaceArgs = { ...args, _: [] };
|
|
405
|
+
const workspace = await chooseWorkspace(workspaceArgs, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
406
|
+
const requested = String(args.client || args._[0] || "all").trim().toLowerCase();
|
|
407
|
+
const profile = String(args.profile || "full").trim().toLowerCase();
|
|
408
|
+
if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
|
|
409
|
+
if (!["all", "claude", "cursor", "codex", "generic"].includes(requested)) throw new Error("client must be all, claude, cursor, codex, or generic");
|
|
410
|
+
const command = process.execPath;
|
|
411
|
+
const argsList = [resolve(process.argv[1]), "stdio", "--workspace", workspace, "--profile", profile];
|
|
412
|
+
const jsonConfig = { mcpServers: { "machine-bridge": { command, args: argsList } } };
|
|
413
|
+
const codex = `[mcp_servers.machine_bridge]\ncommand = ${JSON.stringify(command)}\nargs = ${JSON.stringify(argsList)}\n`;
|
|
414
|
+
if (args.json) {
|
|
415
|
+
console.log(JSON.stringify({ workspace, profile, claude: jsonConfig, cursor: jsonConfig, generic: jsonConfig, codex_toml: codex }, null, 2));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (["all", "claude", "cursor", "generic"].includes(requested)) {
|
|
419
|
+
console.log(`${requested === "all" ? "Claude Desktop / Cursor / generic stdio" : requested}:`);
|
|
420
|
+
console.log(JSON.stringify(jsonConfig, null, 2));
|
|
421
|
+
}
|
|
422
|
+
if (["all", "codex"].includes(requested)) {
|
|
423
|
+
if (requested === "all") console.log("");
|
|
424
|
+
console.log("Codex CLI:");
|
|
425
|
+
console.log(codex.trimEnd());
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
335
429
|
function assertNoRemovedLocalApiOptions(args) {
|
|
336
430
|
const removed = ["api", "noApi", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port"].filter((key) => args[key] !== undefined);
|
|
337
431
|
if (removed.length) throw removedLocalApiError();
|
|
@@ -438,7 +532,7 @@ function workerHashContent(file) {
|
|
|
438
532
|
|
|
439
533
|
function workerDeployHashFiles() {
|
|
440
534
|
const files = [];
|
|
441
|
-
for (const item of ["src/worker", "wrangler.jsonc", "tsconfig.json"]) {
|
|
535
|
+
for (const item of ["src/worker", "src/shared", "wrangler.jsonc", "tsconfig.json"]) {
|
|
442
536
|
collectHashFiles(resolve(packageRoot, item), files);
|
|
443
537
|
}
|
|
444
538
|
return files.sort();
|
|
@@ -500,7 +594,7 @@ async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
|
|
|
500
594
|
}
|
|
501
595
|
|
|
502
596
|
|
|
503
|
-
function printStartJson(state, { noPrintCredentials = false } = {}) {
|
|
597
|
+
function printStartJson(state, { noPrintCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
|
|
504
598
|
const mcpPassword = noPrintCredentials ? previewSecret(state.worker.oauthPassword) : state.worker.oauthPassword;
|
|
505
599
|
createLogger({ component: "ready" }).json({
|
|
506
600
|
mcp: {
|
|
@@ -512,6 +606,8 @@ function printStartJson(state, { noPrintCredentials = false } = {}) {
|
|
|
512
606
|
workspace: state.workspace.path,
|
|
513
607
|
state_path: state.paths.statePath,
|
|
514
608
|
policy: state.policy,
|
|
609
|
+
requested_changes_applied: requestedChangesApplied,
|
|
610
|
+
...(notice ? { notice } : {}),
|
|
515
611
|
});
|
|
516
612
|
}
|
|
517
613
|
|
|
@@ -541,7 +637,7 @@ function printMcpConnection(state, { json = false, noPrintCredentials = false, i
|
|
|
541
637
|
logger.plain(" Use --print-mcp-credentials only when a ChatGPT app needs to reconnect.");
|
|
542
638
|
}
|
|
543
639
|
logger.plain(` Workspace cwd: ${payload.workspace}`);
|
|
544
|
-
logger.plain(` Policy: write=${payload.policy.allowWrite ? "on" : "off"},
|
|
640
|
+
logger.plain(` Policy: profile=${payload.policy.profile || "custom"}, write=${payload.policy.allowWrite ? "on" : "off"}, exec_mode=${payload.policy.execMode || (payload.policy.allowExec ? "shell" : "off")}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}, absolute_paths=${payload.policy.exposeAbsolutePaths ? "on" : "off"}`);
|
|
545
641
|
logger.plain(` State: ${payload.state_path}`);
|
|
546
642
|
}
|
|
547
643
|
|
|
@@ -624,7 +720,7 @@ async function serviceCommand(args) {
|
|
|
624
720
|
if (!state.worker?.url) {
|
|
625
721
|
throw new Error("No deployed Worker is recorded for this workspace. Run `machine-mcp` once before `machine-mcp service install`.");
|
|
626
722
|
}
|
|
627
|
-
const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1],
|
|
723
|
+
const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], logger: structuredLogger(Boolean(args.quiet)) });
|
|
628
724
|
console.log(JSON.stringify(result, null, 2));
|
|
629
725
|
return;
|
|
630
726
|
}
|
|
@@ -646,10 +742,10 @@ async function serviceCommand(args) {
|
|
|
646
742
|
throw new Error(`Unknown service action: ${action}`);
|
|
647
743
|
}
|
|
648
744
|
|
|
649
|
-
async function installAutostartBestEffort({ workspace, stateRoot, entryScript
|
|
745
|
+
async function installAutostartBestEffort({ workspace, stateRoot, entryScript }) {
|
|
650
746
|
try {
|
|
651
747
|
const { installAutostart } = await import("./service.mjs");
|
|
652
|
-
const result = await installAutostart({ workspace, stateRoot, entryScript,
|
|
748
|
+
const result = await installAutostart({ workspace, stateRoot, entryScript, logger: structuredLogger(false) });
|
|
653
749
|
if (result?.ok) console.log("Autostart installed for future logins. Use `machine-mcp service status` to inspect or `machine-mcp service uninstall` to remove.");
|
|
654
750
|
else console.warn("Autostart installation returned a warning; run `machine-mcp service status` for details.");
|
|
655
751
|
} catch (error) {
|
|
@@ -820,7 +916,9 @@ Usage:
|
|
|
820
916
|
.\\mbm.cmd # from source checkout on Windows cmd
|
|
821
917
|
|
|
822
918
|
Commands:
|
|
823
|
-
start Deploy/update Worker, install autostart, start daemon
|
|
919
|
+
start Deploy/update Worker, install autostart, start remote daemon
|
|
920
|
+
stdio Run a local MCP stdio server for Claude, Cursor, Codex, and compatible clients
|
|
921
|
+
client-config Print stdio client configuration snippets
|
|
824
922
|
workspace show Show remembered workspace
|
|
825
923
|
workspace set Re-select workspace; prompts with current/default path
|
|
826
924
|
service status Show autostart status
|
|
@@ -842,10 +940,13 @@ Start options:
|
|
|
842
940
|
--no-autostart Do not install login autostart during start
|
|
843
941
|
--no-print-credentials Redact credentials in console output
|
|
844
942
|
--print-mcp-credentials Print MCP URL/password again for reconnecting ChatGPT apps
|
|
845
|
-
--
|
|
846
|
-
--
|
|
847
|
-
--
|
|
848
|
-
--
|
|
943
|
+
--profile NAME Policy profile: full (default), agent, edit, or review
|
|
944
|
+
--exec-mode MODE Command mode: off, direct argv, or full shell
|
|
945
|
+
--no-write Disable write_file, edit_file, and apply_patch
|
|
946
|
+
--no-exec Disable run_process and exec_command
|
|
947
|
+
--full-env Pass the full parent environment to local commands
|
|
948
|
+
--unrestricted-paths Allow filesystem tools outside the workspace
|
|
949
|
+
--absolute-paths Return absolute local paths in tool results (off by default)
|
|
849
950
|
--state-dir DIR Override state root
|
|
850
951
|
--json Print MCP connection details as JSON
|
|
851
952
|
|