diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 12317bfb4..0fe204ccc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -137,6 +137,10 @@ - Fixed Windows stdio MCP servers launched through PATH shims such as `codegraph.cmd` so bare commands like `codegraph` resolve via `PATHEXT` before spawn ([#2174](https://github.com/can1357/oh-my-pi/issues/2174)). - Fixed compiled-binary extensions failing to load `@oh-my-pi/pi-*` packages when `bun --compile` quietly dropped one of the extra entrypoints (observed on macOS arm64 release builds): the legacy-pi compat shim's package-root override branch returned the bunfs path without checking the target was present, so the rewrite emitted a `file://` URL to a missing module and the #1216 fallback (scoped to the throwing `getResolvedSpecifier` path) never ran. Override targets are now validated against the on-disk filesystem at module init, missing entries are dropped, and resolution falls through to canonical lookup so Bun resolves the import from the extension's own `node_modules` ([#2168](https://github.com/can1357/oh-my-pi/issues/2168)). +### Fixed + +- Fixed MCP OAuth flows accepting pasted redirect URLs or authorization codes through `/login` in headless environments ([#2122](https://github.com/can1357/oh-my-pi/issues/2122)). + ## [15.10.8] - 2026-06-09 ### Added diff --git a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts index bf0a5e79c..8f292c5ec 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -46,9 +46,14 @@ import { theme } from "../theme/theme"; import type { InteractiveModeContext } from "../types"; import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared"; -function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { +const MCP_MANUAL_INPUT_PROVIDER_ID = "mcp"; +const MCP_MANUAL_LOGIN_TIP = "Headless? Paste the redirect URL or code with /login ."; +function withTimeout(promise: Promise, timeoutMs: number, message: string, onTimeout?: () => void): Promise { const { promise: timeoutPromise, reject } = Promise.withResolvers(); - const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + const timer = setTimeout(() => { + onTimeout?.(); + reject(new Error(message)); + }, timeoutMs); return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); } @@ -591,6 +596,13 @@ export class MCPCommandController { const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined; const resolvedClientSecret = clientSecret.trim() || undefined; + const manualInput = this.ctx.oauthManualInput; + if (manualInput.hasPending() && manualInput.pendingProviderId !== MCP_MANUAL_INPUT_PROVIDER_ID) { + throw new Error( + `OAuth login already in progress for ${manualInput.pendingProviderId}. Complete or cancel it before starting MCP OAuth.`, + ); + } + const oauthTimeout = new AbortController(); try { // Create OAuth flow const flow = new MCPOAuthFlow( @@ -620,6 +632,7 @@ export class MCPCommandController { 0, ), ); + block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0)); block.addChild(new Spacer(1)); block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0)); // Try to open browser automatically @@ -644,11 +657,26 @@ export class MCPCommandController { onProgress: (message: string) => { this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]); }, + onManualCodeInput: () => { + const pendingInput = manualInput.tryWaitForInput(MCP_MANUAL_INPUT_PROVIDER_ID); + if (!pendingInput) { + throw new Error( + `OAuth login already in progress for ${manualInput.pendingProviderId}. Complete or cancel it before starting MCP OAuth.`, + ); + } + return pendingInput; + }, + signal: oauthTimeout.signal, }, ); // Execute OAuth flow with 5 minute timeout - const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes"); + const credentials = await withTimeout( + flow.login(), + 5 * 60 * 1000, + "OAuth flow timed out after 5 minutes", + () => oauthTimeout.abort("MCP OAuth flow timed out"), + ); this.ctx.present([ new Spacer(1), @@ -687,6 +715,8 @@ export class MCPCommandController { } else { throw new Error(`OAuth authentication failed: ${errorMsg}`); } + } finally { + manualInput.clear("Manual MCP OAuth input cleared"); } } diff --git a/packages/coding-agent/src/modes/oauth-manual-input.ts b/packages/coding-agent/src/modes/oauth-manual-input.ts index aa0d0b1b8..4591fcb1d 100644 --- a/packages/coding-agent/src/modes/oauth-manual-input.ts +++ b/packages/coding-agent/src/modes/oauth-manual-input.ts @@ -17,6 +17,11 @@ export class OAuthManualInputManager { return promise; } + tryWaitForInput(providerId: string): Promise | undefined { + if (this.#pending) return undefined; + return this.waitForInput(providerId); + } + submit(input: string): boolean { if (!this.#pending) return false; const { resolve } = this.#pending; diff --git a/packages/coding-agent/test/oauth-flow.test.ts b/packages/coding-agent/test/oauth-flow.test.ts index 684493a5e..cfa8e916a 100644 --- a/packages/coding-agent/test/oauth-flow.test.ts +++ b/packages/coding-agent/test/oauth-flow.test.ts @@ -346,4 +346,39 @@ describe("mcp oauth flow", () => { expect(flow.registeredClientSecret).toBeUndefined(); expect(registrationCalled).toBe(false); }); + + it("accepts pasted redirect URLs through manual input", async () => { + let tokenRequestBody = ""; + let manualAuthUrl = ""; + + const flow = new MCPOAuthFlow( + { + authorizationUrl: "https://provider.example/authorize", + tokenUrl: "https://provider.example/token", + clientId: "client-id", + callbackPort: 14570, + fetch: mockProviderTokenEndpoint(body => { + tokenRequestBody = body; + }), + }, + { + onAuth: info => { + manualAuthUrl = info.url; + }, + onManualCodeInput: async () => { + const authUrl = new URL(manualAuthUrl); + const redirectUri = authUrl.searchParams.get("redirect_uri") ?? ""; + const state = authUrl.searchParams.get("state") ?? ""; + return `${redirectUri}?code=manual-code&state=${encodeURIComponent(state)}`; + }, + signal: AbortSignal.timeout(1_000), + }, + ); + + const credentials = await flow.login(); + const tokenParams = new URLSearchParams(tokenRequestBody); + + expect(credentials.access).toBe("access-token"); + expect(tokenParams.get("code")).toBe("manual-code"); + }); }); diff --git a/packages/coding-agent/test/oauth-manual-input.test.ts b/packages/coding-agent/test/oauth-manual-input.test.ts index 164a61934..89dcc2653 100644 --- a/packages/coding-agent/test/oauth-manual-input.test.ts +++ b/packages/coding-agent/test/oauth-manual-input.test.ts @@ -13,6 +13,17 @@ describe("OAuthManualInputManager", () => { expect(manager.hasPending()).toBe(false); }); + it("does not replace pending input when using tryWaitForInput", async () => { + const manager = new OAuthManualInputManager(); + const first = manager.waitForInput("openai-codex"); + + expect(manager.tryWaitForInput("mcp")).toBeUndefined(); + expect(manager.pendingProviderId).toBe("openai-codex"); + + expect(manager.submit("callback-url")).toBe(true); + expect(await first).toBe("callback-url"); + }); + it("returns false when no pending input", () => { const manager = new OAuthManualInputManager();