machine-bridge-mcp 1.0.0 → 1.0.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 +18 -0
- package/README.md +1 -1
- package/browser-extension/browser-operations.js +3 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +9 -6
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/AUDIT.md +22 -1
- package/docs/ENGINEERING.md +3 -0
- package/docs/OPERATIONS.md +2 -2
- package/docs/TESTING.md +5 -3
- package/package.json +9 -3
- package/src/local/app-automation.mjs +2 -1
- package/src/local/call-registry.mjs +2 -1
- package/src/local/cli.mjs +2 -1
- package/src/local/daemon-process.mjs +7 -6
- package/src/local/full-access-test.mjs +3 -2
- package/src/local/job-runner.mjs +6 -4
- package/src/local/monotonic-deadline.mjs +30 -0
- package/src/local/process-sessions.mjs +4 -3
- package/src/local/shell.mjs +7 -4
- package/src/local/state.mjs +4 -3
- package/src/local/tool-executor.mjs +4 -3
- package/src/worker/index.ts +1 -1
- package/src/worker/observability.ts +2 -2
- package/src/worker/pending-calls.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.2 - 2026-07-14
|
|
4
|
+
|
|
5
|
+
### Deadline and architecture integrity
|
|
6
|
+
|
|
7
|
+
- Replace wall-clock arithmetic in startup-lock waits, verified daemon takeover/stop, process-session exit waits, managed-job recovery-lock handoff, full-access diagnostics, browser waits, page actionability/stability checks, application-discovery cache freshness, and local/Worker duration metrics with monotonic elapsed time. System clock rollback can no longer extend these bounded state machines, and a forward correction cannot force premature timeout; persisted expiry and retention timestamps remain wall-clock based.
|
|
8
|
+
- Add deterministic clock-fault regressions at the shared deadline primitive, startup-lock production path, and browser-extension production path, and make the deadline suite a mandatory part of `npm run check`.
|
|
9
|
+
- Correct stale architecture claims after the state-schema-6 and named-account releases: remote tokens are bound to named account principals and roles, duplicate JSON-RPC ids are scoped to one signed MCP session, and application-level account isolation is distinguished from OS/browser tenancy. Architecture tests reject a return to the obsolete statements.
|
|
10
|
+
|
|
11
|
+
## 1.0.1 - 2026-07-14
|
|
12
|
+
|
|
13
|
+
### Startup and release-gate reliability
|
|
14
|
+
|
|
15
|
+
- Fix two production CLI references that were used without imports: `readdirSync` in the default Worker deployment fingerprint path and `inspectProcessInstance` in secret rotation. The former made a freshly installed 1.0.0 package fail immediately on zero-argument startup even though syntax, package, and cross-platform CI checks were green.
|
|
16
|
+
- Fix the Windows Wrangler execution boundary uncovered by the new startup probe. Runtime code no longer passes `wrangler.cmd` to `spawn(..., { shell: false })`, which fails with `EINVAL` on Node 26; it invokes the dependency's declared JavaScript entrypoint through the current Node executable on every platform. A focused regression and the installed startup probe prevent a return to shell shims.
|
|
17
|
+
- Add an ESLint correctness gate over Node production code, tests, scripts, and the packaged browser extension. Undefined identifiers, redeclarations, duplicate keys, unreachable statements, and non-loop constant conditions now fail `npm run check` before packaging or publication. A configuration self-test injects synthetic undefined Node/browser bindings so retaining a no-op or mis-scoped lint script cannot satisfy the gate.
|
|
18
|
+
- Strengthen `install:test` to install the real `npm pack` tarball into an isolated global prefix and execute the installed CLI with zero arguments from a package-free workspace and isolated state root. A cross-platform fake Wrangler shim provides a controlled external boundary; the test requires state initialization, rejects `ReferenceError`/`is not defined`, and proves that default startup reaches Worker orchestration rather than only supporting `--version`.
|
|
19
|
+
- Make both static lint and the installed zero-argument startup probe mandatory parts of the complete local, prepublish, Linux, macOS, and Windows release gate. Architecture checks prevent either gate from being removed silently and now enforce the existing exact-semver dependency policy instead of leaving it as documentation only.
|
|
20
|
+
|
|
3
21
|
## 1.0.0 - 2026-07-14
|
|
4
22
|
|
|
5
23
|
### Current-only protocol and runtime contract
|
package/README.md
CHANGED
|
@@ -489,7 +489,7 @@ npm audit --omit=dev --audit-level=high
|
|
|
489
489
|
npm pack --dry-run
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
`npm run check` covers privacy and release-impact gates, architecture/link invariants, generated Worker types, TypeScript, recursively discovered JavaScript syntax, catalog-to-runtime handler parity, deterministic relay lifecycle and secure-file tests, concurrent lock/atomic-replacement/PID-reuse fixtures, fail-closed service lifecycle tests, descendant process-tree termination, local path/write/state/log/service invariants, Ed25519/RSA generation and key-pair validation, real-machine `full` sandbox acceptance, a clean npm package-manifest/mode/sensitive-artifact check, managed-job integrity/redaction/finally/cancellation/recovery, a live stdio MCP flow, and a live local OAuth/Worker/WebSocket/MCP flow. GitHub Actions runs the suite on Linux, macOS, and Windows with the pinned Node 26/npm 12 baseline
|
|
492
|
+
`npm run check` covers privacy and release-impact gates, architecture/link invariants, generated Worker types, TypeScript, recursively discovered JavaScript syntax, semantic undefined-identifier checks, catalog-to-runtime handler parity, deterministic relay lifecycle and secure-file tests, concurrent lock/atomic-replacement/PID-reuse fixtures, fail-closed service lifecycle tests, descendant process-tree termination, local path/write/state/log/service invariants, Ed25519/RSA generation and key-pair validation, real-machine `full` sandbox acceptance, a clean npm package-manifest/mode/sensitive-artifact check, an isolated installation of the actual packed tarball whose zero-argument CLI startup must reach a controlled Worker-deployment boundary, managed-job integrity/redaction/finally/cancellation/recovery, a live stdio MCP flow, and a live local OAuth/Worker/WebSocket/MCP flow. GitHub Actions runs the complete suite on Linux, macOS, and Windows with the pinned Node 26/npm 12 baseline.
|
|
493
493
|
|
|
494
494
|
The cross-cutting 0.12.0 review, corrected failure modes, and residual OS-level limits are recorded in [docs/AUDIT.md](docs/AUDIT.md). See also [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md), [docs/MULTI_ACCOUNT.md](docs/MULTI_ACCOUNT.md), [docs/AGENT_CONTEXT.md](docs/AGENT_CONTEXT.md), [docs/LOCAL_AUTOMATION.md](docs/LOCAL_AUTOMATION.md), [docs/MANAGED_JOBS.md](docs/MANAGED_JOBS.md), [docs/TESTING.md](docs/TESTING.md), [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/ENGINEERING.md](docs/ENGINEERING.md), [docs/PROJECT_STANDARDS.md](docs/PROJECT_STANDARDS.md), the generated [MCP tool reference](docs/TOOL_REFERENCE.md), and [SECURITY.md](SECURITY.md).
|
|
495
495
|
|
|
@@ -58,9 +58,10 @@
|
|
|
58
58
|
|
|
59
59
|
async function browserWait(params, state) {
|
|
60
60
|
const tab = await resolveTab(params.tabId);
|
|
61
|
-
const
|
|
61
|
+
const timeoutMs = Math.max(1, Number(params.timeoutMs) || 30000);
|
|
62
|
+
const startedAt = performance.now();
|
|
62
63
|
let last = null;
|
|
63
|
-
while (
|
|
64
|
+
while (performance.now() - startedAt <= timeoutMs) {
|
|
64
65
|
throwIfCancelled(state);
|
|
65
66
|
const current = await chrome.tabs.get(tab.id);
|
|
66
67
|
const urlMatched = !params.urlContains || String(current.url || "").includes(params.urlContains);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Machine Bridge Browser",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.2",
|
|
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.0.
|
|
33
|
+
"version_name": "1.0.2"
|
|
34
34
|
}
|
|
@@ -310,9 +310,10 @@
|
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
async function waitForActionable(selector, actionName, timeoutMs) {
|
|
313
|
-
const
|
|
313
|
+
const boundedTimeoutMs = Math.max(1, timeoutMs);
|
|
314
|
+
const startedAt = performance.now();
|
|
314
315
|
let lastProblem = "no element matched selector";
|
|
315
|
-
while (
|
|
316
|
+
while (performance.now() - startedAt <= boundedTimeoutMs) {
|
|
316
317
|
const element = findOne(selector);
|
|
317
318
|
if (!element && selector?.ref) throw new Error("element reference is stale; inspect the page again");
|
|
318
319
|
if (element) {
|
|
@@ -337,9 +338,10 @@
|
|
|
337
338
|
}
|
|
338
339
|
|
|
339
340
|
async function waitForStableBox(element, timeoutMs) {
|
|
340
|
-
const
|
|
341
|
+
const boundedTimeoutMs = Math.max(1, timeoutMs);
|
|
342
|
+
const startedAt = performance.now();
|
|
341
343
|
let previous = boundingBox(element);
|
|
342
|
-
while (
|
|
344
|
+
while (performance.now() - startedAt <= boundedTimeoutMs) {
|
|
343
345
|
await delay(50);
|
|
344
346
|
const current = boundingBox(element);
|
|
345
347
|
if (current && previous && boxDistance(previous, current) <= 0.5) return current;
|
|
@@ -349,9 +351,10 @@
|
|
|
349
351
|
}
|
|
350
352
|
|
|
351
353
|
async function waitForPointerTarget(element, timeoutMs) {
|
|
352
|
-
const
|
|
354
|
+
const boundedTimeoutMs = Math.max(1, timeoutMs);
|
|
355
|
+
const startedAt = performance.now();
|
|
353
356
|
let lastProblem = "element does not receive pointer events";
|
|
354
|
-
while (
|
|
357
|
+
while (performance.now() - startedAt <= boundedTimeoutMs) {
|
|
355
358
|
const point = actionTarget(element).point;
|
|
356
359
|
if (!point) lastProblem = "element has no usable viewport box";
|
|
357
360
|
else if (point.x < 0 || point.y < 0 || point.x >= innerWidth || point.y >= innerHeight) lastProblem = "element is outside the viewport";
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -20,7 +20,7 @@ No transport is treated as a sandbox. Both transports invoke the same local runt
|
|
|
20
20
|
|
|
21
21
|
The CLI canonicalizes workspaces, resolves policy profiles, maintains per-workspace state and credentials, serializes startup/deploy/rotation with process-identity locks, deploys the Worker, installs optional platform-native autostart, and starts either remote daemon or stdio mode.
|
|
22
22
|
|
|
23
|
-
A canonical workspace receives an independent profile, Worker name, secret set, resource registry, managed-job directory, daemon/startup locks, and state file. State schema version
|
|
23
|
+
A canonical workspace receives an independent profile, Worker name, secret set, resource registry, managed-job directory, daemon/startup locks, and state file. State schema version 6 records named accounts, policy origin/revision, and local resource metadata in addition to the capability fields. `exclusive-file.mjs` owns complete-before-visible exclusive claims and flushed atomic replacement. `process-identity.mjs` owns PID liveness, process start-time comparison, bounded command-line inspection, and PID-reuse classification. `service-lifecycle.mjs` owns the fail-closed stop-daemons-before-remove state machine shared by service removal and full uninstall.
|
|
24
24
|
|
|
25
25
|
### Local runtime
|
|
26
26
|
|
|
@@ -140,7 +140,7 @@ flowchart LR
|
|
|
140
140
|
CLI --> S[Autostart provider]
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
Remote OAuth
|
|
143
|
+
Remote OAuth binds each code and token to a named Machine Bridge account, account version, and role, so accounts are independent application-level authorization principals. The Worker intersects that role with the connected daemon policy, and the local runtime enforces the relayed authorization again. This distinction is not operating-system tenancy: every account still reaches one daemon, workspace, browser profile, and OS user authority. Local stdio access relies on the local process and configuration boundary. A connector host can independently present a smaller tool subset to a session; this post-relay filtering is outside the protocol state visible to Machine Bridge. Canonical resolution limits direct filesystem tools. Processes retain local-user authority and can escape workspace constraints through their own code or system calls.
|
|
144
144
|
|
|
145
145
|
## Remote request lifecycle
|
|
146
146
|
|
|
@@ -158,7 +158,7 @@ Remote OAuth currently determines which registered client token may call tools;
|
|
|
158
158
|
12. A matching cancellation notification removes the pending call, tells the daemon to cancel, and terminates any ordinary child processes bound to it.
|
|
159
159
|
13. `start_job` is different: after durable acceptance, the detached runner is no longer bound to the relay call or socket. Later cancellation uses `cancel_job` or the local CLI.
|
|
160
160
|
|
|
161
|
-
Duplicate in-flight JSON-RPC IDs
|
|
161
|
+
Duplicate in-flight JSON-RPC IDs are rejected only within the same authenticated MCP session. The request key includes OAuth token identity, the HMAC-bound MCP session, JSON-RPC id type, and id value, so separate initialized clients may safely reuse the same numeric id.
|
|
162
162
|
|
|
163
163
|
## Stdio request lifecycle
|
|
164
164
|
|
|
@@ -204,7 +204,7 @@ This is a process-level transaction, not a filesystem-wide atomic transaction ac
|
|
|
204
204
|
|
|
205
205
|
The default `full` profile passes the complete parent environment. Isolated environment mode, used by the narrower named profiles unless overridden, creates private runtime HOME, temporary, and cache directories and passes only a small set of path/locale/platform variables. It reduces accidental credential inheritance but cannot prevent explicit access to known filesystem paths, credential stores, network services, or other user resources.
|
|
206
206
|
|
|
207
|
-
One-shot calls have bounded output and timeouts. Process sessions retain bounded byte buffers with monotonic offsets, accept bounded stdin, support short output/exit waits, and are capped per runtime. Valid UTF-8 is returned as text; byte slices that are not valid UTF-8 also include lossless base64 data. Session IDs are random. Sessions are memory-only and are killed on runtime stop, remote disconnect, or daemon replacement.
|
|
207
|
+
One-shot calls have bounded output and timeouts. Startup-lock waits, daemon takeover, process-session reads, managed-job recovery handoff, browser/page waits, application-cache freshness, and in-memory duration metrics use monotonic elapsed time, so wall-clock correction cannot extend or prematurely terminate their configured duration. Persisted timestamps and retention/credential expiry continue to use wall time. Process sessions retain bounded byte buffers with monotonic offsets, accept bounded stdin, support short output/exit waits, and are capped per runtime. Valid UTF-8 is returned as text; byte slices that are not valid UTF-8 also include lossless base64 data. Session IDs are random. Sessions are memory-only and are killed on runtime stop, remote disconnect, or daemon replacement.
|
|
208
208
|
|
|
209
209
|
Child processes run in a separate process group where supported. Timeout, cancellation, disconnect, and replacement send termination to process trees, with a referenced forced-escalation timer that remains alive even when the direct child exits before a resistant descendant. Windows uses tree-aware task termination.
|
|
210
210
|
|
|
@@ -260,4 +260,4 @@ Third-party workflow actions are pinned to immutable commit SHAs. Dependabot pro
|
|
|
260
260
|
- model-level prompt-injection prevention or semantic validation of browser/application actions;
|
|
261
261
|
- universal desktop UI automation beyond the implemented OS Accessibility backend;
|
|
262
262
|
- scripting browser-internal/enterprise-blocked pages or inaccessible cross-origin frames;
|
|
263
|
-
-
|
|
263
|
+
- operating-system or browser-profile isolation between mutually distrustful account principals in one Worker/daemon deployment; named accounts provide Worker/local authorization and targeted revocation, but all authorized roles still converge on one local OS user and workspace trust domain.
|
package/docs/AUDIT.md
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
# Engineering and security audit
|
|
2
2
|
|
|
3
|
-
This document records the cross-cutting audit initiated for version 0.12.0,
|
|
3
|
+
This document records the cross-cutting audit initiated for version 0.12.0, its later architecture, browser, lifecycle, authorization, release-gate, and version 1 follow-ups, plus the version 1.0.2 elapsed-time and documentation-integrity review. It complements, but does not replace, the continuously enforced contracts in `SECURITY.md`, `docs/ENGINEERING.md`, and the test suite.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## 2026-07-14 version 1.0.2 elapsed-time and documentation-integrity audit
|
|
7
|
+
|
|
8
|
+
The review began from exact released commit `10931c11d7cdab1051018059fc461b73bb5505ff` (tag and npm version 1.0.1), with a clean worktree, zero high-severity npm audit findings, no open issue or pull request, and successful required Linux, macOS, Windows, governance, CodeQL, dependency-review, and Scorecard checks. The threat-model pass separated persisted wall-clock facts—token expiry, file age, retention, alarms, and operator timestamps—from in-memory elapsed durations.
|
|
9
|
+
|
|
10
|
+
A confirmed lifecycle defect remained across otherwise bounded code paths: startup-lock acquisition, verified daemon takeover/stop, process-session exit waits, managed-job recovery-lock release, full-access diagnostics, and browser/page waits calculated deadlines from `Date.now()`. A backward system-clock correction could extend those state machines beyond their documented maximum, while a forward correction could trigger premature timeout. Application-discovery cache freshness and local/Worker duration metrics had the same clock-domain error, with lower impact. One shared Node monotonic-deadline primitive now owns bounded elapsed waits; browser contexts use their native monotonic `performance.now()`. Persisted timestamps deliberately remain wall-clock based. Deterministic tests freeze or roll back wall time while exercising the real startup-lock and extension wait paths, and the shared helper clamps anomalous backward samples.
|
|
11
|
+
|
|
12
|
+
The same review confirmed architecture-documentation drift introduced by earlier releases. The document still named state schema 5 after schema 6, described OAuth tokens as lacking independently authorized human principals after named accounts and roles shipped, scoped duplicate request IDs to an entire access token after signed MCP sessions shipped, and listed per-principal authorization as absent. The corrected model distinguishes named application-level account principals and targeted revocation from OS/browser-profile tenancy: all authorized accounts still converge on one daemon, workspace, browser profile, and local OS user. Architecture tests now reject the obsolete statements.
|
|
13
|
+
|
|
14
|
+
External comparison retained the existing conservative product boundary. Current MCP authorization and security guidance supports exact redirect/resource binding, PKCE S256, server-side authorization, and no token passthrough; the implementation already enforces those controls. Browser projects that expose arbitrary page scripts were not copied because Machine Bridge intentionally restricts callers to packaged structured operations. Remaining limits are explicit: a same-user malicious process, MCP host filtering, browser/OS enforcement, Cloudflare lifecycle behavior, and live deployment state are outside repository-only guarantees.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## 2026-07-14 version 1.0.1 installed-startup incident
|
|
18
|
+
|
|
19
|
+
Version 1.0.0 passed the complete repository suite, three operating-system CI jobs, package manifest inspection, and a real isolated global installation, but the installed CLI failed immediately on the documented zero-argument command with `readdirSync is not defined`. The direct cause was a missing `node:fs` import in a function reached only while hashing Worker deployment inputs. The installation smoke test invoked only `--version`, which loaded the module but did not execute the default startup function body; `node --check` validates syntax, not lexical name resolution inside unexecuted branches. A second scan found another latent missing import, `inspectProcessInstance`, in secret rotation.
|
|
20
|
+
|
|
21
|
+
The correction uses independent evidence layers. ESLint now rejects undefined bindings across Node production code, scripts, tests, and browser-extension code. The real-tarball installation test still verifies package layout and documented npm options, but now replaces only the isolated package's Wrangler JavaScript entrypoint with a deterministic failing executable and starts the installed CLI with no arguments from a package-free workspace and private temporary state root. Success means the process initializes current state, computes the packaged Worker fingerprint, and reaches the controlled Wrangler boundary without `ReferenceError` or any `is not defined` failure. Existing workerd/WebSocket and runtime integration tests continue from that external boundary through authenticated readiness. Both gates are part of `npm run check`, prepublish validation, and every supported OS matrix job; architecture tests reject their removal.
|
|
22
|
+
|
|
23
|
+
The first Windows run of the new gate found another real production defect: `runWrangler` selected `wrangler.cmd` and passed it to `spawn` with `shell: false`, which Node 26 rejected with `EINVAL`. Runtime execution now uses the current Node binary plus `node_modules/wrangler/bin/wrangler.js` on every platform. This keeps the shell-disabled execution invariant while avoiding platform command-shim semantics; a focused command-boundary test and the final-artifact startup probe enforce the choice.
|
|
24
|
+
|
|
25
|
+
The incident establishes a release rule: entrypoint existence, module import, `--help`, or `--version` are not startup evidence. Every user-documented primary command must be executed from the final packed artifact far enough to cross its composition root and reach either a verified ready state or a deliberately controlled external boundary.
|
|
26
|
+
|
|
6
27
|
## 2026-07-14 version 1.0 security-boundary audit
|
|
7
28
|
|
|
8
29
|
The 1.0 review began from a clean `0.18.1` main branch whose complete local suite and required GitHub checks passed. The green status was incomplete evidence: GitHub still contained open CodeQL findings because the workflow uploaded SARIF but did not fail on security results. The review separated real path races and one lifecycle defect from intentional high-authority process boundaries and static-analysis noise.
|
package/docs/ENGINEERING.md
CHANGED
|
@@ -110,6 +110,7 @@ A higher branch count is acceptable only when the function is an explicit state
|
|
|
110
110
|
- Preserve a stable coarse error class for automation and a concise human message for operators.
|
|
111
111
|
- Do not retry an operation merely because it failed; retry only when the error is positively classified as transient and the operation is idempotent or server state is checked after ambiguity.
|
|
112
112
|
- When a remote write may have succeeded before the response was lost, query authoritative state before repeating it.
|
|
113
|
+
- Duration-based deadlines must use a monotonic clock. Wall time remains appropriate for persisted timestamps, credential expiry, retention, and operator-visible dates, but a system clock correction must not extend or prematurely terminate an in-process wait.
|
|
113
114
|
- Timeouts must terminate the relevant process tree. Forced escalation must remain referenced until resistant descendants are handled; do not clear it merely because the direct child exited.
|
|
114
115
|
- Half-open connections need liveness detection, not only periodic writes.
|
|
115
116
|
- Lock reclamation must consider process liveness, process start time, absolute age, ownership token, and file identity to defend against PID reuse and replacement races.
|
|
@@ -129,6 +130,8 @@ The required matrix includes:
|
|
|
129
130
|
- current-tree and reachable-history privacy gates, release-impact enforcement, and exact-commit release-CI gating;
|
|
130
131
|
- package-manifest and sensitive-artifact inspection;
|
|
131
132
|
- generated type checks and recursively discovered JavaScript/shell syntax checks;
|
|
133
|
+
- static undefined-identifier/redeclaration checks over all production JavaScript, tests, scripts, and browser-extension code;
|
|
134
|
+
- a real packed-tarball isolated global installation whose zero-argument CLI startup initializes state and reaches a controlled external Worker-deployment boundary; `--version` or import-only smoke tests are insufficient release evidence;
|
|
132
135
|
- concurrent exclusive-lock/atomic-replacement tests, PID-reuse/age tests, and fail-closed service-lifecycle tests;
|
|
133
136
|
- real process-tree timeout/cancellation tests with descendants that ignore graceful termination;
|
|
134
137
|
- local runtime and real `full` acceptance tests;
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -73,7 +73,7 @@ Application UI inspection/actions require Accessibility permission for the Node/
|
|
|
73
73
|
|
|
74
74
|
`machine-mcp` is a foreground command. It remains attached to the terminal, defaults to `info` logging, and stops on `Ctrl+C`. `machine-mcp service start` launches the installed platform service in the background and returns to the shell; that service uses `warn` logging.
|
|
75
75
|
|
|
76
|
-
A global npm install changes the CLI files on disk but does not replace an already running Node process. Startup and other state-changing CLI operations use a token/process-identity lock and wait up to 30 seconds for a normal concurrent startup to finish; a short launchd/systemd overlap is therefore serialized rather than reported immediately as an error. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd, systemd, or Task Scheduler no longer tracks. Only current lock records containing service mode, version, PID, process start time, entrypoint, workspace, and state root are eligible for takeover. Before sending `SIGTERM`, Machine Bridge verifies PID and process start time plus the live command line, entrypoint, canonical workspace, canonical state root, and daemon-only flag. It waits up to 15 seconds for both the PID and lock to disappear and does not add a later forced-kill escalation. On POSIX, a daemon that handles and ignores `SIGTERM` therefore reaches the bounded timeout and remains alive. Node maps the supported termination signals to immediate process termination on Windows, so the same verified-daemon stop normally completes rather than exercising the POSIX timeout branch. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
|
|
76
|
+
A global npm install changes the CLI files on disk but does not replace an already running Node process. Startup and other state-changing CLI operations use a token/process-identity lock and wait up to 30 seconds for a normal concurrent startup to finish; duration limits use monotonic elapsed time, so NTP or manual wall-clock correction does not lengthen or shorten the wait; a short launchd/systemd overlap is therefore serialized rather than reported immediately as an error. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd, systemd, or Task Scheduler no longer tracks. Only current lock records containing service mode, version, PID, process start time, entrypoint, workspace, and state root are eligible for takeover. Before sending `SIGTERM`, Machine Bridge verifies PID and process start time plus the live command line, entrypoint, canonical workspace, canonical state root, and daemon-only flag. It waits up to 15 seconds for both the PID and lock to disappear and does not add a later forced-kill escalation. On POSIX, a daemon that handles and ignores `SIGTERM` therefore reaches the bounded timeout and remains alive. Node maps the supported termination signals to immediate process termination on Windows, so the same verified-daemon stop normally completes rather than exercising the POSIX timeout branch. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
|
|
77
77
|
|
|
78
78
|
`machine-mcp service status [WORKSPACE]` reports two independent layers: the platform service (`active`) and `workspace_daemon`, plus `effective_active` and `orphaned_workspace_daemon` summary flags. On macOS it is possible for launchd to report inactive while a prior Node process remains alive with parent PID 1; that is an orphan-daemon condition, not proof that the daemon stopped. `service stop` unloads the provider when present and then terminates only a verified service-style workspace daemon. `service uninstall` and full uninstall are ordered fail-closed operations: provider stop → verified daemon stop(s) → definition removal. A failed or ambiguous stop leaves definitions and state intact. If takeover reaches its deadline, run:
|
|
79
79
|
|
|
@@ -219,7 +219,7 @@ Defense-in-depth limits include:
|
|
|
219
219
|
- local simultaneous tool calls: 16;
|
|
220
220
|
- Worker pending daemon calls: 32;
|
|
221
221
|
- command timeout: 1–600 seconds;
|
|
222
|
-
- process-session read wait: at most 30 seconds;
|
|
222
|
+
- process-session read wait: at most 30 seconds, measured with monotonic elapsed time;
|
|
223
223
|
- direct directory result: 10,000 entries and 4 MiB of path metadata;
|
|
224
224
|
- recursive walk: 200,000 visited entries;
|
|
225
225
|
- managed jobs: 50 retained, seven-day retention;
|
package/docs/TESTING.md
CHANGED
|
@@ -20,10 +20,10 @@ The suite includes:
|
|
|
20
20
|
- recursive syntax validation for every JavaScript file under the shipped/runtime/test roots plus the shell wrapper;
|
|
21
21
|
- shared tool-catalog schema, annotation, and profile-inventory checks;
|
|
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
|
-
- concurrent complete-before-visible lock claims, atomic replacement under active readers, malformed-lock grace, snapshot/token-safe reclamation, absolute-age expiry, PID-reuse detection,
|
|
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
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
|
-
- 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, installed-application discovery caching, and name-based task matching;
|
|
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
27
|
- relay environment-proxy direct/bypass/agent selection, unsupported proxy rejection, fail-fast invalid configuration, and route observability without endpoint or credential disclosure;
|
|
28
28
|
- canonical path and symbolic-link escape tests;
|
|
29
29
|
- relative-path privacy and error-path redaction tests;
|
|
@@ -110,7 +110,9 @@ Run `npm run privacy:check` before committing and before packaging. Run and revi
|
|
|
110
110
|
|
|
111
111
|
## Package manifest
|
|
112
112
|
|
|
113
|
-
`npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts, credential-like file classes, and generated Worker type declarations, validates every packaged mode as `0644` or `0755`, and verifies that privacy/engineering guidance, runtime/relay/secure-file/lock/service/browser/app modules, all package-script helpers, the packaged browser extension, contribution discipline, and privacy/release-impact checkers are present. `npm run install:test` requires npm 12, installs the real tarball from a package-free directory into an isolated global prefix with the documented options, verifies the packaged npm engine requirement, rejects blocked-script warnings, confirms optional `fsevents` is absent,
|
|
113
|
+
`npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts, credential-like file classes, and generated Worker type declarations, validates every packaged mode as `0644` or `0755`, and verifies that privacy/engineering guidance, runtime/relay/secure-file/lock/service/browser/app modules, all package-script helpers, the packaged browser extension, contribution discipline, and privacy/release-impact checkers are present. `npm run install:test` requires npm 12, installs the real tarball from a package-free directory into an isolated global prefix with the documented options, verifies the packaged npm engine requirement, rejects blocked-script warnings, confirms optional `fsevents` is absent, verifies `--version`, then runs the installed CLI with zero arguments from an isolated workspace/state root. A fake Wrangler JavaScript entrypoint terminates at a controlled deployment boundary, so the probe executes default command normalization, state initialization, deployment fingerprint collection, and Wrangler dispatch without changing a live account; any undefined identifier or earlier crash fails the release. The test is part of `npm run check` and runs on Linux, macOS, and Windows CI.
|
|
114
|
+
|
|
115
|
+
`npm run lint` uses ESLint as a semantic JavaScript correctness gate rather than a style formatter. It covers the Node CLI/runtime, repository scripts, tests, and packaged browser extension and rejects undefined identifiers in function bodies that `node --check` cannot detect. A dedicated lint-gate self-test proves that both Node and browser configurations reject a synthetic undefined binding while accepting the service-worker `importScripts` global. A focused `shell:test` requires Wrangler to run through the current Node executable and its package JavaScript entrypoint rather than a `.cmd` or shell shim. Architecture tests require `shell:test`, `lint:test`, `lint`, and `install:test` to remain in the complete check pipeline and reject non-exact direct dependency ranges.
|
|
114
116
|
|
|
115
117
|
The stdio integration test also sends an oversized line, verifies bounded rejection, and confirms that the next valid request is still processed.
|
|
116
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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 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 typecheck && npm run syntax && 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 ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-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 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",
|
|
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",
|
|
@@ -102,7 +102,11 @@
|
|
|
102
102
|
"tool-docs:check": "node scripts/generate-tool-reference.mjs --check",
|
|
103
103
|
"account:test": "node tests/account-admin-test.mjs",
|
|
104
104
|
"sarif-security:test": "node tests/sarif-security-gate-test.mjs",
|
|
105
|
-
"security-properties:test": "node tests/security-properties-test.mjs"
|
|
105
|
+
"security-properties:test": "node tests/security-properties-test.mjs",
|
|
106
|
+
"lint": "eslint eslint.config.mjs bin src/local scripts tests browser-extension",
|
|
107
|
+
"lint:test": "node tests/lint-gate-test.mjs",
|
|
108
|
+
"shell:test": "node tests/shell-test.mjs",
|
|
109
|
+
"deadline:test": "node tests/monotonic-deadline-test.mjs"
|
|
106
110
|
},
|
|
107
111
|
"dependencies": {
|
|
108
112
|
"https-proxy-agent": "9.1.0",
|
|
@@ -112,6 +116,8 @@
|
|
|
112
116
|
},
|
|
113
117
|
"devDependencies": {
|
|
114
118
|
"@types/node": "26.1.1",
|
|
119
|
+
"eslint": "10.7.0",
|
|
120
|
+
"globals": "17.7.0",
|
|
115
121
|
"typescript": "7.0.2"
|
|
116
122
|
},
|
|
117
123
|
"keywords": [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { opendir, realpath } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
4
5
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
5
6
|
|
|
6
7
|
const MAX_APPLICATIONS = 1000;
|
|
@@ -13,7 +14,7 @@ const APP_NAME_PATTERN = /^[^\0\r\n]{1,300}$/;
|
|
|
13
14
|
const DEFAULT_APPLICATION_CACHE_MS = 30_000;
|
|
14
15
|
|
|
15
16
|
export class AppAutomationManager {
|
|
16
|
-
constructor({ policy, authorizeTool = null, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null, applicationCacheMs = DEFAULT_APPLICATION_CACHE_MS, now = () =>
|
|
17
|
+
constructor({ policy, authorizeTool = null, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null, applicationCacheMs = DEFAULT_APPLICATION_CACHE_MS, now = () => performance.now() }) {
|
|
17
18
|
this.policy = policy || {};
|
|
18
19
|
this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
|
|
19
20
|
this.displayPath = displayPath;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
2
3
|
import { BridgeError } from "./errors.mjs";
|
|
3
4
|
|
|
4
5
|
export class CallRegistry {
|
|
5
6
|
constructor(options = {}) {
|
|
6
7
|
this.maximum = positiveInteger(options.maximum, 16);
|
|
7
|
-
this.now = typeof options.now === "function" ? options.now :
|
|
8
|
+
this.now = typeof options.now === "function" ? options.now : () => performance.now();
|
|
8
9
|
this.scheduler = options.scheduler || { setTimeout, clearTimeout };
|
|
9
10
|
this.onCancel = typeof options.onCancel === "function" ? options.onCancel : () => {};
|
|
10
11
|
this.onFinish = typeof options.onFinish === "function" ? options.onFinish : () => {};
|
package/src/local/cli.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import path, { join, resolve } from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
6
|
import { LocalRuntime } from "./runtime.mjs";
|
|
7
7
|
import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
|
|
8
|
+
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
8
9
|
import { runStdioServer } from "./stdio.mjs";
|
|
9
10
|
import { assertCanonicalFullPolicy, POLICY_PROFILES, toolsForPolicy } from "./tools.mjs";
|
|
10
11
|
import { resolvePolicy } from "./cli-policy.mjs";
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
readDaemonLockOwner,
|
|
7
7
|
resolveWorkspace,
|
|
8
8
|
} from "./state.mjs";
|
|
9
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
9
10
|
import { inspectProcessInstance, isPidAlive, processCommandLine, splitProcessCommandLine } from "./process-identity.mjs";
|
|
10
11
|
|
|
11
12
|
const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
|
|
@@ -33,7 +34,7 @@ export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
|
33
34
|
|
|
34
35
|
// A terminated daemon can release its token immediately before the filesystem
|
|
35
36
|
// makes the lock removal visible to this process. Retry only this handoff.
|
|
36
|
-
const handoffDeadline =
|
|
37
|
+
const handoffDeadline = createMonotonicDeadline(Math.min(timeoutMs, 1_000));
|
|
37
38
|
do {
|
|
38
39
|
lock = acquireDaemonLock(state, ownerMetadata);
|
|
39
40
|
if (lock.acquired) {
|
|
@@ -41,8 +42,8 @@ export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
|
41
42
|
return lock;
|
|
42
43
|
}
|
|
43
44
|
if (lock.owner?.pid && isPidAlive(lock.owner.pid)) return lock;
|
|
44
|
-
await sleep(Math.min(pollMs, Math.max(1, handoffDeadline
|
|
45
|
-
} while (
|
|
45
|
+
await sleep(Math.min(pollMs, Math.max(1, handoffDeadline.remainingMs())));
|
|
46
|
+
} while (!handoffDeadline.expired());
|
|
46
47
|
return lock;
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -50,7 +51,7 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
50
51
|
const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
|
|
51
52
|
const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
|
|
52
53
|
const logger = options.logger || { info() {}, warn() {} };
|
|
53
|
-
const deadline =
|
|
54
|
+
const deadline = createMonotonicDeadline(timeoutMs);
|
|
54
55
|
const signalled = new Set();
|
|
55
56
|
let owner = options.owner || readDaemonLockOwner(daemonLockPathForState(state));
|
|
56
57
|
let verified = false;
|
|
@@ -96,7 +97,7 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
96
97
|
const currentOwnerAlive = Boolean(owner?.pid && isPidAlive(owner.pid));
|
|
97
98
|
if (!currentOwnerAlive && liveSignalled.length === 0) break;
|
|
98
99
|
|
|
99
|
-
if (
|
|
100
|
+
if (deadline.expired()) {
|
|
100
101
|
logger.warn?.(`detached background daemon did not stop within ${Math.ceil(timeoutMs / 1000)} seconds`);
|
|
101
102
|
const remainingPid = Number(owner?.pid) || liveSignalled[0] || null;
|
|
102
103
|
return {
|
|
@@ -111,7 +112,7 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
111
112
|
version: typeof lastOwner?.version === "string" ? lastOwner.version : "unknown",
|
|
112
113
|
};
|
|
113
114
|
}
|
|
114
|
-
await sleep(Math.min(pollMs, Math.max(1, deadline
|
|
115
|
+
await sleep(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
|
|
115
116
|
owner = readDaemonLockOwner(daemonLockPathForState(state));
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -2,6 +2,7 @@ import { chmod, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
5
6
|
import { LocalRuntime } from "./runtime.mjs";
|
|
6
7
|
import { generateSshKeyPair } from "./ssh-key.mjs";
|
|
7
8
|
import { runExecutable } from "./shell.mjs";
|
|
@@ -178,8 +179,8 @@ export async function runFullAccessTest({ workspace, policy = policyProfile("ful
|
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
async function waitForJob(manager, jobId, timeoutMs) {
|
|
181
|
-
const deadline =
|
|
182
|
-
while (
|
|
182
|
+
const deadline = createMonotonicDeadline(timeoutMs);
|
|
183
|
+
while (!deadline.expired()) {
|
|
183
184
|
const value = manager.read({ job_id: jobId });
|
|
184
185
|
if (TERMINAL_JOB_STATES.has(value.status)) return value;
|
|
185
186
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -2,9 +2,11 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
5
6
|
import { executionEnv } from "./shell.mjs";
|
|
6
7
|
import { terminateProcessTree } from "./process-sessions.mjs";
|
|
7
8
|
import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
9
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
8
10
|
import { currentProcessStartTimeMs } from "./process-identity.mjs";
|
|
9
11
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
10
12
|
|
|
@@ -66,8 +68,8 @@ try {
|
|
|
66
68
|
async function releaseRecoveryClaim() {
|
|
67
69
|
if (!/^[a-f0-9]{32}$/.test(recoveryLockToken)) throw new Error("recovery runner is missing its ownership token");
|
|
68
70
|
const file = join(jobDir, "recovery.lock");
|
|
69
|
-
const deadline =
|
|
70
|
-
while (
|
|
71
|
+
const deadline = createMonotonicDeadline(5000);
|
|
72
|
+
while (!deadline.expired()) {
|
|
71
73
|
if (removeOwnedJsonFileSync(file, { pid: process.pid, token: recoveryLockToken })) return;
|
|
72
74
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
|
73
75
|
}
|
|
@@ -224,7 +226,7 @@ async function runStep(step, index, phase, plan, resourceContext, cancellationAw
|
|
|
224
226
|
: step.stdin === null || step.stdin === undefined
|
|
225
227
|
? null
|
|
226
228
|
: Buffer.from(step.stdin, "utf8");
|
|
227
|
-
const started =
|
|
229
|
+
const started = performance.now();
|
|
228
230
|
const raw = await spawnStep(argv, {
|
|
229
231
|
cwd: step.cwd,
|
|
230
232
|
env,
|
|
@@ -242,7 +244,7 @@ async function runStep(step, index, phase, plan, resourceContext, cancellationAw
|
|
|
242
244
|
code: raw.code,
|
|
243
245
|
signal: raw.signal,
|
|
244
246
|
timed_out: raw.timedOut,
|
|
245
|
-
duration_ms:
|
|
247
|
+
duration_ms: performance.now() - started,
|
|
246
248
|
stdout: step.capture_output === "discard" ? "" : redactOutput(raw.stdout, resourceContext),
|
|
247
249
|
stderr: step.capture_output === "discard" ? "" : redactOutput(raw.stderr, resourceContext),
|
|
248
250
|
output_discarded: step.capture_output === "discard",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
|
|
3
|
+
export function createMonotonicDeadline(timeoutMs, now = () => performance.now()) {
|
|
4
|
+
const durationMs = Number(timeoutMs);
|
|
5
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) throw new TypeError("timeoutMs must be a finite non-negative number");
|
|
6
|
+
let lastSample = readSample(now);
|
|
7
|
+
const startedAt = lastSample;
|
|
8
|
+
|
|
9
|
+
function elapsedMs() {
|
|
10
|
+
const sample = readSample(now);
|
|
11
|
+
if (sample > lastSample) lastSample = sample;
|
|
12
|
+
return Math.max(0, lastSample - startedAt);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Object.freeze({
|
|
16
|
+
expired() {
|
|
17
|
+
return elapsedMs() >= durationMs;
|
|
18
|
+
},
|
|
19
|
+
remainingMs() {
|
|
20
|
+
return Math.max(0, durationMs - elapsedMs());
|
|
21
|
+
},
|
|
22
|
+
elapsedMs,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readSample(now) {
|
|
27
|
+
const value = Number(now());
|
|
28
|
+
if (!Number.isFinite(value)) throw new TypeError("monotonic clock returned a non-finite value");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { executionEnv } from "./shell.mjs";
|
|
5
5
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
6
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
6
7
|
import { clampInteger } from "./numbers.mjs";
|
|
7
8
|
|
|
8
9
|
export const MAX_COMMAND_BYTES = 64 * 1024;
|
|
@@ -120,10 +121,10 @@ export class ProcessSessionManager {
|
|
|
120
121
|
this.throwIfCancelled(context);
|
|
121
122
|
const waitForExit = args.wait_for_exit === true;
|
|
122
123
|
if (waitMs > 0 && session.closedAt === null) {
|
|
123
|
-
const deadline =
|
|
124
|
+
const deadline = createMonotonicDeadline(waitMs);
|
|
124
125
|
if (waitForExit) {
|
|
125
|
-
while (session.closedAt === null &&
|
|
126
|
-
await waitForSessionChange(session, Math.max(1, deadline
|
|
126
|
+
while (session.closedAt === null && !deadline.expired()) {
|
|
127
|
+
await waitForSessionChange(session, Math.max(1, deadline.remainingMs()), () => this.throwIfCancelled(context));
|
|
127
128
|
}
|
|
128
129
|
} else if (!sessionHasOutputAfter(session, stdoutOffset, stderrOffset)) {
|
|
129
130
|
await waitForSessionChange(session, waitMs, () => this.throwIfCancelled(context));
|
package/src/local/shell.mjs
CHANGED
|
@@ -113,13 +113,16 @@ function capturedResult(code, stdout, stderr, extraStderr = "") {
|
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
function
|
|
117
|
-
const
|
|
118
|
-
|
|
116
|
+
export function wranglerCommand(options = {}) {
|
|
117
|
+
const root = path.resolve(String(options.packageRoot || packageRoot));
|
|
118
|
+
const node = path.resolve(String(options.node || process.execPath));
|
|
119
|
+
const script = path.join(root, "node_modules", "wrangler", "bin", "wrangler.js");
|
|
120
|
+
if (!existsSync(script)) throw new Error(`Wrangler JavaScript entrypoint is missing: ${script}`);
|
|
121
|
+
return { cmd: node, argsPrefix: [script] };
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
export async function runWrangler(args, options = {}) {
|
|
122
|
-
const wrangler =
|
|
125
|
+
const wrangler = wranglerCommand();
|
|
123
126
|
const operation = String(args[0] || "");
|
|
124
127
|
const timeoutMs = options.timeoutMs ?? (operation === "login" || operation === "deploy" ? 10 * 60 * 1000 : 2 * 60 * 1000);
|
|
125
128
|
return runExecutable(wrangler.cmd, [...wrangler.argsPrefix, ...args], { cwd: packageRoot, timeoutMs, ...options });
|
package/src/local/state.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
7
7
|
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
8
8
|
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
9
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
9
10
|
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
10
11
|
import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
11
12
|
|
|
@@ -266,9 +267,9 @@ export async function acquireStartupLockWithWait(state, options = {}) {
|
|
|
266
267
|
if (lock.acquired) return lock;
|
|
267
268
|
const ownerPid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "another process";
|
|
268
269
|
logger.info?.(`waiting for ${ownerPid} to finish the current startup/state operation`);
|
|
269
|
-
const deadline =
|
|
270
|
-
while (
|
|
271
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, Math.min(pollMs, Math.max(1, deadline
|
|
270
|
+
const deadline = createMonotonicDeadline(timeoutMs);
|
|
271
|
+
while (!deadline.expired()) {
|
|
272
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, Math.min(pollMs, Math.max(1, deadline.remainingMs()))));
|
|
272
273
|
lock = acquireStartupLock(state, metadata);
|
|
273
274
|
if (lock.acquired) {
|
|
274
275
|
logger.info?.("the previous startup/state operation finished; continuing");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
1
2
|
import { errorCode, normalizeBridgeError } from "./errors.mjs";
|
|
2
3
|
|
|
3
4
|
export class ToolExecutor {
|
|
@@ -60,7 +61,7 @@ function lifecycleMiddleware(callRegistry) {
|
|
|
60
61
|
|
|
61
62
|
function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
62
63
|
return async (operation, next) => {
|
|
63
|
-
const started =
|
|
64
|
+
const started = performance.now();
|
|
64
65
|
observability.start(operation.tool);
|
|
65
66
|
logger.event?.("debug", "tool.call.started", {
|
|
66
67
|
call_id: shortCallId(operation.context.callId),
|
|
@@ -69,7 +70,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
69
70
|
});
|
|
70
71
|
try {
|
|
71
72
|
const result = await next(operation);
|
|
72
|
-
const durationMs =
|
|
73
|
+
const durationMs = performance.now() - started;
|
|
73
74
|
const slow = durationMs >= slowMs;
|
|
74
75
|
observability.finish(operation.tool, { status: "completed", durationMs, slow });
|
|
75
76
|
logger.event?.("debug", slow ? "tool.call.slow" : "tool.call.completed", {
|
|
@@ -78,7 +79,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
78
79
|
return result;
|
|
79
80
|
} catch (error) {
|
|
80
81
|
const normalized = normalizeBridgeError(error, { safeMessage: () => safeMessage(error, operation.args) });
|
|
81
|
-
const durationMs =
|
|
82
|
+
const durationMs = performance.now() - started;
|
|
82
83
|
const code = errorCode(normalized);
|
|
83
84
|
const status = code === "cancelled" ? "cancelled" : code === "timeout" ? "timeout" : "failed";
|
|
84
85
|
observability.finish(operation.tool, { status, durationMs, errorCode: code, slow: durationMs >= slowMs });
|
package/src/worker/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "./http";
|
|
23
23
|
|
|
24
24
|
const SERVER_NAME = String(serverMetadata.name);
|
|
25
|
-
const SERVER_VERSION = "1.0.
|
|
25
|
+
const SERVER_VERSION = "1.0.2";
|
|
26
26
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
27
27
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
28
28
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -3,7 +3,7 @@ const MAX_TOOLS = 128;
|
|
|
3
3
|
const SENSITIVE_FIELD = /(?:authorization|cookie|credential|password|secret|token|verifier|private[_-]?key)/i;
|
|
4
4
|
|
|
5
5
|
export class WorkerObservability {
|
|
6
|
-
private readonly startedAt =
|
|
6
|
+
private readonly startedAt = performance.now();
|
|
7
7
|
private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
|
|
8
8
|
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
9
9
|
private readonly sockets = { candidates: 0, authenticated: 0, disconnected: 0, protocol_errors: 0 };
|
|
@@ -49,7 +49,7 @@ export class WorkerObservability {
|
|
|
49
49
|
|
|
50
50
|
snapshot(): Record<string, unknown> {
|
|
51
51
|
return {
|
|
52
|
-
uptime_ms: Math.max(0,
|
|
52
|
+
uptime_ms: Math.max(0, performance.now() - this.startedAt),
|
|
53
53
|
requests: { ...this.requests },
|
|
54
54
|
calls: { ...this.calls },
|
|
55
55
|
sockets: { ...this.sockets },
|
|
@@ -53,7 +53,7 @@ export class PendingCallRegistry {
|
|
|
53
53
|
if (input.clientRequestKey && this.byRequestKey.has(input.clientRequestKey)) {
|
|
54
54
|
throw new PendingCallRegistrationError("conflict", "duplicate in-flight JSON-RPC request id within this MCP session");
|
|
55
55
|
}
|
|
56
|
-
const startedAt =
|
|
56
|
+
const startedAt = performance.now();
|
|
57
57
|
return new Promise((resolve, reject) => {
|
|
58
58
|
const timeout = setTimeout(() => {
|
|
59
59
|
const record = this.take(input.id);
|
|
@@ -111,7 +111,7 @@ export class PendingCallRegistry {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
snapshot(): { active: number; request_keys: number; maximum: number; oldest_ms: number; by_tool: Record<string, number> } {
|
|
114
|
-
const now =
|
|
114
|
+
const now = performance.now();
|
|
115
115
|
const byTool: Record<string, number> = {};
|
|
116
116
|
let oldestMs = 0;
|
|
117
117
|
for (const record of this.byId.values()) {
|