machine-bridge-mcp 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/browser-extension/browser-operations.js +9 -3
- package/browser-extension/devtools-input.js +2 -2
- package/browser-extension/manifest.json +2 -2
- package/docs/AUDIT.md +14 -0
- package/docs/ENGINEERING.md +3 -0
- package/docs/PROJECT_STANDARDS.md +1 -0
- package/docs/TESTING.md +6 -2
- package/docs/UPGRADING.md +4 -2
- package/package.json +4 -3
- package/scripts/coverage-check.mjs +3 -1
- package/src/local/account-access.mjs +7 -5
- package/src/local/cli-local-admin.mjs +74 -38
- package/src/local/cli-options.mjs +18 -18
- package/src/local/cli-policy.mjs +1 -1
- package/src/local/cli-service.mjs +132 -0
- package/src/local/cli.mjs +24 -106
- package/src/local/managed-job-plan.mjs +3 -3
- package/src/local/policy.mjs +11 -6
- package/src/local/project-package.mjs +1 -1
- package/src/local/resource-operations.mjs +1 -1
- package/src/local/runtime-reporting.mjs +1 -1
- package/src/local/runtime.mjs +1 -1
- package/src/local/worker-deployment.mjs +15 -10
- package/src/local/worker-health.mjs +7 -3
- package/src/worker/access.ts +4 -3
- package/src/worker/http.ts +2 -2
- package/src/worker/index.ts +1 -1
- package/src/worker/oauth-controller.ts +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.1 - 2026-07-16
|
|
4
|
+
|
|
5
|
+
### Fail-closed input contracts and bounded CLI adapters
|
|
6
|
+
|
|
7
|
+
- Eliminate prototype-chain lookup from externally controlled command, action, account-role, policy-profile, form-field, keyboard, and local-resource keys. Dispatch and enumerations now use `Map`, `Set`, `Object.hasOwn`, or null-prototype records; names such as `constructor` and `__proto__` can no longer become inherited handlers or unauthorized enum members. A malformed current-schema OAuth account role is repaired in place to a disabled reviewer account, its version is advanced, and every existing authorization code and token is revoked so an affected installation remains administrable without granting authority.
|
|
8
|
+
- Make Worker deployment output parsing and health verification share one canonical `workers.dev` origin validator. Wrangler output containing unrelated `/mcp`, `/healthz`, path-bearing, or wrong-name URLs is no longer accepted as deployment evidence and cannot poison the persisted URL or deployment fingerprint.
|
|
9
|
+
- Preserve the exact browser `maxBytes` contract for non-ASCII page source. The DOM serializer now backs off to a complete UTF-8 prefix instead of decoding a split code point into a replacement character whose encoded size exceeds the reported budget; regressions cover emoji and Chinese text at every partial-byte boundary.
|
|
10
|
+
- Replace ordinary browser and service CLI branch trees with named, map-driven adapters. The service adapter is independently injectable and reaches 100% function and over 90% branch coverage; architecture checks are split into module boundaries, repository hygiene, browser/security structure, and release/documentation contracts so source-shape guards remain supplemental to executable behavior tests.
|
|
11
|
+
- Keep local state schema 6, policy revision 5, browser pairing, resources, jobs, and Worker identity unchanged for an in-place upgrade from 1.2.0. Normal startup still converges the versioned Worker and the unpacked extension must be reloaded; no live deployment, credential rotation, daemon replacement, global installation, or npm publication is performed by this source change.
|
|
12
|
+
|
|
3
13
|
## 1.2.0 - 2026-07-16
|
|
4
14
|
|
|
5
15
|
### Typed evolution boundaries and project governance
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
function boundedDocumentSource(limit) {
|
|
182
182
|
const maxBytes = Math.max(1, Number(limit) || 1);
|
|
183
183
|
const encoder = new TextEncoder();
|
|
184
|
-
const decoder = new TextDecoder();
|
|
184
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
185
185
|
const chunks = [];
|
|
186
186
|
const VOID_ELEMENTS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
187
187
|
const MAX_NODES = 100000;
|
|
@@ -207,8 +207,14 @@
|
|
|
207
207
|
returnedBytes += bytes.byteLength;
|
|
208
208
|
return true;
|
|
209
209
|
}
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
let end = remaining;
|
|
211
|
+
let prefix = "";
|
|
212
|
+
while (end > 0) {
|
|
213
|
+
try { prefix = decoder.decode(bytes.slice(0, end)); break; }
|
|
214
|
+
catch { end -= 1; }
|
|
215
|
+
}
|
|
216
|
+
if (prefix) chunks.push(prefix);
|
|
217
|
+
returnedBytes += end;
|
|
212
218
|
budgetExhausted = true;
|
|
213
219
|
return false;
|
|
214
220
|
};
|
|
@@ -100,10 +100,10 @@
|
|
|
100
100
|
let modifiers = 0;
|
|
101
101
|
for (const raw of parts) {
|
|
102
102
|
const name = raw === "Ctrl" ? "Control" : raw === "Cmd" || raw === "Command" ? "Meta" : raw;
|
|
103
|
-
if (!(name
|
|
103
|
+
if (!Object.hasOwn(MODIFIERS, name)) throw new Error(`unsupported key modifier: ${raw}`);
|
|
104
104
|
modifiers |= MODIFIERS[name];
|
|
105
105
|
}
|
|
106
|
-
const known = KEY_DATA[keyName];
|
|
106
|
+
const known = Object.hasOwn(KEY_DATA, keyName) ? KEY_DATA[keyName] : null;
|
|
107
107
|
if (known) return {
|
|
108
108
|
key: known.key || keyName,
|
|
109
109
|
code: known.code,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Machine Bridge Browser",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.1",
|
|
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.2.
|
|
33
|
+
"version_name": "1.2.1"
|
|
34
34
|
}
|
package/docs/AUDIT.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Engineering and security audit
|
|
2
2
|
|
|
3
|
+
## 2026-07-16 version 1.2.1 fail-closed input-contract audit
|
|
4
|
+
|
|
5
|
+
The review began from clean `v1.2.0` with the complete repository gate, dependency audit, Worker dry run, and package checks passing. Hostile-input reproduction nevertheless found one shared JavaScript failure mechanism outside those gates: ordinary objects were being used as external string-key maps. Inherited names such as `constructor` and `__proto__` were therefore accepted as account roles, policy profiles, top-level commands, resource actions, and keyboard-table entries. The CLI variants could exit successfully without performing a command; a Worker account could persist an invalid role and later fail during authority calculation. Adjacent review found the same semantic hazard in form-field aggregation and valid local-resource names.
|
|
6
|
+
|
|
7
|
+
The correction removes prototype-chain semantics rather than blacklisting a few names. Command/action dispatch and role registries use `Map` or exact own-key membership; profile and availability contracts use explicit sets/own-key checks; URL-encoded bodies and normalized resource registries use null-prototype records. Regression tests exercise the standard prototype property names at every affected public boundary. A malformed role already persisted under the unchanged current OAuth schema is repaired in place to a disabled `reviewer`, its version advances, and all codes/tokens are pruned. This preserves an operator-recoverable account record while ensuring the upgrade cannot retain or expand authority.
|
|
8
|
+
|
|
9
|
+
The deployment review found a separate validation split: Wrangler output parsing accepted any HTTPS URL containing `/mcp` or `/healthz`, while the health verifier later required an exact root `workers.dev` origin matching the Worker name. Because successful upload evidence is intentionally saved before secondary verification, unrelated output could persist a URL/fingerprint pair that later starts would refuse to verify. Parsing and verification now share one canonical origin function; nonmatching, path-bearing, credential-bearing, ported, queried, or fragmented candidates are ignored and cannot mutate state.
|
|
10
|
+
|
|
11
|
+
The browser source limit had an encoding-level off-by-contract defect. Truncating raw UTF-8 bytes inside a code point and decoding non-fatally produced `U+FFFD`, whose re-encoded size could exceed both the returned count and caller budget. The serializer now backs off to the longest valid prefix and reports its actual encoded byte count. Emoji and Chinese fixtures cover every partial boundary and aggregate source budgeting.
|
|
12
|
+
|
|
13
|
+
Ordinary CLI branching was also brought back into the repository's own architecture standard. Browser actions were split into named handlers; service status/install/start/stop/uninstall/remove moved to an independently injectable adapter with complete action coverage and over ninety percent branch coverage. The former 580-line architecture test was separated into module boundaries, repository hygiene, browser/security structure, and release/documentation contracts. Source-string checks remain useful drift alarms, but the security and lifecycle claims above are enforced by executable behavior and fault-path tests.
|
|
14
|
+
|
|
15
|
+
Version 1.2.1 retains local state schema 6 and policy revision 5. Existing 1.2.0 state upgrades in place; the versioned Worker is converged by normal startup and the unpacked extension must be reloaded. Historical changelog and audit records remain as history, while obsolete runtime fallback semantics and stale migration wording were removed.
|
|
16
|
+
|
|
3
17
|
## 2026-07-16 version 1.2 trustworthy-evolution audit
|
|
4
18
|
|
|
5
19
|
The review began from clean released commit `58d54c21a0ac37f8e5f82a6f895738508efad0c6` (`v1.1.5`). The complete local gate, exact-commit Linux/macOS/Windows CI, CodeQL, Governance, and OpenSSF Scorecard were green; dependency audits reported zero vulnerabilities; and the GitHub Release asset, npm registry tarball, and a fresh local pack were byte-identical. That evidence ruled out an immediate release-integrity incident but did not answer whether the project could continue evolving safely: the main Worker, runtime, Agent-context, and browser-broker modules were all above ninety-seven percent of their own architecture limits, while high-authority local JavaScript had almost no static shape checking and several critical Worker modules had no branch floor.
|
package/docs/ENGINEERING.md
CHANGED
|
@@ -55,6 +55,7 @@ Rules:
|
|
|
55
55
|
- Every protocol control message emitted by one side must be explicitly accepted, rejected, or version-gated by the other side, with an end-to-end contract test covering the message name and semantics.
|
|
56
56
|
- State transitions are explicit; readiness is not inferred from a lower-level event. For example, an open WebSocket is not an authenticated relay until `hello_ack` is received.
|
|
57
57
|
- Every externally controlled input is bounded before expensive allocation, traversal, parsing, storage, or execution.
|
|
58
|
+
- Externally controlled string keys must not use prototype-chain membership or truthiness on ordinary objects. Use `Map`, `Set`, `Object.hasOwn`, or null-prototype records for command dispatch, enums, ACLs, form fields, registries, and other key-addressed contracts.
|
|
58
59
|
- Repository text must not contain invisible ASCII controls other than tab, CR, and LF; architecture tests enforce this even when JavaScript syntax remains valid.
|
|
59
60
|
- Persistent mutations use owner-only files, bounded no-follow reads, flushed atomic replacement, and integrity checks appropriate to the data.
|
|
60
61
|
- Exclusive locks use the shared complete-before-visible hard-link claim. Reclamation requires process identity plus a matching file snapshot/token; do not unlink a path merely because an earlier read looked stale.
|
|
@@ -122,6 +123,8 @@ A higher branch count is acceptable only when the function is an explicit state
|
|
|
122
123
|
|
|
123
124
|
Every defect fix includes a regression test that fails for the original reason. Prefer two layers when applicable:
|
|
124
125
|
|
|
126
|
+
Prototype-shaped strings such as `constructor`, `__proto__`, `hasOwnProperty`, `toString`, and `valueOf` are mandatory negative or ordinary-data cases for any new externally indexed object boundary. Byte-bounded text tests must include non-ASCII input and assert the encoded result size, not only JavaScript string length.
|
|
127
|
+
|
|
125
128
|
1. a deterministic test of the extracted policy or lifecycle;
|
|
126
129
|
2. an integration test proving the adapter uses it correctly.
|
|
127
130
|
|
|
@@ -64,6 +64,7 @@ The architectural dependency direction in [ENGINEERING.md](ENGINEERING.md) is no
|
|
|
64
64
|
- **High cohesion and low coupling:** one source file or function owns one coherent responsibility and reason to change; collaboration occurs through narrow explicit contracts rather than cross-layer reach-through.
|
|
65
65
|
- **KISS:** prefer the simplest explicit implementation that satisfies current requirements. Do not introduce factories, registries, inheritance, generic frameworks, or configuration layers without an observed variation that needs them.
|
|
66
66
|
- **DRY:** extract repeated business rules, validation, security boundaries, or lifecycle logic into one authoritative implementation. Do not merge merely similar code when its semantics or failure policy differ.
|
|
67
|
+
- **Exact key membership:** external string keys used for dispatch, enums, ACLs, forms, or registries use `Map`, `Set`, `Object.hasOwn`, or null-prototype records. Prototype-chain membership and truthy ordinary-object lookup are not contract validation.
|
|
67
68
|
- Design patterns are used only when they remove an observed variation or coupling. A direct function or small module is preferred over speculative abstractions.
|
|
68
69
|
|
|
69
70
|
Any deliberate boundary exception must document the dependency, reason, owner, test coverage, and removal condition.
|
package/docs/TESTING.md
CHANGED
|
@@ -54,6 +54,10 @@ The suite includes:
|
|
|
54
54
|
- owner-only directory enforcement rejecting final symlinks, failing closed on POSIX chmod errors, verifying `0700`, and retaining Windows portability; Worker temporary-secret lifecycle coverage for process-start-bound names, valid stale-owner reclamation, ambiguous-owner retention, `0600` mode, deletion failures, and simultaneous deployment/cleanup failures;
|
|
55
55
|
- SARIF security-gate behavior for unknown findings, exact accepted rule/path matches, path mismatch rejection, rationale quality, and exception expiry;
|
|
56
56
|
- deterministic property tests over hostile browser-protocol byte strings, canonical/custom policy combinations, argv bounds/NULs, and a real direct process proving shell metacharacters remain literal argv;
|
|
57
|
+
- prototype-shaped command, action, role, profile, form-field, keyboard, and resource names proving that inherited object properties are never interpreted as dispatch or authority; current-schema malformed OAuth roles are repaired to disabled reviewer accounts with credential revocation;
|
|
58
|
+
- canonical Worker deployment URL extraction proving unrelated `/mcp`, `/healthz`, path-bearing, and wrong-name URLs cannot be persisted as upload evidence;
|
|
59
|
+
- byte-exact UTF-8 DOM-source truncation across emoji and Chinese partial-code-point boundaries, including equality between the reported byte count and the encoded returned source;
|
|
60
|
+
- independently injected service CLI status/install/start/stop/uninstall/remove paths, including provider failure, no selected workspace, no deployed Worker, aliases, and default output/exit adapters;
|
|
57
61
|
- CLI parsing, policy profiles, and client configuration boundaries;
|
|
58
62
|
- live stdio MCP initialization with session instructions, capability resolution, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
|
|
59
63
|
- live local Worker OAuth registration, the unauthenticated `resource_metadata` challenge, protected-resource and authorization-server discovery, Streamable transport metadata, consent, URL-constructed `303` callbacks including the ChatGPT and hosted Claude redirect URIs with encoded state, PKCE, `offline_access`, form-encoded authorization-code and refresh-token exchanges, access/refresh rotation, stale refresh replay rejection, account-version refresh revocation, authorization-code replay rejection, pending-registration throttling that excludes already authorized DCR clients, exact built-in ChatGPT/Grok browser origins, additive custom origins, unrelated-origin preflight rejection, no CORS response sharing for unrelated or opaque origins, opaque-origin authorization-form routing, exact per-request redirect-origin CSP with narrowly scoped Microsoft regional-consent and final Copilot Studio handoff exceptions, accessible credential-error rendering, protocol negotiation, HMAC-bound MCP session issuance, two-session same-id concurrency, sessionless same-id independence, session-scoped cancellation isolation, same-session duplicate rejection, daemon-backed session bootstrap, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure. The metadata/refresh contract is the path used by Claude DCR and Copilot Studio Dynamic discovery. The same integration runs an `editor` account against a canonical `full` daemon and proves that `server_info` and remote `project_overview` report effective `edit` authority while retaining the full daemon ceiling only in explicitly scoped fields.
|
|
@@ -75,7 +79,7 @@ For deterministic release validation, perform an isolated-profile smoke test wit
|
|
|
75
79
|
|
|
76
80
|
## Critical-module coverage gate
|
|
77
81
|
|
|
78
|
-
`npm run coverage:test` runs selected in-process and lightweight entrypoint fixtures under V8 coverage and enforces per-module function and branch baselines. The measured set now includes policy, typed errors, call registration, execution middleware, lifecycle/observability, logging, Runtime/CLI orchestration, Agent configuration, capability ranking, browser protocol/operation boundaries, runtime reporting/diagnostics/capability composition, and Worker pending/policy/error modules. The full stdio and workerd OAuth/MCP integration still runs separately; a focused direct Node test covers `OAuthController` registration throttling, authorization failure/success, resource-bound access tokens, expiry pruning, invalid-state rejection, and identity-key failure.
|
|
82
|
+
`npm run coverage:test` runs selected in-process and lightweight entrypoint fixtures under V8 coverage and enforces per-module function and branch baselines. The measured set now includes policy, typed errors, call registration, execution middleware, lifecycle/observability, logging, Runtime/CLI orchestration, the independently injected service CLI adapter, Agent configuration, capability ranking, browser protocol/operation boundaries, runtime reporting/diagnostics/capability composition, and Worker pending/policy/error modules. The full stdio and workerd OAuth/MCP integration still runs separately; a focused direct Node test covers `OAuthController` registration throttling, authorization failure/success, resource-bound access tokens, expiry pruning, invalid-state rejection, and identity-key failure.
|
|
79
83
|
|
|
80
84
|
The gate deliberately reports each module rather than one aggregate percentage. New extracted pure/domain modules carry explicit branch floors, while the broad CLI entrypoint remains reported and locked independently. Worker pending calls and policy now have branch minima instead of function-only gates. A refactor may raise a threshold, but must not lower one merely to make CI green without an explicit audit explanation.
|
|
81
85
|
|
|
@@ -124,4 +128,4 @@ The stdio integration test also sends an oversized line, verifies bounded reject
|
|
|
124
128
|
|
|
125
129
|
## Architecture and documentation regression checks
|
|
126
130
|
|
|
127
|
-
`npm run architecture:test` rejects movable GitHub Action references and loss of the CI history scan, release-gate script drift, local-module dependency cycles, missing static or dynamic relative imports, package scripts that reference missing files, drift from the recursive syntax scanner, incomplete executable package directories, inconsistent installation guidance, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
|
|
131
|
+
`npm run architecture:test` runs independent module-boundary, repository-hygiene, browser/security-structure, and release/documentation-contract checks. It rejects movable GitHub Action references and loss of the CI history scan, release-gate script drift, local-module dependency cycles, missing static or dynamic relative imports, package scripts that reference missing files, drift from the recursive syntax scanner, incomplete executable package directories, inconsistent installation guidance, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
|
package/docs/UPGRADING.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
Machine Bridge does not retain parallel implementations for obsolete MCP protocol dates, policy revisions, state schemas, lock formats, or browser-extension protocols. The supported path is a direct upgrade from the immediately preceding published package while its state already uses the current schema.
|
|
6
6
|
|
|
7
|
-
Version 1.2 keeps local state schema version 6 and policy revision 5 unchanged. Existing 1.
|
|
7
|
+
Version 1.2.1 keeps local state schema version 6 and policy revision 5 unchanged. Existing 1.2.0 workspaces, named accounts, resource registrations, managed-job history, Worker identity, and browser pairing state are reused without a schema conversion. The package change affects validation and adapter ownership, not the stored authority model.
|
|
8
|
+
|
|
9
|
+
Version 1.2.0 could accept prototype-shaped account roles through malformed administration input. On the first 1.2.1 Worker access, such an account is preserved for recovery but repaired fail-closed: its role becomes `reviewer`, it is disabled, its account version advances, and its authorization codes and tokens are removed. An operator can then assign a valid role, enable the account, and rotate its password through the normal account administration flow. A local policy record with an unknown profile label is normalized to `custom` while retaining its explicit capability fields; an invalid explicit `--profile` is rejected.
|
|
8
10
|
|
|
9
11
|
A state file from an older unsupported schema is rejected rather than guessed or silently rewritten. Upgrade an old installation through the last release that understands its schema, or initialize a new workspace and re-register resources. Do not edit schema numbers by hand.
|
|
10
12
|
|
|
@@ -25,6 +27,6 @@ Machine Bridge never treats an unreadable or foreign-schema state file as empty
|
|
|
25
27
|
|
|
26
28
|
## Rollback
|
|
27
29
|
|
|
28
|
-
Rollback is supported only when the older package understands every persisted schema and protocol already written by the newer package. Version 1.2 does not advance local state or policy schemas, so rollback to 1.
|
|
30
|
+
Rollback is supported only when the older package understands every persisted schema and protocol already written by the newer package. Version 1.2.1 does not advance local state or policy schemas, so rollback to 1.2.0 remains structurally possible. A repaired malformed account remains a valid disabled reviewer record that 1.2.0 can read. The preferred recovery is still to fix forward because the browser extension and deployed Worker must match the running package exactly, and rollback would restore the defective input validation.
|
|
29
31
|
|
|
30
32
|
Never roll back by copying only selected state files or changing version fields. Restore one complete verified state backup, package version, Worker build, and browser extension as a single operational unit.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"worker:types": "node scripts/generate-worker-types.mjs",
|
|
48
48
|
"typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit && npm run typecheck:local",
|
|
49
49
|
"syntax": "node scripts/syntax-check.mjs",
|
|
50
|
-
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run worker-deployment:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run worker-oauth-controller:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-boundaries:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-environment:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run install:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test && npm run oauth-browser:test",
|
|
50
|
+
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run worker-deployment:test && npm run relay:test && npm run secure-file:test && npm run worker-secret-file:test && npm run sarif-security:test && npm run security-properties:test && npm run shell:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run worker-oauth-controller:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-boundaries:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run cli-service:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run lint:test && npm run lint && npm run typecheck && npm run syntax && npm run deadline:test && npm run process-lock:test && npm run service-lifecycle:test && npm run service-environment:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run install:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test && npm run oauth-browser:test",
|
|
51
51
|
"worker:dry-run": "wrangler deploy --dry-run",
|
|
52
52
|
"worker:integration-test": "node tests/worker-integration-test.mjs",
|
|
53
53
|
"release:check": "node scripts/github-release.mjs --check",
|
|
@@ -116,7 +116,8 @@
|
|
|
116
116
|
"worker-deployment:test": "node tests/worker-deployment-test.mjs",
|
|
117
117
|
"typecheck:local": "tsc -p tsconfig.local.json --noEmit",
|
|
118
118
|
"runtime-boundaries:test": "node tests/runtime-boundaries-test.mjs",
|
|
119
|
-
"worker-oauth-controller:test": "node tests/worker-oauth-controller-test.mjs"
|
|
119
|
+
"worker-oauth-controller:test": "node tests/worker-oauth-controller-test.mjs",
|
|
120
|
+
"cli-service:test": "node tests/cli-service-test.mjs"
|
|
120
121
|
},
|
|
121
122
|
"dependencies": {
|
|
122
123
|
"https-proxy-agent": "9.1.0",
|
|
@@ -15,6 +15,7 @@ const tests = [
|
|
|
15
15
|
"tests/logging-structure-test.mjs",
|
|
16
16
|
"tests/runtime-handler-matrix-test.mjs",
|
|
17
17
|
"tests/cli-entrypoint-test.mjs",
|
|
18
|
+
"tests/cli-service-test.mjs",
|
|
18
19
|
"tests/local-self-test.mjs",
|
|
19
20
|
"tests/runtime-self-test.mjs",
|
|
20
21
|
"tests/numbers-test.mjs",
|
|
@@ -53,7 +54,8 @@ try {
|
|
|
53
54
|
"src/local/process-tracker.mjs": [65, 35],
|
|
54
55
|
"src/local/log.mjs": [60, 40],
|
|
55
56
|
"src/local/runtime.mjs": [75, 55],
|
|
56
|
-
"src/local/cli.mjs": [
|
|
57
|
+
"src/local/cli.mjs": [48, 21.9],
|
|
58
|
+
"src/local/cli-service.mjs": [90, 65],
|
|
57
59
|
"src/local/cli-options.mjs": [65, 35],
|
|
58
60
|
"src/local/cli-policy.mjs": [70, 35],
|
|
59
61
|
"src/local/numbers.mjs": [100, 100],
|
|
@@ -3,20 +3,22 @@ import { BridgeError } from "./errors.mjs";
|
|
|
3
3
|
import { policyProfile, toolNamesForPolicy, assertToolAllowed } from "./policy.mjs";
|
|
4
4
|
|
|
5
5
|
export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
)
|
|
6
|
+
const ACCOUNT_ROLE_ENTRIES = Object.entries(accessContract.roles)
|
|
7
|
+
.map(([name, value]) => [name, Object.freeze({ ...value })]);
|
|
8
|
+
const ACCOUNT_ROLE_BY_NAME = new Map(ACCOUNT_ROLE_ENTRIES);
|
|
9
|
+
export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(ACCOUNT_ROLE_ENTRIES));
|
|
10
|
+
export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole);
|
|
9
11
|
export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole);
|
|
10
12
|
|
|
11
13
|
export function normalizeAccountRole(value) {
|
|
12
14
|
const role = String(value || "").trim().toLowerCase();
|
|
13
|
-
if (!
|
|
15
|
+
if (!ACCOUNT_ROLE_BY_NAME.has(role)) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
|
|
14
16
|
return role;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export function accountRolePolicy(role) {
|
|
18
20
|
const normalized = normalizeAccountRole(role);
|
|
19
|
-
return policyProfile(
|
|
21
|
+
return policyProfile(ACCOUNT_ROLE_BY_NAME.get(normalized).profile, "explicit");
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function accountRoleToolNames(role) {
|
|
@@ -25,17 +25,17 @@ export function createLocalAdminCommands(dependencies) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const RESOURCE_ACTION_HANDLERS =
|
|
29
|
-
list
|
|
30
|
-
add
|
|
31
|
-
"generate-ssh-key"
|
|
32
|
-
remove
|
|
33
|
-
check
|
|
34
|
-
|
|
28
|
+
const RESOURCE_ACTION_HANDLERS = new Map([
|
|
29
|
+
["list", resourceListAction],
|
|
30
|
+
["add", resourceAddAction],
|
|
31
|
+
["generate-ssh-key", resourceGenerateSshKeyAction],
|
|
32
|
+
["remove", resourceRemoveAction],
|
|
33
|
+
["check", resourceCheckAction],
|
|
34
|
+
]);
|
|
35
35
|
|
|
36
36
|
async function resourceCommand(args, { chooseWorkspace }) {
|
|
37
37
|
const action = String(args._[0] || "list").toLowerCase();
|
|
38
|
-
const handler = RESOURCE_ACTION_HANDLERS
|
|
38
|
+
const handler = RESOURCE_ACTION_HANDLERS.get(action);
|
|
39
39
|
if (!handler) throw new Error(`Unknown resource action: ${action}`);
|
|
40
40
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
41
41
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
@@ -155,7 +155,7 @@ async function resourceRemoveAction({ args, workspace, state }) {
|
|
|
155
155
|
|
|
156
156
|
function resourceCheckAction({ args, state }) {
|
|
157
157
|
const name = validateResourceName(args._[1]);
|
|
158
|
-
const resource = state.resources[name];
|
|
158
|
+
const resource = Object.hasOwn(state.resources, name) ? state.resources[name] : null;
|
|
159
159
|
if (!resource) throw new Error(`local resource is not registered: ${name}`);
|
|
160
160
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
161
161
|
const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
|
|
@@ -181,15 +181,47 @@ function publicResourceInspection(name, inspected, { includePath = false, ...ext
|
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
const BROWSER_ACTION_HANDLERS = new Map([
|
|
185
|
+
["path", browserPathAction],
|
|
186
|
+
["status", browserStatusAction],
|
|
187
|
+
["setup", browserPairAction],
|
|
188
|
+
["pair", browserPairAction],
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
async function browserCommand(args, dependencies) {
|
|
185
192
|
const action = String(args._[0] || "status").toLowerCase();
|
|
193
|
+
const handler = BROWSER_ACTION_HANDLERS.get(action);
|
|
194
|
+
if (!handler) throw new Error(`Unknown browser action: ${action}`);
|
|
195
|
+
return handler(args, dependencies);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function browserPathAction(args) {
|
|
186
199
|
const extensionPath = resolve(packageRoot, "browser-extension");
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
200
|
+
if (args.json) console.log(JSON.stringify({ extension_path: extensionPath }, null, 2));
|
|
201
|
+
else console.log(extensionPath);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function browserStatusAction(args, { chooseWorkspace }) {
|
|
205
|
+
const context = await browserCommandContext(args, chooseWorkspace);
|
|
206
|
+
renderBrowserStatus(context.result, args.json === true);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function browserPairAction(args, { chooseWorkspace }) {
|
|
210
|
+
const context = await browserCommandContext(args, chooseWorkspace);
|
|
211
|
+
if (!context.result.running) throw new Error("browser bridge is not reachable; keep machine-mcp running and retry");
|
|
212
|
+
await openExternal(context.pairingUrl);
|
|
213
|
+
if (args.json) {
|
|
214
|
+
console.log(JSON.stringify({ ...context.result, pairing_page_opened: true }, null, 2));
|
|
190
215
|
return;
|
|
191
216
|
}
|
|
192
|
-
|
|
217
|
+
console.log(`Extension path: ${context.extensionPath}`);
|
|
218
|
+
console.log("Load this directory in the Chromium profile you use every day; Machine Bridge does not install it into Playwright or a separate automation profile.");
|
|
219
|
+
console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
|
|
220
|
+
console.log(`Pairing page opened: ${context.pairingUrl}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function browserCommandContext(args, chooseWorkspace) {
|
|
224
|
+
const extensionPath = resolve(packageRoot, "browser-extension");
|
|
193
225
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
194
226
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
195
227
|
const pairingFile = join(state.paths.stateRoot, "browser-bridge.json");
|
|
@@ -197,6 +229,13 @@ async function browserCommand(args, { chooseWorkspace }) {
|
|
|
197
229
|
throw new Error("browser bridge is not initialized; start machine-mcp once, then run this command again");
|
|
198
230
|
}
|
|
199
231
|
ownerOnlyFile(pairingFile);
|
|
232
|
+
const pairing = readBrowserPairingState(pairingFile);
|
|
233
|
+
const pairingUrl = `http://127.0.0.1:${pairing.port}/pair`;
|
|
234
|
+
const health = await readBrowserHealth(`http://127.0.0.1:${pairing.port}/healthz`);
|
|
235
|
+
return { extensionPath, pairingUrl, result: browserStatusResult(health, extensionPath, pairingUrl) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readBrowserPairingState(pairingFile) {
|
|
200
239
|
let pairing;
|
|
201
240
|
try {
|
|
202
241
|
pairing = JSON.parse(readBoundedRegularFileSync(pairingFile, 64 * 1024).toString("utf8"));
|
|
@@ -206,12 +245,17 @@ async function browserCommand(args, { chooseWorkspace }) {
|
|
|
206
245
|
const port = Number(pairing.port);
|
|
207
246
|
if (!/^[A-Za-z0-9_-]{32,100}$/.test(String(pairing.token || ""))) throw new Error("browser bridge state contains an invalid token");
|
|
208
247
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("browser bridge state contains an invalid port");
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
248
|
+
return { port };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readBrowserHealth(healthUrl) {
|
|
252
|
+
return fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
|
|
212
253
|
.then(async (response) => response.ok ? await response.json() : null)
|
|
213
254
|
.catch(() => null);
|
|
214
|
-
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function browserStatusResult(health, extensionPath, pairingUrl) {
|
|
258
|
+
return {
|
|
215
259
|
running: health?.ok === true && health?.broker === "machine-bridge-browser",
|
|
216
260
|
connected: health?.broker === "machine-bridge-browser" && health?.connected === true,
|
|
217
261
|
extension_path: extensionPath,
|
|
@@ -227,28 +271,20 @@ async function browserCommand(args, { chooseWorkspace }) {
|
|
|
227
271
|
profile_identity_verifiable: health?.profile_identity_verifiable === true,
|
|
228
272
|
token_exposed: false,
|
|
229
273
|
};
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
|
|
236
|
-
if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
|
|
237
|
-
console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
|
|
238
|
-
if (result.controls_extension_profile) console.log(`Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.`);
|
|
239
|
-
console.log(`Extension path: ${extensionPath}`);
|
|
240
|
-
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderBrowserStatus(result, json) {
|
|
277
|
+
if (json) {
|
|
278
|
+
console.log(JSON.stringify(result, null, 2));
|
|
241
279
|
return;
|
|
242
280
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
console.log(`Pairing page opened: ${pairingUrl}`);
|
|
251
|
-
}
|
|
281
|
+
console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
|
|
282
|
+
console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
|
|
283
|
+
if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
|
|
284
|
+
if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
|
|
285
|
+
console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
|
|
286
|
+
if (result.controls_extension_profile) console.log("Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.");
|
|
287
|
+
console.log(`Extension path: ${result.extension_path}`);
|
|
252
288
|
}
|
|
253
289
|
|
|
254
290
|
function openExternal(target) {
|
|
@@ -11,7 +11,7 @@ const VALUE_OPTIONS = new Set([
|
|
|
11
11
|
|
|
12
12
|
const LOG_FORMATS = new Set(["text", "json"]);
|
|
13
13
|
|
|
14
|
-
const COMMAND_OPTIONS = {
|
|
14
|
+
const COMMAND_OPTIONS = new Map(Object.entries({
|
|
15
15
|
start: new Set([
|
|
16
16
|
"workspace", "stateDir", "workerName", "quiet", "json", "verbose", "logLevel", "logFormat", "rotateSecrets", "forceWorker",
|
|
17
17
|
"daemonOnly", "noAutostart",
|
|
@@ -31,17 +31,17 @@ const COMMAND_OPTIONS = {
|
|
|
31
31
|
browser: new Set(["workspace", "stateDir", "json"]),
|
|
32
32
|
job: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
33
33
|
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
34
|
-
};
|
|
34
|
+
}));
|
|
35
35
|
|
|
36
36
|
const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
|
|
37
|
-
const STATIC_POSITIONAL_RULES =
|
|
38
|
-
"client-config"
|
|
39
|
-
uninstall
|
|
40
|
-
|
|
41
|
-
const RESOURCE_POSITIONAL_LIMITS = Object.
|
|
42
|
-
const JOB_POSITIONAL_LIMITS = Object.
|
|
43
|
-
const ACCOUNT_POSITIONAL_LIMITS = Object.
|
|
44
|
-
const ACTION_POSITIONAL_RULES = Object.
|
|
37
|
+
const STATIC_POSITIONAL_RULES = new Map([
|
|
38
|
+
["client-config", Object.freeze({ max: 1, tooMany: "client-config accepts at most one positional client name" })],
|
|
39
|
+
["uninstall", Object.freeze({ max: 0, tooMany: "uninstall does not accept positional arguments" })],
|
|
40
|
+
]);
|
|
41
|
+
const RESOURCE_POSITIONAL_LIMITS = new Map(Object.entries({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 }));
|
|
42
|
+
const JOB_POSITIONAL_LIMITS = new Map(Object.entries({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 }));
|
|
43
|
+
const ACCOUNT_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 }));
|
|
44
|
+
const ACTION_POSITIONAL_RULES = new Map(Object.entries({
|
|
45
45
|
workspace(args) {
|
|
46
46
|
const action = String(args._[0] || "show");
|
|
47
47
|
return { max: action === "set" || action === "select" ? 2 : 1, tooMany: `workspace ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
@@ -50,14 +50,14 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
|
50
50
|
const action = String(args._[0] || "status");
|
|
51
51
|
return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
52
52
|
},
|
|
53
|
-
autostart(args) { return ACTION_POSITIONAL_RULES.service(args); },
|
|
53
|
+
autostart(args) { return ACTION_POSITIONAL_RULES.get("service")(args); },
|
|
54
54
|
resource(args) {
|
|
55
55
|
const action = String(args._[0] || "list");
|
|
56
|
-
return { max: RESOURCE_POSITIONAL_LIMITS
|
|
56
|
+
return { max: RESOURCE_POSITIONAL_LIMITS.get(action) ?? 1, tooMany: `resource ${action} received too many positional arguments` };
|
|
57
57
|
},
|
|
58
58
|
account(args) {
|
|
59
59
|
const action = String(args._[0] || "list");
|
|
60
|
-
return { max: ACCOUNT_POSITIONAL_LIMITS
|
|
60
|
+
return { max: ACCOUNT_POSITIONAL_LIMITS.get(action) ?? 1, tooMany: `account ${action} received too many positional arguments` };
|
|
61
61
|
},
|
|
62
62
|
browser(args) {
|
|
63
63
|
const action = String(args._[0] || "status");
|
|
@@ -65,9 +65,9 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
|
65
65
|
},
|
|
66
66
|
job(args) {
|
|
67
67
|
const action = String(args._[0] || "list");
|
|
68
|
-
return { max: JOB_POSITIONAL_LIMITS
|
|
68
|
+
return { max: JOB_POSITIONAL_LIMITS.get(action) ?? 1, tooMany: `job ${action} received too many positional arguments` };
|
|
69
69
|
},
|
|
70
|
-
});
|
|
70
|
+
}));
|
|
71
71
|
|
|
72
72
|
export function normalizeCommand(argv) {
|
|
73
73
|
if (!argv.length || argv[0].startsWith("--")) return ["start", argv];
|
|
@@ -105,7 +105,7 @@ export function parseArgs(argv) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export function validateCommandOptions(command, args) {
|
|
108
|
-
const allowed = COMMAND_OPTIONS
|
|
108
|
+
const allowed = COMMAND_OPTIONS.get(command);
|
|
109
109
|
if (!allowed) return;
|
|
110
110
|
for (const key of Object.keys(args)) {
|
|
111
111
|
if (key === "_" || key === "help" || key === "version") continue;
|
|
@@ -144,8 +144,8 @@ function positionalRule(command, args) {
|
|
|
144
144
|
if (SINGLE_WORKSPACE_POSITIONAL_COMMANDS.has(command)) {
|
|
145
145
|
return { max: 1, tooMany: `${command} accepts at most one positional workspace path`, workspaceConflictAfter: 0 };
|
|
146
146
|
}
|
|
147
|
-
if (STATIC_POSITIONAL_RULES
|
|
148
|
-
return ACTION_POSITIONAL_RULES
|
|
147
|
+
if (STATIC_POSITIONAL_RULES.has(command)) return STATIC_POSITIONAL_RULES.get(command);
|
|
148
|
+
return ACTION_POSITIONAL_RULES.get(command)?.(args) || null;
|
|
149
149
|
}
|
|
150
150
|
function parseBooleanOption(value, key) {
|
|
151
151
|
if (value === "true" || value === "1") return true;
|
package/src/local/cli-policy.mjs
CHANGED
|
@@ -37,7 +37,7 @@ function policyState(policy) {
|
|
|
37
37
|
function selectPolicyBase(args, stored, hasStored) {
|
|
38
38
|
if (args.profile !== undefined) {
|
|
39
39
|
const profile = String(args.profile).trim().toLowerCase();
|
|
40
|
-
if (!POLICY_PROFILES
|
|
40
|
+
if (!Object.hasOwn(POLICY_PROFILES, profile)) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
|
|
41
41
|
return policyProfile(profile, "explicit");
|
|
42
42
|
}
|
|
43
43
|
if (!hasStored) return policyProfile(DEFAULT_POLICY_PROFILE, "default");
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import * as defaultService from "./service.mjs";
|
|
3
|
+
import { inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
|
|
4
|
+
import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
|
|
5
|
+
import { serviceEnvironmentSummary } from "./service-environment.mjs";
|
|
6
|
+
import { loadState, resolveWorkspace, selectedWorkspace } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
const SERVICE_ACTION_HANDLERS = new Map([
|
|
9
|
+
["status", serviceStatusAction],
|
|
10
|
+
["install", serviceInstallAction],
|
|
11
|
+
["start", serviceStartAction],
|
|
12
|
+
["stop", serviceStopAction],
|
|
13
|
+
["uninstall", serviceUninstallAction],
|
|
14
|
+
["remove", serviceUninstallAction],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function createServiceCommand(dependencies) {
|
|
18
|
+
const context = {
|
|
19
|
+
chooseWorkspace: requiredFunction(dependencies.chooseWorkspace, "chooseWorkspace"),
|
|
20
|
+
stateRootFromArgs: requiredFunction(dependencies.stateRootFromArgs, "stateRootFromArgs"),
|
|
21
|
+
structuredLogger: requiredFunction(dependencies.structuredLogger, "structuredLogger"),
|
|
22
|
+
service: dependencies.service || defaultService,
|
|
23
|
+
inspectWorkspaceDaemon: dependencies.inspectWorkspaceDaemon || inspectWorkspaceDaemon,
|
|
24
|
+
stopWorkspaceServiceDaemon: dependencies.stopWorkspaceServiceDaemon || stopWorkspaceServiceDaemon,
|
|
25
|
+
stopAndRemoveAutostart: dependencies.stopAndRemoveAutostart || stopAndRemoveAutostart,
|
|
26
|
+
serviceEnvironmentSummary: dependencies.serviceEnvironmentSummary || serviceEnvironmentSummary,
|
|
27
|
+
loadState: dependencies.loadState || loadState,
|
|
28
|
+
resolveWorkspace: dependencies.resolveWorkspace || resolveWorkspace,
|
|
29
|
+
selectedWorkspace: dependencies.selectedWorkspace || selectedWorkspace,
|
|
30
|
+
entryScript: dependencies.entryScript || process.argv[1],
|
|
31
|
+
setExitCode: dependencies.setExitCode || setProcessExitCode,
|
|
32
|
+
print: dependencies.print || printLine,
|
|
33
|
+
};
|
|
34
|
+
return (args) => serviceCommand(args, context);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function serviceCommand(args, context) {
|
|
38
|
+
const action = String(args._[0] || "status").toLowerCase();
|
|
39
|
+
const handler = SERVICE_ACTION_HANDLERS.get(action);
|
|
40
|
+
if (!handler) throw new Error(`Unknown service action: ${action}`);
|
|
41
|
+
const stateRoot = context.stateRootFromArgs(args);
|
|
42
|
+
return handler({ args, stateRoot, service: context.service, context });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function serviceStatusAction({ args, stateRoot, service, context }) {
|
|
46
|
+
const status = await service.autostartStatus();
|
|
47
|
+
const state = optionalServiceState(args, stateRoot, context);
|
|
48
|
+
const workspaceDaemon = state ? context.inspectWorkspaceDaemon(state) : null;
|
|
49
|
+
printServiceResult({
|
|
50
|
+
...status,
|
|
51
|
+
workspace: state?.workspace?.path || null,
|
|
52
|
+
workspace_daemon: workspaceDaemon,
|
|
53
|
+
service_environment: context.serviceEnvironmentSummary(stateRoot),
|
|
54
|
+
effective_active: Boolean(status.active || workspaceDaemon?.alive),
|
|
55
|
+
orphaned_workspace_daemon: Boolean(status.active === false && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
|
|
56
|
+
}, context, false);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function serviceInstallAction({ args, stateRoot, service, context }) {
|
|
60
|
+
const workspaceArgs = { ...args, _: args._.slice(1) };
|
|
61
|
+
const workspace = await context.chooseWorkspace(workspaceArgs, { promptOnFirstRun: true, save: true, allowPositional: true });
|
|
62
|
+
const state = context.loadState(workspace, { stateDir: stateRoot });
|
|
63
|
+
if (!state.worker?.url) {
|
|
64
|
+
throw new Error("No deployed Worker is recorded for this workspace. Run `machine-mcp` once before `machine-mcp service install`.");
|
|
65
|
+
}
|
|
66
|
+
const result = await service.installAutostart({
|
|
67
|
+
workspace,
|
|
68
|
+
stateRoot,
|
|
69
|
+
entryScript: context.entryScript,
|
|
70
|
+
logger: context.structuredLogger(Boolean(args.quiet)),
|
|
71
|
+
});
|
|
72
|
+
printServiceResult(result, context);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function serviceStartAction({ args, service, context }) {
|
|
76
|
+
const result = await service.startAutostart({ logger: context.structuredLogger(Boolean(args.quiet)) });
|
|
77
|
+
printServiceResult(result, context);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function serviceStopAction({ args, stateRoot, service, context }) {
|
|
81
|
+
const logger = context.structuredLogger(Boolean(args.quiet));
|
|
82
|
+
const provider = await service.stopAutostart({ logger });
|
|
83
|
+
const state = optionalServiceState(args, stateRoot, context);
|
|
84
|
+
const workspaceDaemon = state
|
|
85
|
+
? await context.stopWorkspaceServiceDaemon(state, { logger, reason: "service stop" })
|
|
86
|
+
: { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
|
|
87
|
+
printServiceResult({
|
|
88
|
+
...provider,
|
|
89
|
+
ok: provider?.ok !== false && workspaceDaemon.ok,
|
|
90
|
+
workspace: state?.workspace?.path || null,
|
|
91
|
+
workspace_daemon: workspaceDaemon,
|
|
92
|
+
}, context);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function serviceUninstallAction({ args, stateRoot, service, context }) {
|
|
96
|
+
const logger = context.structuredLogger(Boolean(args.quiet));
|
|
97
|
+
const state = optionalServiceState(args, stateRoot, context);
|
|
98
|
+
const lifecycle = await context.stopAndRemoveAutostart({
|
|
99
|
+
states: state ? [state] : [],
|
|
100
|
+
stateRoot,
|
|
101
|
+
logger,
|
|
102
|
+
reason: "service uninstall",
|
|
103
|
+
stopAutostart: service.stopAutostart,
|
|
104
|
+
uninstallAutostart: service.uninstallAutostart,
|
|
105
|
+
stopWorkspaceServiceDaemon: context.stopWorkspaceServiceDaemon,
|
|
106
|
+
});
|
|
107
|
+
printServiceResult({
|
|
108
|
+
...lifecycle,
|
|
109
|
+
workspace: state?.workspace?.path || null,
|
|
110
|
+
workspace_daemon: lifecycle.workspace_daemons[0] || null,
|
|
111
|
+
autostart_removed: lifecycle.removed,
|
|
112
|
+
}, context);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function optionalServiceState(args, stateRoot, context) {
|
|
116
|
+
const requested = args.workspace || args._[1] || context.selectedWorkspace(stateRoot);
|
|
117
|
+
if (!requested || requested === true) return null;
|
|
118
|
+
return context.loadState(context.resolveWorkspace(String(requested)), { stateDir: stateRoot });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function printServiceResult(result, context, updateExitCode = true) {
|
|
122
|
+
context.print(JSON.stringify(result, null, 2));
|
|
123
|
+
if (updateExitCode && result?.ok === false) context.setExitCode(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requiredFunction(value, name) {
|
|
127
|
+
if (typeof value !== "function") throw new TypeError(`service command requires ${name}`);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function setProcessExitCode(value) { process.exitCode = value; }
|
|
132
|
+
function printLine(value) { console.log(value); }
|
package/src/local/cli.mjs
CHANGED
|
@@ -3,13 +3,14 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import { LocalRuntime } from "./runtime.mjs";
|
|
6
|
-
import { acquireDaemonLockWithTakeover,
|
|
6
|
+
import { acquireDaemonLockWithTakeover, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
|
|
7
7
|
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
8
8
|
import { runStdioServer } from "./stdio.mjs";
|
|
9
9
|
import { assertCanonicalFullPolicy, POLICY_PROFILES, toolsForPolicy } from "./tools.mjs";
|
|
10
10
|
import { resolvePolicy } from "./cli-policy.mjs";
|
|
11
11
|
import { effectiveLogFormat, effectiveLogLevel, normalizeCommand, parseArgs, validateCommandOptions, validateLoggingOptions, validatePositionals } from "./cli-options.mjs";
|
|
12
12
|
import { createLocalAdminCommands } from "./cli-local-admin.mjs";
|
|
13
|
+
import { createServiceCommand } from "./cli-service.mjs";
|
|
13
14
|
import { generateAccountPassword } from "./account-admin.mjs";
|
|
14
15
|
import { accountAdminClient, createAccountCommand } from "./cli-account-admin.mjs";
|
|
15
16
|
export { resolvePolicy } from "./cli-policy.mjs";
|
|
@@ -18,7 +19,7 @@ import { classifyOperationalError, createLogger, sanitizeLogText } from "./log.m
|
|
|
18
19
|
import { runExecutable, runWrangler } from "./shell.mjs";
|
|
19
20
|
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
20
21
|
import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
|
|
21
|
-
import { loadServiceEnvironment
|
|
22
|
+
import { loadServiceEnvironment } from "./service-environment.mjs";
|
|
22
23
|
import { ensureWorkerDeployment } from "./worker-deployment.mjs";
|
|
23
24
|
import { workerHealth } from "./worker-health.mjs";
|
|
24
25
|
export { workerHealthUserReason } from "./worker-health.mjs";
|
|
@@ -48,24 +49,25 @@ import {
|
|
|
48
49
|
|
|
49
50
|
const localAdminCommands = createLocalAdminCommands({ chooseWorkspace, confirm });
|
|
50
51
|
const accountCommand = createAccountCommand({ chooseWorkspace, confirm });
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
const serviceCommand = createServiceCommand({ chooseWorkspace, stateRootFromArgs, structuredLogger });
|
|
53
|
+
|
|
54
|
+
const COMMAND_HANDLERS = new Map([
|
|
55
|
+
["start", startCommand],
|
|
56
|
+
["stdio", stdioCommand],
|
|
57
|
+
["client-config", clientConfigCommand],
|
|
58
|
+
["status", statusCommand],
|
|
59
|
+
["doctor", doctorCommand],
|
|
60
|
+
["full-test", fullTestCommand],
|
|
61
|
+
["workspace", workspaceCommand],
|
|
62
|
+
["service", serviceCommand],
|
|
63
|
+
["autostart", serviceCommand],
|
|
64
|
+
["rotate-secrets", rotateSecretsCommand],
|
|
65
|
+
["resource", localAdminCommands.resourceCommand],
|
|
66
|
+
["account", accountCommand],
|
|
67
|
+
["browser", localAdminCommands.browserCommand],
|
|
68
|
+
["job", localAdminCommands.jobCommand],
|
|
69
|
+
["uninstall", uninstallCommand],
|
|
70
|
+
]);
|
|
69
71
|
|
|
70
72
|
export async function main(argv = process.argv.slice(2)) {
|
|
71
73
|
const [command, rest] = normalizeCommand(argv);
|
|
@@ -75,7 +77,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
75
77
|
validateCommandOptions(command, args);
|
|
76
78
|
validatePositionals(command, args);
|
|
77
79
|
validateLoggingOptions(args);
|
|
78
|
-
const handler = COMMAND_HANDLERS
|
|
80
|
+
const handler = COMMAND_HANDLERS.get(command);
|
|
79
81
|
if (handler) return handler(args);
|
|
80
82
|
console.error(`Unknown command: ${command}`);
|
|
81
83
|
usage();
|
|
@@ -352,7 +354,7 @@ async function clientConfigCommand(args) {
|
|
|
352
354
|
const workspace = await chooseWorkspace(workspaceArgs, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
353
355
|
const requested = String(args.client || args._[0] || "all").trim().toLowerCase();
|
|
354
356
|
const profile = String(args.profile || "full").trim().toLowerCase();
|
|
355
|
-
if (!POLICY_PROFILES
|
|
357
|
+
if (!Object.hasOwn(POLICY_PROFILES, profile)) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
|
|
356
358
|
if (!["all", "claude", "cursor", "codex", "generic"].includes(requested)) throw new Error("client must be all, claude, cursor, codex, or generic");
|
|
357
359
|
const command = process.execPath;
|
|
358
360
|
const argsList = [resolve(process.argv[1]), "stdio", "--workspace", workspace, "--profile", profile];
|
|
@@ -537,90 +539,6 @@ async function rotateSecretsCommand(args) {
|
|
|
537
539
|
}
|
|
538
540
|
}
|
|
539
541
|
|
|
540
|
-
async function serviceCommand(args) {
|
|
541
|
-
const action = String(args._[0] || "status");
|
|
542
|
-
const stateRoot = stateRootFromArgs(args);
|
|
543
|
-
const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
|
|
544
|
-
if (action === "status") {
|
|
545
|
-
const status = await autostartStatus();
|
|
546
|
-
const state = optionalServiceState(args, stateRoot);
|
|
547
|
-
const workspaceDaemon = state ? inspectWorkspaceDaemon(state) : null;
|
|
548
|
-
console.log(JSON.stringify({
|
|
549
|
-
...status,
|
|
550
|
-
workspace: state?.workspace?.path || null,
|
|
551
|
-
workspace_daemon: workspaceDaemon,
|
|
552
|
-
service_environment: serviceEnvironmentSummary(stateRoot),
|
|
553
|
-
effective_active: Boolean(status.active || workspaceDaemon?.alive),
|
|
554
|
-
orphaned_workspace_daemon: Boolean(status.active === false && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
|
|
555
|
-
}, null, 2));
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
if (action === "install") {
|
|
559
|
-
const workspaceArgs = { ...args, _: args._.slice(1) };
|
|
560
|
-
const workspace = await chooseWorkspace(workspaceArgs, { promptOnFirstRun: true, save: true, allowPositional: true });
|
|
561
|
-
const state = loadState(workspace, { stateDir: stateRoot });
|
|
562
|
-
if (!state.worker?.url) {
|
|
563
|
-
throw new Error("No deployed Worker is recorded for this workspace. Run `machine-mcp` once before `machine-mcp service install`.");
|
|
564
|
-
}
|
|
565
|
-
const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], logger: structuredLogger(Boolean(args.quiet)) });
|
|
566
|
-
console.log(JSON.stringify(result, null, 2));
|
|
567
|
-
if (result?.ok === false) process.exitCode = 1;
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
if (action === "start") {
|
|
571
|
-
const result = await startAutostart({ logger: structuredLogger(Boolean(args.quiet)) });
|
|
572
|
-
console.log(JSON.stringify(result, null, 2));
|
|
573
|
-
if (result?.ok === false) process.exitCode = 1;
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
if (action === "stop") {
|
|
577
|
-
const logger = structuredLogger(Boolean(args.quiet));
|
|
578
|
-
const provider = await stopAutostart({ logger });
|
|
579
|
-
const state = optionalServiceState(args, stateRoot);
|
|
580
|
-
const workspaceDaemon = state
|
|
581
|
-
? await stopWorkspaceServiceDaemon(state, { logger, reason: "service stop" })
|
|
582
|
-
: { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
|
|
583
|
-
const result = {
|
|
584
|
-
...provider,
|
|
585
|
-
ok: provider?.ok !== false && workspaceDaemon.ok,
|
|
586
|
-
workspace: state?.workspace?.path || null,
|
|
587
|
-
workspace_daemon: workspaceDaemon,
|
|
588
|
-
};
|
|
589
|
-
console.log(JSON.stringify(result, null, 2));
|
|
590
|
-
if (!result.ok) process.exitCode = 1;
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
if (action === "uninstall" || action === "remove") {
|
|
594
|
-
const logger = structuredLogger(Boolean(args.quiet));
|
|
595
|
-
const state = optionalServiceState(args, stateRoot);
|
|
596
|
-
const lifecycle = await stopAndRemoveAutostart({
|
|
597
|
-
states: state ? [state] : [],
|
|
598
|
-
stateRoot,
|
|
599
|
-
logger,
|
|
600
|
-
reason: "service uninstall",
|
|
601
|
-
stopAutostart,
|
|
602
|
-
uninstallAutostart,
|
|
603
|
-
stopWorkspaceServiceDaemon,
|
|
604
|
-
});
|
|
605
|
-
const output = {
|
|
606
|
-
...lifecycle,
|
|
607
|
-
workspace: state?.workspace?.path || null,
|
|
608
|
-
workspace_daemon: lifecycle.workspace_daemons[0] || null,
|
|
609
|
-
autostart_removed: lifecycle.removed,
|
|
610
|
-
};
|
|
611
|
-
console.log(JSON.stringify(output, null, 2));
|
|
612
|
-
if (!output.ok) process.exitCode = 1;
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
throw new Error(`Unknown service action: ${action}`);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function optionalServiceState(args, stateRoot) {
|
|
619
|
-
const requested = args.workspace || args._[1] || selectedWorkspace(stateRoot);
|
|
620
|
-
if (!requested || requested === true) return null;
|
|
621
|
-
return loadState(resolveWorkspace(String(requested)), { stateDir: stateRoot });
|
|
622
|
-
}
|
|
623
|
-
|
|
624
542
|
async function installAutostartBestEffort({ workspace, stateRoot, entryScript, logger }) {
|
|
625
543
|
try {
|
|
626
544
|
const { installAutostart } = await import("./service.mjs");
|
|
@@ -156,10 +156,10 @@ function referencedResources(steps, registry) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
if (names.size > MAX_RESOURCES) throw new Error(`job references more than ${MAX_RESOURCES} local resources`);
|
|
159
|
-
const out =
|
|
159
|
+
const out = Object.create(null);
|
|
160
160
|
let totalBytes = 0;
|
|
161
161
|
for (const name of names) {
|
|
162
|
-
const resource = registry[name];
|
|
162
|
+
const resource = Object.hasOwn(registry, name) ? registry[name] : null;
|
|
163
163
|
if (!resource) throw new Error(`unknown local resource: ${name}`);
|
|
164
164
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
|
|
165
165
|
totalBytes += inspected.size;
|
|
@@ -174,7 +174,7 @@ function referencedResources(steps, registry) {
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
export function normalizeResourceRegistry(resources) {
|
|
177
|
-
const out =
|
|
177
|
+
const out = Object.create(null);
|
|
178
178
|
if (!resources || typeof resources !== "object" || Array.isArray(resources)) return out;
|
|
179
179
|
for (const [rawName, rawValue] of Object.entries(resources).slice(0, MAX_RESOURCES)) {
|
|
180
180
|
const name = validateResourceName(rawName);
|
package/src/local/policy.mjs
CHANGED
|
@@ -53,6 +53,7 @@ export const DEFAULT_POLICY_REVISION = Number(contract.revision);
|
|
|
53
53
|
export const POLICY_PROFILES = Object.freeze(Object.fromEntries(
|
|
54
54
|
Object.entries(contract.profiles).map(([name, value]) => [name, Object.freeze(/** @type {PolicyCapabilities} */ ({ ...value }))]),
|
|
55
55
|
));
|
|
56
|
+
const POLICY_PROFILE_NAMES = Object.freeze(new Set(Object.keys(POLICY_PROFILES)));
|
|
56
57
|
export const POLICY_ORIGINS = Object.freeze(new Set(contract.origins.map(String)));
|
|
57
58
|
/** @type {Readonly<Record<string, Readonly<AvailabilityRequirements>>>} */
|
|
58
59
|
export const POLICY_AVAILABILITY = Object.freeze(Object.fromEntries(
|
|
@@ -69,8 +70,8 @@ const TOOL_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
|
|
|
69
70
|
/** @param {unknown} name @param {unknown} [origin] */
|
|
70
71
|
export function policyProfile(name, origin = "explicit") {
|
|
71
72
|
const profile = String(name || "").trim().toLowerCase();
|
|
73
|
+
if (!POLICY_PROFILE_NAMES.has(profile)) throw new BridgeError("invalid_request", `unknown policy profile: ${profile}`);
|
|
72
74
|
const canonical = POLICY_PROFILES[profile];
|
|
73
|
-
if (!canonical) throw new BridgeError("invalid_request", `unknown policy profile: ${profile}`);
|
|
74
75
|
return normalizePolicy({ ...canonical, origin, revision: DEFAULT_POLICY_REVISION });
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -89,7 +90,8 @@ export function normalizePolicy(policy = {}) {
|
|
|
89
90
|
const revision = Number.isInteger(requestedRevision) && requestedRevision > 0
|
|
90
91
|
? requestedRevision
|
|
91
92
|
: DEFAULT_POLICY_REVISION;
|
|
92
|
-
const
|
|
93
|
+
const rawProfile = typeof policy.profile === "string" && policy.profile ? policy.profile : "custom";
|
|
94
|
+
const requestedProfile = POLICY_PROFILE_NAMES.has(rawProfile) ? rawProfile : "custom";
|
|
93
95
|
/** @type {NormalizedPolicy} */
|
|
94
96
|
const normalized = {
|
|
95
97
|
profile: requestedProfile,
|
|
@@ -102,8 +104,10 @@ export function normalizePolicy(policy = {}) {
|
|
|
102
104
|
minimalEnv: policy.minimalEnv !== false,
|
|
103
105
|
exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
|
|
104
106
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
if (POLICY_PROFILE_NAMES.has(requestedProfile)) {
|
|
108
|
+
const canonical = POLICY_PROFILES[requestedProfile];
|
|
109
|
+
Object.assign(normalized, canonical, { allowExec: canonical.execMode !== "off" });
|
|
110
|
+
}
|
|
107
111
|
return Object.freeze(normalized);
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -136,8 +140,9 @@ export function assertCanonicalFullPolicy(policy = {}) {
|
|
|
136
140
|
/** @param {PolicyInput} policy @param {unknown} availability */
|
|
137
141
|
export function policyAllowsAvailability(policy, availability) {
|
|
138
142
|
const normalized = normalizePolicy(policy);
|
|
139
|
-
const
|
|
140
|
-
if (!
|
|
143
|
+
const availabilityName = String(availability || "");
|
|
144
|
+
if (!Object.hasOwn(POLICY_AVAILABILITY, availabilityName)) return false;
|
|
145
|
+
const requirements = POLICY_AVAILABILITY[availabilityName];
|
|
141
146
|
if (requirements.profile && normalized.profile !== requirements.profile) return false;
|
|
142
147
|
if (requirements.allowWrite === true && normalized.allowWrite !== true) return false;
|
|
143
148
|
if (Array.isArray(requirements.execModes) && !requirements.execModes.includes(normalized.execMode)) return false;
|
|
@@ -138,7 +138,7 @@ export function safeVersionValue(value) {
|
|
|
138
138
|
function packageScriptSearchTerms(script) {
|
|
139
139
|
const normalized = String(script).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
140
140
|
const root = normalized.split("-")[0];
|
|
141
|
-
return PACKAGE_SCRIPT_INTENTS[root]
|
|
141
|
+
return Object.hasOwn(PACKAGE_SCRIPT_INTENTS, root) ? PACKAGE_SCRIPT_INTENTS[root] : "";
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function invalidPackageMetadata(packagePath, lockfiles, packageState) {
|
|
@@ -14,7 +14,7 @@ export async function generateRegisteredSshKey({ workspace, stateDir, name: rawN
|
|
|
14
14
|
let key = null;
|
|
15
15
|
try {
|
|
16
16
|
state.resources ||= {};
|
|
17
|
-
const existing = state.resources[name];
|
|
17
|
+
const existing = Object.hasOwn(state.resources, name) ? state.resources[name] : null;
|
|
18
18
|
if (existing?.path && !samePathIdentity(existing.path, target)) {
|
|
19
19
|
throw new Error(`local resource ${name} is already registered to a different file; remove it first`);
|
|
20
20
|
}
|
|
@@ -103,8 +103,8 @@ export async function buildProjectOverview({
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function policyMatchesNamedProfile(policy) {
|
|
106
|
+
if (!Object.hasOwn(POLICY_PROFILES, policy.profile)) return false;
|
|
106
107
|
const named = POLICY_PROFILES[policy.profile];
|
|
107
|
-
if (!named) return false;
|
|
108
108
|
return policy.allowWrite === named.allowWrite
|
|
109
109
|
&& policy.execMode === named.execMode
|
|
110
110
|
&& policy.unrestrictedPaths === named.unrestrictedPaths
|
package/src/local/runtime.mjs
CHANGED
|
@@ -513,7 +513,7 @@ export class LocalRuntime {
|
|
|
513
513
|
|
|
514
514
|
readLocalResourceBinary(name) {
|
|
515
515
|
const registry = this.managedJobManager.currentResources();
|
|
516
|
-
const resource = registry[name];
|
|
516
|
+
const resource = Object.hasOwn(registry, name) ? registry[name] : null;
|
|
517
517
|
if (!resource) throw new Error(`unknown local resource: ${name}`);
|
|
518
518
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
519
519
|
if (inspected.size > 1024 * 1024) throw new Error("local resource exceeds 1 MiB browser injection limit");
|
|
@@ -5,6 +5,7 @@ import { runWrangler } from "./shell.mjs";
|
|
|
5
5
|
import { packageRoot, saveState } from "./state.mjs";
|
|
6
6
|
import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
|
|
7
7
|
import {
|
|
8
|
+
normalizeWorkerOrigin,
|
|
8
9
|
retryWorkerHealth,
|
|
9
10
|
workerHealthRequiresRedeploy,
|
|
10
11
|
workerHealthUserReason,
|
|
@@ -52,7 +53,7 @@ export async function ensureWorkerDeployment(state, args = {}, options = {}) {
|
|
|
52
53
|
"--secrets-file", secretFile,
|
|
53
54
|
], { capture: true }));
|
|
54
55
|
|
|
55
|
-
const detectedUrl = extractWorkerUrl(deploy.stdout) || extractWorkerUrl(deploy.stderr);
|
|
56
|
+
const detectedUrl = extractWorkerUrl(deploy.stdout, state.worker.name) || extractWorkerUrl(deploy.stderr, state.worker.name);
|
|
56
57
|
const recordedUrl = workerUrlMatchesName(state.worker.url, state.worker.name) ? state.worker.url : "";
|
|
57
58
|
const workerUrl = detectedUrl || recordedUrl;
|
|
58
59
|
if (!workerUrl) {
|
|
@@ -93,20 +94,24 @@ export function workerDeploymentFingerprint(state, options = {}) {
|
|
|
93
94
|
return fingerprint.digest("hex");
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
export function extractWorkerUrl(text = "") {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
97
|
+
export function extractWorkerUrl(text = "", workerName = "") {
|
|
98
|
+
const candidates = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)]
|
|
99
|
+
.map((match) => match[0].replace(/[),.;:!?]+$/, ""));
|
|
100
|
+
for (const candidate of candidates.reverse()) {
|
|
101
|
+
try {
|
|
102
|
+
return normalizeWorkerOrigin(candidate, workerName);
|
|
103
|
+
} catch {
|
|
104
|
+
// Wrangler output may contain unrelated links; only a canonical matching workers.dev origin is deployment evidence.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return "";
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
export function workerUrlMatchesName(workerUrl, workerName) {
|
|
104
111
|
if (!workerUrl || !workerName) return false;
|
|
105
112
|
try {
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
&& url.hostname.endsWith(".workers.dev")
|
|
109
|
-
&& url.hostname.startsWith(`${String(workerName)}.`);
|
|
113
|
+
normalizeWorkerOrigin(workerUrl, workerName);
|
|
114
|
+
return true;
|
|
110
115
|
} catch {
|
|
111
116
|
return false;
|
|
112
117
|
}
|
|
@@ -53,8 +53,8 @@ export async function retryWorkerHealth(workerUrl, expectedVersion, attempts, op
|
|
|
53
53
|
return last;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function
|
|
57
|
-
const base = new URL(
|
|
56
|
+
export function normalizeWorkerOrigin(workerUrl, expectedWorkerName = "") {
|
|
57
|
+
const base = new URL(String(workerUrl));
|
|
58
58
|
const hostname = base.hostname.toLowerCase();
|
|
59
59
|
const expectedName = String(expectedWorkerName || "").toLowerCase();
|
|
60
60
|
if (base.protocol !== "https:" || base.port || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
|
|
@@ -64,7 +64,11 @@ export function workerHealthUrl(workerUrl, expectedWorkerName = "") {
|
|
|
64
64
|
if (expectedName && (!WORKER_NAME.test(expectedName) || hostname.split(".")[0] !== expectedName)) {
|
|
65
65
|
throw new Error("Worker URL hostname does not match the recorded Worker name");
|
|
66
66
|
}
|
|
67
|
-
return `https://${hostname}
|
|
67
|
+
return `https://${hostname}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function workerHealthUrl(workerUrl, expectedWorkerName = "") {
|
|
71
|
+
return `${normalizeWorkerOrigin(workerUrl, expectedWorkerName)}/healthz`;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
export function isRetryableWorkerHealthError(value) {
|
package/src/worker/access.ts
CHANGED
|
@@ -11,16 +11,17 @@ const toolAvailability = new Map((toolCatalog as Array<{ name: string; availabil
|
|
|
11
11
|
|
|
12
12
|
export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
|
|
13
13
|
export const ACCOUNT_ROLES = Object.freeze(Object.keys(roles));
|
|
14
|
+
export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole) as AccountRole;
|
|
14
15
|
export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole) as AccountRole;
|
|
15
16
|
|
|
16
17
|
export function normalizeAccountRole(value: unknown): AccountRole | null {
|
|
17
18
|
const role = String(value ?? "").trim().toLowerCase();
|
|
18
|
-
return role
|
|
19
|
+
return Object.hasOwn(roles, role) ? role as AccountRole : null;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function accountRolePolicy(role: AccountRole): DaemonPolicy {
|
|
22
|
-
const profileName = roles[role]
|
|
23
|
-
const profile = profiles[profileName];
|
|
23
|
+
const profileName = Object.hasOwn(roles, role) ? roles[role].profile : undefined;
|
|
24
|
+
const profile = profileName && Object.hasOwn(profiles, profileName) ? profiles[profileName] : undefined;
|
|
24
25
|
if (!profile) throw new Error(`account role references an unknown policy profile: ${role}`);
|
|
25
26
|
return {
|
|
26
27
|
profile: String(profile.profile),
|
package/src/worker/http.ts
CHANGED
|
@@ -218,9 +218,9 @@ export function searchParamsEntries(params: URLSearchParams): Array<[string, str
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
export function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
221
|
-
const out
|
|
221
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
222
222
|
params.forEach((value, key) => {
|
|
223
|
-
if (out
|
|
223
|
+
if (!Object.hasOwn(out, key)) out[key] = value;
|
|
224
224
|
else if (Array.isArray(out[key])) (out[key] as string[]).push(value);
|
|
225
225
|
else out[key] = [out[key] as string, value];
|
|
226
226
|
});
|
package/src/worker/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./http.ts";
|
|
18
18
|
|
|
19
19
|
const SERVER_NAME = String(serverMetadata.name);
|
|
20
|
-
const SERVER_VERSION = "1.2.
|
|
20
|
+
const SERVER_VERSION = "1.2.1";
|
|
21
21
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
22
22
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
23
23
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AccountRole } from "./access.ts";
|
|
1
|
+
import { DEFAULT_ACCOUNT_ROLE, normalizeAccountRole, type AccountRole } from "./access.ts";
|
|
2
2
|
import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin.ts";
|
|
3
3
|
import { exchangeOAuthToken } from "./oauth-tokens.ts";
|
|
4
4
|
import {
|
|
@@ -50,12 +50,21 @@ export class OAuthController {
|
|
|
50
50
|
private async oauthStore(): Promise<OAuthStore> {
|
|
51
51
|
const raw = await this.ctx.storage.get<unknown>("oauth");
|
|
52
52
|
if (raw !== undefined && !isCurrentOAuthStore(raw)) {
|
|
53
|
-
throw new HttpError(503, "oauth_state_schema_mismatch", "OAuth state
|
|
53
|
+
throw new HttpError(503, "oauth_state_schema_mismatch", "OAuth state does not match the current schema");
|
|
54
54
|
}
|
|
55
55
|
const store = isCurrentOAuthStore(raw) ? raw : emptyOAuthStore();
|
|
56
56
|
let changed = false;
|
|
57
57
|
const now = Math.floor(Date.now() / 1000);
|
|
58
58
|
|
|
59
|
+
for (const account of Object.values(store.accounts)) {
|
|
60
|
+
if (normalizeAccountRole(account.role)) continue;
|
|
61
|
+
account.role = DEFAULT_ACCOUNT_ROLE;
|
|
62
|
+
account.active = false;
|
|
63
|
+
account.version = Number.isInteger(account.version) && account.version > 0 ? account.version + 1 : 1;
|
|
64
|
+
account.updated_at = now;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
for (const [code, value] of Object.entries(store.codes)) {
|
|
60
69
|
const account = store.accounts[value.account_id];
|
|
61
70
|
if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
|