@wickedevolutions/abilities-mcp 1.5.3 → 1.6.0
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 +79 -0
- package/README.md +8 -0
- package/lib/auth/keychain-secret-store.js +174 -31
- package/lib/auth/oauth-client.js +11 -1
- package/lib/cli/commands/add-site.js +65 -2
- package/lib/cli/commands/reauth.js +24 -2
- package/lib/cli/commands/upgrade-auth.js +15 -9
- package/lib/cli/index.js +16 -1
- package/lib/cli/multisite-probe.js +498 -0
- package/lib/cli/scope-mutation.js +177 -0
- package/lib/config.js +16 -6
- package/lib/connection-pool.js +20 -4
- package/lib/transports/oauth-http-transport.js +29 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,85 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Abilities MCP are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.6.0] - 2026-05-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **macOS keychain backend defaults to security-CLI for cross-runtime ACL identity (Issue [#61](https://github.com/Wicked-Evolutions/abilities-mcp/issues/61), Alpha Release Gate Phase B.2).** v1.5.5's [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58) shipped `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` as an opt-in alignment, but the default `auto` backend still picked keytar in terminal / Claude Code contexts and security-CLI only in Claude Desktop's `.mcpb` runtime. Multi-client operator setups (Claude Desktop + Claude Code + Codex on the same workstation — the standard alpha install reality) saw a fresh macOS Keychain ACL prompt on every cross-runtime read, because each runtime's keychain syscall caller binary was a different ACL identity (system `node`, Codex's bundled `node`, `/usr/bin/security`). On darwin under the default `auto` backend, `KeychainSecretStore` now engages `/usr/bin/security` directly without attempting to load keytar. Every bridge spawn — Claude Desktop, Claude Code, Codex, terminal CLI — issues `SecKeychainItem*` calls through the same caller binary, so macOS's per-binary ACL trusted-application list contains exactly one entry. After the operator's first "Always Allow" the entry is silently readable from every runtime. The fix preserves least-privilege ACL semantics: no `-A` all-apps flag, no `-T` consumer-path additions, no broadening of the trusted-application set; the trusted set is *narrower* than today (one binary instead of N). Linux / win32 behavior unchanged (keytar via libsecret / Credential Manager — no analogous identity-split bug on those platforms).
|
|
10
|
+
- **Security CLI existence probe.** `_load()` now probes `/usr/bin/security` on first use and surfaces a typed `SecretStoreError` (`code: 'security_cli_unavailable'`) if absent, so corporate-locked or non-standard macOS hosts get a clear early diagnostic instead of an opaque execFile spawn failure on the first ability call.
|
|
11
|
+
- **`findAll()` darwin behavior:** `findAll()` is unavailable on the Darwin security-cli backend; current bridge flows do not rely on it. Linux / Windows (keytar) continue to enumerate normally.
|
|
12
|
+
- **Operator opt-out:** operators who need keytar on darwin (uncommon — debugging, custom build) can opt back in with `ABILITIES_MCP_KEYCHAIN_BACKEND=keytar`. Behavior matches v1.5.5: keytar is attempted, throws `SecretStoreError` (`code: 'keytar_unavailable'`) if it can't load.
|
|
13
|
+
|
|
14
|
+
### Known limitations
|
|
15
|
+
|
|
16
|
+
- **Existing macOS keychain entries written under v1.5.5 keytar may prompt once on first read under the new default (Issue [#61](https://github.com/Wicked-Evolutions/abilities-mcp/issues/61)).** v1.5.5 wrote OAuth and App Password entries via keytar in terminal / Claude Code contexts, so the entry's macOS Keychain ACL trusted-application list contains the system `node` binary, not `/usr/bin/security`. After upgrading, the first read by `/usr/bin/security` against an existing entry triggers a one-time ACL prompt — operators have **two recovery paths** (same shape as the v1.5.5 [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58) opt-in note): (1) click **Always Allow** at the prompt — the ACL is updated to add `/usr/bin/security` and no further prompts appear from any runtime, OR (2) re-run `abilities-mcp add-site --force <site>` / `abilities-mcp reauth <site>` to write a fresh entry under the new default backend. New entries written from this release forward never carry the legacy ACL state. The remaining one-time prompt is UX, not security: existing keychain ACLs continue to protect secrets correctly throughout.
|
|
17
|
+
|
|
18
|
+
- **Multisite OAuth dot-notation routing is discovery-only in alpha (carried forward from v1.5.5; tracked on [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60)).** **Multisite discovery is available in alpha.** For OAuth subsite execution, add each subsite as a separate site entry using `add-site --site-id=<subsite-id>`. Use the explicit subsite IDs in your MCP client. Dot-notation subsite routing from a single network-root OAuth token is a known alpha limitation; a first-class fix is post-alpha design work tracked on [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60).
|
|
19
|
+
|
|
20
|
+
- **Default OAuth grants are intentionally baseline-scoped.** `add-site` / `upgrade-auth` request a baseline scope set (`abilities:read`, `abilities:write`, `abilities:multisite:read`, `abilities:multisite:write`) that expands to common read/write categories. Operators who need delete-tier scopes, sensitive WordPress core scopes (`users`, `settings`, `filesystem`, `plugins`, `cron`, `themes`, `rewrite`), suite scopes (`astra`, `spectra`, `surecart`, `surecart-ecommerce`, `presto-player`), or Fluent suite scopes must request them explicitly with `add-site --scope="<list>"` at registration time or `reauth <site> --add-scope="<list>"` after OAuth. Scope expansion through `reauth --add-scope` is supported; an effective-permissions diagnostic feed is post-alpha.
|
|
21
|
+
|
|
22
|
+
- **Full OAuth grant does not imply full execution. Abilities for AI module permissions remain an independent per-blog gate.** A granted OAuth scope is necessary but not sufficient to execute an ability. Abilities for AI's module permission settings (per-blog on multisite, configured at *Abilities for AI → Permissions* in wp-admin) gate execution independently — if a module's read/write/delete tier is disabled there, the OAuth-scope-bearing token will receive `[ability_disabled]` 403 errors with explicit operator remediation guidance, regardless of the granted scope set. The two gates apply together by design (Principle 5 — Permissions Stay Layered) to prevent AI sessions from escalating their own permissions via the OAuth surface.
|
|
23
|
+
|
|
24
|
+
- **Pre-1.6.0 add-site runs may have written stale `main` aliases into multisite blocks of `wp-sites.json` (Issue [#70](https://github.com/Wicked-Evolutions/abilities-mcp/issues/70)).** v1.6.0 includes the fix that no longer generates `main` slugs, but existing `wp-sites.json` files written by earlier versions retain those keys and would silently route `<site>.main` to the source subsite instead of the network root. Operators upgrading from earlier versions: either re-run `add-site` against any affected subsite to regenerate its multisite block fresh under v1.6.0+ logic, or manually edit `~/.abilities-mcp/wp-sites.json` to remove the `"main": ...` entry from each affected `multisite` block. The `<site>.<network-root-domain-label>` slug (e.g., `wicked-community.wickedevolutions`) continues to route to the network root and remains the supported alias.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## [1.5.5] - 2026-05-05
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **OAuth subsite routing actually targets the subsite host (Issue [#48](https://github.com/Wicked-Evolutions/abilities-mcp/issues/48), Public Alpha Hardening Phase A.1).** v1.5.4 wrote the `multisite` block on add-site but the OAuth dispatch path ignored the resolved subsite endpoint — every `<network-id>.<subsite-slug>` ability call was POSTed to the network root, so WordPress booted blog 1 regardless of which subsite was named. Three connected fixes:
|
|
32
|
+
- `lib/config.js:resolveSiteKey` now derives `resolvedEndpoint` for OAuth subsites (was HTTP/App-Password only). OAuth sites carry their endpoint on `mcp_resource`, not `http.endpoint`, so the substitution branch never fired for the OAuth case.
|
|
33
|
+
- `lib/connection-pool.js:_createTransport` passes `{ resolvedEndpoint, subsiteUrl: finalSubsiteUrl }` through to the OAuth branch, mirroring the HTTP branch's pattern. `_createOAuthHttpTransport` uses `resolvedEndpoint || siteConfig.mcp_resource` and forwards `subsiteUrl` to the transport.
|
|
34
|
+
- `lib/connection-pool.js:_findExistingHttpTransport` dedupes OAuth subsites by `resolvedEndpoint || mcp_resource` (was always `mcp_resource`). Without this, the cached network-root transport was returned for every subsite key, making the endpoint fix functionally inert behind the cache lookup.
|
|
35
|
+
- `lib/transports/oauth-http-transport.js` accepts `subsiteUrl` and forwards it on every POST as `X-Abilities-MCP-Subsite-URL`. Subdomain-style multisite (the alpha-locked scope) routes by host in the endpoint URL and does not need the header; it's forward-looking infrastructure for path-style multisite (Phase B) so the adapter can `switch_to_blog()` per request without re-parsing the request URL.
|
|
36
|
+
- **Multisite probe pages through `multisite-list-sites` (Issue [#49](https://github.com/Wicked-Evolutions/abilities-mcp/issues/49), Public Alpha Hardening Phase A.2).** v1.5.4's `add-site` probe issued a single `multisite-list-sites` call with `per_page=100`, so networks with more than 100 sites silently got a truncated `multisite` block while `add-site` reported success. `lib/cli/multisite-probe.js` now loops `page=1..N` accumulating items across pages, terminating on the first of: empty page, partial page (length < `PROBE_PER_PAGE`), body-level `total` / `total_pages` reached, or the 50-page cap (`PROBE_PAGE_CAP × PROBE_PER_PAGE = 5,000` sites). When the cap is reached with a still-full final page, the probe throws a typed `Error` with `code === 'probe_cap_exceeded'` and `data: { count, cap }` so an operator hitting it gets a clean diagnostic rather than a silently-truncated block. The cap is a hard constant — no env shim — because >5,000-site networks are an exceptional case that should engage maintainers. Body-level `total` / `total_pages` (when the adapter exposes them) take precedence over the partial-page fallback to avoid one redundant page request when the last page is exactly full.
|
|
37
|
+
- **Multisite auto-populate via `add-site` is now functional end-to-end on multisite-network OAuth setups (Issue [#54](https://github.com/Wicked-Evolutions/abilities-mcp/issues/54)).** Two bugs in `BearerJsonRpcClient` (the one-shot probe client distinct from the long-running MCP runtime transports) blocked auto-populate since v1.5.4: (a) the client did not echo the adapter's `Mcp-Session-Token` HMAC on requests after `initialize`, so the adapter's `HttpSessionValidator::validate_session()` correctly rejected the probe call as session-fixation defense; and (b) the probe sent the tool name `multisite/list-sites` (slash-case) but the adapter registers it as `multisite-list-sites` (kebab-case) — the session-token rejection masked the name mismatch until (a) was fixed. Both fixes required to deliver the v1.5.4 multisite-UX promise; the v1.5.4 auto-populate flow has shipped non-functional in production until this release. `BearerJsonRpcClient` now mirrors the long-running runtime transports' established session-token capture/echo pattern (see `lib/transports/http-transport.js:42-46, 442-447, 470-477`); the kebab-case tool name is verified against `tools/list` from the live adapter.
|
|
38
|
+
- **`upgrade-auth` no longer corrupts config when the legacy keychain copy fails (Issue [#56](https://github.com/Wicked-Evolutions/abilities-mcp/issues/56)).** The App Password → OAuth migration's dual-write step swallowed keychain read/write errors but still rewrote the fallback `password_ref` to `<siteId>/apppassword-legacy`. If the keychain read was denied, hung, or failed (common during macOS cross-process ACL prompts), config could end up pointing at a legacy fallback secret that was never created. `lib/cli/commands/upgrade-auth.js` now only switches the fallback ref to `apppassword-legacy` after the legacy keychain copy actually succeeds; otherwise it preserves the original `password_ref` as the fallback and surfaces a stderr advisory naming what happened. Operators see a clean diagnostic rather than silent config corruption.
|
|
39
|
+
- **macOS `security` CLI fallback has a 30s timeout (Issue [#57](https://github.com/Wicked-Evolutions/abilities-mcp/issues/57)).** The darwin-only fallback (introduced in v1.5.3 to bypass the hardened-runtime keytar barrier inside Claude Desktop's `.mcpb` host) shelled out to `security find-generic-password -w` with no timeout. Stuck macOS ACL prompts (common when multiple bridge consumer processes — Claude Desktop, Claude Code, terminal CLI — race for the same keychain entry) caused `upgrade-auth` and other CLI flows to hang indefinitely while additional bridge processes piled up more prompts. `lib/auth/keychain-secret-store.js` now wraps every `security` shell-out (`find`, `add`, `delete`) with a 30-second default timeout (configurable via constructor option). On timeout, surfaces a typed `SecretStoreError` with `code: 'security_cli_timeout'` and a message naming the situation, kills the child process, and lets the caller decide how to recover. CLI flows fail clean instead of hanging silently.
|
|
40
|
+
- **macOS operators can opt into security-CLI keychain backend alignment (Issue [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58)).** Terminal `add-site` / `reauth` normally uses keytar when it loads successfully, while Claude Desktop's `.mcpb` runtime falls back to `/usr/bin/security` after macOS hardened-runtime rejects the native keytar module. That split can make Terminal write OAuth token entries through one macOS Keychain app identity and Claude Desktop read them through another, producing repeated cross-process ACL prompts. `KeychainSecretStore` now accepts `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` (or constructor `backend: 'security-cli'`) on darwin so terminal setup can write tokens through the same backend Claude Desktop reads through. The setting is opt-in, macOS-only, and does **not** use the insecure `security -A` all-apps trust flag. Defaults remain unchanged (`auto` prefers keytar and falls back to security-CLI only when keytar fails on darwin).
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **`reauth` scope-mutation flag triad replaces the bare `--scope` footgun (Issue [#50](https://github.com/Wicked-Evolutions/abilities-mcp/issues/50), Public Alpha Hardening Phase A.3).** The previous `reauth --scope=<list>` behavior *replaced* the persisted scope array — an operator (or AI assistant) adding new scopes via `--scope='<new only>'` would silently drop every existing scope. The locked design adds two new flags and keeps `--scope` as the explicit-replace escape hatch:
|
|
45
|
+
- `reauth <site> --add-scope=<scopes>` — merge into the existing scope set, deduped (preserves order: existing first, additions appended). The recommended way to add scopes.
|
|
46
|
+
- `reauth <site> --remove-scope=<scopes>` — drop scopes by exact match. Removals that aren't in the existing set are reported on stderr as no-op warnings, not errors.
|
|
47
|
+
- `reauth <site> --scope=<scopes>` — replace the entire scope set. Emits a stderr warning when the supplied set is a strict subset of the existing scopes (i.e., the replace would drop scopes), naming the count and recommending `--add-scope` instead.
|
|
48
|
+
- All three flags are **mutually exclusive** — passing more than one is a typed `EXIT_USAGE` error before the OAuth flow runs at all. Mirrors `git remote add` / `git remote remove` conventions; each flag has one job.
|
|
49
|
+
- Scope lists accept comma- or whitespace-separated tokens.
|
|
50
|
+
- Bare `reauth <site>` (no flag) continues to use the persisted scope set unchanged — no behavior change for the common refresh case.
|
|
51
|
+
- New pure module `lib/cli/scope-mutation.js` houses the parse / merge / remove / subset-detect logic so the reauth command stays a thin wrapper. CLI help output (`abilities-mcp --help`) documents all three flags + the mutual-exclusion rule.
|
|
52
|
+
|
|
53
|
+
### Known limitations
|
|
54
|
+
|
|
55
|
+
- **Existing macOS keychain entries may still carry old ACL state (Issue [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58)).** The new `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` escape hatch aligns future Terminal OAuth writes with Claude Desktop's `.mcpb` reader backend, but it does not rewrite ACLs on entries that already exist in the operator's Keychain. If macOS is already showing a prompt, operators may still need to click **Always Allow**, kill a stale `SecurityAgent`, or rerun `add-site --force` / `reauth` with the env var so fresh token entries are written through the aligned backend. A fuller long-term design (explicit `-T` consumer-path list at write time, or canonical helper identity) remains a post-alpha improvement. The remaining cross-process gap is UX, not security: existing keychain ACLs continue to protect secrets correctly.
|
|
56
|
+
- **Multisite OAuth dot-notation routing is discovery-only in alpha (Issue [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60)).** `add-site` against a Multisite Network root probes `multisite-list-sites` and writes the `multisite` slug→URL block to `wp-sites.json` (the v1.5.4 promise, end-to-end functional after the v1.5.5 fixes for [#48](https://github.com/Wicked-Evolutions/abilities-mcp/issues/48), [#49](https://github.com/Wicked-Evolutions/abilities-mcp/issues/49), [#54](https://github.com/Wicked-Evolutions/abilities-mcp/issues/54)). However, dot-notation execution against a subsite (e.g., `<network-id>.<subsite-slug>.<ability>`) routed by a single network-root OAuth token is rejected by the adapter at token validation: the adapter's `mcp_resource` check binds tokens to a specific blog URL, so a token minted against the network root cannot satisfy a subsite resource match even when the bridge has correctly switched the request endpoint and forwarded `X-Abilities-MCP-Subsite-URL`. Subsite execution **does** work today by adding each subsite as a separate site entry: `abilities-mcp add-site <subsite-id> https://<subsite-host>` (or, for path-style, `--site-id=<subsite-id>` against the subsite URL) provisions an OAuth token bound to that subsite's resource, and AI clients can then call abilities by the explicit subsite site_id. **Operator guidance:** Multisite discovery is available in alpha. For OAuth subsite execution, add each subsite that you want to operate on as a separate site entry using `--site-id`. Use the explicit subsite IDs in your MCP client. Dot-notation subsite routing from a single network-root OAuth token is a known alpha limitation. A first-class fix (network-root token + adapter-side per-blog `switch_to_blog()` validation, or first-class per-subsite token discovery) is tracked as a post-alpha design item on [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60).
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## [1.5.4] - 2026-05-04
|
|
60
|
+
|
|
61
|
+
This release lands the bridge-side foundations for the multisite UX promised in [#43](https://github.com/Wicked-Evolutions/abilities-mcp/issues/43). `add-site` now requests multisite OAuth scopes during DCR (so super-admin operators consent through the standard consent flow), runs a one-shot `multisite/list-sites` probe after OAuth completes, and on success writes a slug→subsite-URL `multisite` block to `wp-sites.json` so the bridge's existing dot-notation routing serves multi-site OAuth in any AI client without operator JSON editing. End-to-end dot-notation routing validated on darwin-arm64 against a 4-subsite multisite by manually populating the block from the verified `multisite/list-sites` response.
|
|
62
|
+
|
|
63
|
+
Bridge-only release — no companion adapter or ai release this release.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **`add-site` auto-populate: probes `multisite/list-sites` after OAuth on the freshly-authenticated bridge connection** (PR [#44](https://github.com/Wicked-Evolutions/abilities-mcp/pull/44), closes [#43](https://github.com/Wicked-Evolutions/abilities-mcp/issues/43)). Builds the slug→subsite-URL block from the response and attaches it to the new site entry before persisting `wp-sites.json`. Schema verified against the existing dot-notation routing implementation (`lib/config.js:resolveSiteKey` + `lib/connection-pool.js:_findExistingHttpTransport`) — slug→URL string map, no schema migration. Single-site / non-multisite / permission-denied / network-error all degrade gracefully, with stderr advisory naming the failure where appropriate (silent for the expected single-site case). New `lib/cli/multisite-probe.js` houses a one-shot bearer JSON-RPC client (minimal MCP handshake + `tools/call`, distinct from the runtime `OAuthHttpTransport` so the probe runs once with the freshly-minted in-memory access token without the queue/batch machinery) plus pure schema-mapping helpers (`buildMultisiteBlock`, `deriveSubsiteSlug`) covering subdomain mode, path-based mode, slug-collision disambiguation by `blog_id`, and `www.` parent stripping.
|
|
68
|
+
- **`add-site` DCR scope request now includes `abilities:multisite:read` and `abilities:multisite:write`** (PR [#46](https://github.com/Wicked-Evolutions/abilities-mcp/pull/46), closes [#45](https://github.com/Wicked-Evolutions/abilities-mcp/issues/45)). The adapter's `ScopeRegistry` classifies multisite scopes as `SENSITIVE_SCOPES` and intentionally excludes them from the `abilities:read` / `abilities:write` umbrella expansion, so explicit DCR requests are the only way the consent screen surfaces them for super-admin operators on a Multisite Network root. Single source of truth in `DEFAULT_SCOPE` (`lib/auth/oauth-client.js`), picked up by `add-site` / `reauth` / `upgrade-auth` automatically. Single-site WP installs unaffected — the adapter declines to grant scopes the OAuth user lacks WP capability for, so single-site operator UX is preserved.
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- **Permission-denied advisory wording in `add-site` rewritten to surface BOTH possible failure causes** so operators don't chase the wrong layer: (1) the OAuth user lacks the `manage_network_options` WP capability, or (2) the OAuth token lacks the `abilities:multisite:read` scope (granted on the consent screen). Both gates can produce the same observable rejection from `multisite/list-sites`; the advisory now names both explicitly with re-run guidance.
|
|
73
|
+
|
|
74
|
+
### Internal
|
|
75
|
+
|
|
76
|
+
- **Test count:** `275 → 293` (+18 across PR #44 +14 and PR #46 +4). Node CI matrix unchanged: 18, 20, 22.
|
|
77
|
+
- **Bundle size unchanged** (~413 kB packed / ~1.2 MB unpacked — no binary or dependency changes this release).
|
|
78
|
+
- **Run-contract extension:** `lib/cli/index.js` now forwards `errLines` from successful subcommand returns so non-fatal stderr advisories can surface without changing exit semantics. Backwards-compatible — subcommands that don't set `errLines` get the previous empty-array behavior. Internal CLI surface only; the bin entrypoint already writes `errLines` to stderr (`abilities-mcp.js:62`).
|
|
79
|
+
|
|
80
|
+
### Known issue (linked to adapter follow-up)
|
|
81
|
+
|
|
82
|
+
- **The auto-populate's happy path does not currently fire end-to-end** due to an adapter-side bearer-auth quirk that rejects `multisite/list-sites` from the bridge's fresh-token one-shot probe — even when the OAuth user is a super admin and the token carries the required scopes. The same operation against the same tokens succeeds when invoked from an established MCP runtime session in any AI client. Tracked at [abilities-mcp-adapter#87](https://github.com/Wicked-Evolutions/abilities-mcp-adapter/issues/87) — adapter-side fix; bridge code is correct in isolation. Operators following the documented v1.5.4 flow today will hit the bridge's documented graceful-degrade path: site entry written without the `multisite` block, advisory printed, manual block edit OR an immediate `multisite/list-sites` call from any already-connected MCP client (which writes nothing — operator copies the response into `wp-sites.json`) lets dot-notation routing work end-to-end. End-to-end dot-notation routing validated on darwin-arm64 against `wickedevolutions.com` multisite (4 subsites: `main`, `community`, `knowledge`, `test1`) by manually populating the block — 106 published posts returned correctly from `wickedevolutions.community` through the bridge's existing routing.
|
|
83
|
+
|
|
5
84
|
## [1.5.3] - 2026-05-04
|
|
6
85
|
|
|
7
86
|
**macOS hotfix: OAuth in Claude Desktop's `.mcpb` runtime now works.** Hotfix to v1.5.2's `.mcpb` operator UX on macOS. v1.5.2 shipped with keytar prebuilds bundled, but Claude Desktop's hardened-runtime host process on macOS rejects native modules with mismatched code-signing Team IDs — a system-level macOS protection that applies to every hardened app, not a Claude Desktop quirk. This blocked OAuth inside Claude Desktop's `.mcpb` runtime even though the bundle itself is structurally correct (loads cleanly via system Node from the extracted `.mcpb`). The hotfix adds a darwin-only shell-out to the macOS `security` CLI when keytar fails to load. Validated end-to-end on darwin-arm64 by an operator running the documented progression (install `.mcpb` → `upgrade-auth` → `add-site` → multi-site OAuth in the same Claude Desktop entry, read and write confirmed live on two production WordPress sites via OAuth bearer) before release.
|
package/README.md
CHANGED
|
@@ -84,6 +84,14 @@ Single-click install for Claude Desktop on macOS and Windows. The Application Pa
|
|
|
84
84
|
|
|
85
85
|
The bundle covers the single-site case. For multi-site (one bridge connected to several WordPress sites at once), use Path 3.
|
|
86
86
|
|
|
87
|
+
**macOS + Claude Desktop keychain note.** Claude Desktop's `.mcpb` runtime may use Apple's `/usr/bin/security` keychain path when macOS rejects native keytar loading. If you set up OAuth from Terminal and then Claude Desktop repeatedly asks for Keychain access, rerun the terminal setup with the same backend Claude Desktop uses:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli abilities-mcp add-site --force https://example.com
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This is macOS-only and opt-in. It does not loosen Keychain permissions; it just writes tokens through the same Keychain doorway Claude Desktop reads through.
|
|
94
|
+
|
|
87
95
|
---
|
|
88
96
|
|
|
89
97
|
### Path 2 — Env vars (Claude Code, Cursor, Docker, any MCP client)
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { execFile } = require('node:child_process');
|
|
4
|
+
const { existsSync } = require('node:fs');
|
|
4
5
|
|
|
5
6
|
const { SecretStoreError } = require('./errors');
|
|
6
7
|
|
|
8
|
+
const DEFAULT_SECURITY_TIMEOUT_MS = 30_000;
|
|
9
|
+
const DEFAULT_KEYCHAIN_BACKEND = 'auto';
|
|
10
|
+
const KEYCHAIN_BACKENDS = new Set(['auto', 'keytar', 'security-cli']);
|
|
11
|
+
const SECURITY_CLI_PATH = '/usr/bin/security';
|
|
12
|
+
|
|
7
13
|
/**
|
|
8
14
|
* KeychainSecretStore — keytar-backed SecretStore with a darwin-only
|
|
9
15
|
* `security` CLI fallback for Claude Desktop's hardened-runtime barrier.
|
|
@@ -13,19 +19,32 @@ const { SecretStoreError } = require('./errors');
|
|
|
13
19
|
* `optionalDependency` so a failed native build does not break `npm install`
|
|
14
20
|
* for env-var-only operators.
|
|
15
21
|
*
|
|
16
|
-
* **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* **Darwin default: security-CLI (issue #61).** On darwin under the default
|
|
23
|
+
* `auto` backend, the store engages the `/usr/bin/security` CLI directly
|
|
24
|
+
* without attempting to load keytar. This is the alpha-gate fix for the
|
|
25
|
+
* multi-client macOS keychain ACL identity split: every runtime that spawns
|
|
26
|
+
* the bridge — Claude Desktop's `.mcpb`, Claude Code via npm/node, Codex,
|
|
27
|
+
* terminal CLI, etc. — issues `SecKeychainItem*` calls through the same
|
|
28
|
+
* caller binary (`/usr/bin/security`), so macOS's per-binary ACL trusted-
|
|
29
|
+
* application list contains exactly one entry. After the operator's first
|
|
30
|
+
* "Always Allow" the entry is silently readable from every runtime.
|
|
31
|
+
*
|
|
32
|
+
* Issue #58's `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` env var was the
|
|
33
|
+
* opt-in shape of this fix; #61 promotes it to the default. Operators who
|
|
34
|
+
* need keytar on darwin (uncommon — debugging, custom build) can opt back in
|
|
35
|
+
* with `ABILITIES_MCP_KEYCHAIN_BACKEND=keytar`.
|
|
36
|
+
*
|
|
37
|
+
* **The original darwin fallback (issue #39).** When the bundled keytar
|
|
38
|
+
* binary failed to dlopen inside Claude Desktop's hardened-runtime process,
|
|
39
|
+
* the store fell back to `/usr/bin/security` automatically. With the #61
|
|
40
|
+
* default in place, darwin auto never attempts keytar in the first place, so
|
|
41
|
+
* the dlopen-rejection branch is no longer reachable from `auto`. The
|
|
42
|
+
* security-CLI implementation itself is the same code path that's been
|
|
43
|
+
* shipping inside the .mcpb runtime since v1.5.3.
|
|
26
44
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
45
|
+
* Linux / win32 behavior unchanged: keytar via libsecret / Credential
|
|
46
|
+
* Manager. There is no `/usr/bin/security` equivalent and no analogous ACL
|
|
47
|
+
* prompt, so the per-platform identity-split bug doesn't apply.
|
|
29
48
|
*
|
|
30
49
|
* If keytar is unavailable at runtime AND the darwin fallback also can't run,
|
|
31
50
|
* every method throws `SecretStoreError` with code `keytar_unavailable` —
|
|
@@ -53,24 +72,45 @@ class KeychainSecretStore {
|
|
|
53
72
|
* fallback-eligibility decision. Test seam.
|
|
54
73
|
* @param {Function} [opts.exec] Override `child_process.execFile`. Test
|
|
55
74
|
* seam for the security-CLI fallback path.
|
|
75
|
+
* @param {number} [opts.securityTimeoutMs] Max time to wait for the darwin
|
|
76
|
+
* `security` CLI before surfacing a typed
|
|
77
|
+
* timeout error. Defaults to 30s.
|
|
78
|
+
* @param {string} [opts.backend] Secret backend: auto (default), keytar,
|
|
79
|
+
* or security-cli. When omitted, reads
|
|
80
|
+
* ABILITIES_MCP_KEYCHAIN_BACKEND.
|
|
81
|
+
* @param {object} [opts.env] Env object for backend selection tests.
|
|
82
|
+
* @param {Function} [opts.fsExistsSync] Override `fs.existsSync` for the
|
|
83
|
+
* `/usr/bin/security` existence probe.
|
|
84
|
+
* Test seam.
|
|
56
85
|
*/
|
|
57
86
|
constructor(opts = {}) {
|
|
58
87
|
this._injected = opts.keytar || null;
|
|
59
88
|
this._keytar = null;
|
|
60
89
|
this._loadAttempted = false;
|
|
61
90
|
this._loadError = null;
|
|
91
|
+
this._loadErrorCode = 'keytar_unavailable';
|
|
62
92
|
this._fallbackMode = null; // null | 'security-cli'
|
|
63
93
|
|
|
64
94
|
this._requireKeytar = opts.requireKeytar || ((id) => require(id));
|
|
65
95
|
this._platform = opts.platform || process.platform;
|
|
66
96
|
this._exec = opts.exec || execFile;
|
|
97
|
+
this._fsExistsSync = opts.fsExistsSync || existsSync;
|
|
98
|
+
const env = opts.env || process.env;
|
|
99
|
+
this._backend = _normalizeBackend(opts.backend || env.ABILITIES_MCP_KEYCHAIN_BACKEND);
|
|
100
|
+
this._securityTimeoutMs = Number.isFinite(opts.securityTimeoutMs) && opts.securityTimeoutMs > 0
|
|
101
|
+
? opts.securityTimeoutMs
|
|
102
|
+
: DEFAULT_SECURITY_TIMEOUT_MS;
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
/**
|
|
70
106
|
* Lazy load. Sets one of three terminal states:
|
|
71
|
-
* - `this.
|
|
72
|
-
*
|
|
73
|
-
* - `this.
|
|
107
|
+
* - `this._fallbackMode === 'security-cli'` (darwin auto default + explicit
|
|
108
|
+
* security-cli backend)
|
|
109
|
+
* - `this._keytar` populated (keytar loaded normally; non-darwin auto, or
|
|
110
|
+
* explicit `backend: 'keytar'` anywhere)
|
|
111
|
+
* - `this._loadError` set + throws (security-cli engagement on a host
|
|
112
|
+
* without `/usr/bin/security`, keytar load failure when keytar is the
|
|
113
|
+
* selected backend, invalid backend value)
|
|
74
114
|
*/
|
|
75
115
|
_load() {
|
|
76
116
|
if (this._keytar || this._fallbackMode) {
|
|
@@ -80,11 +120,50 @@ class KeychainSecretStore {
|
|
|
80
120
|
// Previously failed and we cached the error.
|
|
81
121
|
throw new SecretStoreError(
|
|
82
122
|
`OS keychain unavailable: ${this._loadError.message}`,
|
|
83
|
-
{ code:
|
|
123
|
+
{ code: this._loadErrorCode, cause: this._loadError }
|
|
84
124
|
);
|
|
85
125
|
}
|
|
86
126
|
this._loadAttempted = true;
|
|
87
127
|
|
|
128
|
+
if (!KEYCHAIN_BACKENDS.has(this._backend)) {
|
|
129
|
+
const err = new Error(
|
|
130
|
+
`Unsupported ABILITIES_MCP_KEYCHAIN_BACKEND="${this._backend}". ` +
|
|
131
|
+
`Use one of: auto, keytar, security-cli.`
|
|
132
|
+
);
|
|
133
|
+
this._loadError = err;
|
|
134
|
+
this._loadErrorCode = 'invalid_keychain_backend';
|
|
135
|
+
throw new SecretStoreError(
|
|
136
|
+
`OS keychain unavailable: ${err.message}`,
|
|
137
|
+
{ code: this._loadErrorCode, cause: err }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this._backend === 'security-cli') {
|
|
142
|
+
if (this._platform !== 'darwin') {
|
|
143
|
+
const err = new Error(
|
|
144
|
+
`ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli is only supported on macOS.`
|
|
145
|
+
);
|
|
146
|
+
this._loadError = err;
|
|
147
|
+
this._loadErrorCode = 'security_cli_unavailable';
|
|
148
|
+
throw new SecretStoreError(
|
|
149
|
+
`OS keychain unavailable: ${err.message}`,
|
|
150
|
+
{ code: this._loadErrorCode, cause: err }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
this._engageSecurityCliMode();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Issue #61: darwin default = security-CLI for cross-runtime ACL identity.
|
|
158
|
+
// All bridge spawn paths (Claude Desktop .mcpb, Claude Code, Codex,
|
|
159
|
+
// terminal CLI) issue keychain syscalls through the same `/usr/bin/security`
|
|
160
|
+
// caller binary, so macOS sees one ACL identity instead of N. Operators
|
|
161
|
+
// who want keytar on darwin can opt back in with backend=keytar.
|
|
162
|
+
if (this._backend === 'auto' && this._platform === 'darwin') {
|
|
163
|
+
this._engageSecurityCliMode();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
88
167
|
if (this._injected) {
|
|
89
168
|
this._keytar = this._injected;
|
|
90
169
|
return;
|
|
@@ -94,22 +173,44 @@ class KeychainSecretStore {
|
|
|
94
173
|
this._keytar = this._requireKeytar('keytar');
|
|
95
174
|
return;
|
|
96
175
|
} catch (err) {
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// macOS Keychain, just via shell-out.
|
|
101
|
-
if (this._platform === 'darwin') {
|
|
102
|
-
this._fallbackMode = 'security-cli';
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
176
|
+
// backend === 'keytar' (any platform), or backend === 'auto' on
|
|
177
|
+
// linux/win32. No fallback: the security-CLI is darwin-only, and the
|
|
178
|
+
// darwin auto path above already engaged it before we got here.
|
|
105
179
|
this._loadError = err;
|
|
180
|
+
this._loadErrorCode = 'keytar_unavailable';
|
|
106
181
|
throw new SecretStoreError(
|
|
107
182
|
`OS keychain unavailable: ${err.message}`,
|
|
108
|
-
{ code:
|
|
183
|
+
{ code: this._loadErrorCode, cause: err }
|
|
109
184
|
);
|
|
110
185
|
}
|
|
111
186
|
}
|
|
112
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Probe `/usr/bin/security` and engage security-CLI mode. Surfaces a typed
|
|
190
|
+
* error early (at first `_load()`) on hosts where the binary is missing —
|
|
191
|
+
* the corporate-locked-macOS edge case — instead of waiting for the first
|
|
192
|
+
* keychain operation to fail at execFile spawn time.
|
|
193
|
+
*
|
|
194
|
+
* Sets `_fallbackMode = 'security-cli'` on success; throws
|
|
195
|
+
* SecretStoreError code `security_cli_unavailable` on failure.
|
|
196
|
+
*/
|
|
197
|
+
_engageSecurityCliMode() {
|
|
198
|
+
if (!this._fsExistsSync(SECURITY_CLI_PATH)) {
|
|
199
|
+
const err = new Error(
|
|
200
|
+
`${SECURITY_CLI_PATH} not found. macOS Keychain access requires the ` +
|
|
201
|
+
`security CLI (standard at ${SECURITY_CLI_PATH} on a normal macOS ` +
|
|
202
|
+
`install). This may indicate a corporate-locked or non-standard host.`
|
|
203
|
+
);
|
|
204
|
+
this._loadError = err;
|
|
205
|
+
this._loadErrorCode = 'security_cli_unavailable';
|
|
206
|
+
throw new SecretStoreError(
|
|
207
|
+
`OS keychain unavailable: ${err.message}`,
|
|
208
|
+
{ code: this._loadErrorCode, cause: err }
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
this._fallbackMode = 'security-cli';
|
|
212
|
+
}
|
|
213
|
+
|
|
113
214
|
/**
|
|
114
215
|
* @returns {Promise<boolean>} true if keytar can be loaded on this host OR
|
|
115
216
|
* the darwin security-CLI fallback is engaged.
|
|
@@ -150,14 +251,14 @@ class KeychainSecretStore {
|
|
|
150
251
|
return this._keytar.deletePassword(service, account);
|
|
151
252
|
}
|
|
152
253
|
|
|
254
|
+
// findAll() is unavailable on the Darwin security-cli backend; current
|
|
255
|
+
// bridge flows do not rely on it. The macOS `security` CLI has no clean
|
|
256
|
+
// enumerate-by-service mode, and with #61 making security-cli the darwin
|
|
257
|
+
// default this method returns [] for every darwin caller. Linux/Windows
|
|
258
|
+
// (keytar) continue to enumerate normally.
|
|
153
259
|
async findAll(service) {
|
|
154
260
|
this._load();
|
|
155
261
|
if (this._fallbackMode === 'security-cli') {
|
|
156
|
-
// The macOS `security` CLI has no clean enumerate-by-service mode.
|
|
157
|
-
// Returning [] here is safe because the bridge runtime path never
|
|
158
|
-
// calls findAll — only the CLI subcommand `list-sites` does, and that
|
|
159
|
-
// runs in system Node where keytar loads normally and this branch
|
|
160
|
-
// is never taken. Documented in the issue body's "findAll" note.
|
|
161
262
|
return [];
|
|
162
263
|
}
|
|
163
264
|
return this._keytar.findCredentials(service);
|
|
@@ -176,7 +277,16 @@ class KeychainSecretStore {
|
|
|
176
277
|
*/
|
|
177
278
|
_execSecurity(args) {
|
|
178
279
|
return new Promise((resolve, reject) => {
|
|
179
|
-
|
|
280
|
+
const opts = {
|
|
281
|
+
timeout: this._securityTimeoutMs,
|
|
282
|
+
killSignal: 'SIGTERM',
|
|
283
|
+
};
|
|
284
|
+
// Issue #66: pass the absolute path explicitly so execFile bypasses
|
|
285
|
+
// PATH resolution. Bare `'security'` would resolve through the
|
|
286
|
+
// operator's PATH and could route the syscall through a shadowing
|
|
287
|
+
// binary (brew, nvm, ~/bin, etc.), breaking #61's "trusted caller
|
|
288
|
+
// binary at syscall time = /usr/bin/security" guarantee.
|
|
289
|
+
this._exec(SECURITY_CLI_PATH, args, opts, (err, stdout, stderr) => {
|
|
180
290
|
const stdoutStr = typeof stdout === 'string'
|
|
181
291
|
? stdout
|
|
182
292
|
: (stdout ? stdout.toString() : '');
|
|
@@ -190,6 +300,9 @@ class KeychainSecretStore {
|
|
|
190
300
|
// and don't pass via cb args); fall back to the cb args otherwise.
|
|
191
301
|
if (typeof err.stderr !== 'string') err.stderr = stderrStr;
|
|
192
302
|
if (typeof err.stdout !== 'string') err.stdout = stdoutStr;
|
|
303
|
+
if (_isTimeout(err) && typeof err.timeoutMs !== 'number') {
|
|
304
|
+
err.timeoutMs = this._securityTimeoutMs;
|
|
305
|
+
}
|
|
193
306
|
return reject(err);
|
|
194
307
|
}
|
|
195
308
|
resolve({ stdout: stdoutStr, stderr: stderrStr });
|
|
@@ -206,6 +319,12 @@ class KeychainSecretStore {
|
|
|
206
319
|
return stdout.replace(/\n$/, '');
|
|
207
320
|
} catch (err) {
|
|
208
321
|
if (_isNotFound(err)) return null;
|
|
322
|
+
if (_isTimeout(err)) {
|
|
323
|
+
throw new SecretStoreError(
|
|
324
|
+
`security find-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
|
|
325
|
+
{ code: 'security_cli_timeout', cause: err }
|
|
326
|
+
);
|
|
327
|
+
}
|
|
209
328
|
throw new SecretStoreError(
|
|
210
329
|
`security find-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
|
|
211
330
|
{ code: 'security_cli_failed', cause: err }
|
|
@@ -225,6 +344,12 @@ class KeychainSecretStore {
|
|
|
225
344
|
'add-generic-password', '-U', '-s', service, '-a', account, '-w', secret,
|
|
226
345
|
]);
|
|
227
346
|
} catch (err) {
|
|
347
|
+
if (_isTimeout(err)) {
|
|
348
|
+
throw new SecretStoreError(
|
|
349
|
+
`security add-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
|
|
350
|
+
{ code: 'security_cli_timeout', cause: err }
|
|
351
|
+
);
|
|
352
|
+
}
|
|
228
353
|
throw new SecretStoreError(
|
|
229
354
|
`security add-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
|
|
230
355
|
{ code: 'security_cli_failed', cause: err }
|
|
@@ -240,6 +365,12 @@ class KeychainSecretStore {
|
|
|
240
365
|
return true;
|
|
241
366
|
} catch (err) {
|
|
242
367
|
if (_isNotFound(err)) return false;
|
|
368
|
+
if (_isTimeout(err)) {
|
|
369
|
+
throw new SecretStoreError(
|
|
370
|
+
`security delete-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
|
|
371
|
+
{ code: 'security_cli_timeout', cause: err }
|
|
372
|
+
);
|
|
373
|
+
}
|
|
243
374
|
throw new SecretStoreError(
|
|
244
375
|
`security delete-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
|
|
245
376
|
{ code: 'security_cli_failed', cause: err }
|
|
@@ -262,4 +393,16 @@ function _isNotFound(err) {
|
|
|
262
393
|
return /could not be found/i.test(stderr);
|
|
263
394
|
}
|
|
264
395
|
|
|
396
|
+
function _isTimeout(err) {
|
|
397
|
+
if (!err) return false;
|
|
398
|
+
if (err.code === 'ETIMEDOUT') return true;
|
|
399
|
+
if (err.killed && err.signal === 'SIGTERM') return true;
|
|
400
|
+
return /timed out/i.test(err.message || '');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function _normalizeBackend(value) {
|
|
404
|
+
if (!value) return DEFAULT_KEYCHAIN_BACKEND;
|
|
405
|
+
return String(value).trim().toLowerCase();
|
|
406
|
+
}
|
|
407
|
+
|
|
265
408
|
module.exports = { KeychainSecretStore };
|
package/lib/auth/oauth-client.js
CHANGED
|
@@ -46,7 +46,17 @@ const {
|
|
|
46
46
|
* @license GPL-2.0-or-later
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// `abilities:multisite:read` / `abilities:multisite:write` are SENSITIVE_SCOPES
|
|
50
|
+
// in the adapter's ScopeRegistry (Auth/OAuth/ScopeRegistry.php) — never implied
|
|
51
|
+
// by the `abilities:read` / `abilities:write` umbrella expansion. Requesting
|
|
52
|
+
// them explicitly during DCR is the only way the consent screen can surface
|
|
53
|
+
// them for super-admin operators on a Multisite Network root, which in turn
|
|
54
|
+
// is the only way `add-site`'s post-OAuth multisite/list-sites probe (#43)
|
|
55
|
+
// can fire end-to-end. Single-site WP installs accept the request — the
|
|
56
|
+
// adapter simply won't grant scopes the OAuth user lacks WP capability for,
|
|
57
|
+
// so single-site UX is preserved.
|
|
58
|
+
const DEFAULT_SCOPE =
|
|
59
|
+
'abilities:read abilities:write abilities:multisite:read abilities:multisite:write';
|
|
50
60
|
const DEFAULT_LOOPBACK_TIMEOUT_MS = 5 * 60_000;
|
|
51
61
|
|
|
52
62
|
class OAuthClient extends EventEmitter {
|
|
@@ -12,6 +12,7 @@ const { makeRef } = require('../../auth/secret-store');
|
|
|
12
12
|
const { CliError, EXIT_USAGE, EXIT_CONFIG, fromAuthError } = require('../errors');
|
|
13
13
|
const { subscribeProgress } = require('../output');
|
|
14
14
|
const { readConfig, writeConfig, freshConfig } = require('../config-store');
|
|
15
|
+
const { probeMultisite: defaultProbeMultisite } = require('../multisite-probe');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* `add-site <url>` — register a new site with the bridge using OAuth (default)
|
|
@@ -108,6 +109,7 @@ async function run(args, ctx) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const out = [];
|
|
112
|
+
const errLines = [];
|
|
111
113
|
let exitCode = 0;
|
|
112
114
|
|
|
113
115
|
if (args.apppassword) {
|
|
@@ -139,7 +141,7 @@ async function run(args, ctx) {
|
|
|
139
141
|
await writeConfig(ctx.configPath, config);
|
|
140
142
|
out.push(`✓ Site "${siteId}" configured with App Password authentication.`);
|
|
141
143
|
out.push(` Config: ${ctx.configPath}`);
|
|
142
|
-
return { exitCode, lines: out };
|
|
144
|
+
return { exitCode, lines: out, errLines };
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
// OAuth path — full authorization-code + PKCE flow via OAuthClient.
|
|
@@ -199,12 +201,73 @@ async function run(args, ctx) {
|
|
|
199
201
|
if (result.prMetadata && result.prMetadata.resource) {
|
|
200
202
|
config.sites[siteId].mcp_resource = result.prMetadata.resource;
|
|
201
203
|
}
|
|
204
|
+
|
|
205
|
+
// Multisite Network root probe. If the freshly authenticated bridge can
|
|
206
|
+
// resolve `multisite/list-sites`, populate the multisite block so dot-
|
|
207
|
+
// notation routing works without operator JSON editing. Single-site,
|
|
208
|
+
// permission-denied, and network errors all degrade gracefully — the
|
|
209
|
+
// site entry is still written without a multisite block.
|
|
210
|
+
const probeEndpoint = result.prMetadata && result.prMetadata.resource;
|
|
211
|
+
if (probeEndpoint && result.tokens && result.tokens.access_token) {
|
|
212
|
+
const probe = (ctx.deps && ctx.deps.probeMultisite) || defaultProbeMultisite;
|
|
213
|
+
try {
|
|
214
|
+
const probeResult = await probe({
|
|
215
|
+
endpoint: probeEndpoint,
|
|
216
|
+
accessToken: result.tokens.access_token,
|
|
217
|
+
siteUrl: parsedUrl.origin,
|
|
218
|
+
log: typeof ctx.log === 'function' ? ctx.log : null,
|
|
219
|
+
deps: ctx.deps && ctx.deps.probeMultisiteDeps,
|
|
220
|
+
});
|
|
221
|
+
if (probeResult && probeResult.block && Object.keys(probeResult.block).length > 0) {
|
|
222
|
+
config.sites[siteId].multisite = probeResult.block;
|
|
223
|
+
const slugs = Object.keys(probeResult.block).join(', ');
|
|
224
|
+
out.push(` Multisite: discovered ${Object.keys(probeResult.block).length} subsite(s) → ${slugs}`);
|
|
225
|
+
}
|
|
226
|
+
} catch (probeErr) {
|
|
227
|
+
_appendProbeAdvisory(probeErr, siteId, errLines);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
202
231
|
if (!config.defaultSite) config.defaultSite = siteId;
|
|
203
232
|
await writeConfig(ctx.configPath, config);
|
|
204
233
|
|
|
205
234
|
out.push(`✓ Site "${siteId}" configured. Granted scopes: ${result.scopes.join(', ')}.`);
|
|
206
235
|
out.push(` Config: ${ctx.configPath}`);
|
|
207
|
-
return { exitCode, lines: out };
|
|
236
|
+
return { exitCode, lines: out, errLines };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Append a stderr advisory naming the multisite-probe failure so operators
|
|
241
|
+
* who expected dot-notation routing know why it isn't wired and can either
|
|
242
|
+
* fix the underlying issue or hand-add the block per existing docs.
|
|
243
|
+
*
|
|
244
|
+
* Silent for `tool_not_registered` (single-site is the expected case for
|
|
245
|
+
* the vast majority of `add-site` invocations).
|
|
246
|
+
*/
|
|
247
|
+
function _appendProbeAdvisory(probeErr, siteId, errLines) {
|
|
248
|
+
const code = probeErr && probeErr.code;
|
|
249
|
+
if (code === 'tool_not_registered') return;
|
|
250
|
+
|
|
251
|
+
if (code === 'permission_denied' || code === 'unauthorized') {
|
|
252
|
+
errLines.push(
|
|
253
|
+
`Multisite discovery skipped for "${siteId}": multisite/list-sites was rejected ` +
|
|
254
|
+
`by the adapter. Two possible causes — verify both before manually adding the block: ` +
|
|
255
|
+
`(1) the OAuth user lacks the manage_network_options WP capability (super-admin ` +
|
|
256
|
+
`required on a Multisite Network root), or (2) the OAuth token lacks the ` +
|
|
257
|
+
`abilities:multisite:read scope (it must be granted on the consent screen — ` +
|
|
258
|
+
`re-run add-site and confirm the multisite scope checkbox if it was unchecked). ` +
|
|
259
|
+
`Site entry written without multisite block — dot-notation subsite routing ` +
|
|
260
|
+
`will not be available until the block is added manually or add-site is re-run.`
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const detail = (probeErr && probeErr.message) || String(probeErr);
|
|
266
|
+
errLines.push(
|
|
267
|
+
`Multisite discovery failed for "${siteId}": ${detail}. ` +
|
|
268
|
+
`Site entry written without multisite block — dot-notation subsite routing ` +
|
|
269
|
+
`will not be available until the block is added manually or add-site is re-run.`
|
|
270
|
+
);
|
|
208
271
|
}
|
|
209
272
|
|
|
210
273
|
/**
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
const { CliError, EXIT_USAGE, fromAuthError } = require('../errors');
|
|
10
10
|
const { subscribeProgress } = require('../output');
|
|
11
11
|
const { readConfig, writeConfig } = require('../config-store');
|
|
12
|
+
const { computeScopeMutation } = require('../scope-mutation');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `reauth <site_id>` — re-run the OAuth flow for an existing site.
|
|
@@ -49,7 +50,28 @@ async function run(args, ctx) {
|
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Resolve the requested scope set from the three mutually-exclusive flags
|
|
54
|
+
// (--scope replaces, --add-scope merges, --remove-scope drops). See
|
|
55
|
+
// lib/cli/scope-mutation.js for the full design (Issue #50). Empty
|
|
56
|
+
// existing → DEFAULT_SCOPE fallback to preserve the prior bare-reauth
|
|
57
|
+
// contract.
|
|
58
|
+
const persistedScopes = Array.isArray(site.auth.scopes) ? site.auth.scopes : null;
|
|
59
|
+
const mutation = computeScopeMutation({
|
|
60
|
+
scope: args.scope,
|
|
61
|
+
addScope: args['add-scope'],
|
|
62
|
+
removeScope: args['remove-scope'],
|
|
63
|
+
existing: persistedScopes,
|
|
64
|
+
});
|
|
65
|
+
if (mutation.errorCode) {
|
|
66
|
+
throw new CliError(mutation.errorMessage, {
|
|
67
|
+
exitCode: EXIT_USAGE,
|
|
68
|
+
nextAction: 'Run: abilities-mcp reauth <site_id> --add-scope=<scopes> | --remove-scope=<scopes> | --scope=<scopes>',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const requestedScope = mutation.scopes.length > 0 ? mutation.scopes : (persistedScopes || DEFAULT_SCOPE);
|
|
72
|
+
|
|
52
73
|
const out = [];
|
|
74
|
+
const errLines = mutation.warnings.slice();
|
|
53
75
|
out.push(`Re-running OAuth flow for site "${siteId}" (${site.url})…`);
|
|
54
76
|
|
|
55
77
|
const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
|
|
@@ -58,7 +80,7 @@ async function run(args, ctx) {
|
|
|
58
80
|
siteUrl: site.url,
|
|
59
81
|
clientName,
|
|
60
82
|
softwareVersion: ctx.softwareVersion,
|
|
61
|
-
scope:
|
|
83
|
+
scope: requestedScope,
|
|
62
84
|
identityProvider: ctx.identityProvider,
|
|
63
85
|
allowInsecure: ctx.allowInsecure,
|
|
64
86
|
capabilityPin: site.oauth_capability_pinned ? {
|
|
@@ -102,7 +124,7 @@ async function run(args, ctx) {
|
|
|
102
124
|
await writeConfig(ctx.configPath, config);
|
|
103
125
|
|
|
104
126
|
out.push(`✓ Site "${siteId}" re-authorized. Granted scopes: ${result.scopes.join(', ')}.`);
|
|
105
|
-
return { exitCode: 0, lines: out };
|
|
127
|
+
return { exitCode: 0, lines: out, errLines };
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
module.exports = { run };
|