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 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
- chunks.push(decoder.decode(bytes.slice(0, remaining)));
211
- returnedBytes = maxBytes;
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 in MODIFIERS)) throw new Error(`unsupported key modifier: ${raw}`);
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.0",
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.0"
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.
@@ -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.1.5 workspaces, named accounts, resource registrations, managed-job history, Worker identity, and browser pairing state are reused without conversion. The architecture refactor changes module ownership, not persisted formats or authority.
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.1.5 remains structurally possible, but the preferred recovery is to fix forward because the browser extension and deployed Worker must match the running package exactly.
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.0",
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": [45, 20],
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
- export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(
7
- Object.entries(accessContract.roles).map(([name, value]) => [name, Object.freeze({ ...value })]),
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 (!ACCOUNT_ROLES[role]) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
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(ACCOUNT_ROLES[normalized].profile, "explicit");
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 = Object.freeze({
29
- list: resourceListAction,
30
- add: resourceAddAction,
31
- "generate-ssh-key": resourceGenerateSshKeyAction,
32
- remove: resourceRemoveAction,
33
- check: resourceCheckAction,
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[action];
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
- async function browserCommand(args, { chooseWorkspace }) {
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 (action === "path") {
188
- if (args.json) console.log(JSON.stringify({ extension_path: extensionPath }, null, 2));
189
- else console.log(extensionPath);
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
- if (!["status", "setup", "pair"].includes(action)) throw new Error(`Unknown browser action: ${action}`);
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
- const pairingUrl = `http://127.0.0.1:${port}/pair`;
210
- const healthUrl = `http://127.0.0.1:${port}/healthz`;
211
- const health = await fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
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
- const result = {
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
- if (action === "status") {
231
- if (args.json) console.log(JSON.stringify(result, null, 2));
232
- else {
233
- console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
234
- console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
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
- if (!result.running) throw new Error("browser bridge is not reachable; keep machine-mcp running and retry");
244
- await openExternal(pairingUrl);
245
- if (args.json) console.log(JSON.stringify({ ...result, pairing_page_opened: true }, null, 2));
246
- else {
247
- console.log(`Extension path: ${extensionPath}`);
248
- 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.");
249
- console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
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 = Object.freeze({
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 = Object.freeze({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 });
42
- const JOB_POSITIONAL_LIMITS = Object.freeze({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 });
43
- const ACCOUNT_POSITIONAL_LIMITS = Object.freeze({ list: 1, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 });
44
- const ACTION_POSITIONAL_RULES = Object.freeze({
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[action] ?? 1, tooMany: `resource ${action} received too many positional arguments` };
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[action] ?? 1, tooMany: `account ${action} received too many positional arguments` };
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[action] ?? 1, tooMany: `job ${action} received too many positional arguments` };
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[command];
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[command]) return STATIC_POSITIONAL_RULES[command];
148
- return ACTION_POSITIONAL_RULES[command]?.(args) || null;
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;
@@ -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[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
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, inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
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, serviceEnvironmentSummary } from "./service-environment.mjs";
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
- const COMMAND_HANDLERS = Object.freeze({
53
- start: startCommand,
54
- stdio: stdioCommand,
55
- "client-config": clientConfigCommand,
56
- status: statusCommand,
57
- doctor: doctorCommand,
58
- "full-test": fullTestCommand,
59
- workspace: workspaceCommand,
60
- service: serviceCommand,
61
- autostart: serviceCommand,
62
- "rotate-secrets": rotateSecretsCommand,
63
- resource: localAdminCommands.resourceCommand,
64
- account: accountCommand,
65
- browser: localAdminCommands.browserCommand,
66
- job: localAdminCommands.jobCommand,
67
- uninstall: uninstallCommand,
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[command];
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[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
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);
@@ -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 requestedProfile = typeof policy.profile === "string" && policy.profile ? policy.profile : "custom";
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
- const canonical = POLICY_PROFILES[requestedProfile];
106
- if (canonical) Object.assign(normalized, canonical, { allowExec: canonical.execMode !== "off" });
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 requirements = POLICY_AVAILABILITY[String(availability || "")];
140
- if (!requirements) return false;
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
@@ -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 matches = [...String(text).matchAll(/https:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/g)];
98
- if (matches.length) return matches.at(-1)[0].replace(/[),.]+$/, "");
99
- const anyHttps = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)];
100
- return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
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
- const url = new URL(String(workerUrl));
107
- return url.protocol === "https:"
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 workerHealthUrl(workerUrl, expectedWorkerName = "") {
57
- const base = new URL(`${String(workerUrl).replace(/\/+$/, "")}/`);
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}/healthz`;
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) {
@@ -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 in roles ? role as AccountRole : null;
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]?.profile;
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),
@@ -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: Record<string, unknown> = {};
221
+ const out = Object.create(null) as Record<string, unknown>;
222
222
  params.forEach((value, key) => {
223
- if (out[key] === undefined) out[key] = value;
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
  });
@@ -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.0";
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 requires the one-time multi-account upgrade");
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) {