@wickedevolutions/abilities-mcp 1.3.1 → 1.5.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +182 -113
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +98 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +161 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config.js +248 -19
  37. package/lib/connection-pool.js +214 -11
  38. package/lib/router.js +29 -11
  39. package/lib/transports/oauth-http-transport.js +601 -0
  40. package/package.json +7 -2
package/lib/config.js CHANGED
@@ -1,24 +1,78 @@
1
1
  'use strict';
2
2
 
3
- const { execSync } = require('child_process');
3
+ const { exec } = require('child_process');
4
+ const { promisify } = require('util');
4
5
  const fs = require('fs');
6
+ const fsp = require('fs').promises;
5
7
  const path = require('path');
6
8
  const os = require('os');
7
9
 
10
+ const execAsync = promisify(exec);
11
+
8
12
  /**
9
- * Load configuration from wp-sites.json or build from CLI args.
13
+ * Load configuration from wp-sites.json, environment variables, or CLI args.
10
14
  *
11
- * Search order for wp-sites.json:
15
+ * Search order:
12
16
  * 1. --config=<path> explicit path
13
17
  * 2. Same directory as abilities-mcp.js
14
18
  * 3. ~/.abilities-mcp/wp-sites.json
19
+ * 4. ABILITIES_MCP_URL/USERNAME/PASSWORD env vars (single-site, .mcpb path)
20
+ * 5. --host/--path CLI args (legacy SSH single-site)
15
21
  *
16
- * If no config file and --host/--path provided, builds a single-site legacy config.
22
+ * Per Issue #5 / Phase E.2 (Stretch to Stable sprint): the startup chain
23
+ * — `resolveConfigFilePath`, `loadConfig`, `loadConfigFile`,
24
+ * `validateSiteConfig`, `resolvePassword` — is async so file reads and
25
+ * `passwordCommand` shell-outs do not block the event loop during boot.
17
26
  *
18
27
  * Copyright (C) 2026 Influencentricity | Wicked Evolutions
19
28
  * @license GPL-2.0-or-later
20
29
  */
21
- function loadConfig(args) {
30
+
31
+ /**
32
+ * Async non-throwing existence check. Returns true iff the path resolves
33
+ * via fs.access(); any error (ENOENT, EACCES, …) yields false. Used by the
34
+ * search-order helpers which only care whether a candidate file is readable.
35
+ */
36
+ async function _exists(p) {
37
+ try {
38
+ await fsp.access(p, fs.constants.F_OK);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Resolve the on-disk wp-sites.json path that `loadConfig` would consume,
47
+ * without reading or validating it. Returns `null` when no file path applies
48
+ * (env-var single-site, legacy --host/--path, or no config at all).
49
+ *
50
+ * Used by the v1→v2 migration shim in `abilities-mcp.js` so the migration can
51
+ * run before `loadConfig` parses + validates a stale v1 file.
52
+ *
53
+ * Mirrors steps 1-3 of `loadConfig`'s search order. Steps 4-5 (env / legacy
54
+ * CLI) intentionally return `null` — there is no on-disk file to migrate.
55
+ *
56
+ * @param {object} args
57
+ * @returns {Promise<string|null>} Absolute path of the resolved wp-sites.json, or null.
58
+ */
59
+ async function resolveConfigFilePath(args) {
60
+ if (args && args.config) {
61
+ return path.resolve(args.config);
62
+ }
63
+ const scriptDir = path.resolve(__dirname, '..');
64
+ const scriptConfig = path.join(scriptDir, 'wp-sites.json');
65
+ if (await _exists(scriptConfig)) {
66
+ return scriptConfig;
67
+ }
68
+ const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
69
+ if (await _exists(homeConfig)) {
70
+ return homeConfig;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ async function loadConfig(args) {
22
76
  // Explicit config path
23
77
  if (args.config) {
24
78
  return loadConfigFile(args.config);
@@ -27,33 +81,111 @@ function loadConfig(args) {
27
81
  // Check alongside script (lib/ → package root)
28
82
  const scriptDir = path.resolve(__dirname, '..');
29
83
  const scriptConfig = path.join(scriptDir, 'wp-sites.json');
30
- if (fs.existsSync(scriptConfig)) {
84
+ if (await _exists(scriptConfig)) {
31
85
  return loadConfigFile(scriptConfig);
32
86
  }
33
87
 
34
88
  // Check home directory
35
89
  const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
36
- if (fs.existsSync(homeConfig)) {
90
+ if (await _exists(homeConfig)) {
37
91
  return loadConfigFile(homeConfig);
38
92
  }
39
93
 
94
+ // Env-var single-site config — covers the .mcpb install path and any
95
+ // env-var-based MCP client configuration (claude mcp add, Docker, etc.)
96
+ if (process.env.ABILITIES_MCP_URL) {
97
+ return buildEnvConfig(process.env);
98
+ }
99
+
40
100
  // Legacy CLI mode — single site from --host/--path
41
101
  if (args.host && args.path) {
42
102
  return buildLegacyConfig(args);
43
103
  }
44
104
 
45
105
  throw new Error(
46
- 'No wp-sites.json found and no --host/--path provided.\n' +
47
- 'Create wp-sites.json or use: --host=<ssh-host> --path=<wp-path>'
106
+ 'No configuration found.\n' +
107
+ 'Provide one of: wp-sites.json, ABILITIES_MCP_URL+USERNAME+PASSWORD env vars, or --host/--path.'
48
108
  );
49
109
  }
50
110
 
51
- function loadConfigFile(filePath) {
52
- const raw = fs.readFileSync(filePath, 'utf8');
111
+ /**
112
+ * Build single-site config from env vars (ABILITIES_MCP_URL/USERNAME/PASSWORD).
113
+ *
114
+ * Auto-derives the MCP adapter endpoint from the site URL:
115
+ * https://example.com → https://example.com/wp-json/mcp/mcp-adapter-default-server
116
+ *
117
+ * This is the path used by .mcpb bundles installed in Claude Desktop and any
118
+ * other env-var-based MCP client configuration.
119
+ */
120
+ function buildEnvConfig(env) {
121
+ const rawUrl = env.ABILITIES_MCP_URL;
122
+ const username = env.ABILITIES_MCP_USERNAME;
123
+ const password = env.ABILITIES_MCP_PASSWORD;
124
+
125
+ if (!username) {
126
+ throw new Error('ABILITIES_MCP_URL is set but ABILITIES_MCP_USERNAME is missing');
127
+ }
128
+ if (!password) {
129
+ throw new Error('ABILITIES_MCP_URL is set but ABILITIES_MCP_PASSWORD is missing');
130
+ }
131
+
132
+ let parsedUrl;
133
+ try {
134
+ parsedUrl = new URL(rawUrl);
135
+ } catch (e) {
136
+ throw new Error(`ABILITIES_MCP_URL is not a valid URL: ${rawUrl}`);
137
+ }
138
+
139
+ const isHttps = parsedUrl.protocol === 'https:';
140
+ const isHttp = parsedUrl.protocol === 'http:';
141
+ if (!isHttps && !isHttp) {
142
+ throw new Error(`ABILITIES_MCP_URL must be http or https: ${rawUrl}`);
143
+ }
144
+
145
+ // Strip trailing slash from origin+path, then append the adapter route.
146
+ const base = (parsedUrl.origin + parsedUrl.pathname).replace(/\/+$/, '');
147
+ const endpoint = `${base}/wp-json/mcp/mcp-adapter-default-server`;
148
+
149
+ const siteConfig = {
150
+ label: parsedUrl.hostname,
151
+ url: parsedUrl.origin,
152
+ transport: 'http',
153
+ http: {
154
+ endpoint,
155
+ username,
156
+ password,
157
+ },
158
+ };
159
+
160
+ // Allow plain HTTP only when the operator explicitly opts in. The .mcpb path
161
+ // expects HTTPS by default; localhost dev gets a narrow exception.
162
+ if (isHttp) {
163
+ const isLocal = parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1';
164
+ if (!isLocal && env.ABILITIES_MCP_ALLOW_INSECURE !== 'true') {
165
+ throw new Error(
166
+ `ABILITIES_MCP_URL is HTTP (not HTTPS): ${rawUrl}\n` +
167
+ `Set ABILITIES_MCP_ALLOW_INSECURE=true to allow plain HTTP.`
168
+ );
169
+ }
170
+ siteConfig.allowInsecure = true;
171
+ }
172
+
173
+ return {
174
+ defaultSite: 'default',
175
+ _isMultiSite: false,
176
+ _configSource: 'env',
177
+ sites: {
178
+ default: siteConfig,
179
+ },
180
+ };
181
+ }
182
+
183
+ async function loadConfigFile(filePath) {
184
+ const raw = await fsp.readFile(filePath, 'utf8');
53
185
 
54
186
  // Warn if config file is readable by group or world
55
187
  try {
56
- const stat = fs.statSync(filePath);
188
+ const stat = await fsp.stat(filePath);
57
189
  if (stat.mode & 0o077) {
58
190
  process.stderr.write(
59
191
  `WARNING: ${filePath} is readable by group/world (mode ${(stat.mode & 0o777).toString(8)}). ` +
@@ -78,7 +210,7 @@ function loadConfigFile(filePath) {
78
210
 
79
211
  // Validate each site
80
212
  for (const [key, site] of Object.entries(config.sites)) {
81
- validateSiteConfig(key, site);
213
+ await validateSiteConfig(key, site);
82
214
  }
83
215
 
84
216
  config._isMultiSite = Object.keys(config.sites).length > 1 ||
@@ -88,7 +220,57 @@ function loadConfigFile(filePath) {
88
220
  return config;
89
221
  }
90
222
 
91
- function validateSiteConfig(key, site) {
223
+ async function validateSiteConfig(key, site) {
224
+ // v2 OAuth sites carry no transport block (Appendix F.5 + add-site flow).
225
+ // The runtime treats them as HTTP — endpoint comes from auth.mcp_resource
226
+ // or site.mcp_resource (resolved by the OAuth-aware transport).
227
+ if (site.auth && site.auth.method === 'oauth') {
228
+ if (!site.url) {
229
+ throw new Error(`Site "${key}" (oauth): requires "url"`);
230
+ }
231
+ if (!site.auth.access_token_ref || !site.auth.refresh_token_ref) {
232
+ throw new Error(`Site "${key}" (oauth): requires auth.access_token_ref and auth.refresh_token_ref`);
233
+ }
234
+ if (!site.mcp_resource) {
235
+ throw new Error(`Site "${key}" (oauth): requires mcp_resource (set during add-site / reauth)`);
236
+ }
237
+ if (!site.mcp_resource.startsWith('https://') && !site.allowInsecure) {
238
+ throw new Error(`Site "${key}" (oauth): mcp_resource is not HTTPS. Set "allowInsecure": true on the site to allow HTTP`);
239
+ }
240
+ return;
241
+ }
242
+
243
+ // v2 App-Password sites — produced by config-migration from v1 transport-style
244
+ // configs. Secret lives in keychain (auth.password_ref); the legacy http.password*
245
+ // fields are stripped during migration so the keychain is the sole source of
246
+ // truth. Carrier transport (http or ssh) is preserved for the runtime.
247
+ if (site.auth && site.auth.method === 'apppassword') {
248
+ if (!site.auth.username) {
249
+ throw new Error(`Site "${key}" (apppassword): requires auth.username`);
250
+ }
251
+ if (!site.auth.password_ref) {
252
+ throw new Error(`Site "${key}" (apppassword): requires auth.password_ref`);
253
+ }
254
+ if (!site.transport) {
255
+ throw new Error(`Site "${key}" (apppassword): missing "transport" (ssh or http)`);
256
+ }
257
+ if (site.transport === 'ssh') {
258
+ if (!site.ssh || !site.ssh.host || !site.ssh.path) {
259
+ throw new Error(`Site "${key}" (apppassword/ssh): requires ssh.host and ssh.path`);
260
+ }
261
+ } else if (site.transport === 'http') {
262
+ if (!site.http || !site.http.endpoint) {
263
+ throw new Error(`Site "${key}" (apppassword/http): requires http.endpoint`);
264
+ }
265
+ if (!site.http.endpoint.startsWith('https://') && !site.allowInsecure) {
266
+ throw new Error(`Site "${key}" (apppassword/http): endpoint is not HTTPS. Set "allowInsecure": true on the site to allow HTTP`);
267
+ }
268
+ } else {
269
+ throw new Error(`Site "${key}" (apppassword): unknown transport "${site.transport}" (use ssh or http)`);
270
+ }
271
+ return;
272
+ }
273
+
92
274
  if (!site.transport) {
93
275
  throw new Error(`Site "${key}": missing "transport" (ssh or http)`);
94
276
  }
@@ -137,6 +319,7 @@ function buildLegacyConfig(args) {
137
319
 
138
320
  /**
139
321
  * Resolve a composite site key like "wicked.community" into config + subsite URL.
322
+ * Pure in-memory dispatch — kept synchronous since it has no I/O.
140
323
  */
141
324
  function resolveSiteKey(config, compositeKey) {
142
325
  // Direct match — "helena" or "wicked"
@@ -189,17 +372,23 @@ function buildSiteKeyEnum(config) {
189
372
  }
190
373
 
191
374
  /**
192
- * Resolve the password for an HTTP transport config, supporting
193
- * plaintext password, environment variable, or shell command.
375
+ * Resolve the password for an HTTP transport config, supporting plaintext
376
+ * password, environment variable, or shell command.
377
+ *
378
+ * `passwordCommand` is dispatched through `util.promisify(exec)` so the shell
379
+ * interprets the command string the same way the previous `execSync` did
380
+ * (operators rely on pipes, redirects, and command chaining — e.g.
381
+ * `op read 'op://Vault/foo' | tr -d '\n'` — which `execFile` cannot run).
194
382
  */
195
- function resolvePassword(httpConfig) {
383
+ async function resolvePassword(httpConfig) {
196
384
  if (httpConfig.passwordEnv) {
197
385
  const val = process.env[httpConfig.passwordEnv];
198
386
  if (!val) throw new Error(`Environment variable ${httpConfig.passwordEnv} is not set`);
199
387
  return val;
200
388
  }
201
389
  if (httpConfig.passwordCommand) {
202
- return execSync(httpConfig.passwordCommand, { encoding: 'utf8' }).trim();
390
+ const { stdout } = await execAsync(httpConfig.passwordCommand, { encoding: 'utf8' });
391
+ return stdout.trim();
203
392
  }
204
393
  if (httpConfig.password) {
205
394
  return httpConfig.password;
@@ -207,4 +396,44 @@ function resolvePassword(httpConfig) {
207
396
  throw new Error('No password, passwordEnv, or passwordCommand configured');
208
397
  }
209
398
 
210
- module.exports = { loadConfig, resolvePassword, resolveSiteKey, buildSiteKeyEnum };
399
+ /**
400
+ * Resolve a site's HTTP password, dispatching on schema shape.
401
+ *
402
+ * v2 App-Password sites (auth.method === 'apppassword' + auth.password_ref)
403
+ * read the secret from the keychain via the SecretStore. v1 carriers without
404
+ * a ref fall back to the `resolvePassword(http)` resolver.
405
+ *
406
+ * @param {object} site
407
+ * @param {object} [secretStore] SecretStore instance. Lazily defaults to
408
+ * KeychainSecretStore when an apppassword path
409
+ * runs and no store was supplied — preserves
410
+ * the "no keytar for SSH-only / v1-only setups"
411
+ * property by deferring the require to the
412
+ * point of need.
413
+ * @returns {Promise<string>}
414
+ */
415
+ async function resolveSitePassword(site, secretStore) {
416
+ if (site && site.auth && site.auth.method === 'apppassword' && site.auth.password_ref) {
417
+ let store = secretStore;
418
+ if (!store) {
419
+ const { KeychainSecretStore } = require('./auth/keychain-secret-store');
420
+ store = new KeychainSecretStore();
421
+ }
422
+ const { resolveRef } = require('./auth/secret-store');
423
+ return resolveRef(store, site.auth.password_ref);
424
+ }
425
+ if (site && site.http) {
426
+ return resolvePassword(site.http);
427
+ }
428
+ throw new Error('No password source configured for site');
429
+ }
430
+
431
+ module.exports = {
432
+ loadConfig,
433
+ resolveConfigFilePath,
434
+ resolvePassword,
435
+ resolveSitePassword,
436
+ resolveSiteKey,
437
+ buildSiteKeyEnum,
438
+ buildEnvConfig,
439
+ };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { SshTransport } = require('./transports/ssh-transport');
4
- const { resolveSiteKey, resolvePassword } = require('./config');
4
+ const { resolveSiteKey, resolveSitePassword } = require('./config');
5
5
 
6
6
  // Incrementing counter for synthetic handshake IDs.
7
7
  // Avoids integer overflow from Date.now() (13-digit ms timestamps exceed
@@ -9,6 +9,23 @@ const { resolveSiteKey, resolvePassword } = require('./config');
9
9
  // Starting at 1000 to avoid collision with real request IDs (typically 1+).
10
10
  let _synthIdCounter = 1000;
11
11
 
12
+ /**
13
+ * Build a TokenManager-shaped siteAuth object from a v2 OAuth site block.
14
+ * The OAuthHttpTransport and TokenManager use this shape.
15
+ */
16
+ function _siteAuthFromConfig(siteId, siteConfig, asMetadata) {
17
+ return {
18
+ siteId,
19
+ tokenEndpoint: asMetadata && asMetadata.token_endpoint,
20
+ clientId: siteConfig.auth.client_id,
21
+ accessTokenRef: siteConfig.auth.access_token_ref,
22
+ refreshTokenRef: siteConfig.auth.refresh_token_ref,
23
+ accessTokenExpiresAt: siteConfig.auth.access_token_expires_at,
24
+ refreshTokenExpiresAt: siteConfig.auth.refresh_token_expires_at,
25
+ authStatus: siteConfig.auth_status || 'active',
26
+ };
27
+ }
28
+
12
29
  /**
13
30
  * Connection Pool — manages one transport per site, lazily instantiated.
14
31
  *
@@ -21,12 +38,38 @@ let _synthIdCounter = 1000;
21
38
  */
22
39
  class ConnectionPool {
23
40
 
24
- constructor(config, logger) {
41
+ /**
42
+ * @param {object} config
43
+ * @param {function} logger
44
+ * @param {object} [deps] Optional injection seam for tests
45
+ * @param {object} [deps.secretStore] Defaults to KeychainSecretStore
46
+ * @param {object} [deps.tokenManager] Defaults to a TokenManager built from secretStore
47
+ * @param {function} [deps.discover] Defaults to lib/auth/discovery-client.discover
48
+ * @param {function} [deps.persistAuthStatus] (siteId, newStatus) => void
49
+ * Persists to wp-sites.json. Defaults to
50
+ * atomic write via config-migration._atomicWrite
51
+ * when config._configPath is set.
52
+ * @param {boolean} [deps.allowInsecure] For local-dev OAuth over HTTP
53
+ */
54
+ constructor(config, logger, deps = {}) {
25
55
  this.config = config;
26
56
  this.log = logger;
27
57
  this.transports = new Map(); // compositeKey -> Transport
28
58
  this.connecting = new Map(); // compositeKey -> Promise<Transport>
29
59
 
60
+ // OAuth-runtime deps. Built lazily so SSH-only / App-Password-only setups
61
+ // never load keytar or the auth modules at all.
62
+ this._deps = deps;
63
+ this._secretStore = deps.secretStore || null;
64
+ this._tokenManager = deps.tokenManager || null;
65
+ this._discover = deps.discover || null;
66
+ this._allowInsecure = !!deps.allowInsecure;
67
+ this._persistAuthStatus = deps.persistAuthStatus || null;
68
+
69
+ // Cache of OAuth AS metadata per site URL — avoids re-probing .well-known
70
+ // on every transport rebuild. Refreshed when the transport is recreated.
71
+ this._asMetadataCache = new Map(); // siteUrl -> { asMetadata, prMetadata }
72
+
30
73
  // Handshake cache — set after the default site completes init
31
74
  this.cachedInitRequest = null;
32
75
  this.cachedInitNotification = null;
@@ -98,7 +141,7 @@ class ConnectionPool {
98
141
  */
99
142
  async connectDefault(onMessage) {
100
143
  const key = this.config.defaultSite;
101
- const transport = this._createTransport(key, null);
144
+ const transport = await this._createTransport(key, null);
102
145
  transport.onMessage = onMessage;
103
146
  await transport.connect();
104
147
  this.transports.set(key, transport);
@@ -145,6 +188,28 @@ class ConnectionPool {
145
188
  try {
146
189
  const { siteConfig } = resolveSiteKey(this.config, compositeKey);
147
190
 
191
+ // v2 OAuth site — probe the resource URL with HEAD. We do NOT mint a
192
+ // token here; this is just a reachability check, the real auth happens
193
+ // on first MCP request via OAuthHttpTransport.
194
+ if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
195
+ const target = siteConfig.mcp_resource;
196
+ if (!target) {
197
+ return { status: 'unreachable', latencyMs: Date.now() - start, error: 'no mcp_resource configured' };
198
+ }
199
+ const mod = target.startsWith('https://') ? require('https') : require('http');
200
+ const url = new URL(target);
201
+ await new Promise((resolve, reject) => {
202
+ const req = mod.request({
203
+ hostname: url.hostname, port: url.port,
204
+ path: url.pathname, method: 'HEAD', timeout: 10000,
205
+ }, (res) => resolve(res.statusCode));
206
+ req.on('error', reject);
207
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
208
+ req.end();
209
+ });
210
+ return { status: 'reachable', latencyMs: Date.now() - start };
211
+ }
212
+
148
213
  if (siteConfig.transport === 'ssh') {
149
214
  const { execFileSync } = require('child_process');
150
215
  execFileSync('ssh', [
@@ -199,7 +264,7 @@ class ConnectionPool {
199
264
 
200
265
  this.log(`Lazy-connecting to site: ${compositeKey}`);
201
266
 
202
- const transport = this._createTransport(compositeKey, subsiteUrl);
267
+ const transport = await this._createTransport(compositeKey, subsiteUrl);
203
268
 
204
269
  // Set up message callback — route responses back to main
205
270
  // The main entry will set this after getting the transport
@@ -220,10 +285,18 @@ class ConnectionPool {
220
285
  return transport;
221
286
  }
222
287
 
223
- _createTransport(compositeKey, subsiteUrl) {
288
+ async _createTransport(compositeKey, subsiteUrl) {
224
289
  const { siteConfig, subsiteUrl: resolvedSubsiteUrl, resolvedEndpoint } = resolveSiteKey(this.config, compositeKey);
225
290
  const finalSubsiteUrl = subsiteUrl || resolvedSubsiteUrl;
226
291
 
292
+ // v2 OAuth dispatch — single branch per Issue #17 acceptance criteria.
293
+ // Sites with auth.method === 'oauth' use the OAuth-aware HTTP transport;
294
+ // every other site (App Password, SSH carrier-only) keeps the existing
295
+ // legacy code paths untouched.
296
+ if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
297
+ return this._createOAuthHttpTransport(compositeKey, siteConfig);
298
+ }
299
+
227
300
  if (siteConfig.transport === 'ssh') {
228
301
  return new SshTransport({
229
302
  host: siteConfig.ssh.host,
@@ -236,13 +309,25 @@ class ConnectionPool {
236
309
  }
237
310
 
238
311
  if (siteConfig.transport === 'http') {
239
- // HTTP transport — loaded lazily to avoid requiring it when only SSH is used
312
+ // HTTP transport — loaded lazily to avoid requiring it when only SSH is used.
313
+ // v2 apppassword sites resolve the secret from keychain via auth.password_ref;
314
+ // v1 sites fall through to the legacy synchronous resolver. KeychainSecretStore
315
+ // is only constructed when an apppassword path actually runs, preserving the
316
+ // "no keytar for SSH-only / v1-only setups" property.
240
317
  const { HttpTransport } = require('./transports/http-transport');
241
- const password = resolvePassword(siteConfig.http);
318
+ const isV2AppPassword = siteConfig.auth
319
+ && siteConfig.auth.method === 'apppassword'
320
+ && siteConfig.auth.password_ref;
321
+ if (isV2AppPassword && !this._secretStore) {
322
+ const { KeychainSecretStore } = require('./auth/keychain-secret-store');
323
+ this._secretStore = new KeychainSecretStore();
324
+ }
325
+ const password = await resolveSitePassword(siteConfig, this._secretStore);
326
+ const username = (siteConfig.auth && siteConfig.auth.username) || siteConfig.http.username;
242
327
  return new HttpTransport({
243
328
  endpoint: resolvedEndpoint || siteConfig.http.endpoint,
244
- username: siteConfig.http.username,
245
- password: password,
329
+ username,
330
+ password,
246
331
  logger: this.log,
247
332
  });
248
333
  }
@@ -250,15 +335,133 @@ class ConnectionPool {
250
335
  throw new Error(`Unknown transport: ${siteConfig.transport}`);
251
336
  }
252
337
 
338
+ /**
339
+ * Build an OAuthHttpTransport for a v2 OAuth site. Lazily resolves AS
340
+ * metadata via the discovery client (passing capability-pin state per
341
+ * Appendix H.2.3 — pinned-then-404 throws CapabilityPinningError, which
342
+ * we let propagate so the bridge fails loud rather than silently
343
+ * downgrading to App Password).
344
+ */
345
+ async _createOAuthHttpTransport(compositeKey, siteConfig) {
346
+ const { OAuthHttpTransport } = require('./transports/oauth-http-transport');
347
+ const { TokenManager } = require('./auth/token-manager');
348
+ const { discover } = require('./auth/discovery-client');
349
+
350
+ if (!siteConfig.mcp_resource) {
351
+ throw new Error(
352
+ `Site "${compositeKey}" (oauth): no mcp_resource configured — ` +
353
+ `re-run \`abilities-mcp reauth ${compositeKey}\` to repopulate it`
354
+ );
355
+ }
356
+ if (!siteConfig.url) {
357
+ throw new Error(`Site "${compositeKey}" (oauth): no url configured`);
358
+ }
359
+
360
+ if (!this._secretStore) {
361
+ const { KeychainSecretStore } = require('./auth/keychain-secret-store');
362
+ this._secretStore = new KeychainSecretStore();
363
+ }
364
+ if (!this._tokenManager) {
365
+ this._tokenManager = new TokenManager({
366
+ secretStore: this._secretStore,
367
+ allowInsecure: this._allowInsecure,
368
+ });
369
+ }
370
+ const discoverFn = this._discover || discover;
371
+
372
+ let asMetadata = (this._asMetadataCache.get(siteConfig.url) || {}).asMetadata;
373
+ if (!asMetadata) {
374
+ const result = await discoverFn(siteConfig.url, {
375
+ pinned: !!siteConfig.oauth_capability_pinned,
376
+ pinnedFirstSeenAt: siteConfig.oauth_capability_pinned
377
+ && siteConfig.oauth_capability_pinned.first_seen_at,
378
+ allowInsecure: this._allowInsecure,
379
+ });
380
+ asMetadata = result.asMetadata;
381
+ this._asMetadataCache.set(siteConfig.url, {
382
+ asMetadata: result.asMetadata,
383
+ prMetadata: result.prMetadata,
384
+ });
385
+ }
386
+
387
+ if (!asMetadata || !asMetadata.token_endpoint) {
388
+ throw new Error(
389
+ `Site "${compositeKey}" (oauth): discovery did not yield a token_endpoint`
390
+ );
391
+ }
392
+
393
+ const siteAuth = _siteAuthFromConfig(compositeKey, siteConfig, asMetadata);
394
+
395
+ return new OAuthHttpTransport({
396
+ endpoint: siteConfig.mcp_resource,
397
+ tokenManager: this._tokenManager,
398
+ siteAuth,
399
+ onAuthStatusChange: (newStatus, info) => {
400
+ // In-memory update first so subsequent transport rebuilds see it.
401
+ try {
402
+ siteConfig.auth_status = newStatus;
403
+ } catch { /* siteConfig may be frozen in tests — ignore */ }
404
+ this.log(
405
+ `OAuth auth_status change: ${compositeKey} → ${newStatus} (${info && info.reason || 'unknown'})`
406
+ );
407
+ // Persist to wp-sites.json best-effort. A failure to write should
408
+ // not break the request path that triggered this change.
409
+ if (this._persistAuthStatus) {
410
+ Promise.resolve()
411
+ .then(() => this._persistAuthStatus(compositeKey, newStatus, info))
412
+ .catch((err) => this.log(`OAuth auth_status persist failed: ${err.message}`));
413
+ } else if (this.config && this.config._configPath) {
414
+ this._defaultPersistAuthStatus(compositeKey, newStatus)
415
+ .catch((err) => this.log(`OAuth auth_status persist failed: ${err.message}`));
416
+ }
417
+ },
418
+ logger: this.log,
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Default auth_status persistor — atomic rewrite of wp-sites.json. Used
424
+ * when the pool was constructed without a custom persistAuthStatus.
425
+ */
426
+ async _defaultPersistAuthStatus(siteId, newStatus) {
427
+ const { _atomicWrite } = require('./auth/config-migration');
428
+ const fs = require('node:fs');
429
+ const filePath = this.config._configPath;
430
+ if (!filePath) return;
431
+ let raw;
432
+ try {
433
+ raw = await fs.promises.readFile(filePath, 'utf8');
434
+ } catch (err) {
435
+ throw new Error(`read ${filePath}: ${err.message}`);
436
+ }
437
+ let parsed;
438
+ try { parsed = JSON.parse(raw); }
439
+ catch (err) {
440
+ throw new Error(`parse ${filePath}: ${err.message}`);
441
+ }
442
+ if (parsed && parsed.sites && parsed.sites[siteId]) {
443
+ parsed.sites[siteId].auth_status = newStatus;
444
+ await _atomicWrite(filePath, parsed);
445
+ }
446
+ }
447
+
253
448
  /**
254
449
  * Check if a composite key resolves to the same HTTP endpoint as an
255
450
  * already-connected transport. Returns { key, transport } or null.
451
+ *
452
+ * Covers both v1 App-Password HTTP sites and v2 OAuth sites — the dedup
453
+ * target is whichever URL the eventual transport will POST to.
256
454
  */
257
455
  _findExistingHttpTransport(compositeKey) {
258
456
  const { siteConfig, resolvedEndpoint } = resolveSiteKey(this.config, compositeKey);
259
- if (siteConfig.transport !== 'http') return null;
260
457
 
261
- const targetEndpoint = resolvedEndpoint || siteConfig.http.endpoint;
458
+ let targetEndpoint = null;
459
+ if (siteConfig.auth && siteConfig.auth.method === 'oauth') {
460
+ targetEndpoint = siteConfig.mcp_resource;
461
+ } else if (siteConfig.transport === 'http') {
462
+ targetEndpoint = resolvedEndpoint || (siteConfig.http && siteConfig.http.endpoint);
463
+ }
464
+ if (!targetEndpoint) return null;
262
465
 
263
466
  for (const [key, transport] of this.transports) {
264
467
  if (transport.endpoint === targetEndpoint) {