machine-bridge-mcp 1.0.1 → 1.0.3
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/CONTRIBUTING.md +1 -1
- package/SECURITY.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 +21 -1
- package/docs/ENGINEERING.md +5 -4
- package/docs/OPERATIONS.md +2 -2
- package/docs/PROJECT_STANDARDS.md +3 -3
- package/docs/TESTING.md +3 -3
- package/mbm +1 -1
- package/package.json +6 -4
- package/scripts/github-release.mjs +33 -23
- package/scripts/prepare-pinned-npm.mjs +97 -0
- package/scripts/release-ci.mjs +9 -4
- package/scripts/sarif-security-gate.mjs +12 -10
- package/src/local/app-automation.mjs +2 -1
- package/src/local/call-registry.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-execution.mjs +14 -1
- package/src/local/process-sessions.mjs +4 -3
- 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,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.3 - 2026-07-14
|
|
4
|
+
|
|
5
|
+
### Code-scanning and supply-chain integrity
|
|
6
|
+
|
|
7
|
+
- Fix the SARIF gate so results with omitted rule metadata fail closed instead of being silently classified as non-security. Remove stale broad CodeQL exceptions, harden the generic process boundary with a fixed-option non-shell `child_process.spawn` wrapper and behavior regression, and retain only one exact, expiring false-positive record for that intentional authority boundary.
|
|
8
|
+
- Gate OpenSSF Scorecard SARIF before upload. Replace mutable npm bootstrap commands with an exact npm 12.0.1 tarball plus pinned SHA-512 verification, make the source wrapper use `npm ci`, and convert randomized security properties to a deterministic `fast-check` `.js` suite recognized by the pinned Scorecard scanner, and separate the signed Scorecard analysis job from the failing SARIF gate job required by the action's workflow-verification rules.
|
|
9
|
+
- Record only four expiring Scorecard governance/time exceptions that cannot be repaired by source code alone, reject remediable pinning/fuzzing exceptions, and require exact-commit CI, CodeQL, Governance, and Scorecard success before creating a source release.
|
|
10
|
+
|
|
11
|
+
## 1.0.2 - 2026-07-14
|
|
12
|
+
|
|
13
|
+
### Deadline and architecture integrity
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- 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`.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## 1.0.1 - 2026-07-14
|
|
4
20
|
|
|
5
21
|
### Startup and release-gate reliability
|
package/CONTRIBUTING.md
CHANGED
|
@@ -26,7 +26,7 @@ Before the reviewed code is pushed to `main`:
|
|
|
26
26
|
4. inspect the complete diff and the npm package manifest;
|
|
27
27
|
5. commit and push the reviewed change to GitHub.
|
|
28
28
|
|
|
29
|
-
After all required pull-request checks pass, repository automation completes the source release under [AGENTS.md](AGENTS.md): squash-merge through local `gh`, verify the exact `main` push CI, create the annotated version tag, and create or update the matching final GitHub Release. The release operator separately publishes the same version to npm and performs any live machine update. Automation must not publish, deprecate, or unpublish npm packages; install the CLI globally; deploy the Worker; rotate credentials; mutate live deployment state; or start/stop/install/remove the daemon or autostart service without explicit user authorization.
|
|
29
|
+
After all required pull-request checks pass, repository automation completes the source release under [AGENTS.md](AGENTS.md): squash-merge through local `gh`, verify the exact `main` push CI, CodeQL, Governance, and Scorecard runs, create the annotated version tag, and create or update the matching final GitHub Release. The release operator separately publishes the same version to npm and performs any live machine update. Automation must not publish, deprecate, or unpublish npm packages; install the CLI globally; deploy the Worker; rotate credentials; mutate live deployment state; or start/stop/install/remove the daemon or autostart service without explicit user authorization.
|
|
30
30
|
|
|
31
31
|
After npm publication, the standard machine update is:
|
|
32
32
|
|
package/SECURITY.md
CHANGED
|
@@ -23,7 +23,7 @@ Tests, examples, documentation, release notes, and package metadata are publicat
|
|
|
23
23
|
|
|
24
24
|
Removing a value from the current branch does not remove it from Git history, caches, forks, or an already published npm package. Rotate any exposed credential immediately and coordinate destructive history rewriting separately when its risk is justified.
|
|
25
25
|
|
|
26
|
-
CodeQL
|
|
26
|
+
CodeQL and OpenSSF Scorecard are enforced as gates, not merely uploaded as advisory output. The shared SARIF gate rejects every security result and fails closed when a result omits the rule metadata needed to prove it non-security. Any exception requires an exact rule/path entry, a substantive rationale, and a non-expired review date; rule-wide suppression is not acceptable. CodeQL has one exact, expiring accepted result for the authorized non-shell direct-process boundary; its fixed option set and metacharacter behavior are tested. Scorecard exceptions are limited to documented governance or time-dependent conditions that repository code cannot truthfully repair, while remediable dependency-pinning and fuzzing findings remain release-blocking.
|
|
27
27
|
|
|
28
28
|
## Core trust model
|
|
29
29
|
|
|
@@ -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.3",
|
|
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.3"
|
|
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,6 +1,26 @@
|
|
|
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, including the version 1.0.2 elapsed-time review and the version 1.0.3 code-scanning inventory correction. It complements, but does not replace, the continuously enforced contracts in `SECURITY.md`, `docs/ENGINEERING.md`, and the test suite.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## 2026-07-14 version 1.0.3 code-scanning and supply-chain gate audit
|
|
7
|
+
|
|
8
|
+
After npm 1.0.2 and the live Worker/daemon upgrade succeeded, GitHub still reported nine open code-scanning alerts on the exact released `main` commit: one current CodeQL command-boundary result and eight OpenSSF Scorecard results. This disproved the earlier assumption that green CodeQL and Scorecard jobs implied a clean code-scanning inventory. The CodeQL analysis API reported one result, while the repository gate reported zero because the SARIF emitted to the post-analysis step omitted `tool.driver.rules`; the gate treated missing rule metadata as proof that a result was non-security. Scorecard had no gate at all and uploaded every low-score result while the workflow remained successful.
|
|
9
|
+
|
|
10
|
+
The process boundary now routes through an isolated `node:child_process.spawn` call that copies only four allowed options, enforces final `shell: false`, and has a production-path metacharacter regression. CodeQL 2.26.0 nevertheless classifies the environment-derived executable path as a shell-command sink even though no shell interprets either the executable or argv. After reproducing the complete flow and eliminating ambiguous option propagation, the repository retains one exact rule/path exception with a short expiry rather than broad command-execution suppressions. The SARIF gate now fails closed for every result whose rule metadata is absent. The same gate runs on Scorecard output before upload. Four remediable Scorecard findings are addressed: source bootstrap uses `npm ci`; CI obtains npm 12.0.1 from an exact registry URL, rejects redirects, verifies a pinned SHA-512 SRI, and exposes the verified CLI without a mutable global install; and the randomized security suite now uses deterministic `fast-check` properties in a `.js` file, matching the pinned Scorecard v5.3.0 detector's `*.js`/`*.jsx` scope. The Scorecard action remains in a signed analysis job containing only pinned `uses` steps; a dependent gate job downloads its SARIF, enforces the accepted inventory, and uploads the reviewed result.
|
|
11
|
+
|
|
12
|
+
Four Scorecard findings remain explicitly reviewed rather than hidden: the repository is younger than ninety days, historical commits predate mandatory CodeQL, only one human maintainer is available for review, and OpenSSF Best Practices enrollment requires external maintainer attestations. Their exact rule/path exceptions carry short expiry dates, while `PinnedDependenciesID` and `FuzzingID` are forbidden from the accepted inventory. Release orchestration now verifies successful push-triggered CI, CodeQL, Governance, and Scorecard runs for the exact `main` commit instead of checking only the CI workflow. This closes the semantic gap between “a workflow ran” and “the security/governance evidence required for release passed.”
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## 2026-07-14 version 1.0.2 elapsed-time and documentation-integrity audit
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
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.
|
|
4
24
|
|
|
5
25
|
|
|
6
26
|
## 2026-07-14 version 1.0.1 installed-startup incident
|
package/docs/ENGINEERING.md
CHANGED
|
@@ -9,7 +9,7 @@ This document records project-wide decisions that must survive individual fixes,
|
|
|
9
9
|
3. **Machine Bridge authority and host authority are separate.** `full` removes Machine Bridge's own policy, path, shell, and environment restrictions. It cannot override an MCP host, connector gateway, operating system, endpoint-security product, cloud IAM, remote authentication, or `sudo`.
|
|
10
10
|
4. **Publication surfaces contain no real environment metadata.** Source, tests, fixtures, examples, release notes, filenames, package contents, tags, and release assets use synthetic identifiers and reserved example domains.
|
|
11
11
|
5. **Secrets are never operational log data.** Tool arguments, command text, stdin, stdout, stderr, file content, OAuth bodies, credentials, and local resource values are not logged.
|
|
12
|
-
6. **A release is one version with successful cross-platform evidence.** Package metadata, Worker version, browser-extension version/name, Git tag, GitHub Release, npm version, documentation, and deployed health version must agree, and the exact `origin/main` commit must have
|
|
12
|
+
6. **A release is one version with successful cross-platform evidence.** Package metadata, Worker version, browser-extension version/name, Git tag, GitHub Release, npm version, documentation, and deployed health version must agree, and the exact `origin/main` commit must have completed successful push-triggered CI, CodeQL, Governance, and OpenSSF Scorecard runs before a tag or release is created.
|
|
13
13
|
7. **Generic local automation is structured, not arbitrary evaluation.** Browser/application features may expose broad user authority under canonical `full`, but must not accept caller-provided JavaScript, AppleScript, JXA, or extension code.
|
|
14
14
|
8. **Daily-browser integration uses the existing profile.** The supported primary browser path is the packaged authenticated extension and machine-level loopback broker, preserving current tabs/login state; a separate automation profile is not an equivalent replacement.
|
|
15
15
|
9. **Pairing and resource secrets are not conversation or log data.** Tokens and injected local-resource values must not be returned, embedded in URLs, or written to operational logs.
|
|
@@ -17,7 +17,7 @@ This document records project-wide decisions that must survive individual fixes,
|
|
|
17
17
|
11. **Service and state removal are fail-closed state machines.** Stop the platform provider and every verified daemon before removing definitions or recursive state. An unreadable lock, failed stop, active job, or ambiguous identity retains state for diagnosis.
|
|
18
18
|
12. **Read failure is not empty state.** Permission, type, symbolic-link, size, encoding, and I/O errors must propagate. Corrupt backup/reconstruction applies only after a successful read proves that JSON content is invalid.
|
|
19
19
|
13. **The public protocol contract is current-only.** Shared metadata advertises only the current MCP protocol version. Compatibility code for obsolete protocol dates, lock formats, or state schemas is not retained in the final runtime; upgrade safety comes from explicit version negotiation, fail-closed state validation, and bounded operator convergence.
|
|
20
|
-
14. **Security analysis is a failing gate.** CodeQL execution alone is not success. Generated SARIF must contain no unaccepted security
|
|
20
|
+
14. **Security analysis is a failing gate.** CodeQL or Scorecard execution alone is not success. Generated SARIF must contain no unaccepted result, and missing rule metadata fails closed rather than being interpreted as non-security. An intentional or externally constrained finding requires an exact rule/path record with a substantive rationale and an expiry date.
|
|
21
21
|
|
|
22
22
|
A proposed change that conflicts with an invariant requires an explicit owner decision and corresponding documentation update. It must not be hidden inside an unrelated refactor.
|
|
23
23
|
|
|
@@ -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.
|
|
@@ -126,7 +127,7 @@ Every defect fix includes a regression test that fails for the original reason.
|
|
|
126
127
|
The required matrix includes:
|
|
127
128
|
|
|
128
129
|
- Linux, macOS, and Windows on the pinned Node/npm baseline;
|
|
129
|
-
- current-tree and reachable-history privacy gates, release-impact enforcement, and exact-commit
|
|
130
|
+
- current-tree and reachable-history privacy gates, release-impact enforcement, and exact-commit CI/CodeQL/Governance/Scorecard release gating;
|
|
130
131
|
- package-manifest and sensitive-artifact inspection;
|
|
131
132
|
- generated type checks and recursively discovered JavaScript/shell syntax checks;
|
|
132
133
|
- static undefined-identifier/redeclaration checks over all production JavaScript, tests, scripts, and browser-extension code;
|
|
@@ -184,7 +185,7 @@ A thorough review asks:
|
|
|
184
185
|
- Are persistent files atomic, owner-only, size-bounded, and symlink-aware?
|
|
185
186
|
- Can browser/app automation be expressed without arbitrary evaluation, and are pairing/resource values absent from results and logs?
|
|
186
187
|
- Can a stale PID, stale socket, duplicate request, partial write, or ambiguous remote response violate integrity?
|
|
187
|
-
- Are package, CI, Worker, service, and release behavior tested on every supported platform, and does publication verify
|
|
188
|
+
- Are package, CI, Worker, service, and release behavior tested on every supported platform, and does publication verify every required exact-commit push workflow?
|
|
188
189
|
- Does the complete diff contain any real identifier, path, host, account, or credential-shaped value?
|
|
189
190
|
- Does the change require a new npm version and deployment?
|
|
190
191
|
|
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;
|
|
@@ -114,9 +114,9 @@ Flaky tests are defects. A retry may diagnose environmental instability but may
|
|
|
114
114
|
|
|
115
115
|
- GitHub workflow permissions default to read-only and are expanded per job only when required.
|
|
116
116
|
- Third-party Actions are pinned to immutable commit SHAs and reviewed when Dependabot updates them.
|
|
117
|
-
- npm dependencies use exact versions and a committed lockfile. Registry signatures and attestations are verified in CI.
|
|
118
|
-
- Dependency review blocks newly introduced vulnerable dependencies. CodeQL performs JavaScript/TypeScript and workflow analysis. OpenSSF Scorecard
|
|
119
|
-
- CI generates and validates a CycloneDX SBOM. Release artifacts must be reproducible from a reviewed commit and tied to successful exact-commit CI evidence.
|
|
117
|
+
- npm dependencies use exact versions and a committed lockfile. Source bootstrap uses `npm ci`; the CI npm baseline itself is downloaded from an exact URL and verified against a pinned SHA-512 SRI before use. Registry signatures and attestations are verified in CI.
|
|
118
|
+
- Dependency review blocks newly introduced vulnerable dependencies. CodeQL performs JavaScript/TypeScript and workflow analysis. OpenSSF Scorecard audits supply-chain posture, and both SARIF streams are failing gates with exact, expiring exceptions rather than advisory-only uploads.
|
|
119
|
+
- CI generates and validates a CycloneDX SBOM. Release artifacts must be reproducible from a reviewed commit and tied to successful exact-commit CI, CodeQL, Governance, and Scorecard evidence.
|
|
120
120
|
- Secret scanning and push protection are enabled. Repository examples use synthetic identities and reserved domains; reachable history is scanned before release.
|
|
121
121
|
- Long-lived publication tokens should be replaced by npm trusted publishing with GitHub OIDC. Until that external registry configuration is completed, release credentials remain an explicit operator responsibility and must never be stored in the repository.
|
|
122
122
|
- Security reports follow [SECURITY.md](../SECURITY.md), not public issue templates.
|
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;
|
|
@@ -89,7 +89,7 @@ npm run version:check
|
|
|
89
89
|
npm run release-impact:check
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
GitHub Actions executes the main suite on Linux, macOS, and Windows using the pinned Node 26/npm 12 baseline. Because Node 26 currently bundles npm 11, CI
|
|
92
|
+
GitHub Actions executes the main suite on Linux, macOS, and Windows using the pinned Node 26/npm 12 baseline. Because Node 26 currently bundles npm 11, CI downloads the exact npm 12.0.1 tarball from the canonical registry, rejects redirects, verifies its recorded SHA-512 SRI, and exposes that verified CLI through `GITHUB_PATH` before any project-local npm command can trigger strict `devEngines`. Checkout fetches version tags so the release-impact gate can compare the branch with the latest release. A separate package-audit job scans reachable Git history, audits both the complete dependency graph and the production-only graph, verifies registry signatures and attestations, validates a CycloneDX SBOM written under the runner temporary directory, exercises the documented isolated global installation, then performs a dry-run package build. Separate pinned workflows validate governance titles, review dependency changes, run CodeQL over JavaScript/TypeScript and GitHub Actions, and run OpenSSF Scorecard. The SARIF gate fails closed when rule metadata is absent, CodeQL permits only one exact, expiring non-shell process-boundary result, and Scorecard permits only exact reviewed governance/time-dependent findings; new dependency-pinning, fuzzing, or other supply-chain results fail before upload. Scorecard's signed analysis job contains only pinned `uses` steps, while a dependent gate job downloads the SARIF, enforces the accepted inventory, and uploads it to code scanning. Security property tests use a `.js` `fast-check` suite with deterministic seeds because the pinned Scorecard scanner recognizes JavaScript property testing only in `*.js`/`*.jsx`. The macOS matrix job also runs the installation smoke test because Wrangler's optional `fsevents` resolution is platform-specific. Third-party Actions are pinned to immutable 40-character commit SHAs; architecture tests reject movable tags, and Dependabot monitors both action and dependency updates. Source release creation additionally requires successful push-triggered CI, CodeQL, Governance, and Scorecard runs for the exact `main` commit.
|
|
93
93
|
|
|
94
94
|
## Test design rules
|
|
95
95
|
|
package/mbm
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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 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",
|
|
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,10 +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.
|
|
105
|
+
"security-properties:test": "node tests/security-properties-test.js",
|
|
106
106
|
"lint": "eslint eslint.config.mjs bin src/local scripts tests browser-extension",
|
|
107
107
|
"lint:test": "node tests/lint-gate-test.mjs",
|
|
108
|
-
"shell:test": "node tests/shell-test.mjs"
|
|
108
|
+
"shell:test": "node tests/shell-test.mjs",
|
|
109
|
+
"deadline:test": "node tests/monotonic-deadline-test.mjs"
|
|
109
110
|
},
|
|
110
111
|
"dependencies": {
|
|
111
112
|
"https-proxy-agent": "9.1.0",
|
|
@@ -116,6 +117,7 @@
|
|
|
116
117
|
"devDependencies": {
|
|
117
118
|
"@types/node": "26.1.1",
|
|
118
119
|
"eslint": "10.7.0",
|
|
120
|
+
"fast-check": "4.9.0",
|
|
119
121
|
"globals": "17.7.0",
|
|
120
122
|
"typescript": "7.0.2"
|
|
121
123
|
},
|
|
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { runNetworkCommand } from "./network-retry.mjs";
|
|
13
|
-
import {
|
|
13
|
+
import { requireSuccessfulWorkflowRun } from "./release-ci.mjs";
|
|
14
14
|
import { tagSyncError } from "./release-state.mjs";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
|
|
@@ -126,28 +126,38 @@ function remoteTagCommit(tag) {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
function assertSuccessfulCi(head) {
|
|
129
|
-
const
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
".github/workflows/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
129
|
+
const required = [
|
|
130
|
+
[".github/workflows/ci.yml", "CI"],
|
|
131
|
+
[".github/workflows/codeql.yml", "CodeQL"],
|
|
132
|
+
[".github/workflows/governance.yml", "Governance"],
|
|
133
|
+
[".github/workflows/scorecard.yml", "OpenSSF Scorecard"],
|
|
134
|
+
];
|
|
135
|
+
const verified = [];
|
|
136
|
+
for (const [workflow, name] of required) {
|
|
137
|
+
const text = outputNetwork("gh", [
|
|
138
|
+
"run",
|
|
139
|
+
"list",
|
|
140
|
+
"--workflow",
|
|
141
|
+
workflow,
|
|
142
|
+
"--commit",
|
|
143
|
+
head,
|
|
144
|
+
"--event",
|
|
145
|
+
"push",
|
|
146
|
+
"--limit",
|
|
147
|
+
"20",
|
|
148
|
+
"--json",
|
|
149
|
+
"databaseId,status,conclusion,headSha,event,createdAt,url",
|
|
150
|
+
]);
|
|
151
|
+
let runs;
|
|
152
|
+
try { runs = JSON.parse(text); }
|
|
153
|
+
catch { fail(`GitHub Actions did not return valid JSON for ${name}`); }
|
|
154
|
+
let run;
|
|
155
|
+
try { run = requireSuccessfulWorkflowRun(runs, head, name); }
|
|
156
|
+
catch (error) { fail(String(error?.message || error)); }
|
|
157
|
+
console.log(`GitHub Actions ${name} succeeded for ${head} (run ${run.databaseId}).`);
|
|
158
|
+
verified.push(run);
|
|
159
|
+
}
|
|
160
|
+
return verified;
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
function releaseInfo(tag) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const NPM_VERSION = "12.0.1";
|
|
8
|
+
const NPM_TARBALL_URL = "https://registry.npmjs.org/npm/-/npm-12.0.1.tgz";
|
|
9
|
+
const NPM_TARBALL_INTEGRITY = "sha512-L5T9i/YAQWQWqTS/xZxJkei/9zcu99hCeE4qi41IyBVV7mRQad3qc2JfuOktwmH+qwGI/V2rbCL+/UYxb1+RQA==";
|
|
10
|
+
const MAX_TARBALL_BYTES = 20 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
const githubPath = process.env.GITHUB_PATH;
|
|
13
|
+
if (!githubPath) throw new Error("GITHUB_PATH is required; this bootstrap is intended for GitHub Actions");
|
|
14
|
+
|
|
15
|
+
const root = mkdtempSync(join(process.env.RUNNER_TEMP || tmpdir(), "mbm-npm-bootstrap-"));
|
|
16
|
+
try {
|
|
17
|
+
const archive = join(root, `npm-${NPM_VERSION}.tgz`);
|
|
18
|
+
const extracted = join(root, "extracted");
|
|
19
|
+
const bin = join(root, "bin");
|
|
20
|
+
mkdirSync(extracted, { recursive: true });
|
|
21
|
+
mkdirSync(bin, { recursive: true });
|
|
22
|
+
|
|
23
|
+
const response = await fetch(NPM_TARBALL_URL, { redirect: "error" });
|
|
24
|
+
if (!response.ok) throw new Error(`failed to download pinned npm tarball: HTTP ${response.status}`);
|
|
25
|
+
const declaredBytes = Number(response.headers.get("content-length"));
|
|
26
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_TARBALL_BYTES) throw new Error("pinned npm tarball exceeds 20 MiB");
|
|
27
|
+
const bytes = await readBoundedBody(response, MAX_TARBALL_BYTES);
|
|
28
|
+
verifyIntegrity(bytes, NPM_TARBALL_INTEGRITY);
|
|
29
|
+
writeFileSync(archive, bytes, { mode: 0o600 });
|
|
30
|
+
|
|
31
|
+
run("tar", ["-xzf", archive, "-C", extracted]);
|
|
32
|
+
const cli = join(extracted, "package", "bin", "npm-cli.js");
|
|
33
|
+
const version = run(process.execPath, [cli, "--version"], { capture: true }).trim();
|
|
34
|
+
if (version !== NPM_VERSION) throw new Error(`pinned npm tarball reported ${version}, expected ${NPM_VERSION}`);
|
|
35
|
+
|
|
36
|
+
const posixWrapper = join(bin, "npm");
|
|
37
|
+
writeFileSync(posixWrapper, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(cli)} "$@"\n`, { mode: 0o755 });
|
|
38
|
+
chmodSync(posixWrapper, 0o755);
|
|
39
|
+
writeFileSync(join(bin, "npm.cmd"), `@echo off\r\n"${cmdQuote(process.execPath)}" "${cmdQuote(cli)}" %*\r\n`);
|
|
40
|
+
writeFileSync(githubPath, `${bin}\n`, { flag: "a" });
|
|
41
|
+
console.log(`Prepared integrity-verified npm ${NPM_VERSION} at ${bin}`);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
rmSync(root, { recursive: true, force: true });
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async function readBoundedBody(response, maximumBytes) {
|
|
49
|
+
if (!response.body) throw new Error("pinned npm tarball response has no body");
|
|
50
|
+
const reader = response.body.getReader();
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
try {
|
|
54
|
+
while (true) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
total += value.byteLength;
|
|
58
|
+
if (total > maximumBytes) throw new Error("pinned npm tarball exceeds 20 MiB");
|
|
59
|
+
chunks.push(Buffer.from(value));
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
reader.releaseLock();
|
|
63
|
+
}
|
|
64
|
+
return Buffer.concat(chunks, total);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function verifyIntegrity(bytes, integrity) {
|
|
68
|
+
const match = /^sha512-(.+)$/.exec(integrity);
|
|
69
|
+
if (!match) throw new Error("pinned npm integrity must be SHA-512 SRI");
|
|
70
|
+
const actual = createHash("sha512").update(bytes).digest();
|
|
71
|
+
const expected = Buffer.from(match[1], "base64");
|
|
72
|
+
if (actual.length !== expected.length || !actual.equals(expected)) throw new Error("pinned npm tarball failed SHA-512 verification");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(command, args, options = {}) {
|
|
76
|
+
const result = spawnSync(command, args, {
|
|
77
|
+
encoding: options.capture ? "utf8" : undefined,
|
|
78
|
+
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
79
|
+
shell: false,
|
|
80
|
+
});
|
|
81
|
+
if (result.error) throw result.error;
|
|
82
|
+
if (result.status !== 0) {
|
|
83
|
+
const detail = options.capture ? String(result.stderr || result.stdout || "").trim() : "";
|
|
84
|
+
throw new Error(`${command} exited ${result.status}${detail ? `: ${detail}` : ""}`);
|
|
85
|
+
}
|
|
86
|
+
return options.capture ? String(result.stdout || "") : "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shellQuote(value) {
|
|
90
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cmdQuote(value) {
|
|
94
|
+
const text = String(value);
|
|
95
|
+
if (/[\0\r\n"%&|<>^!]/.test(text)) throw new Error("Windows wrapper path contains an unsupported character");
|
|
96
|
+
return text;
|
|
97
|
+
}
|
package/scripts/release-ci.mjs
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
export function
|
|
1
|
+
export function requireSuccessfulWorkflowRun(runs, head, workflowName = "CI") {
|
|
2
2
|
if (!Array.isArray(runs)) throw new Error("GitHub Actions response is not an array");
|
|
3
3
|
if (!/^[0-9a-f]{40,64}$/i.test(String(head || ""))) throw new Error("release commit SHA is invalid");
|
|
4
|
+
const label = String(workflowName || "workflow");
|
|
4
5
|
const matching = runs
|
|
5
6
|
.filter((run) => run && run.headSha === head && run.event === "push")
|
|
6
7
|
.sort((left, right) => String(right.createdAt || "").localeCompare(String(left.createdAt || "")));
|
|
7
8
|
const run = matching[0];
|
|
8
9
|
if (!run) {
|
|
9
|
-
throw new Error(`no push-triggered
|
|
10
|
+
throw new Error(`no push-triggered ${label} run exists for release commit ${head}; wait for GitHub Actions to register the run and retry`);
|
|
10
11
|
}
|
|
11
12
|
if (run.status !== "completed") {
|
|
12
|
-
throw new Error(
|
|
13
|
+
throw new Error(`${label} run ${run.databaseId || "unknown"} for release commit ${head} is ${run.status || "unknown"}; wait for completion and retry`);
|
|
13
14
|
}
|
|
14
15
|
if (run.conclusion !== "success") {
|
|
15
|
-
throw new Error(
|
|
16
|
+
throw new Error(`${label} run ${run.databaseId || "unknown"} for release commit ${head} concluded ${run.conclusion || "unknown"}; fix or rerun the workflow before release`);
|
|
16
17
|
}
|
|
17
18
|
return run;
|
|
18
19
|
}
|
|
20
|
+
|
|
21
|
+
export function requireSuccessfulCiRun(runs, head) {
|
|
22
|
+
return requireSuccessfulWorkflowRun(runs, head, "CI");
|
|
23
|
+
}
|
|
@@ -12,7 +12,7 @@ if (!targets.length) throw new Error("usage: node scripts/sarif-security-gate.mj
|
|
|
12
12
|
|
|
13
13
|
const accepted = loadAcceptedFindings(allowlistPath);
|
|
14
14
|
const sarifFiles = targets.flatMap((target) => collectSarifFiles(resolve(target)));
|
|
15
|
-
if (!sarifFiles.length) throw new Error("
|
|
15
|
+
if (!sarifFiles.length) throw new Error("SARIF gate received no SARIF files");
|
|
16
16
|
|
|
17
17
|
const blocked = [];
|
|
18
18
|
const acknowledged = [];
|
|
@@ -22,28 +22,30 @@ for (const file of sarifFiles) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
for (const finding of acknowledged) {
|
|
25
|
-
process.stderr.write(`accepted
|
|
25
|
+
process.stderr.write(`accepted SARIF finding: ${finding.ruleId} at ${finding.path} (${finding.reason}; expires ${finding.expires})\n`);
|
|
26
26
|
}
|
|
27
27
|
if (blocked.length) {
|
|
28
28
|
for (const finding of blocked.slice(0, 100)) {
|
|
29
29
|
process.stderr.write(`${finding.path}:${finding.line}: ${finding.ruleId}: ${finding.message}\n`);
|
|
30
30
|
}
|
|
31
|
-
if (blocked.length > 100) process.stderr.write(`... ${blocked.length - 100} additional
|
|
32
|
-
throw new Error(`
|
|
31
|
+
if (blocked.length > 100) process.stderr.write(`... ${blocked.length - 100} additional SARIF findings omitted\n`);
|
|
32
|
+
throw new Error(`SARIF gate rejected ${blocked.length} unaccepted SARIF finding(s)`);
|
|
33
33
|
}
|
|
34
|
-
process.stderr.write(`
|
|
34
|
+
process.stderr.write(`SARIF gate ok (${sarifFiles.length} SARIF file(s); ${acknowledged.length} explicitly accepted finding(s))\n`);
|
|
35
35
|
|
|
36
36
|
function inspectRun(run, file, allowlist, blockedFindings, acceptedFindings) {
|
|
37
37
|
const rules = new Map();
|
|
38
38
|
for (const rule of run.tool?.driver?.rules || []) rules.set(rule.id, rule);
|
|
39
39
|
for (const result of run.results || []) {
|
|
40
|
-
const ruleId = String(result.ruleId || "");
|
|
41
|
-
const rule = rules.get(ruleId)
|
|
42
|
-
|
|
40
|
+
const ruleId = String(result.ruleId || "<unknown-rule>");
|
|
41
|
+
const rule = rules.get(ruleId);
|
|
42
|
+
// CodeQL's post-upload SARIF can omit rule metadata even when results exist.
|
|
43
|
+
// Missing metadata must fail closed because the result cannot be proven non-security.
|
|
44
|
+
if (rule && !isSecurityRule(rule)) continue;
|
|
43
45
|
const location = primaryLocation(result);
|
|
44
46
|
const path = normalizeSarifPath(location.path, run, file);
|
|
45
47
|
const line = Number(location.line) || 1;
|
|
46
|
-
const message = boundedMessage(result.message?.text || result.message?.markdown || rule
|
|
48
|
+
const message = boundedMessage(result.message?.text || result.message?.markdown || rule?.shortDescription?.text || "SARIF finding");
|
|
47
49
|
const exception = allowlist.get(`${ruleId}\0${path}`);
|
|
48
50
|
if (exception) acceptedFindings.push({ ruleId, path, ...exception });
|
|
49
51
|
else blockedFindings.push({ ruleId, path, line, message });
|
|
@@ -101,7 +103,7 @@ function loadAcceptedFindings(file) {
|
|
|
101
103
|
}
|
|
102
104
|
if (expires < today) throw new Error(`accepted CodeQL finding expired: ${ruleId} at ${path}`);
|
|
103
105
|
const key = `${ruleId}\0${path}`;
|
|
104
|
-
if (entries.has(key)) throw new Error(`duplicate accepted
|
|
106
|
+
if (entries.has(key)) throw new Error(`duplicate accepted SARIF finding: ${ruleId} at ${path}`);
|
|
105
107
|
entries.set(key, { reason, expires });
|
|
106
108
|
}
|
|
107
109
|
return entries;
|
|
@@ -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 : () => {};
|
|
@@ -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
|
+
}
|
|
@@ -9,8 +9,20 @@ import { clampInteger } from "./numbers.mjs";
|
|
|
9
9
|
const MAX_STDIN_BYTES = 1024 * 1024;
|
|
10
10
|
const DEFAULT_OUTPUT_BYTES = 512 * 1024;
|
|
11
11
|
|
|
12
|
+
function spawnDirectProcess(command, args, options) {
|
|
13
|
+
// Keep the production child_process API call structurally separate from the
|
|
14
|
+
// injectable test seam and enforce non-shell execution at the final boundary.
|
|
15
|
+
return spawn(command, args, {
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
env: options.env,
|
|
18
|
+
detached: options.detached,
|
|
19
|
+
windowsHide: options.windowsHide,
|
|
20
|
+
shell: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
export class ProcessExecutionService {
|
|
13
|
-
constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled, spawnProcess =
|
|
25
|
+
constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled, spawnProcess = spawnDirectProcess, terminateProcess = terminateProcessTreeWithEscalation }) {
|
|
14
26
|
this.workspace = workspace;
|
|
15
27
|
this.policy = policy;
|
|
16
28
|
this.policyGate = policyGate;
|
|
@@ -80,6 +92,7 @@ export class ProcessExecutionService {
|
|
|
80
92
|
env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
|
|
81
93
|
detached: process.platform !== "win32",
|
|
82
94
|
windowsHide: true,
|
|
95
|
+
shell: false,
|
|
83
96
|
});
|
|
84
97
|
} catch (error) {
|
|
85
98
|
rejectPromise(error);
|
|
@@ -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/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.3";
|
|
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()) {
|