@wickedevolutions/abilities-mcp 1.5.4 → 1.6.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 +72 -0
- package/README.md +149 -47
- package/lib/auth/keychain-secret-store.js +174 -31
- package/lib/cli/commands/reauth.js +24 -2
- package/lib/cli/commands/upgrade-auth.js +15 -9
- package/lib/cli/index.js +10 -0
- package/lib/cli/multisite-probe.js +112 -6
- 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
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -119,24 +119,30 @@ async function run(args, ctx) {
|
|
|
119
119
|
username: site.auth.username,
|
|
120
120
|
password_ref: site.auth.password_ref,
|
|
121
121
|
};
|
|
122
|
-
//
|
|
123
|
-
// <siteId>/apppassword-legacy per the F.5 example.
|
|
124
|
-
// the
|
|
125
|
-
//
|
|
122
|
+
// Copy the keychain entry from <siteId>/apppassword to
|
|
123
|
+
// <siteId>/apppassword-legacy per the F.5 example. If macOS refuses or
|
|
124
|
+
// delays the secret read, keep the original password_ref as the fallback
|
|
125
|
+
// rather than writing config that points at a secret we did not create.
|
|
126
126
|
const legacyAccount = `${siteId}/apppassword-legacy`;
|
|
127
|
+
let legacyCopied = false;
|
|
127
128
|
try {
|
|
128
129
|
const oldAccount = parseRef(site.auth.password_ref).account;
|
|
129
130
|
const value = await ctx.secretStore.get(SECRET_SERVICE, oldAccount);
|
|
130
131
|
if (typeof value === 'string') {
|
|
131
132
|
await ctx.secretStore.set(SECRET_SERVICE, legacyAccount, value);
|
|
133
|
+
legacyCopied = true;
|
|
132
134
|
}
|
|
133
135
|
} catch {
|
|
134
|
-
// Non-fatal —
|
|
136
|
+
// Non-fatal — preserve the original fallback reference below.
|
|
137
|
+
}
|
|
138
|
+
if (legacyCopied) {
|
|
139
|
+
pendingFallback = {
|
|
140
|
+
username: site.auth.username,
|
|
141
|
+
password_ref: makeRef(SECRET_SERVICE, legacyAccount),
|
|
142
|
+
};
|
|
143
|
+
} else {
|
|
144
|
+
out.push(' ! Could not copy App Password fallback to a legacy keychain entry; keeping existing fallback reference.');
|
|
135
145
|
}
|
|
136
|
-
pendingFallback = {
|
|
137
|
-
username: site.auth.username,
|
|
138
|
-
password_ref: makeRef(SECRET_SERVICE, legacyAccount),
|
|
139
|
-
};
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
|
package/lib/cli/index.js
CHANGED
|
@@ -140,6 +140,11 @@ const HELP_TEXT = [
|
|
|
140
140
|
' --label=<text> Human-readable label',
|
|
141
141
|
' --force Overwrite an existing site_id',
|
|
142
142
|
' reauth <site_id> Re-run OAuth flow for an existing site',
|
|
143
|
+
' --add-scope="<scopes>" Merge scopes into the existing set (recommended)',
|
|
144
|
+
' --remove-scope="<scopes>" Drop scopes by exact match (missing = no-op warning)',
|
|
145
|
+
' --scope="<scopes>" Replace the entire scope set (warns if dropping any)',
|
|
146
|
+
' (the three flags above are mutually exclusive;',
|
|
147
|
+
' accept comma- or space-separated scope lists)',
|
|
143
148
|
' revoke <site_id> Revoke OAuth tokens (local + remote)',
|
|
144
149
|
' list-sites Show configured sites + auth status',
|
|
145
150
|
' test <site_id> Ping the adapter and report scopes',
|
|
@@ -156,6 +161,11 @@ const HELP_TEXT = [
|
|
|
156
161
|
' --debug Include cause stack on errors',
|
|
157
162
|
' --allow-insecure Allow plain HTTP (localhost dev only)',
|
|
158
163
|
'',
|
|
164
|
+
'Environment:',
|
|
165
|
+
' ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli',
|
|
166
|
+
' macOS-only opt-in: use the same /usr/bin/security',
|
|
167
|
+
' keychain backend Claude Desktop .mcpb uses',
|
|
168
|
+
'',
|
|
159
169
|
'Exit codes:',
|
|
160
170
|
' 0 success',
|
|
161
171
|
' 1 unexpected error',
|
|
@@ -26,6 +26,10 @@ const { URL } = require('node:url');
|
|
|
26
26
|
const PROBE_PROTOCOL_VERSION = '2025-06-18';
|
|
27
27
|
const PROBE_PER_PAGE = 100;
|
|
28
28
|
const PROBE_TIMEOUT_MS = 30000;
|
|
29
|
+
// Page cap = 50 pages × 100/page = 5,000 sites. Networks larger than this
|
|
30
|
+
// are an exceptional case that should engage maintainers rather than silently
|
|
31
|
+
// override; not exposed as an env shim (Issue #49).
|
|
32
|
+
const PROBE_PAGE_CAP = 50;
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* @typedef {object} ProbeResult
|
|
@@ -69,9 +73,63 @@ async function probeMultisite(opts) {
|
|
|
69
73
|
: new BearerJsonRpcClient(endpoint, accessToken, logger);
|
|
70
74
|
|
|
71
75
|
await client.initialize();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
|
|
77
|
+
// Page through multisite/list-sites until the network is fully covered
|
|
78
|
+
// (Issue #49). Networks with >100 sites previously got a truncated block
|
|
79
|
+
// because the probe issued a single per_page=100 call.
|
|
80
|
+
//
|
|
81
|
+
// Termination order matters: when a page returns fewer than per_page items
|
|
82
|
+
// (or exposes a body-level total/total_pages we've reached), we still
|
|
83
|
+
// accumulate that page's items first, then exit the loop. Dropping the
|
|
84
|
+
// partial page's items would silently lose subsites.
|
|
85
|
+
let items = null;
|
|
86
|
+
let totalKnown = null;
|
|
87
|
+
let totalPagesKnown = null;
|
|
88
|
+
for (let page = 1; page <= PROBE_PAGE_CAP; page++) {
|
|
89
|
+
// Adapter exposes the ability as kebab-case `multisite-list-sites`
|
|
90
|
+
// (verified via tools/list against wickedevolutions). The slash form
|
|
91
|
+
// shipped in v1.5.4 (Issue #54 same-files extension) — masked by the
|
|
92
|
+
// session-token rejection until the bridge fix above surfaced it.
|
|
93
|
+
const toolResp = await client.callTool('multisite-list-sites', {
|
|
94
|
+
per_page: PROBE_PER_PAGE,
|
|
95
|
+
page,
|
|
96
|
+
});
|
|
97
|
+
const payload = parseToolResponse(toolResp);
|
|
98
|
+
const pageItems = extractSites(payload);
|
|
99
|
+
if (pageItems === null) {
|
|
100
|
+
// Malformed payload on page 1 → preserve the existing 'empty-list'
|
|
101
|
+
// contract that downstream callers (add-site) already handle.
|
|
102
|
+
// On a later page, treat as "no more items" and stop.
|
|
103
|
+
if (page === 1) { items = null; break; }
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (items === null) items = [];
|
|
107
|
+
items.push(...pageItems);
|
|
108
|
+
|
|
109
|
+
// Body-level total / total_pages take precedence — they're authoritative
|
|
110
|
+
// when present. Without them we use the partial-page fallback.
|
|
111
|
+
const meta = extractMeta(payload);
|
|
112
|
+
if (meta.total != null) totalKnown = meta.total;
|
|
113
|
+
if (meta.totalPages != null) totalPagesKnown = meta.totalPages;
|
|
114
|
+
|
|
115
|
+
const fullPage = pageItems.length === PROBE_PER_PAGE;
|
|
116
|
+
const reachedKnownTotal = totalKnown != null && items.length >= totalKnown;
|
|
117
|
+
const reachedKnownTotalPages = totalPagesKnown != null && page >= totalPagesKnown;
|
|
118
|
+
if (!fullPage || reachedKnownTotal || reachedKnownTotalPages) break;
|
|
119
|
+
|
|
120
|
+
// Full page AND no metadata signal we're done AND we're at the cap.
|
|
121
|
+
// This is the "5,000+ site network" case — fail loud rather than write
|
|
122
|
+
// a silently truncated block.
|
|
123
|
+
if (page === PROBE_PAGE_CAP) {
|
|
124
|
+
const e = new Error(
|
|
125
|
+
`multisite probe: page cap exceeded (${PROBE_PAGE_CAP} pages × ${PROBE_PER_PAGE}/page = ${PROBE_PAGE_CAP * PROBE_PER_PAGE} sites). ` +
|
|
126
|
+
`This network exceeds the supported probe size; contact maintainers.`
|
|
127
|
+
);
|
|
128
|
+
e.code = 'probe_cap_exceeded';
|
|
129
|
+
e.data = { count: items.length, cap: PROBE_PAGE_CAP * PROBE_PER_PAGE };
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
75
133
|
|
|
76
134
|
if (items === null) {
|
|
77
135
|
return { block: null, reason: 'empty-list' };
|
|
@@ -119,15 +177,28 @@ function buildMultisiteBlock(parentSiteUrl, items) {
|
|
|
119
177
|
/**
|
|
120
178
|
* Map a `multisite/list-sites` item to a slug usable for dot-notation
|
|
121
179
|
* routing. Subdomain mode → first label of the subdomain. Path mode →
|
|
122
|
-
* first segment of the path.
|
|
123
|
-
*
|
|
180
|
+
* first segment of the path. Mapped-domain subsites → first label of
|
|
181
|
+
* the domain.
|
|
182
|
+
*
|
|
183
|
+
* Issue #70: returns null when `itemDomain === parentHost && itemPath
|
|
184
|
+
* is empty`. That case used to synthesize a `'main'` slug intended to
|
|
185
|
+
* point at the network root, but in subdomain-style multisite the only
|
|
186
|
+
* blog matching parent host is the parent subsite itself — so the
|
|
187
|
+
* resulting `<site>.main` URL was the source subsite, not the root,
|
|
188
|
+
* silently routing dot-notation calls to the wrong context. Skipping
|
|
189
|
+
* the slug means: in subdomain-style the network root is reachable via
|
|
190
|
+
* its domain-label slug from the fall-through (`<site>.<root-label>`);
|
|
191
|
+
* in path-style the network root is the parent itself and reachable as
|
|
192
|
+
* `<site>` (no dot). A first-class `'main' → blog_id 1` alias is
|
|
193
|
+
* post-alpha work.
|
|
124
194
|
*/
|
|
125
195
|
function deriveSubsiteSlug(parentHost, item) {
|
|
126
196
|
const itemDomain = String(item.domain || '').toLowerCase().replace(/^www\./, '');
|
|
127
197
|
const itemPath = String(item.path || '/').replace(/^\/+|\/+$/g, '');
|
|
128
198
|
|
|
129
199
|
if (itemDomain === parentHost) {
|
|
130
|
-
|
|
200
|
+
if (itemPath === '') return null;
|
|
201
|
+
return itemPath.split('/')[0];
|
|
131
202
|
}
|
|
132
203
|
if (itemDomain.endsWith('.' + parentHost)) {
|
|
133
204
|
const prefix = itemDomain.slice(0, itemDomain.length - parentHost.length - 1);
|
|
@@ -197,6 +268,26 @@ function extractSites(payload) {
|
|
|
197
268
|
return null;
|
|
198
269
|
}
|
|
199
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Extract pagination metadata from a multisite/list-sites payload, if the
|
|
273
|
+
* adapter exposes it on the body. The adapter may surface totals on HTTP
|
|
274
|
+
* headers instead — body-only is the supported channel for the probe loop;
|
|
275
|
+
* absence is fine because the partial-page fallback handles termination.
|
|
276
|
+
*/
|
|
277
|
+
function extractMeta(payload) {
|
|
278
|
+
if (!payload || typeof payload !== 'object') return { total: null, totalPages: null };
|
|
279
|
+
const root = payload.data && typeof payload.data === 'object' ? payload.data : payload;
|
|
280
|
+
const total = numericOrNull(root.total);
|
|
281
|
+
const totalPages = numericOrNull(root.total_pages);
|
|
282
|
+
return { total, totalPages };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function numericOrNull(v) {
|
|
286
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
287
|
+
if (typeof v === 'string' && /^-?\d+$/.test(v)) return parseInt(v, 10);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
200
291
|
function mapJsonRpcErrorCode(code, message) {
|
|
201
292
|
// -32601 = Method not found → tool not registered (single-site install)
|
|
202
293
|
if (code === -32601) return 'tool_not_registered';
|
|
@@ -235,6 +326,7 @@ class BearerJsonRpcClient {
|
|
|
235
326
|
this.log = log;
|
|
236
327
|
this.module = this.url.protocol === 'https:' ? https : http;
|
|
237
328
|
this.sessionId = null;
|
|
329
|
+
this.sessionToken = null; // Mcp-Session-Token (HMAC, echoed back on every request)
|
|
238
330
|
this.cookies = new Map();
|
|
239
331
|
this._idCounter = 1;
|
|
240
332
|
}
|
|
@@ -281,6 +373,11 @@ class BearerJsonRpcClient {
|
|
|
281
373
|
'Content-Length': Buffer.byteLength(body),
|
|
282
374
|
};
|
|
283
375
|
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
|
|
376
|
+
// Echo the per-session HMAC token captured from initialize. The
|
|
377
|
+
// adapter's HttpSessionValidator rejects any non-initialize request
|
|
378
|
+
// missing this header as session-fixation defense — see Issue #54
|
|
379
|
+
// and the equivalent handling in lib/transports/http-transport.js.
|
|
380
|
+
if (this.sessionToken) headers['Mcp-Session-Token'] = this.sessionToken;
|
|
284
381
|
if (this.cookies.size > 0) {
|
|
285
382
|
headers['Cookie'] = Array.from(this.cookies.entries())
|
|
286
383
|
.map(([k, v]) => `${k}=${v}`).join('; ');
|
|
@@ -298,6 +395,8 @@ class BearerJsonRpcClient {
|
|
|
298
395
|
res.on('end', () => {
|
|
299
396
|
const newSession = res.headers['mcp-session-id'];
|
|
300
397
|
if (newSession) this.sessionId = newSession;
|
|
398
|
+
const newSessionToken = res.headers['mcp-session-token'];
|
|
399
|
+
if (newSessionToken) this.sessionToken = newSessionToken;
|
|
301
400
|
const setCookie = res.headers['set-cookie'];
|
|
302
401
|
if (setCookie) {
|
|
303
402
|
const list = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
@@ -388,5 +487,12 @@ module.exports = {
|
|
|
388
487
|
buildMultisiteBlock,
|
|
389
488
|
deriveSubsiteSlug,
|
|
390
489
|
parseToolResponse,
|
|
490
|
+
// Exported for direct testing of the per-session HMAC echo contract
|
|
491
|
+
// (Issue #54). The runtime contract (capture Mcp-Session-Token from
|
|
492
|
+
// initialize and echo on every subsequent request) is observable
|
|
493
|
+
// protocol behavior, not implementation detail.
|
|
494
|
+
BearerJsonRpcClient,
|
|
391
495
|
PROBE_PROTOCOL_VERSION,
|
|
496
|
+
PROBE_PER_PAGE,
|
|
497
|
+
PROBE_PAGE_CAP,
|
|
392
498
|
};
|