diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fdb3f0d37..59dda0140 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,8 +2,9 @@ ## [Unreleased] +### Changed +- Allowed OAuth provider logins to supply a manual authorization code handler with a default prompt when none is provided ## [13.2.0] - 2026-02-23 - ### Added - Added support for GitHub Copilot provider in strict mode for both openai-completions and openai-responses tool schemas diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 7802f5f4a..e1b7b1248 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -730,12 +730,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": @@ -747,16 +747,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); @@ -877,8 +889,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/ai/src/utils/oauth/callback-server.ts b/packages/ai/src/utils/oauth/callback-server.ts index 404ec75db..860ae9ad7 100644 --- a/packages/ai/src/utils/oauth/callback-server.ts +++ b/packages/ai/src/utils/oauth/callback-server.ts @@ -191,24 +191,24 @@ export abstract class OAuthCallbackFlow { }); // Manual input race (if supported) - // Errors from manual input should not abort the flow - only successful input wins the race if (this.ctrl.onManualCodeInput) { - const manualPromise = this.ctrl - .onManualCodeInput() - .then((input): CallbackResult => { - const parsed = parseCallbackInput(input); - if (!parsed.code) { - throw new Error("No authorization code found in input"); - } - if (expectedState && parsed.state && parsed.state !== expectedState) { - throw new Error("State mismatch - possible CSRF attack"); - } - return { code: parsed.code, state: parsed.state ?? "" }; - }) - .catch((): Promise => { - // On manual input error, wait forever - let callback or abort signal win - return new Promise(() => {}); - }); + const requestManualInput = this.ctrl.onManualCodeInput; + const manualPromise = (async (): Promise => { + while (true) { + const result = await Promise.race([ + callbackPromise, + requestManualInput() + .then((input): CallbackResult | null => { + const parsed = parseCallbackInput(input); + if (!parsed.code) return null; + if (expectedState && parsed.state && parsed.state !== expectedState) return null; + return { code: parsed.code, state: parsed.state ?? "" }; + }) + .catch((): CallbackResult | null => null), + ]); + if (result) return result; + } + })(); return Promise.race([callbackPromise, manualPromise]); } diff --git a/packages/ai/test/callback-server-manual-input.test.ts b/packages/ai/test/callback-server-manual-input.test.ts new file mode 100644 index 000000000..4583bf228 --- /dev/null +++ b/packages/ai/test/callback-server-manual-input.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import { OAuthCallbackFlow } from "../src/utils/oauth/callback-server"; +import type { OAuthCredentials } from "../src/utils/oauth/types"; + +class TestCallbackFlow extends OAuthCallbackFlow { + async generateAuthUrl(_state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { + return { url: `${redirectUri}?start=1` }; + } + + async exchangeToken(code: string, _state: string, _redirectUri: string): Promise { + return { + access: `access-${code}`, + refresh: "refresh-token", + expires: Date.now() + 60_000, + }; + } +} + +describe("OAuthCallbackFlow manual input retries", () => { + it("retries manual input until a valid callback payload is provided", async () => { + const attempts = ["http://localhost/callback?state=missing-code", "http://localhost/callback?code=valid-code"]; + let promptCount = 0; + + const flow = new TestCallbackFlow( + { + onAuth: () => {}, + onManualCodeInput: async () => { + const value = attempts[promptCount]; + promptCount += 1; + if (!value) { + throw new Error("unexpected extra manual input request"); + } + return value; + }, + signal: AbortSignal.timeout(1_000), + }, + 14555, + ); + + const credentials = await flow.login(); + + expect(promptCount).toBe(2); + expect(credentials.access).toBe("access-valid-code"); + }); + + it("retries when manual callback state does not match", async () => { + const attempts = [ + "http://localhost/callback?code=first-code&state=wrong-state", + "http://localhost/callback?code=second-code", + ]; + let promptCount = 0; + + const flow = new TestCallbackFlow( + { + onAuth: () => {}, + onManualCodeInput: async () => { + const value = attempts[promptCount]; + promptCount += 1; + if (!value) { + throw new Error("unexpected extra manual input request"); + } + return value; + }, + signal: AbortSignal.timeout(1_000), + }, + 14556, + ); + + const credentials = await flow.login(); + + expect(promptCount).toBe(2); + expect(credentials.access).toBe("access-second-code"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a0f3a1155..415d24d3e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,7 @@ - Added `task.isolation.commits` setting (`generic` or `ai`) for commit messages on isolated task branches and nested repos. `ai` mode uses a smol model to generate conventional commit messages from diffs - Nested non-submodule git repos are now discovered and handled during task isolation (changes captured and applied independently from parent repo) - Added `task.eager` setting to encourage the agent to delegate work to subagents by default +- Added manual OAuth login flow that lets users paste redirect URLs with /login for callback-server providers and prevents overlapping logins ### Fixed @@ -56,12 +57,14 @@ - Removed unused SSH resource cleanup functions `closeAllConnections` and `unmountAll` from session imports ## [13.1.2] - 2026-02-23 -### 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 +### 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 704818c09..4d2f1b05c 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,8 @@ 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); try { await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { onAuth: (info: { url: string; instructions?: string }) => { @@ -612,6 +624,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 +657,7 @@ export class SelectorController { this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); this.ctx.ui.requestRender(); }, + onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined, }); // Refresh models to pick up new baseUrl (e.g., github-copilot) await this.ctx.session.modelRegistry.refresh(); @@ -658,6 +675,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 f1698853b..688caab8f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -51,6 +51,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"; @@ -133,6 +134,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; 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..f47614fde --- /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 unknown 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."); + }); +});