@yagni-app/code 1.0.5 → 1.0.7

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 (111) hide show
  1. package/README.md +89 -27
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -3
  5. package/dist/claudePlugins.js +129 -19
  6. package/dist/cli.js +28 -4
  7. package/dist/doctor.d.ts +42 -4
  8. package/dist/doctor.js +150 -15
  9. package/dist/extension/condensedTools.d.ts +12 -1
  10. package/dist/extension/condensedTools.js +17 -9
  11. package/dist/extension/index.d.ts +18 -5
  12. package/dist/extension/index.js +319 -72
  13. package/dist/extension/mcp/approval.d.ts +45 -0
  14. package/dist/extension/mcp/approval.js +164 -0
  15. package/dist/extension/mcp/auth.d.ts +124 -0
  16. package/dist/extension/mcp/auth.js +560 -0
  17. package/dist/extension/mcp/authStore.d.ts +61 -0
  18. package/dist/extension/mcp/authStore.js +105 -0
  19. package/dist/extension/mcp/callbackPage.d.ts +31 -0
  20. package/dist/extension/mcp/callbackPage.js +222 -0
  21. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  22. package/dist/extension/mcp/cliConfig.js +12 -0
  23. package/dist/extension/mcp/config.d.ts +153 -0
  24. package/dist/extension/mcp/config.js +381 -0
  25. package/dist/extension/mcp/log.d.ts +28 -0
  26. package/dist/extension/mcp/log.js +82 -0
  27. package/dist/extension/mcp/manager.d.ts +100 -0
  28. package/dist/extension/mcp/manager.js +273 -0
  29. package/dist/extension/mcp/names.d.ts +25 -0
  30. package/dist/extension/mcp/names.js +40 -0
  31. package/dist/extension/mcp/panel.d.ts +33 -0
  32. package/dist/extension/mcp/panel.js +268 -0
  33. package/dist/extension/mcp/prompts.d.ts +23 -0
  34. package/dist/extension/mcp/prompts.js +93 -0
  35. package/dist/extension/mcp/startup.d.ts +55 -0
  36. package/dist/extension/mcp/startup.js +152 -0
  37. package/dist/extension/mcp/tools.d.ts +31 -0
  38. package/dist/extension/mcp/tools.js +117 -0
  39. package/dist/extension/mcp/transports.d.ts +17 -0
  40. package/dist/extension/mcp/transports.js +44 -0
  41. package/dist/extension/permission/gate.d.ts +104 -2
  42. package/dist/extension/permission/gate.js +387 -31
  43. package/dist/extension/permission/guardian.d.ts +24 -5
  44. package/dist/extension/permission/guardian.js +162 -24
  45. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  46. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  47. package/dist/extension/permissionRules/engine.d.ts +50 -0
  48. package/dist/extension/permissionRules/engine.js +238 -0
  49. package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
  50. package/dist/extension/permissionRules/loadConfig.js +90 -0
  51. package/dist/extension/permissionRules/parser.d.ts +38 -0
  52. package/dist/extension/permissionRules/parser.js +136 -0
  53. package/dist/extension/permissionRules/pathRules.d.ts +58 -0
  54. package/dist/extension/permissionRules/pathRules.js +120 -0
  55. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  56. package/dist/extension/permissionRules/shellRules.js +221 -0
  57. package/dist/extension/pipeline/invocation.d.ts +3 -6
  58. package/dist/extension/pipeline/invocation.js +3 -6
  59. package/dist/extension/pipeline/personas.js +5 -0
  60. package/dist/extension/pipeline/runner.d.ts +0 -1
  61. package/dist/extension/pipeline/runner.js +6 -14
  62. package/dist/extension/plugins/inventory.d.ts +88 -0
  63. package/dist/extension/plugins/inventory.js +144 -0
  64. package/dist/extension/plugins/panel.d.ts +45 -0
  65. package/dist/extension/plugins/panel.js +293 -0
  66. package/dist/extension/sandbox/bash.d.ts +99 -0
  67. package/dist/extension/sandbox/bash.js +190 -0
  68. package/dist/extension/sandbox/config.d.ts +114 -0
  69. package/dist/extension/sandbox/config.js +366 -0
  70. package/dist/extension/sandbox/manager.d.ts +98 -0
  71. package/dist/extension/sandbox/manager.js +216 -0
  72. package/dist/extension/sandbox/panel.d.ts +111 -0
  73. package/dist/extension/sandbox/panel.js +342 -0
  74. package/dist/extension/sandbox/session.d.ts +85 -0
  75. package/dist/extension/sandbox/session.js +775 -0
  76. package/dist/extension/telemetry/attrs.d.ts +96 -0
  77. package/dist/extension/telemetry/attrs.js +149 -0
  78. package/dist/extension/telemetry/config.d.ts +99 -0
  79. package/dist/extension/telemetry/config.js +193 -0
  80. package/dist/extension/telemetry/index.d.ts +7 -0
  81. package/dist/extension/telemetry/index.js +7 -0
  82. package/dist/extension/telemetry/probe.d.ts +29 -0
  83. package/dist/extension/telemetry/probe.js +122 -0
  84. package/dist/extension/telemetry/register.d.ts +40 -0
  85. package/dist/extension/telemetry/register.js +192 -0
  86. package/dist/extension/telemetry/sdk.d.ts +63 -0
  87. package/dist/extension/telemetry/sdk.js +207 -0
  88. package/dist/extension/telemetry/tracker.d.ts +131 -0
  89. package/dist/extension/telemetry/tracker.js +551 -0
  90. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  91. package/dist/extension/vendor/ignore.d.ts +86 -0
  92. package/dist/extension/vendor/ignore.js +788 -0
  93. package/dist/goHeadless.d.ts +1 -1
  94. package/dist/goHeadless.js +2 -2
  95. package/dist/launch.d.ts +4 -3
  96. package/dist/launch.js +7 -4
  97. package/dist/mcpCommand.d.ts +122 -0
  98. package/dist/mcpCommand.js +787 -0
  99. package/dist/otel.d.ts +69 -63
  100. package/dist/otel.js +154 -119
  101. package/dist/paths.d.ts +13 -0
  102. package/dist/paths.js +18 -0
  103. package/dist/pluginCommand.d.ts +43 -0
  104. package/dist/pluginCommand.js +499 -0
  105. package/dist/pluginStore.d.ts +170 -0
  106. package/dist/pluginStore.js +554 -0
  107. package/dist/upgrade.d.ts +11 -2
  108. package/dist/upgrade.js +48 -8
  109. package/package.json +20 -3
  110. package/dist/extension/mcpTools.d.ts +0 -57
  111. package/dist/extension/mcpTools.js +0 -132
@@ -0,0 +1,560 @@
1
+ /**
2
+ * OAuth 2.1 for MCP servers over HTTP/SSE transports, mirroring Claude Code's
3
+ * flow but keeping it to the application layer: the MCP SDK
4
+ * (`@modelcontextprotocol/sdk/client/auth.js`) already implements discovery,
5
+ * DCR, PKCE, token refresh, and the authorization-code exchange. This module
6
+ * is the glue the SDK needs from us:
7
+ *
8
+ * - `YagniAuthProvider` implements `OAuthClientProvider`: reflects our
9
+ * client metadata, persists DCR client info + tokens in the auth store,
10
+ * and captures the authorization URL when the SDK redirects.
11
+ * - `authenticate()` drives the interactive flow: SDK `auth()` (no code) →
12
+ * REDIRECT + authorization URL → open browser → loopback listener on
13
+ * 127.0.0.1 → validate `state` → SDK `auth()` with the code → AUTHORIZED.
14
+ * - `revokeServerTokens()` revokes access + refresh tokens (RFC 7009) when a
15
+ * server is removed — refresh first, access second, both best-effort.
16
+ *
17
+ * Loopback + redirect semantics follow RFC 8252: bind 127.0.0.1 on a random
18
+ * high port (or a fixed `oauth.callbackPort`), redirect_uri path `/callback`,
19
+ * and validate the returned `state` to prevent CSRF.
20
+ */
21
+ import { createServer } from "node:http";
22
+ import { randomBytes } from "node:crypto";
23
+ import { auth as sdkAuth, discoverOAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js";
24
+ import { expandServerEnv } from "./config.js";
25
+ import { runningUnderTest } from "../crashReport.js";
26
+ import { getStoredOAuthEntry, updateStoredOAuthEntry } from "./authStore.js";
27
+ import { deleteStoredOAuthEntry as clearStoredOAuthEntry } from "./authStore.js";
28
+ import { logMcpEvent, redactSensitiveUrlParams } from "./log.js";
29
+ import { renderAuthErrorPage, renderAuthStateMismatchPage, renderAuthSuccessPage, } from "./callbackPage.js";
30
+ /** Cancellation via an AbortSignal (Ctrl-C / Esc in the /mcp panel). */
31
+ export class AuthenticationCancelledError extends Error {
32
+ constructor(message = "OAuth cancelled") {
33
+ super(message);
34
+ this.name = "AuthenticationCancelledError";
35
+ }
36
+ }
37
+ /**
38
+ * Escape a provider-controlled string for interpolation into the loopback
39
+ * callback page's HTML. Dependency-free equivalent of the `xss` filter Claude
40
+ * Code applies at this exact spot: `&`, `<`, `>`, quotes → entities.
41
+ */
42
+ export function escapeHtml(value) {
43
+ return value
44
+ .replace(/&/g, "&amp;")
45
+ .replace(/</g, "&lt;")
46
+ .replace(/>/g, "&gt;")
47
+ .replace(/"/g, "&quot;")
48
+ .replace(/'/g, "&#39;");
49
+ }
50
+ /** Five-minute loopback wait, matching Claude Code's `Authentication timeout`. */
51
+ export const OAuthFlowTimeoutMs = 5 * 60 * 1000; // exported so tests can shrink it via opts
52
+ /**
53
+ * Run one bounded sdkAuth phase (discovery+DCR, or the token exchange).
54
+ *
55
+ * The deadline and the caller's abort signal both drive ONE AbortController
56
+ * that is threaded into the SDK as `fetchFn` — so a hung authorization-server
57
+ * endpoint doesn't just lose the race, its socket is actually closed (an
58
+ * unbounded fetch would otherwise outlive the flow and leak the handle). On
59
+ * settle the controller is aborted regardless, as socket hygiene.
60
+ *
61
+ * Classification mirrors Claude Code: caller-abort → AuthenticationCancelledError
62
+ * (swallowed quietly upstream), deadline → `Authentication timeout (<phase>)`,
63
+ * anything else rethrown as-is.
64
+ */
65
+ async function sdkAuthPhase(label, provider, args, opts) {
66
+ const controller = new AbortController();
67
+ const forward = () => controller.abort();
68
+ opts.signal?.addEventListener("abort", forward, { once: true });
69
+ let timedOut = false;
70
+ const timer = setTimeout(() => {
71
+ timedOut = true;
72
+ controller.abort();
73
+ }, opts.timeoutMs);
74
+ timer.unref?.();
75
+ const fetchFn = (input, init) => {
76
+ const inner = init?.signal;
77
+ if (!inner)
78
+ return fetch(input, { ...init, signal: controller.signal });
79
+ const combo = new AbortController();
80
+ inner.addEventListener("abort", () => combo.abort(), { once: true });
81
+ controller.signal.addEventListener("abort", () => combo.abort(), { once: true });
82
+ return fetch(input, { ...init, signal: combo.signal });
83
+ };
84
+ try {
85
+ return await sdkAuth(provider, { ...args, fetchFn });
86
+ }
87
+ catch (err) {
88
+ if (opts.signal?.aborted)
89
+ throw new AuthenticationCancelledError();
90
+ if (timedOut)
91
+ throw new Error(`Authentication timeout (${label})`);
92
+ throw err;
93
+ }
94
+ finally {
95
+ clearTimeout(timer);
96
+ controller.abort();
97
+ opts.signal?.removeEventListener("abort", forward);
98
+ }
99
+ }
100
+ /**
101
+ * Wrap the fetch used by an OAuth transport so a failed silent token-refresh
102
+ * POST is recorded with its HTTP status + OAuth error code — the exact thing
103
+ * that is otherwise invisible when a cached token's refresh fails at connect
104
+ * time and the SDK falls through to `REDIRECT`. The response is cloned before
105
+ * inspection so the SDK's own `parseErrorResponse` still reads the original
106
+ * body untouched.
107
+ */
108
+ export function instrumentOAuthFetch(serverName, baseFetch = fetch) {
109
+ return async (input, init) => {
110
+ const response = await baseFetch(input, init);
111
+ try {
112
+ const method = (init?.method ?? "GET").toUpperCase();
113
+ if (method === "POST" && init?.body != null) {
114
+ const body = typeof init.body === "string" ? init.body : init.body.toString();
115
+ if (body.includes("grant_type=refresh_token") && !response.ok) {
116
+ let errorCode = "unknown";
117
+ try {
118
+ const text = await response.clone().text();
119
+ const m = text.match(/"error"\s*:\s*"([^"]+)"/);
120
+ if (m)
121
+ errorCode = m[1];
122
+ }
123
+ catch {
124
+ /* body may be empty/unparseable */
125
+ }
126
+ logMcpEvent(serverName, "oauth_refresh_failed", { httpStatus: response.status, errorCode });
127
+ }
128
+ }
129
+ }
130
+ catch {
131
+ // instrumentation must never break the fetch chain
132
+ }
133
+ return response;
134
+ };
135
+ }
136
+ /** Fallback loopback port for the transport-level (refresh-only) provider. */
137
+ export const DEFAULT_REDIRECT_PORT = 42000;
138
+ /**
139
+ * Build a provider for a server at connect time. The redirect URL is only used
140
+ * when the SDK needs to (re)authorize; refresh- and access-token paths ignore
141
+ * it, so a stable default port is fine here. `authenticate()` binds its own
142
+ * fresh port and builds a dedicated provider for the interactive flow.
143
+ */
144
+ export function authProviderForServer(serverName, config, redirectUrl) {
145
+ const fixed = config.oauth?.callbackPort;
146
+ const uri = redirectUrl ?? buildRedirectUri(fixed ?? DEFAULT_REDIRECT_PORT);
147
+ return new YagniAuthProvider(serverName, config, uri);
148
+ }
149
+ /**
150
+ * The OAuthClientProvider the SDK drives. One instance per connection; the DCR
151
+ * client info and tokens persist in the auth store (not in memory) so a
152
+ * reconnect reads what the previous connection stored.
153
+ */
154
+ export class YagniAuthProvider {
155
+ serverName;
156
+ config;
157
+ redirectUri;
158
+ _codeVerifier;
159
+ _state;
160
+ _authorizationUrl;
161
+ constructor(serverName, config, redirectUri) {
162
+ this.serverName = serverName;
163
+ this.config = config;
164
+ this.redirectUri = redirectUri;
165
+ }
166
+ get redirectUrl() {
167
+ return this.redirectUri;
168
+ }
169
+ /** The authorization URL the SDK last asked us to redirect to. */
170
+ get authorizationUrl() {
171
+ return this._authorizationUrl;
172
+ }
173
+ get clientMetadata() {
174
+ return {
175
+ client_name: `YAGNI Code (${this.serverName})`,
176
+ redirect_uris: [this.redirectUri],
177
+ grant_types: ["authorization_code", "refresh_token"],
178
+ response_types: ["code"],
179
+ token_endpoint_auth_method: "none",
180
+ };
181
+ }
182
+ async state() {
183
+ this._state ??= randomBytes(32).toString("base64url");
184
+ return this._state;
185
+ }
186
+ async clientInformation() {
187
+ const entry = getStoredOAuthEntry(this.serverName, this.config);
188
+ if (!entry?.clientId)
189
+ return undefined;
190
+ return {
191
+ client_id: entry.clientId,
192
+ ...(entry.clientSecret ? { client_secret: entry.clientSecret } : {}),
193
+ };
194
+ }
195
+ async saveClientInformation(info) {
196
+ updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
197
+ entry.clientId = info.client_id;
198
+ if (info.client_secret != null)
199
+ entry.clientSecret = info.client_secret;
200
+ });
201
+ }
202
+ async tokens() {
203
+ const entry = getStoredOAuthEntry(this.serverName, this.config);
204
+ if (!entry?.accessToken)
205
+ return undefined;
206
+ const expiresIn = entry.expiresAt ? Math.max(1, Math.floor((entry.expiresAt - Date.now()) / 1000)) : undefined;
207
+ return {
208
+ access_token: entry.accessToken,
209
+ token_type: "Bearer",
210
+ ...(entry.refreshToken ? { refresh_token: entry.refreshToken } : {}),
211
+ ...(expiresIn != null ? { expires_in: expiresIn } : {}),
212
+ ...(entry.scopes ? { scope: entry.scopes.join(" ") } : {}),
213
+ };
214
+ }
215
+ async saveTokens(tokens) {
216
+ const expiresIn = typeof tokens.expires_in === "number" ? tokens.expires_in : 3600;
217
+ updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
218
+ entry.accessToken = tokens.access_token;
219
+ if (tokens.refresh_token)
220
+ entry.refreshToken = tokens.refresh_token;
221
+ entry.expiresAt = Date.now() + expiresIn * 1000;
222
+ if (tokens.scope)
223
+ entry.scopes = tokens.scope.split(/\s+/).filter(Boolean);
224
+ });
225
+ }
226
+ async saveCodeVerifier(codeVerifier) {
227
+ this._codeVerifier = codeVerifier;
228
+ }
229
+ async codeVerifier() {
230
+ if (!this._codeVerifier)
231
+ throw new Error("No PKCE code verifier stored for this session");
232
+ return this._codeVerifier;
233
+ }
234
+ async redirectToAuthorization(authorizationUrl) {
235
+ this._authorizationUrl = authorizationUrl.toString();
236
+ logMcpEvent(this.serverName, "oauth_redirect", {
237
+ authorizationUrl: redactSensitiveUrlParams(this._authorizationUrl),
238
+ scope: authorizationUrl.searchParams.get("scope") ?? undefined,
239
+ });
240
+ }
241
+ async invalidateCredentials(scope) {
242
+ updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
243
+ if (scope === "client" || scope === "all") {
244
+ delete entry.clientId;
245
+ delete entry.clientSecret;
246
+ }
247
+ if (scope === "tokens" || scope === "all") {
248
+ delete entry.accessToken;
249
+ delete entry.refreshToken;
250
+ delete entry.expiresAt;
251
+ }
252
+ });
253
+ if (scope === "verifier" || scope === "all")
254
+ this._codeVerifier = undefined;
255
+ }
256
+ }
257
+ function traceOutcomeFromError(err) {
258
+ if (err instanceof AuthenticationCancelledError)
259
+ return "cancelled";
260
+ const msg = err instanceof Error ? err.message : String(err);
261
+ if (msg.includes("Authentication timeout"))
262
+ return "timeout";
263
+ if (msg.includes("state mismatch"))
264
+ return "state_mismatch";
265
+ if (msg.includes("OAuth error:"))
266
+ return "provider_denied";
267
+ if (msg.includes("authorization code") || msg.includes("token") || msg.includes("exchange"))
268
+ return "token_exchange_failed";
269
+ return "error";
270
+ }
271
+ export async function authenticate(serverName, config, deps = {}, opts = {}) {
272
+ const openUrl = deps.openUrl ?? defaultOpenUrl;
273
+ const timeoutMs = opts.timeoutMs ?? OAuthFlowTimeoutMs;
274
+ // Discovery and the token exchange go to the ${VAR}-expanded URL when the
275
+ // caller provides one; the provider below keeps the RAW config so the
276
+ // auth-store key (sha256 of type+url+headers) is stable across env changes.
277
+ const serverUrl = opts.serverUrl ?? config.url;
278
+ // Bind the loopback port FIRST so redirect_uri and the listener agree.
279
+ const fixedPort = config.oauth?.callbackPort;
280
+ const port = fixedPort ?? (await findFreePort());
281
+ const redirectUri = buildRedirectUri(port);
282
+ const provider = new YagniAuthProvider(serverName, config, redirectUri);
283
+ // Step 1: discovery + DCR + PKCE challenge; SDK calls redirectToAuthorization
284
+ // (captured on the provider) and returns REDIRECT. Bounded: a hung discovery
285
+ // or DCR endpoint must fail the flow AND close its socket, not hold the
286
+ // command handler hostage.
287
+ let first;
288
+ try {
289
+ first = await sdkAuthPhase("discovery", provider, { serverUrl }, { signal: opts.signal, timeoutMs });
290
+ }
291
+ catch (err) {
292
+ if (err instanceof AuthenticationCancelledError)
293
+ throw err;
294
+ logMcpEvent(serverName, "oauth_result", {
295
+ phase: "discovery",
296
+ outcome: traceOutcomeFromError(err),
297
+ errorDescription: err instanceof Error ? err.message.slice(0, 300) : String(err),
298
+ });
299
+ throw err;
300
+ }
301
+ if (first !== "REDIRECT") {
302
+ // Cached tokens refreshed cleanly — already authorized.
303
+ const tokens = await provider.tokens();
304
+ logMcpEvent(serverName, "oauth_result", { outcome: "refreshed", redirectUri });
305
+ return { result: "AUTHORIZED", tokens };
306
+ }
307
+ const authorizationUrl = provider.authorizationUrl;
308
+ if (!authorizationUrl) {
309
+ logMcpEvent(serverName, "oauth_result", { phase: "redirect", outcome: "error", errorDescription: "SDK did not produce an authorization URL", redirectUri });
310
+ throw new Error("SDK did not produce an authorization URL");
311
+ }
312
+ // Bind the loopback listener FIRST, then open the browser (Claude Code
313
+ // parity: `server.listen` before `openBrowser`), so a fast redirect can never
314
+ // race the bind. The wait itself is bounded by the timeout below, so a source
315
+ // that errors before redirecting (Sentry's consent 500) can no longer wedge
316
+ // the command loop forever.
317
+ const state = await provider.state();
318
+ const codePromise = waitForCode(port, state, { signal: opts.signal, timeoutMs, onEvent: (evt, fields) => logMcpEvent(serverName, evt, { ...fields, redirectUri }) });
319
+ await openUrl(authorizationUrl);
320
+ let code;
321
+ try {
322
+ code = await codePromise;
323
+ }
324
+ catch (err) {
325
+ logMcpEvent(serverName, "oauth_result", { phase: "callback", outcome: traceOutcomeFromError(err), errorDescription: err instanceof Error ? err.message.slice(0, 300) : String(err), redirectUri });
326
+ throw err;
327
+ }
328
+ if (!code) {
329
+ logMcpEvent(serverName, "oauth_result", { phase: "callback", outcome: "provider_denied", errorDescription: "no authorization code received", redirectUri });
330
+ throw new Error("OAuth cancelled — no authorization code received");
331
+ }
332
+ // Step 3: exchange the code for tokens (SDK persists them via saveTokens).
333
+ // Bounded like discovery: a hung token endpoint must fail, not wedge.
334
+ let result;
335
+ try {
336
+ result = await sdkAuthPhase("token exchange", provider, { serverUrl, authorizationCode: code }, { signal: opts.signal, timeoutMs });
337
+ }
338
+ catch (err) {
339
+ if (err instanceof AuthenticationCancelledError)
340
+ throw err;
341
+ logMcpEvent(serverName, "oauth_result", { phase: "exchange", outcome: traceOutcomeFromError(err), errorDescription: err instanceof Error ? err.message.slice(0, 300) : String(err) });
342
+ throw err;
343
+ }
344
+ if (result !== "AUTHORIZED") {
345
+ logMcpEvent(serverName, "oauth_result", { phase: "exchange", outcome: "token_exchange_failed", errorDescription: `result: ${result}` });
346
+ throw new Error(`OAuth flow did not complete (result: ${result})`);
347
+ }
348
+ logMcpEvent(serverName, "oauth_result", { outcome: "authorized", redirectUri });
349
+ return { result, tokens: await provider.tokens() };
350
+ }
351
+ /** Find a free loopback port (OS-assigned). */
352
+ export function findFreePort() {
353
+ return new Promise((resolve, reject) => {
354
+ const probe = createServer();
355
+ probe.once("error", reject);
356
+ probe.listen(0, "127.0.0.1", () => {
357
+ const port = probe.address().port;
358
+ probe.close(() => resolve(port));
359
+ });
360
+ });
361
+ }
362
+ export function buildRedirectUri(port) {
363
+ return `http://127.0.0.1:${port}/callback`;
364
+ }
365
+ /** Listen on 127.0.0.1 for the callback; resolves with the code, rejects with a typed error on timeout/cancel/no-code. */
366
+ function waitForCode(port, expectedState, opts = {}) {
367
+ return new Promise((resolve, reject) => {
368
+ let server = null;
369
+ let timeoutId = null;
370
+ let settled = false;
371
+ const { onEvent } = opts;
372
+ const finish = (code) => {
373
+ if (settled)
374
+ return;
375
+ settled = true;
376
+ if (timeoutId)
377
+ clearTimeout(timeoutId);
378
+ if (server) {
379
+ try {
380
+ server.close();
381
+ }
382
+ catch {
383
+ /* best-effort */
384
+ }
385
+ }
386
+ resolve(code);
387
+ };
388
+ const fail = (err) => {
389
+ if (settled)
390
+ return;
391
+ settled = true;
392
+ if (timeoutId)
393
+ clearTimeout(timeoutId);
394
+ if (server) {
395
+ try {
396
+ server.close();
397
+ }
398
+ catch {
399
+ /* best-effort */
400
+ }
401
+ }
402
+ reject(err);
403
+ };
404
+ if (opts.signal) {
405
+ if (opts.signal.aborted) {
406
+ onEvent?.("oauth_cancelled", {});
407
+ fail(new AuthenticationCancelledError());
408
+ return;
409
+ }
410
+ opts.signal.addEventListener("abort", () => {
411
+ onEvent?.("oauth_cancelled", {});
412
+ fail(new AuthenticationCancelledError());
413
+ }, { once: true });
414
+ }
415
+ server = createServer((req, res) => {
416
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
417
+ if (url.pathname !== "/callback") {
418
+ res.writeHead(404);
419
+ res.end();
420
+ return;
421
+ }
422
+ const state = url.searchParams.get("state");
423
+ const error = url.searchParams.get("error");
424
+ const errorDescription = url.searchParams.get("error_description");
425
+ const errorUri = url.searchParams.get("error_uri");
426
+ const code = url.searchParams.get("code") ?? undefined;
427
+ if (error) {
428
+ onEvent?.("oauth_callback_error", { errorCode: error, errorDescription, errorUri });
429
+ res.writeHead(200, { "Content-Type": "text/html" });
430
+ // The provider-controlled values are HTML-escaped: any local process
431
+ // can hit the loopback with a crafted ?error=<script> payload, and the
432
+ // page must never reflect it (same spot Claude Code sanitizes).
433
+ res.end(renderAuthErrorPage({
434
+ error: escapeHtml(error),
435
+ errorDescription: errorDescription ? escapeHtml(errorDescription) : undefined,
436
+ }));
437
+ finish(undefined);
438
+ return;
439
+ }
440
+ if (state !== expectedState) {
441
+ onEvent?.("oauth_callback_state_mismatch", {});
442
+ res.writeHead(400, { "Content-Type": "text/html" });
443
+ res.end(renderAuthStateMismatchPage());
444
+ fail(new Error("OAuth state mismatch - possible CSRF attack"));
445
+ return;
446
+ }
447
+ onEvent?.("oauth_callback_code", {});
448
+ res.writeHead(200, { "Content-Type": "text/html" });
449
+ res.end(renderAuthSuccessPage());
450
+ finish(code);
451
+ });
452
+ server.on("error", () => {
453
+ onEvent?.("oauth_cancelled", {});
454
+ fail(new Error("OAuth callback server failed"));
455
+ });
456
+ server.listen(port, "127.0.0.1");
457
+ server.unref();
458
+ const timeoutMs = opts.timeoutMs ?? OAuthFlowTimeoutMs;
459
+ timeoutId = setTimeout(() => {
460
+ onEvent?.("oauth_timeout", {});
461
+ fail(new Error("Authentication timeout"));
462
+ }, timeoutMs);
463
+ timeoutId.unref();
464
+ });
465
+ }
466
+ /**
467
+ * Revoke access + refresh tokens on the server (RFC 7009) — refresh first,
468
+ * access second, both best-effort. Public clients put client_id in the body
469
+ * (no Authorization header); a 401 falls back to Bearer auth for
470
+ * non-compliant servers (CC parity). Never throws.
471
+ */
472
+ export async function revokeServerTokens(serverName, config, metadata, clientInfo, deps = {}) {
473
+ const fetchFn = deps.fetch ?? fetch;
474
+ const rawEndpoint = metadata.revocation_endpoint;
475
+ if (!rawEndpoint)
476
+ return;
477
+ const endpoint = rawEndpoint instanceof URL ? rawEndpoint.toString() : rawEndpoint;
478
+ const entry = getStoredOAuthEntry(serverName, config);
479
+ if (!entry)
480
+ return;
481
+ const clientId = clientInfo.client_id;
482
+ const secret = clientInfo.client_secret;
483
+ const revokeOne = async (token, hint) => {
484
+ if (!token)
485
+ return;
486
+ const body = new URLSearchParams({ token, token_type_hint: hint });
487
+ if (clientId)
488
+ body.set("client_id", clientId);
489
+ const baseHeaders = { "Content-Type": "application/x-www-form-urlencoded" };
490
+ if (secret)
491
+ baseHeaders.Authorization = `Basic ${Buffer.from(`${clientId}:${secret}`).toString("base64")}`;
492
+ try {
493
+ let res = await fetchFn(endpoint, { method: "POST", headers: baseHeaders, body });
494
+ if (!secret && res.status === 401) {
495
+ res = await fetchFn(endpoint, {
496
+ method: "POST",
497
+ headers: { ...baseHeaders, Authorization: `Bearer ${token}` },
498
+ body,
499
+ });
500
+ }
501
+ void res;
502
+ }
503
+ catch {
504
+ // best-effort
505
+ }
506
+ };
507
+ await revokeOne(entry.refreshToken, "refresh_token");
508
+ await revokeOne(entry.accessToken, "access_token");
509
+ }
510
+ /** Best-effort browser open without a shell (injected in tests).
511
+ *
512
+ * A no-op under the test runner (crashReport.ts's runningUnderTest): a test
513
+ * that forgets to inject `openUrl` must not pop a real browser window on the
514
+ * developer's machine. Same philosophy as crash-report suppression — make the
515
+ * whole class of leak impossible rather than fixing one call site at a time.
516
+ */
517
+ async function defaultOpenUrl(url) {
518
+ if (runningUnderTest())
519
+ return;
520
+ const { spawn } = await import("node:child_process");
521
+ const platform = process.platform;
522
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
523
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
524
+ try {
525
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
526
+ }
527
+ catch {
528
+ // best-effort — the URL is surfaced to the user by the panel caller
529
+ }
530
+ }
531
+ /**
532
+ * Revoke tokens + clear stored credentials for a server being removed. Runs
533
+ * RFC 9728/8414 discovery to find the revocation endpoint, revokes (refresh →
534
+ * access, best-effort), then deletes the local auth store entry. Callers use
535
+ * this so `yagni mcp remove` also logs the server out rather than orphaning
536
+ * live tokens on disk. Never throws — a failed revoke must not block removal.
537
+ */
538
+ export async function revokeTokensOnRemove(serverName, config, deps = {}) {
539
+ try {
540
+ // Discovery needs the resolvable (${VAR}-expanded) URL; store operations
541
+ // below key off the raw config. Best-effort: an unresolvable var leaves
542
+ // the raw URL, discovery fails, and the local entry is still cleared.
543
+ const discoveryUrl = expandServerEnv(config).config.url;
544
+ const info = await discoverOAuthServerInfo(discoveryUrl, { fetchFn: deps.fetch });
545
+ const metadata = info.authorizationServerMetadata;
546
+ const entry = getStoredOAuthEntry(serverName, config);
547
+ if (metadata && entry?.clientId) {
548
+ const clientInfo = {
549
+ client_id: entry.clientId,
550
+ ...(entry.clientSecret ? { client_secret: entry.clientSecret } : {}),
551
+ };
552
+ await revokeServerTokens(serverName, config, metadata, clientInfo, deps);
553
+ }
554
+ }
555
+ catch {
556
+ // discovery or revoke failed — still clear locally below
557
+ }
558
+ clearStoredOAuthEntry(serverName, config);
559
+ }
560
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * OAuth token + client-credential store for MCP servers, pure file I/O (no
3
+ * network, no pi imports) so it is shared verbatim between the extension
4
+ * session and the `yagni mcp` CLI (revoke-on-remove reads it too).
5
+ *
6
+ * ~/.yagni-code/mcp-auth.json (0600 — access/refresh tokens and any
7
+ * pre-registered client secret are secrets)
8
+ *
9
+ * Entries are keyed `serverName|<sha256(type+url+headers)[:16]>` — the same
10
+ * key Claude Code derives via `getServerKey`, so a server whose URL/headers
11
+ * change invalidates its stored credentials automatically. Writes are atomic
12
+ * (temp + rename) and chmod 0600, exactly like the mcp.json config writes.
13
+ */
14
+ import type { McpHttpServerConfig } from "./config.js";
15
+ export interface StoredOAuthEntry {
16
+ serverName: string;
17
+ serverUrl: string;
18
+ /** Client id from DCR, or a pre-registered `oauth.clientId`. */
19
+ clientId?: string;
20
+ /** Pre-registered client secret (`yagni mcp add --client-secret`), NOT a DCR secret. */
21
+ clientSecret?: string;
22
+ accessToken?: string;
23
+ refreshToken?: string;
24
+ /** Epoch ms when the access token expires (refresh happens before this). */
25
+ expiresAt?: number;
26
+ scopes?: string[];
27
+ }
28
+ export interface McpAuthFile {
29
+ servers?: Record<string, StoredOAuthEntry>;
30
+ }
31
+ export declare function _setMcpAuthHomeForTest(dir: string | null): void;
32
+ export declare function mcpAuthPath(): string;
33
+ /**
34
+ * The stable key for one server's credentials. Hash covers type + url +
35
+ * headers (NOT the client id, matching Claude Code) so a URL/header change
36
+ * orphans old tokens rather than silently reusing them against a different
37
+ * endpoint.
38
+ */
39
+ export declare function getServerKey(serverName: string, config: McpHttpServerConfig): string;
40
+ /** Read + parse the auth store; missing/unparseable → empty with error noted. */
41
+ export declare function readMcpAuth(): {
42
+ file: McpAuthFile;
43
+ errors: string[];
44
+ };
45
+ /** Atomic write + chmod 0600 (tokens/secrets must never be world-readable). */
46
+ export declare function writeMcpAuth(file: McpAuthFile): void;
47
+ /** Read one entry by server key; undefined when absent. */
48
+ export declare function getStoredOAuthEntry(serverName: string, config: McpHttpServerConfig): StoredOAuthEntry | undefined;
49
+ /**
50
+ * Read-modify-write one entry. `mutate` receives the current entry (or a fresh
51
+ * one) and may edit it in place; returning `false` deletes it. Used by both
52
+ * the provider (save tokens/creds) and revocation (clear on remove).
53
+ */
54
+ export declare function updateStoredOAuthEntry(serverName: string, config: McpHttpServerConfig, mutate: (entry: StoredOAuthEntry) => void | false): {
55
+ errors: string[];
56
+ };
57
+ /** Remove every credential entry (used by revoke-on-remove). */
58
+ export declare function deleteStoredOAuthEntry(serverName: string, config: McpHttpServerConfig): {
59
+ errors: string[];
60
+ };
61
+ //# sourceMappingURL=authStore.d.ts.map