From 85219821ffc0aeecf5d965e9f692fa8e56ddb0ec Mon Sep 17 00:00:00 2001 From: julia Date: Mon, 23 Feb 2026 06:21:32 +0000 Subject: [PATCH] feat(oauth): Enabled manual OAuth callbacks via slash command input - Introduced OAuthManualInputManager and routed callback-server logins to await pasted redirect URLs with a visible tip. - Extended /login slash command to submit redirect URLs, block overlapping logins, and covered manual callbacks in tests. - Propagated onManualCodeInput through OAuth provider login helpers with a default authorization prompt. - Resolved docs index generation paths using path.resolve relative to the script directory. --- packages/ai/CHANGELOG.md | 2 + packages/ai/src/auth-storage.ts | 27 +++++-- packages/coding-agent/CHANGELOG.md | 7 +- .../scripts/generate-docs-index.ts | 4 +- .../modes/controllers/selector-controller.ts | 22 +++++ .../src/modes/interactive-mode.ts | 3 + .../src/modes/oauth-manual-input.ts | 42 ++++++++++ packages/coding-agent/src/modes/types.ts | 2 + .../src/slash-commands/builtin-registry.ts | 27 ++++++- .../test/oauth-manual-input.test.ts | 31 +++++++ .../test/slash-commands/login.test.ts | 80 +++++++++++++++++++ 11 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/src/modes/oauth-manual-input.ts create mode 100644 packages/coding-agent/test/oauth-manual-input.test.ts create mode 100644 packages/coding-agent/test/slash-commands/login.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 86f26e17c..2e15c9e36 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +### Changed +- Allowed OAuth provider logins to supply a manual authorization code handler with a default prompt when none is provided ## [12.19.1] - 2026-02-22 ### Added diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index e811b45fb..04766cecd 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -726,12 +726,12 @@ export class AuthStorage { } await this.set(provider, [...existing, newCredential]); }; + const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }); switch (provider) { case "anthropic": credentials = await loginAnthropic({ ...ctrl, - onManualCodeInput: async () => - ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }), + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, }); break; case "github-copilot": @@ -743,16 +743,28 @@ export class AuthStorage { }); break; case "google-gemini-cli": - credentials = await loginGeminiCli(ctrl); + credentials = await loginGeminiCli({ + ...ctrl, + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, + }); break; case "google-antigravity": - credentials = await loginAntigravity(ctrl); + credentials = await loginAntigravity({ + ...ctrl, + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, + }); break; case "openai-codex": - credentials = await loginOpenAICodex(ctrl); + credentials = await loginOpenAICodex({ + ...ctrl, + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, + }); break; case "gitlab-duo": - credentials = await loginGitLabDuo(ctrl); + credentials = await loginGitLabDuo({ + ...ctrl, + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, + }); break; case "kimi-code": credentials = await loginKimi(ctrl); @@ -873,8 +885,7 @@ export class AuthStorage { onAuth: info => ctrl.onAuth(info), onProgress: ctrl.onProgress, onPrompt: ctrl.onPrompt, - onManualCodeInput: async () => - ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }), + onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput, signal: ctrl.signal, }); if (typeof customLoginResult === "string") { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d57ac1ac..d2b0395e3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,12 +1,17 @@ # Changelog ## [Unreleased] -### Breaking Changes +### Breaking Changes - Removed `timeout` parameter from await tool—tool now waits indefinitely until jobs complete or the call is aborted - Renamed `job_ids` parameter to `jobs` in await tool schema - Removed `timedOut` field from await tool result details +### Added +- Added manual OAuth login flow that lets users paste redirect URLs with /login for callback-server providers and prevents overlapping logins + +### Changed +- Resolved docs index generation paths using path.resolve relative to the script directory ## [13.1.1] - 2026-02-23 ### Fixed diff --git a/packages/coding-agent/scripts/generate-docs-index.ts b/packages/coding-agent/scripts/generate-docs-index.ts index 420dc48f9..6a5ac88cb 100644 --- a/packages/coding-agent/scripts/generate-docs-index.ts +++ b/packages/coding-agent/scripts/generate-docs-index.ts @@ -3,8 +3,8 @@ import { Glob } from "bun"; import * as path from "node:path"; -const docsDir = new URL("../../../docs/", import.meta.url).pathname; -const outputPath = new URL("../src/internal-urls/docs-index.generated.ts", import.meta.url).pathname; +const docsDir = path.resolve(import.meta.dir, "../../../docs"); +const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.ts"); const glob = new Glob("**/*.md"); const entries: string[] = []; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 2c92ae162..1903aecb8 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -31,6 +31,16 @@ import { ToolExecutionComponent } from "../components/tool-execution"; import { TreeSelectorComponent } from "../components/tree-selector"; import { UserMessageSelectorComponent } from "../components/user-message-selector"; +const CALLBACK_SERVER_PROVIDERS = new Set([ + "anthropic", + "openai-codex", + "gitlab-duo", + "google-gemini-cli", + "google-antigravity", +]); + +const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login ."; + export class SelectorController { constructor(private ctx: InteractiveModeContext) {} @@ -600,6 +610,9 @@ export class SelectorController { done(); if (mode === "login") { this.ctx.showStatus(`Logging in to ${providerId}…`); + const manualInput = this.ctx.oauthManualInput; + const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider); + const manualInputPromise = useManualInput ? manualInput.waitForInput(providerId) : undefined; try { await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { onAuth: (info: { url: string; instructions?: string }) => { @@ -612,6 +625,10 @@ export class SelectorController { this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0)); } + if (useManualInput) { + this.ctx.chatContainer.addChild(new Spacer(1)); + this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0)); + } this.ctx.ui.requestRender(); this.ctx.openInBrowser(info.url); }, @@ -641,6 +658,7 @@ export class SelectorController { this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); this.ctx.ui.requestRender(); }, + onManualCodeInput: manualInputPromise ? () => manualInputPromise : undefined, }); // Refresh models to pick up new baseUrl (e.g., github-copilot) await this.ctx.session.modelRegistry.refresh(); @@ -658,6 +676,10 @@ export class SelectorController { this.ctx.ui.requestRender(); } catch (error: unknown) { this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + if (useManualInput) { + manualInput.clear(`Manual OAuth input cleared for ${providerId}`); + } } } else { try { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index f32562d47..42117cdfd 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -52,6 +52,7 @@ import { InputController } from "./controllers/input-controller"; import { MCPCommandController } from "./controllers/mcp-command-controller"; import { SelectorController } from "./controllers/selector-controller"; import { SSHCommandController } from "./controllers/ssh-command-controller"; +import { OAuthManualInputManager } from "./oauth-manual-input"; import { setMermaidRenderCallback } from "./theme/mermaid-cache"; import type { Theme } from "./theme/theme"; import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme"; @@ -134,6 +135,7 @@ export class InteractiveMode implements InteractiveModeContext { lastStatusText: Text | undefined = undefined; fileSlashCommands: Set = new Set(); skillCommands: Map = new Map(); + oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager(); #pendingSlashCommands: SlashCommand[] = []; #cleanupUnsubscribe?: () => void; @@ -1285,3 +1287,4 @@ export class InteractiveMode implements InteractiveModeContext { this.#eventController.subscribeToAgent(); } } + diff --git a/packages/coding-agent/src/modes/oauth-manual-input.ts b/packages/coding-agent/src/modes/oauth-manual-input.ts new file mode 100644 index 000000000..aa0d0b1b8 --- /dev/null +++ b/packages/coding-agent/src/modes/oauth-manual-input.ts @@ -0,0 +1,42 @@ +type PendingInput = { + providerId: string; + resolve: (value: string) => void; + reject: (error: Error) => void; +}; + +export class OAuthManualInputManager { + #pending?: PendingInput; + + waitForInput(providerId: string): Promise { + if (this.#pending) { + this.clear("Manual OAuth input superseded by a new login"); + } + + const { promise, resolve, reject } = Promise.withResolvers(); + this.#pending = { providerId, resolve, reject }; + return promise; + } + + submit(input: string): boolean { + if (!this.#pending) return false; + const { resolve } = this.#pending; + this.#pending = undefined; + resolve(input); + return true; + } + + clear(reason = "Manual OAuth input cleared"): void { + if (!this.#pending) return; + const { reject } = this.#pending; + this.#pending = undefined; + reject(new Error(reason)); + } + + hasPending(): boolean { + return Boolean(this.#pending); + } + + get pendingProviderId(): string | undefined { + return this.#pending?.providerId; + } +} diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 0c4e31c95..a9e59c4f6 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -19,6 +19,7 @@ import type { HookSelectorComponent } from "./components/hook-selector"; import type { PythonExecutionComponent } from "./components/python-execution"; import type { StatusLineComponent } from "./components/status-line"; import type { ToolExecutionHandle } from "./components/tool-execution"; +import type { OAuthManualInputManager } from "./oauth-manual-input"; import type { Theme } from "./theme/theme"; export type CompactionQueuedMessage = { @@ -97,6 +98,7 @@ export interface InteractiveModeContext { lastStatusText: Text | undefined; fileSlashCommands: Set; skillCommands: Map; + oauthManualInput: OAuthManualInputManager; todoPhases: TodoPhase[]; // Lifecycle diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 46148ae22..494dd9a29 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -259,7 +259,32 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "login", description: "Login with OAuth provider", - handle: (_command, runtime) => { + inlineHint: "[redirect URL]", + allowArgs: true, + handle: (command, runtime) => { + const manualInput = runtime.ctx.oauthManualInput; + const args = command.args.trim(); + if (args.length > 0) { + const submitted = manualInput.submit(args); + if (submitted) { + runtime.ctx.showStatus("OAuth callback received; completing login…"); + } else { + runtime.ctx.showWarning("No OAuth login is waiting for a manual callback."); + } + runtime.ctx.editor.setText(""); + return; + } + + if (manualInput.hasPending()) { + const provider = manualInput.pendingProviderId; + const message = provider + ? `OAuth login already in progress for ${provider}. Paste the redirect URL with /login .` + : "OAuth login already in progress. Paste the redirect URL with /login ."; + runtime.ctx.showWarning(message); + runtime.ctx.editor.setText(""); + return; + } + void runtime.ctx.showOAuthSelector("login"); runtime.ctx.editor.setText(""); }, diff --git a/packages/coding-agent/test/oauth-manual-input.test.ts b/packages/coding-agent/test/oauth-manual-input.test.ts new file mode 100644 index 000000000..164a61934 --- /dev/null +++ b/packages/coding-agent/test/oauth-manual-input.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { OAuthManualInputManager } from "@oh-my-pi/pi-coding-agent/modes/oauth-manual-input"; + +describe("OAuthManualInputManager", () => { + it("resolves waitForInput with submitted value", async () => { + const manager = new OAuthManualInputManager(); + const promise = manager.waitForInput("openai-codex"); + + const submitted = manager.submit("callback-url"); + + expect(submitted).toBe(true); + expect(await promise).toBe("callback-url"); + expect(manager.hasPending()).toBe(false); + }); + + it("returns false when no pending input", () => { + const manager = new OAuthManualInputManager(); + + expect(manager.submit("callback-url")).toBe(false); + }); + + it("clears pending input and rejects promise", async () => { + const manager = new OAuthManualInputManager(); + const promise = manager.waitForInput("anthropic"); + + manager.clear(); + + await expect(promise).rejects.toThrow("Manual OAuth input cleared"); + expect(manager.submit("late-url")).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/slash-commands/login.test.ts b/packages/coding-agent/test/slash-commands/login.test.ts new file mode 100644 index 000000000..b78ee1b3d --- /dev/null +++ b/packages/coding-agent/test/slash-commands/login.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { OAuthManualInputManager } from "@oh-my-pi/pi-coding-agent/modes/oauth-manual-input"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; + +type RuntimeHarness = { + runtime: { ctx: InteractiveModeContext; handleBackgroundCommand: () => void }; + getStatus: () => string | undefined; + getWarning: () => string | undefined; + getSelectorMode: () => "login" | "logout" | undefined; +}; + +const createRuntimeHarness = (manualInput: OAuthManualInputManager): RuntimeHarness => { + let statusMessage: string | undefined; + let warningMessage: string | undefined; + let selectorMode: "login" | "logout" | undefined; + + const ctx = { + oauthManualInput: manualInput, + editor: { + setText: () => {}, + } as InteractiveModeContext["editor"], + showStatus: (message: string) => { + statusMessage = message; + }, + showWarning: (message: string) => { + warningMessage = message; + }, + showOAuthSelector: async (mode: "login" | "logout") => { + selectorMode = mode; + }, + } as InteractiveModeContext; + + return { + runtime: { + ctx, + handleBackgroundCommand: () => {}, + }, + getStatus: () => statusMessage, + getWarning: () => warningMessage, + getSelectorMode: () => selectorMode, + }; +}; + +describe("/login slash command", () => { + it("submits manual callback URL without opening selector", async () => { + const manualInput = new OAuthManualInputManager(); + const callbackUrl = "http://localhost:1455/auth/callback?code=abc&state=xyz"; + const pending = manualInput.waitForInput("openai-codex"); + const harness = createRuntimeHarness(manualInput); + + const handled = await executeBuiltinSlashCommand(`/login ${callbackUrl}`, harness.runtime); + + expect(handled).toBe(true); + expect(harness.getSelectorMode()).toBeUndefined(); + expect(harness.getStatus()).toBe("OAuth callback received; completing login…"); + expect(await pending).toBe(callbackUrl); + }); + + it("opens selector when no args are provided", async () => { + const manualInput = new OAuthManualInputManager(); + const harness = createRuntimeHarness(manualInput); + + const handled = await executeBuiltinSlashCommand("/login", harness.runtime); + + expect(handled).toBe(true); + expect(harness.getSelectorMode()).toBe("login"); + }); + + it("warns when no pending login exists for manual callback", async () => { + const manualInput = new OAuthManualInputManager(); + const harness = createRuntimeHarness(manualInput); + + const handled = await executeBuiltinSlashCommand("/login http://localhost/callback", harness.runtime); + + expect(handled).toBe(true); + expect(harness.getSelectorMode()).toBeUndefined(); + expect(harness.getWarning()).toBe("No OAuth login is waiting for a manual callback."); + }); +});