From 3b47ee93e9662ab0a5c1d1b0ac178a6983dcfe5c Mon Sep 17 00:00:00 2001 From: danzaio <213864024+danzaio@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:16:44 -0300 Subject: [PATCH 1/4] fix(mcp): accept manual OAuth redirect input --- .../controllers/mcp-command-controller.ts | 7 ++++ packages/coding-agent/test/oauth-flow.test.ts | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+) 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..53bcadc60 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -591,6 +591,7 @@ export class MCPCommandController { const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined; const resolvedClientSecret = clientSecret.trim() || undefined; + const manualInput = this.ctx.oauthManualInput; try { // Create OAuth flow const flow = new MCPOAuthFlow( @@ -620,6 +621,9 @@ export class MCPCommandController { 0, ), ); + block.addChild( + new Text(theme.fg("muted", "Headless? Paste the redirect URL or code with /login ."), 1, 0), + ); block.addChild(new Spacer(1)); block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0)); // Try to open browser automatically @@ -644,6 +648,7 @@ export class MCPCommandController { onProgress: (message: string) => { this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]); }, + onManualCodeInput: () => manualInput.waitForInput("mcp"), }, ); @@ -687,6 +692,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/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"); + }); }); From 8e6f6c13e500e8d6d397235e42522be6e917edd9 Mon Sep 17 00:00:00 2001 From: danzaio <213864024+danzaio@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:55:21 -0300 Subject: [PATCH 2/4] fix(mcp): cancel manual OAuth waits on timeout --- packages/coding-agent/CHANGELOG.md | 4 ++++ .../controllers/mcp-command-controller.ts | 24 +++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee70e90fe..b0ab9c1b2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -133,6 +133,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 53bcadc60..8f26e9740 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)); } @@ -592,6 +597,7 @@ export class MCPCommandController { const resolvedClientSecret = clientSecret.trim() || undefined; const manualInput = this.ctx.oauthManualInput; + const oauthTimeout = new AbortController(); try { // Create OAuth flow const flow = new MCPOAuthFlow( @@ -621,9 +627,7 @@ export class MCPCommandController { 0, ), ); - block.addChild( - new Text(theme.fg("muted", "Headless? Paste the redirect URL or code with /login ."), 1, 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 @@ -648,12 +652,18 @@ export class MCPCommandController { onProgress: (message: string) => { this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]); }, - onManualCodeInput: () => manualInput.waitForInput("mcp"), + onManualCodeInput: () => manualInput.waitForInput(MCP_MANUAL_INPUT_PROVIDER_ID), + 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), From 314cea633ac00b208b4a8e2874b58a5beeced302 Mon Sep 17 00:00:00 2001 From: danzaio <213864024+danzaio@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:15:17 -0300 Subject: [PATCH 3/4] fix(mcp): preserve existing manual login waits --- .../modes/controllers/mcp-command-controller.ts | 16 ++++++++++++++-- .../coding-agent/src/modes/oauth-manual-input.ts | 5 +++++ .../coding-agent/test/oauth-manual-input.test.ts | 11 +++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) 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 8f26e9740..cfbef3400 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -597,6 +597,11 @@ export class MCPCommandController { 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 @@ -652,8 +657,15 @@ export class MCPCommandController { onProgress: (message: string) => { this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]); }, - onManualCodeInput: () => manualInput.waitForInput(MCP_MANUAL_INPUT_PROVIDER_ID), - signal: oauthTimeout.signal, + 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; + }, }, ); 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-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(); From 7db4d143e16ceb03dcd97a2683a776f47fc456dd Mon Sep 17 00:00:00 2001 From: danzaio <213864024+danzaio@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:48:34 -0300 Subject: [PATCH 4/4] fix(mcp): wire manual OAuth abort signal --- .../coding-agent/src/modes/controllers/mcp-command-controller.ts | 1 + 1 file changed, 1 insertion(+) 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 cfbef3400..8f292c5ec 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -666,6 +666,7 @@ export class MCPCommandController { } return pendingInput; }, + signal: oauthTimeout.signal, }, );