machine-bridge-mcp 1.0.8 → 1.1.1
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 +16 -0
- package/README.md +22 -10
- package/SECURITY.md +4 -4
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +17 -9
- package/docs/AUDIT.md +18 -0
- package/docs/CLIENTS.md +15 -7
- package/docs/ENGINEERING.md +2 -1
- package/docs/GETTING_STARTED.md +38 -5
- package/docs/LOGGING.md +7 -1
- package/docs/MULTI_ACCOUNT.md +1 -1
- package/docs/OPERATIONS.md +24 -0
- package/docs/TESTING.md +4 -2
- package/package.json +4 -3
- package/scripts/coverage-check.mjs +6 -2
- package/src/local/cli-options.mjs +1 -1
- package/src/local/cli.mjs +17 -150
- package/src/local/network-proxy.mjs +27 -9
- package/src/local/state-inventory.mjs +8 -4
- package/src/local/state.mjs +20 -1
- package/src/local/worker-deployment.mjs +170 -0
- package/src/local/worker-health.mjs +246 -0
- package/src/worker/index.ts +23 -53
- package/src/worker/oauth-state.ts +37 -2
- package/src/worker/oauth-tokens.ts +208 -0
package/docs/TESTING.md
CHANGED
|
@@ -24,6 +24,8 @@ The suite includes:
|
|
|
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
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;
|
|
26
26
|
- 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
|
+
- 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;
|
|
27
29
|
- relay environment-proxy direct/bypass/agent selection, unsupported proxy rejection, fail-fast invalid configuration, and route observability without endpoint or credential disclosure;
|
|
28
30
|
- canonical path and symbolic-link escape tests;
|
|
29
31
|
- relative-path privacy and error-path redaction tests;
|
|
@@ -53,7 +55,7 @@ The suite includes:
|
|
|
53
55
|
- deterministic property tests over hostile browser-protocol byte strings, canonical/custom policy combinations, argv bounds/NULs, and a real direct process proving shell metacharacters remain literal argv;
|
|
54
56
|
- CLI parsing, policy profiles, and client configuration boundaries;
|
|
55
57
|
- live stdio MCP initialization with session instructions, capability resolution, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
|
|
56
|
-
- live local Worker OAuth registration, consent, URL-constructed `303` callbacks including the ChatGPT redirect
|
|
58
|
+
- live local Worker OAuth registration, the unauthenticated `resource_metadata` challenge, protected-resource and authorization-server discovery, Streamable transport metadata, consent, URL-constructed `303` callbacks including the ChatGPT and hosted Claude redirect URIs with encoded state, PKCE, `offline_access`, form-encoded authorization-code and refresh-token exchanges, access/refresh rotation, stale refresh replay rejection, account-version refresh revocation, authorization-code replay rejection, pending-registration throttling that excludes already authorized DCR clients, exact built-in ChatGPT/Grok browser origins, additive custom origins, unrelated-origin preflight rejection, no CORS response sharing for unrelated or opaque origins, opaque-origin authorization-form routing, exact per-request redirect-origin CSP, accessible credential-error rendering, protocol negotiation, HMAC-bound MCP session issuance, two-session same-id concurrency, sessionless same-id independence, session-scoped cancellation isolation, same-session duplicate rejection, daemon-backed session bootstrap, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure. The metadata/refresh contract is the path used by Claude DCR and Copilot Studio Dynamic discovery. The same integration runs an `editor` account against a canonical `full` daemon and proves that `server_info` and remote `project_overview` report effective `edit` authority while retaining the full daemon ceiling only in explicitly scoped fields.
|
|
57
59
|
- local runtime proof that one blocked tool handler does not serialize an independent handler, plus relay fault injection proving an undeliverable terminal result interrupts the ambiguous socket and enters reconnect backoff.
|
|
58
60
|
- a real headless-Chrome OAuth navigation regression with a negative control showing that `form-action 'self'` blocks a cross-origin `303`, followed by the production policy shape proving the exact callback origin receives `code` and `state`. Linux CI fails if Chrome is unavailable; other environments skip only this browser executable check while retaining the Worker CSP assertions.
|
|
59
61
|
|
|
@@ -74,7 +76,7 @@ For deterministic release validation, perform an isolated-profile smoke test wit
|
|
|
74
76
|
|
|
75
77
|
`npm run coverage:test` runs selected in-process and lightweight entrypoint fixtures under V8 coverage and enforces per-module baselines for policy, typed errors, call registration, execution middleware, lifecycle/observability, logging, Runtime/CLI orchestration, and Worker pending/policy/error modules. The full stdio and Worker OAuth/MCP integrations run separately in the main suite and are not duplicated inside coverage collection. The gate deliberately reports each module rather than one aggregate percentage. JavaScript modules enforce function and branch minima; Node's direct TypeScript stripping does not expose trustworthy branch ranges, so TypeScript modules enforce function coverage plus explicit state-machine tests and the real workerd integration suite.
|
|
76
78
|
|
|
77
|
-
The current CLI entrypoint remains the weakest covered orchestration surface; its baseline is therefore reported and locked independently, while extracted `cli-options`, `cli-policy`, `records`,
|
|
79
|
+
The current CLI entrypoint remains the weakest covered orchestration surface; its baseline is therefore reported and locked independently, while extracted `cli-options`, `cli-policy`, `records`, `state-inventory`, `network-proxy`, `worker-health`, and `worker-deployment` modules carry substantially higher thresholds. The Worker convergence extraction and expanded command fixtures raised the CLI function floor from 40% to 45% and branch floor from 10% to 20%; a refactor may raise a threshold, but must not lower one merely to make CI green without an explicit audit explanation.
|
|
78
80
|
|
|
79
81
|
## Additional release checks
|
|
80
82
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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 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-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",
|
|
@@ -107,7 +107,8 @@
|
|
|
107
107
|
"lint:test": "node tests/lint-gate-test.mjs",
|
|
108
108
|
"shell:test": "node tests/shell-test.mjs",
|
|
109
109
|
"deadline:test": "node tests/monotonic-deadline-test.mjs",
|
|
110
|
-
"oauth-browser:test": "node tests/oauth-browser-navigation-test.mjs"
|
|
110
|
+
"oauth-browser:test": "node tests/oauth-browser-navigation-test.mjs",
|
|
111
|
+
"worker-deployment:test": "node tests/worker-deployment-test.mjs"
|
|
111
112
|
},
|
|
112
113
|
"dependencies": {
|
|
113
114
|
"https-proxy-agent": "9.1.0",
|
|
@@ -18,6 +18,7 @@ const tests = [
|
|
|
18
18
|
"tests/records-test.mjs",
|
|
19
19
|
"tests/project-metadata-test.mjs",
|
|
20
20
|
"tests/state-inventory-test.mjs",
|
|
21
|
+
"tests/worker-deployment-test.mjs",
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
try {
|
|
@@ -45,13 +46,16 @@ try {
|
|
|
45
46
|
"src/local/process-tracker.mjs": [65, 35],
|
|
46
47
|
"src/local/log.mjs": [60, 40],
|
|
47
48
|
"src/local/runtime.mjs": [60, 35],
|
|
48
|
-
"src/local/cli.mjs": [
|
|
49
|
+
"src/local/cli.mjs": [45, 20],
|
|
49
50
|
"src/local/cli-options.mjs": [65, 35],
|
|
50
51
|
"src/local/cli-policy.mjs": [70, 35],
|
|
51
52
|
"src/local/numbers.mjs": [100, 100],
|
|
52
53
|
"src/local/records.mjs": [100, 100],
|
|
53
54
|
"src/local/project-metadata.mjs": [85, 55],
|
|
54
|
-
"src/local/state-inventory.mjs": [
|
|
55
|
+
"src/local/state-inventory.mjs": [85, 55],
|
|
56
|
+
"src/local/network-proxy.mjs": [90, 65],
|
|
57
|
+
"src/local/worker-health.mjs": [85, 60],
|
|
58
|
+
"src/local/worker-deployment.mjs": [80, 55],
|
|
55
59
|
"src/worker/pending-calls.ts": [85, null],
|
|
56
60
|
"src/worker/policy.ts": [90, null],
|
|
57
61
|
"src/worker/errors.ts": [100, null],
|
|
@@ -22,7 +22,7 @@ const COMMAND_OPTIONS = {
|
|
|
22
22
|
status: new Set(["workspace", "stateDir"]),
|
|
23
23
|
doctor: new Set(["workspace", "stateDir"]),
|
|
24
24
|
"full-test": new Set(["workspace", "stateDir", "json"]),
|
|
25
|
-
"rotate-secrets": new Set(["workspace", "stateDir", "
|
|
25
|
+
"rotate-secrets": new Set(["workspace", "stateDir", "quiet"]),
|
|
26
26
|
workspace: new Set(["workspace", "stateDir"]),
|
|
27
27
|
service: new Set(["workspace", "stateDir", "quiet"]),
|
|
28
28
|
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
package/src/local/cli.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import path, { join, resolve } from "node:path";
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
4
3
|
import process from "node:process";
|
|
5
4
|
import readline from "node:readline/promises";
|
|
6
5
|
import { LocalRuntime } from "./runtime.mjs";
|
|
@@ -19,12 +18,13 @@ import { classifyOperationalError, createLogger, sanitizeLogText } from "./log.m
|
|
|
19
18
|
import { runExecutable, runWrangler } from "./shell.mjs";
|
|
20
19
|
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
21
20
|
import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
|
|
21
|
+
import { ensureWorkerDeployment } from "./worker-deployment.mjs";
|
|
22
|
+
import { workerHealth } from "./worker-health.mjs";
|
|
23
|
+
export { workerHealthUserReason } from "./worker-health.mjs";
|
|
22
24
|
import { activeStateJobs, activeStateLocks, knownProfileStates, knownWorkerNames } from "./state-inventory.mjs";
|
|
23
|
-
import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
|
|
24
25
|
import {
|
|
25
26
|
acquireMaintenanceLock,
|
|
26
27
|
acquireStartupLockWithWait,
|
|
27
|
-
appName,
|
|
28
28
|
daemonLockPathForState,
|
|
29
29
|
defaultFirstRunWorkspace,
|
|
30
30
|
defaultStateRoot,
|
|
@@ -225,7 +225,7 @@ function reportExistingDaemon(args, state, owner, logger) {
|
|
|
225
225
|
logger.plain(` Workspace: ${state.workspace.path}`);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
function isIdempotentDaemonOnlyStart(args) {
|
|
228
|
+
export function isIdempotentDaemonOnlyStart(args) {
|
|
229
229
|
if (!args.daemonOnly || args.json) return false;
|
|
230
230
|
return !Boolean(
|
|
231
231
|
args.profile
|
|
@@ -258,7 +258,11 @@ async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }
|
|
|
258
258
|
|
|
259
259
|
async function prepareRemoteState({ args, workspace, state, logger }) {
|
|
260
260
|
const workerName = validateWorkerName(args.workerName);
|
|
261
|
-
ensureWorkerSecrets(state, {
|
|
261
|
+
ensureWorkerSecrets(state, {
|
|
262
|
+
rotateSecrets: Boolean(args.rotateSecrets),
|
|
263
|
+
workerName,
|
|
264
|
+
allowWorkerRename: Boolean(args.forceWorker),
|
|
265
|
+
});
|
|
262
266
|
state.policy = resolvePolicy(args, state.policy);
|
|
263
267
|
state.policy.updatedAt = new Date().toISOString();
|
|
264
268
|
saveState(state);
|
|
@@ -366,146 +370,9 @@ async function clientConfigCommand(args) {
|
|
|
366
370
|
|
|
367
371
|
async function ensureWorker(state, args) {
|
|
368
372
|
const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "worker" });
|
|
369
|
-
|
|
370
|
-
const expectedVersion = currentPackageVersion();
|
|
371
|
-
const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.accountAdminSecret && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
|
|
372
|
-
if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
|
|
373
|
-
const health = await workerHealth(state.worker.url, expectedVersion);
|
|
374
|
-
if (health.ok) {
|
|
375
|
-
logger.success("Worker unchanged and healthy", { url: state.worker.url });
|
|
376
|
-
return state.worker;
|
|
377
|
-
}
|
|
378
|
-
logger.warn("Worker is not healthy at the expected version; redeploying automatically", { reason: workerHealthUserReason(health.error) });
|
|
379
|
-
logger.debug("Worker health check detail", { health_error: health.error });
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
logger.info("Checking Cloudflare Wrangler login");
|
|
383
|
-
const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
|
|
384
|
-
if (whoami.code !== 0) {
|
|
385
|
-
logger.info("Wrangler is not logged in; opening Cloudflare login");
|
|
386
|
-
await runWrangler(["login"]);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
logger.info("Deploying Cloudflare Worker", { name: state.worker.name });
|
|
390
|
-
const deploy = await withWorkerSecretsFile(state, secretFile => runWrangler([
|
|
391
|
-
"deploy",
|
|
392
|
-
"--name", state.worker.name,
|
|
393
|
-
"--minify",
|
|
394
|
-
"--keep-vars",
|
|
395
|
-
"--secrets-file", secretFile,
|
|
396
|
-
], { capture: true }));
|
|
397
|
-
|
|
398
|
-
const workerUrl = extractWorkerUrl(deploy.stdout) || extractWorkerUrl(deploy.stderr) || state.worker.url;
|
|
399
|
-
if (!workerUrl) throw new Error("Worker deployed but URL could not be detected. Re-run with --worker-name or inspect Wrangler output.");
|
|
400
|
-
state.worker.url = workerUrl.replace(/\/+$/, "");
|
|
401
|
-
state.worker.mcpServerUrl = `${state.worker.url}/mcp`;
|
|
402
|
-
delete state.worker.deployHash;
|
|
403
|
-
state.worker.updatedAt = new Date().toISOString();
|
|
404
|
-
saveState(state);
|
|
405
|
-
|
|
406
|
-
const health = await retryHealth(state.worker.url, expectedVersion, 8);
|
|
407
|
-
if (!health.ok) {
|
|
408
|
-
throw new Error(`Worker deployment did not become healthy at the expected version: ${health.error}`);
|
|
409
|
-
}
|
|
410
|
-
state.worker.deployHash = desiredHash;
|
|
411
|
-
saveState(state);
|
|
412
|
-
logger.success("Worker ready", { url: state.worker.url, version: health.version });
|
|
413
|
-
return state.worker;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
function workerDeploymentFingerprint(state) {
|
|
418
|
-
const keyMaterial = [
|
|
419
|
-
String(state.worker.accountAdminSecret || ""),
|
|
420
|
-
String(state.worker.daemonSecret || ""),
|
|
421
|
-
String(state.worker.oauthTokenVersion || ""),
|
|
422
|
-
].join("\0");
|
|
423
|
-
const fingerprint = createHmac("sha256", keyMaterial);
|
|
424
|
-
fingerprint.update("mbm-worker-deploy-v3");
|
|
425
|
-
fingerprint.update(String(state.worker.name || ""));
|
|
426
|
-
for (const file of workerDeployHashFiles()) {
|
|
427
|
-
fingerprint.update(path.relative(packageRoot, file));
|
|
428
|
-
fingerprint.update(workerHashContent(file));
|
|
429
|
-
}
|
|
430
|
-
return fingerprint.digest("hex");
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function workerHashContent(file) {
|
|
434
|
-
return readFileSync(file, "utf8");
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function workerDeployHashFiles() {
|
|
438
|
-
const files = [];
|
|
439
|
-
for (const item of ["src/worker", "src/shared", "wrangler.jsonc", "tsconfig.json"]) {
|
|
440
|
-
collectHashFiles(resolve(packageRoot, item), files);
|
|
441
|
-
}
|
|
442
|
-
return files.sort();
|
|
373
|
+
return ensureWorkerDeployment(state, args, { logger });
|
|
443
374
|
}
|
|
444
375
|
|
|
445
|
-
function collectHashFiles(target, out) {
|
|
446
|
-
if (!existsSync(target)) return;
|
|
447
|
-
const info = statSync(target);
|
|
448
|
-
if (info.isFile()) {
|
|
449
|
-
if (/\.(ts|js|mjs|json|jsonc|yaml|yml|lock)$/.test(target)) out.push(target);
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
if (!info.isDirectory()) return;
|
|
453
|
-
for (const entry of readdirSync(target, { withFileTypes: true })) {
|
|
454
|
-
if (entry.name === "node_modules" || entry.name === ".wrangler" || entry.name.endsWith(".d.ts")) continue;
|
|
455
|
-
collectHashFiles(resolve(target, entry.name), out);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()) {
|
|
460
|
-
if (!workerUrl) return { ok: false, error: "missing_worker_url" };
|
|
461
|
-
try {
|
|
462
|
-
const response = await fetch(`${String(workerUrl).replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(5000) });
|
|
463
|
-
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
|
|
464
|
-
const body = await response.json().catch(() => null);
|
|
465
|
-
if (body?.ok !== true || body?.server !== appName) return { ok: false, error: "unexpected_health_response" };
|
|
466
|
-
if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}` };
|
|
467
|
-
return { ok: true, version: body.version };
|
|
468
|
-
} catch (error) {
|
|
469
|
-
return { ok: false, error: workerHealthError(error) };
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
export function workerHealthUserReason(value) {
|
|
474
|
-
const reason = String(value || "");
|
|
475
|
-
if (reason.startsWith("version_mismatch:")) return "deployed version does not match the local package";
|
|
476
|
-
if (/^HTTP \d+$/.test(reason)) return "health endpoint returned an HTTP error";
|
|
477
|
-
if (reason === "unexpected_health_response") return "health endpoint returned an unexpected response";
|
|
478
|
-
if (reason === "timeout") return "health check timed out";
|
|
479
|
-
if (reason === "tls_error") return "TLS validation failed";
|
|
480
|
-
if (reason === "network_error") return "network request failed";
|
|
481
|
-
if (reason === "missing_worker_url") return "Worker URL is missing";
|
|
482
|
-
return "health check failed";
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function workerHealthError(error) {
|
|
486
|
-
const message = String(error?.message || error || "");
|
|
487
|
-
if (/timeout|aborted/i.test(message)) return "timeout";
|
|
488
|
-
if (/certificate|TLS|SSL/i.test(message)) return "tls_error";
|
|
489
|
-
if (/fetch failed|network|ECONN|ENOTFOUND|EAI_AGAIN/i.test(message)) return "network_error";
|
|
490
|
-
return "request_failed";
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async function retryHealth(workerUrl, expectedVersion, attempts) {
|
|
494
|
-
let last = { ok: false, error: "not_checked" };
|
|
495
|
-
for (let i = 0; i < attempts; i += 1) {
|
|
496
|
-
last = await workerHealth(workerUrl, expectedVersion);
|
|
497
|
-
if (last.ok) return last;
|
|
498
|
-
await sleep(1000 + i * 500);
|
|
499
|
-
}
|
|
500
|
-
return last;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function extractWorkerUrl(text = "") {
|
|
504
|
-
const matches = [...String(text).matchAll(/https:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/g)];
|
|
505
|
-
if (matches.length) return matches.at(-1)[0].replace(/[),.]+$/, "");
|
|
506
|
-
const anyHttps = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)];
|
|
507
|
-
return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
|
|
508
|
-
}
|
|
509
376
|
|
|
510
377
|
function printStartJson(state, { requestedChangesApplied = true, notice = "", initialOwner = null } = {}) {
|
|
511
378
|
createLogger({ component: "ready" }).json({
|
|
@@ -566,7 +433,7 @@ async function statusCommand(args) {
|
|
|
566
433
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
567
434
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
568
435
|
state.policy = resolvePolicy({}, state.policy);
|
|
569
|
-
const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
|
|
436
|
+
const health = state.worker?.url ? await workerHealth(state.worker.url, currentPackageVersion(), { expectedWorkerName: state.worker.name }) : { ok: false, error: "no worker url" };
|
|
570
437
|
const payload = {
|
|
571
438
|
...redactState(state),
|
|
572
439
|
workerHealth: health,
|
|
@@ -597,7 +464,7 @@ async function doctorCommand(args) {
|
|
|
597
464
|
checks.push({ name: "full-policy-contract", ok: false, detail: sanitizeLines(error?.message || error) });
|
|
598
465
|
}
|
|
599
466
|
}
|
|
600
|
-
const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
|
|
467
|
+
const health = state.worker?.url ? await workerHealth(state.worker.url, currentPackageVersion(), { expectedWorkerName: state.worker.name }) : { ok: false, error: "no worker url" };
|
|
601
468
|
checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
|
|
602
469
|
const diagnosticRuntime = new LocalRuntime({
|
|
603
470
|
workspace,
|
|
@@ -656,7 +523,7 @@ async function rotateSecretsCommand(args) {
|
|
|
656
523
|
if (daemonIdentity?.current) {
|
|
657
524
|
throw new Error(`refusing to rotate secrets while the daemon is active (pid ${daemonOwner.pid}); stop the foreground daemon and retry`);
|
|
658
525
|
}
|
|
659
|
-
ensureWorkerSecrets(state, { rotateSecrets: true
|
|
526
|
+
ensureWorkerSecrets(state, { rotateSecrets: true });
|
|
660
527
|
saveState(state);
|
|
661
528
|
console.log("Rotated account administration, daemon, and global token-version secrets.");
|
|
662
529
|
console.log("All account access tokens are invalid. Run machine-mcp to redeploy, then reconnect clients.");
|
|
@@ -963,8 +830,8 @@ Commands:
|
|
|
963
830
|
|
|
964
831
|
Start options:
|
|
965
832
|
--workspace PATH Use and remember this workspace path
|
|
966
|
-
--worker-name NAME Worker name (default: mbm-<workspace-hash>)
|
|
967
|
-
--force-worker Force
|
|
833
|
+
--worker-name NAME Worker name (default: mbm-<workspace-hash>); changing an existing name requires --force-worker
|
|
834
|
+
--force-worker Force same-name deployment, or explicitly allow an intentional --worker-name replacement
|
|
968
835
|
--rotate-secrets Rotate secrets before deploying
|
|
969
836
|
--daemon-only Skip deploy and only connect daemon from existing state
|
|
970
837
|
--no-autostart Do not install login autostart during start
|
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
2
2
|
import { getProxyForUrl } from "proxy-from-env";
|
|
3
3
|
|
|
4
|
+
const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
|
|
5
|
+
|
|
4
6
|
export function proxyAgentForWebSocket(webSocketUrl, proxyResolver = getProxyForUrl) {
|
|
5
7
|
const target = new URL(String(webSocketUrl));
|
|
6
8
|
if (target.protocol !== "ws:" && target.protocol !== "wss:") throw new Error("relay WebSocket URL must use ws or wss");
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const lookupUrl = new URL(target);
|
|
10
|
+
lookupUrl.protocol = target.protocol === "wss:" ? "https:" : "http:";
|
|
11
|
+
return proxyAgentForLookup(lookupUrl, proxyResolver, {
|
|
12
|
+
errorCode: "relay_proxy_configuration",
|
|
13
|
+
subject: "relay proxy",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function proxyAgentForHttp(httpUrl, proxyResolver = getProxyForUrl) {
|
|
18
|
+
const target = new URL(String(httpUrl));
|
|
19
|
+
if (!HTTP_PROTOCOLS.has(target.protocol)) throw new Error("HTTP request URL must use http or https");
|
|
20
|
+
return proxyAgentForLookup(target, proxyResolver, {
|
|
21
|
+
errorCode: "http_proxy_configuration",
|
|
22
|
+
subject: "HTTP proxy",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function proxyAgentForLookup(lookupUrl, proxyResolver, context) {
|
|
27
|
+
const proxyValue = String(proxyResolver(lookupUrl.href) || "").trim();
|
|
10
28
|
if (!proxyValue) return { agent: null, mode: "direct" };
|
|
11
29
|
let proxyUrl;
|
|
12
30
|
try {
|
|
13
31
|
proxyUrl = new URL(proxyValue);
|
|
14
32
|
} catch {
|
|
15
|
-
throw proxyConfigurationError(
|
|
33
|
+
throw proxyConfigurationError(`${context.subject} configuration is not a valid URL`, context.errorCode);
|
|
16
34
|
}
|
|
17
|
-
if (!
|
|
18
|
-
throw proxyConfigurationError(
|
|
35
|
+
if (!HTTP_PROTOCOLS.has(proxyUrl.protocol)) {
|
|
36
|
+
throw proxyConfigurationError(`${context.subject} must use HTTP or HTTPS`, context.errorCode);
|
|
19
37
|
}
|
|
20
38
|
try {
|
|
21
39
|
return {
|
|
@@ -23,12 +41,12 @@ export function proxyAgentForWebSocket(webSocketUrl, proxyResolver = getProxyFor
|
|
|
23
41
|
mode: "proxy",
|
|
24
42
|
};
|
|
25
43
|
} catch {
|
|
26
|
-
throw proxyConfigurationError(
|
|
44
|
+
throw proxyConfigurationError(`${context.subject} configuration could not be initialized`, context.errorCode);
|
|
27
45
|
}
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
function proxyConfigurationError(message) {
|
|
48
|
+
function proxyConfigurationError(message, code) {
|
|
31
49
|
const error = new Error(message);
|
|
32
|
-
error.code =
|
|
50
|
+
error.code = code;
|
|
33
51
|
return error;
|
|
34
52
|
}
|
|
@@ -28,11 +28,15 @@ export function knownWorkerNames(stateRoot) {
|
|
|
28
28
|
throw unreadableWorkerState(entry.name);
|
|
29
29
|
}
|
|
30
30
|
if (!state || typeof state !== "object" || Array.isArray(state)) throw unreadableWorkerState(entry.name);
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const currentName = String(state?.worker?.name || "");
|
|
32
|
+
const previousNames = Array.isArray(state?.worker?.previousNames) ? state.worker.previousNames : [];
|
|
33
|
+
for (const name of [currentName, ...previousNames]) {
|
|
34
|
+
if (!name) continue;
|
|
35
|
+
if (typeof name !== "string" || !WORKER_NAME.test(name)) {
|
|
36
|
+
throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
|
|
37
|
+
}
|
|
38
|
+
names.add(name);
|
|
34
39
|
}
|
|
35
|
-
if (name) names.add(name);
|
|
36
40
|
}
|
|
37
41
|
return [...names];
|
|
38
42
|
}
|
package/src/local/state.mjs
CHANGED
|
@@ -594,7 +594,26 @@ export function ensureWorkerSecrets(state, options = {}) {
|
|
|
594
594
|
if (!state.worker.accountAdminSecret || options.rotateSecrets) state.worker.accountAdminSecret = randomToken("account_admin");
|
|
595
595
|
if (!state.worker.daemonSecret || options.rotateSecrets) state.worker.daemonSecret = randomToken("daemon_secret");
|
|
596
596
|
if (!state.worker.oauthTokenVersion || options.rotateSecrets) state.worker.oauthTokenVersion = randomToken("token_version");
|
|
597
|
-
|
|
597
|
+
|
|
598
|
+
const requestedName = options.workerName || "";
|
|
599
|
+
if (!state.worker.name) {
|
|
600
|
+
state.worker.name = requestedName || defaultWorkerName(state.workspace.hash);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (!requestedName || requestedName === state.worker.name) return;
|
|
604
|
+
if (!options.allowWorkerRename) {
|
|
605
|
+
throw new Error(`this workspace already uses Worker ${state.worker.name}; changing --worker-name to ${requestedName} would create another Worker. Re-run with --force-worker only when that replacement is intentional`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const previous = String(state.worker.name);
|
|
609
|
+
const previousNames = Array.isArray(state.worker.previousNames) ? state.worker.previousNames : [];
|
|
610
|
+
state.worker.previousNames = [...new Set([...previousNames, previous])].slice(-32);
|
|
611
|
+
state.worker.name = requestedName;
|
|
612
|
+
delete state.worker.url;
|
|
613
|
+
delete state.worker.mcpServerUrl;
|
|
614
|
+
delete state.worker.deployHash;
|
|
615
|
+
delete state.worker.deployedVersion;
|
|
616
|
+
delete state.worker.updatedAt;
|
|
598
617
|
}
|
|
599
618
|
|
|
600
619
|
function defaultWorkerName(hash) {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import path, { resolve } from "node:path";
|
|
4
|
+
import { runWrangler } from "./shell.mjs";
|
|
5
|
+
import { packageRoot, saveState } from "./state.mjs";
|
|
6
|
+
import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
|
|
7
|
+
import {
|
|
8
|
+
retryWorkerHealth,
|
|
9
|
+
workerHealthRequiresRedeploy,
|
|
10
|
+
workerHealthUserReason,
|
|
11
|
+
} from "./worker-health.mjs";
|
|
12
|
+
|
|
13
|
+
export async function ensureWorkerDeployment(state, args = {}, options = {}) {
|
|
14
|
+
const logger = options.logger || console;
|
|
15
|
+
const expectedVersion = options.expectedVersion || currentPackageVersion(options.packageRoot || packageRoot);
|
|
16
|
+
const desiredHash = workerDeploymentFingerprint(state, { packageRoot: options.packageRoot || packageRoot });
|
|
17
|
+
const runWranglerFn = options.runWrangler || runWrangler;
|
|
18
|
+
const saveStateFn = options.saveState || saveState;
|
|
19
|
+
const retryHealthFn = options.retryHealth || retryWorkerHealth;
|
|
20
|
+
const withSecretsFileFn = options.withSecretsFile || withWorkerSecretsFile;
|
|
21
|
+
const healthOptions = { expectedWorkerName: state.worker.name, ...(options.healthOptions || {}) };
|
|
22
|
+
const complete = hasCompleteWorkerState(state.worker);
|
|
23
|
+
|
|
24
|
+
if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
|
|
25
|
+
const health = await retryHealthFn(state.worker.url, expectedVersion, options.existingHealthAttempts || 2, healthOptions);
|
|
26
|
+
if (health.ok) {
|
|
27
|
+
logger.success?.("Worker unchanged and healthy", { url: state.worker.url });
|
|
28
|
+
logger.debug?.("Worker health route", { network_route: health.networkRoute || "unknown" });
|
|
29
|
+
return state.worker;
|
|
30
|
+
}
|
|
31
|
+
if (!workerHealthRequiresRedeploy(health.error)) {
|
|
32
|
+
logger.debug?.("Worker health check detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
|
|
33
|
+
throw workerVerificationError(health.error, { deploymentSucceeded: false });
|
|
34
|
+
}
|
|
35
|
+
logger.warn?.("Recorded Worker is stale; redeploying the same Worker", { reason: workerHealthUserReason(health.error) });
|
|
36
|
+
logger.debug?.("Worker health check detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
logger.info?.("Checking Cloudflare Wrangler login");
|
|
40
|
+
const whoami = await runWranglerFn(["whoami"], { capture: true, allowFailure: true });
|
|
41
|
+
if (whoami.code !== 0) {
|
|
42
|
+
logger.info?.("Wrangler is not logged in; opening Cloudflare login");
|
|
43
|
+
await runWranglerFn(["login"]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.info?.("Deploying Cloudflare Worker", { name: state.worker.name });
|
|
47
|
+
const deploy = await withSecretsFileFn(state, secretFile => runWranglerFn([
|
|
48
|
+
"deploy",
|
|
49
|
+
"--name", state.worker.name,
|
|
50
|
+
"--minify",
|
|
51
|
+
"--keep-vars",
|
|
52
|
+
"--secrets-file", secretFile,
|
|
53
|
+
], { capture: true }));
|
|
54
|
+
|
|
55
|
+
const detectedUrl = extractWorkerUrl(deploy.stdout) || extractWorkerUrl(deploy.stderr);
|
|
56
|
+
const recordedUrl = workerUrlMatchesName(state.worker.url, state.worker.name) ? state.worker.url : "";
|
|
57
|
+
const workerUrl = detectedUrl || recordedUrl;
|
|
58
|
+
if (!workerUrl) {
|
|
59
|
+
throw new Error("Worker upload returned success, but Wrangler output contained no workers.dev URL and no matching recorded URL exists. The deployment fingerprint was not saved; rerun with --verbose and inspect the Wrangler output before retrying.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
state.worker.url = workerUrl.replace(/\/+$/, "");
|
|
63
|
+
state.worker.mcpServerUrl = `${state.worker.url}/mcp`;
|
|
64
|
+
state.worker.deployHash = desiredHash;
|
|
65
|
+
state.worker.deployedVersion = expectedVersion;
|
|
66
|
+
state.worker.updatedAt = new Date().toISOString();
|
|
67
|
+
saveStateFn(state);
|
|
68
|
+
|
|
69
|
+
const health = await retryHealthFn(state.worker.url, expectedVersion, options.deploymentHealthAttempts || 8, healthOptions);
|
|
70
|
+
if (!health.ok) {
|
|
71
|
+
logger.debug?.("Worker post-deployment health detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
|
|
72
|
+
throw workerVerificationError(health.error, { deploymentSucceeded: true });
|
|
73
|
+
}
|
|
74
|
+
logger.success?.("Worker ready", { url: state.worker.url, version: health.version });
|
|
75
|
+
logger.debug?.("Worker health route", { network_route: health.networkRoute || "unknown" });
|
|
76
|
+
return state.worker;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function workerDeploymentFingerprint(state, options = {}) {
|
|
80
|
+
const root = resolve(options.packageRoot || packageRoot);
|
|
81
|
+
const keyMaterial = [
|
|
82
|
+
String(state.worker.accountAdminSecret || ""),
|
|
83
|
+
String(state.worker.daemonSecret || ""),
|
|
84
|
+
String(state.worker.oauthTokenVersion || ""),
|
|
85
|
+
].join("\0");
|
|
86
|
+
const fingerprint = createHmac("sha256", keyMaterial);
|
|
87
|
+
fingerprint.update("mbm-worker-deploy-v3");
|
|
88
|
+
fingerprint.update(String(state.worker.name || ""));
|
|
89
|
+
for (const file of workerDeployHashFiles(root)) {
|
|
90
|
+
fingerprint.update(path.relative(root, file));
|
|
91
|
+
fingerprint.update(readFileSync(file, "utf8"));
|
|
92
|
+
}
|
|
93
|
+
return fingerprint.digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function extractWorkerUrl(text = "") {
|
|
97
|
+
const matches = [...String(text).matchAll(/https:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/g)];
|
|
98
|
+
if (matches.length) return matches.at(-1)[0].replace(/[),.]+$/, "");
|
|
99
|
+
const anyHttps = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)];
|
|
100
|
+
return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function workerUrlMatchesName(workerUrl, workerName) {
|
|
104
|
+
if (!workerUrl || !workerName) return false;
|
|
105
|
+
try {
|
|
106
|
+
const url = new URL(String(workerUrl));
|
|
107
|
+
return url.protocol === "https:"
|
|
108
|
+
&& url.hostname.endsWith(".workers.dev")
|
|
109
|
+
&& url.hostname.startsWith(`${String(workerName)}.`);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function hasCompleteWorkerState(worker = {}) {
|
|
116
|
+
return Boolean(worker.url && worker.mcpServerUrl && worker.accountAdminSecret && worker.daemonSecret && worker.oauthTokenVersion && worker.name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function workerDeployHashFiles(root) {
|
|
120
|
+
const files = [];
|
|
121
|
+
for (const item of ["src/worker", "src/shared", "wrangler.jsonc", "tsconfig.json"]) {
|
|
122
|
+
collectHashFiles(resolve(root, item), files);
|
|
123
|
+
}
|
|
124
|
+
return files.sort();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectHashFiles(target, out) {
|
|
128
|
+
if (!existsSync(target)) return;
|
|
129
|
+
const info = statSync(target);
|
|
130
|
+
if (info.isFile()) {
|
|
131
|
+
if (/\.(ts|js|mjs|json|jsonc|yaml|yml|lock)$/.test(target)) out.push(target);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!info.isDirectory()) return;
|
|
135
|
+
for (const entry of readdirSync(target, { withFileTypes: true })) {
|
|
136
|
+
if (entry.name === "node_modules" || entry.name === ".wrangler" || entry.name.endsWith(".d.ts")) continue;
|
|
137
|
+
collectHashFiles(resolve(target, entry.name), out);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function workerVerificationError(reason, { deploymentSucceeded }) {
|
|
142
|
+
const readable = workerHealthUserReason(reason);
|
|
143
|
+
const guidance = workerVerificationGuidance(reason);
|
|
144
|
+
const message = deploymentSucceeded
|
|
145
|
+
? `Cloudflare reported the Worker deployment succeeded, but ${readable}. The deployment fingerprint was saved, so retrying will verify the same Worker instead of deploying again. ${guidance}`
|
|
146
|
+
: `The recorded Worker could not be verified because ${readable}. No deployment was attempted. ${guidance}`;
|
|
147
|
+
const error = new Error(message);
|
|
148
|
+
error.code = "worker_health_unverified";
|
|
149
|
+
error.healthError = reason;
|
|
150
|
+
error.deploymentSucceeded = deploymentSucceeded;
|
|
151
|
+
return error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function workerVerificationGuidance(reason) {
|
|
155
|
+
if (reason === "proxy_configuration") {
|
|
156
|
+
return "Check HTTPS_PROXY, HTTP_PROXY, and NO_PROXY, then run machine-mcp doctor.";
|
|
157
|
+
}
|
|
158
|
+
if (["timeout", "tls_error", "network_error", "request_failed"].includes(reason)) {
|
|
159
|
+
return "Check network/TLS access and HTTPS_PROXY, HTTP_PROXY, and NO_PROXY, then run machine-mcp doctor.";
|
|
160
|
+
}
|
|
161
|
+
if (/^HTTP 5\d\d$/.test(String(reason || ""))) {
|
|
162
|
+
return "Retry after the service recovers, or run machine-mcp doctor for the current health result.";
|
|
163
|
+
}
|
|
164
|
+
return "Run machine-mcp doctor and inspect the health endpoint before forcing another deployment.";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function currentPackageVersion(root) {
|
|
168
|
+
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
|
|
169
|
+
return String(pkg.version);
|
|
170
|
+
}
|