diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 997e0b50c..532bb4143 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,8 @@ ### Fixed +- `/gpt-account add` now shows the OpenAI Codex login-method chooser as a real selector (`Browser login (default)` / `Device code login (headless)`) instead of an empty text input that failed with `Unknown OpenAI Codex login method:` on Enter. The device-code flow prints the user code next to the verification URL, the browser flow opens the browser in the terminal UI and still prints the URL, and the paste-the-code dialog closes by itself once the local callback completes the login. `/claude-account add` shares the same prompt relay ([#1485](https://github.com/code-yeongyu/senpi/issues/1485)). + - Anthropic requests no longer fail with `Tool reference '' not found in available tools` after a native tool search: references that come back under a gateway namespace (`mcp____`) are folded onto the request's own tool names before the request is sent, references that no longer resolve are dropped, and a search result left with no references is demoted to text instead of being replayed verbatim. A history tool call whose only justification was such a dangling reference is demoted like any other unavailable call, so one stale native search result can no longer hard-error the model and force a fallback. - The GPT-6 Astra prompt preset now does the work itself by default: anything that closes in a handful of calls is the model's own, a follow-up on work it delegated earlier is taken back rather than forwarded to the child, and only a sizeable independent track earns a subagent. The routing line opens a new request instead of every turn, so a steering message gets the work rather than a restatement of what was understood, and a new initiative rule consults stored memory for the user's preferences before asking anything memory may already answer. Observed across the 2026-09-06..08 sessions: Astra spent 15-39% of its tool calls on `task` / `task_send` against 2-4% for the Claude and Kimi presets on the same tools. diff --git a/packages/coding-agent/src/core/extensions/builtin/changes.md b/packages/coding-agent/src/core/extensions/builtin/changes.md index 2d744c533..4e1a127cf 100644 --- a/packages/coding-agent/src/core/extensions/builtin/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/changes.md @@ -1,5 +1,24 @@ # Builtin extensions changes +## Account commands relay OAuth login prompts through the extension UI (2026-09-08) + +### What changed + +- `packages/coding-agent/src/core/extensions/builtin/oauth-login-interaction.ts` (new): `createExtensionLoginInteraction(ctx, { providerLabel, openBrowser? })` builds the `AuthInteraction` an account command hands to `modelRuntime.login`. `select` prompts go to `ctx.ui.select` over the option labels and the chosen label is mapped back to the option id; `text`, `secret` and `manual_code` prompts go to `ctx.ui.input` with the provider's placeholder; every dialog carries the command signal combined with the per-prompt `AuthPrompt.signal`, and a dismissed or aborted dialog rejects with `Login cancelled`. `auth_url` events open the browser when `ctx.mode === "tui"` and always print the URL plus the provider's instructions; `device_code` events print the verification URL together with `Enter code: `; `info` events print their links. +- `packages/coding-agent/src/core/extensions/builtin/gpt-account.ts`: `addAccount` uses the shared interaction instead of relaying every prompt to `ctx.ui.input(prompt.message)`; the factory accepts an optional `GptAccountExtensionDeps` (`openBrowser`) so tests can observe the browser launch. + +### Why + +- code-yeongyu/senpi#1485: `/gpt-account add` rendered `Select OpenAI Codex login method:` as an empty text input because the provider's `select` prompt was relayed as text, so the two login methods were never shown and an empty Enter reached the provider as `Unknown OpenAI Codex login method:`. The device-code flow printed the verification URL without the user code, and the browser flow told the user "A browser window should open" without opening one. `/login` already routes these prompts correctly (`core/auth-storage.ts` `handleLegacyPrompt`, `modes/rpc/login-prompts.ts`); the account commands now share one relay with the same rules. + +### Why an extension could not handle it + +- The commands live in the builtin registry and the relay sits between `modelRuntime.login` and the provider flow, a seam no user extension can interpose on. + +### Expected merge conflict zones + +- LOW: `gpt-account.ts` is fork-only; `oauth-login-interaction.ts` is new. + ## Plugin-root containment resolves against the filesystem (2026-09-07) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/account-command.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/account-command.ts index da5c5e9fe..994ffbc4d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/account-command.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/account-command.ts @@ -1,5 +1,6 @@ import type { Credential } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionCommandContext } from "../../types.ts"; +import { createExtensionLoginInteraction, LOGIN_CANCELLED_MESSAGE } from "../oauth-login-interaction.ts"; import { emitProviderAccountsChanged } from "./account-events.ts"; import { CLAUDE_SDK_OAUTH_PROVIDER_ID, pinProviderAccount, removeProviderAccount } from "./account-management.ts"; import { type AccountSlot, type ClaudeSdkOauthCredential, emptyCredential, listAccounts } from "./accounts.ts"; @@ -13,8 +14,12 @@ type CommandEnvironment = (name: string) => string | undefined; export interface ClaudeAccountCommandDeps { loadSettings?: (cwd: string) => ClaudeSdkOauthProviderSettings; environment?: CommandEnvironment; + /** Browser launcher for the OAuth authorize URL; tests inject a recorder. */ + openBrowser?: ((url: string) => void) | undefined; } +const CLAUDE_SDK_OAUTH_PROVIDER_LABEL = "Claude SDK OAuth"; + function asCredential(value: Credential | undefined): ClaudeSdkOauthCredential | undefined { return value?.type === "oauth" ? (value as ClaudeSdkOauthCredential) : undefined; } @@ -49,18 +54,6 @@ function parseArgs(rawArgs: string): string[] { return rawArgs.trim().split(/\s+/).filter(Boolean); } -function authEventMessage(event: unknown): string { - if (event === null || typeof event !== "object") return "Claude SDK OAuth authentication update."; - const value = event as Record; - if (value.type === "auth_url" && typeof value.url === "string") { - return `Open this URL to authorize Claude SDK OAuth:\n${value.url}`; - } - if (value.type === "device_code" && typeof value.verificationUri === "string") { - return `Open this URL to authorize Claude SDK OAuth:\n${value.verificationUri}`; - } - return typeof value.message === "string" ? value.message : "Claude SDK OAuth authentication update."; -} - export function getSessionClaudeAccountPin(sessionId: string | undefined): string | undefined { return sessionId === undefined ? undefined : cliPinsBySession.get(sessionId); } @@ -102,7 +95,7 @@ export function registerClaudeAccountCommand(pi: ExtensionAPI, deps: ClaudeAccou return; } if (action === "add") { - await addAccount(ctx); + await addAccount(ctx, deps); return; } if (action === "remove") { @@ -156,26 +149,27 @@ function showAccounts( ctx.ui.notify(lines.join("\n"), "info"); } -async function addAccount(ctx: ExtensionCommandContext): Promise { +async function addAccount(ctx: ExtensionCommandContext, deps: ClaudeAccountCommandDeps): Promise { if (!ctx.hasUI) { ctx.ui.notify("/claude-account add requires an interactive UI.", "error"); return; } try { - await ctx.modelRegistry.modelRuntime.login(CLAUDE_SDK_OAUTH_PROVIDER_ID, "oauth", { - signal: ctx.signal, - prompt: async (prompt) => { - const answer = await ctx.ui.input(prompt.message); - if (answer === undefined) throw new Error("Login cancelled"); - return answer; - }, - notify: (event) => ctx.ui.notify(authEventMessage(event), "info"), - }); + await ctx.modelRegistry.modelRuntime.login( + CLAUDE_SDK_OAUTH_PROVIDER_ID, + "oauth", + createExtensionLoginInteraction(ctx, { + providerLabel: CLAUDE_SDK_OAUTH_PROVIDER_LABEL, + openBrowser: deps.openBrowser, + }), + ); emitProviderAccountsChanged(CLAUDE_SDK_OAUTH_PROVIDER_ID); ctx.ui.notify("Claude SDK OAuth account added.", "info"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (message !== "Login cancelled") ctx.ui.notify(`Failed to add Claude SDK OAuth account: ${message}`, "error"); + if (message !== LOGIN_CANCELLED_MESSAGE) { + ctx.ui.notify(`Failed to add Claude SDK OAuth account: ${message}`, "error"); + } } } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index 398736b3c..860dc1d64 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -1,5 +1,23 @@ # claude-sdk-oauth +## 2026-09-08 - `/claude-account add` relays login prompts through the shared account-command interaction + +### What changed + +- `account-command.ts`: `addAccount` builds its `AuthInteraction` with `createExtensionLoginInteraction` from `../oauth-login-interaction.ts` instead of a local relay that sent every prompt to `ctx.ui.input(prompt.message)`. Prompts now honour their type and placeholder and are dismissed when the provider aborts them; `auth_url` opens the browser in the TUI and `device_code` prints the user code. `ClaudeAccountCommandDeps` gains an optional `openBrowser` so tests can observe the launch. The local `authEventMessage` helper is gone. + +### Why + +- code-yeongyu/senpi#1485 fixed the same relay shape in `/gpt-account add`; the Claude command shared the placeholder, per-prompt-signal and browser gaps, so both commands now use one implementation. + +### Why an extension could not handle it + +- The command is registered by the builtin provider extension and drives `modelRuntime.login` directly; no user extension can interpose on that relay. + +### Expected merge conflict zones + +- LOW: `account-command.ts` import block, `ClaudeAccountCommandDeps`, and `addAccount`. Fork-only file. + ## 2026-09-07 - Restart bindings survive ledger entries appended after the committed assistant ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/gpt-account.ts b/packages/coding-agent/src/core/extensions/builtin/gpt-account.ts index 54ef0c837..aa8ab4586 100644 --- a/packages/coding-agent/src/core/extensions/builtin/gpt-account.ts +++ b/packages/coding-agent/src/core/extensions/builtin/gpt-account.ts @@ -5,9 +5,15 @@ import { } from "../../../core/credential-accounts.ts"; import type { ExtensionAPI, ExtensionCommandContext } from "../types.ts"; import { emitProviderAccountsChanged } from "./claude-sdk-oauth/account-events.ts"; +import { createExtensionLoginInteraction, LOGIN_CANCELLED_MESSAGE } from "./oauth-login-interaction.ts"; const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; -const LOGIN_CANCELLED_MESSAGE = "Login cancelled"; +const OPENAI_CODEX_PROVIDER_LABEL = "OpenAI Codex OAuth"; + +export interface GptAccountExtensionDeps { + /** Browser launcher for the browser login method; tests inject a recorder. */ + readonly openBrowser?: ((url: string) => void) | undefined; +} function parseArgs(rawArgs: string): string[] { return rawArgs.trim().split(/\s+/).filter(Boolean); @@ -17,18 +23,6 @@ function usage(ctx: ExtensionCommandContext): void { ctx.ui.notify("Usage: /gpt-account [add | remove | pin | unpin]", "error"); } -function authEventMessage(event: unknown): string { - if (event === null || typeof event !== "object") return "OpenAI Codex OAuth authentication update."; - const value = event as Record; - if (value.type === "auth_url" && typeof value.url === "string") { - return `Open this URL to authorize OpenAI Codex OAuth:\n${value.url}`; - } - if (value.type === "device_code" && typeof value.verificationUri === "string") { - return `Open this URL to authorize OpenAI Codex OAuth:\n${value.verificationUri}`; - } - return typeof value.message === "string" ? value.message : "OpenAI Codex OAuth authentication update."; -} - async function showAccounts(ctx: ExtensionCommandContext): Promise { const accounts = await getCredentialAccounts(ctx.modelRegistry.authStorage, OPENAI_CODEX_PROVIDER_ID); const lines = ["OpenAI Codex OAuth accounts:"]; @@ -41,21 +35,20 @@ async function showAccounts(ctx: ExtensionCommandContext): Promise { ctx.ui.notify(lines.join("\n"), "info"); } -async function addAccount(ctx: ExtensionCommandContext): Promise { +async function addAccount(ctx: ExtensionCommandContext, deps: GptAccountExtensionDeps): Promise { if (!ctx.hasUI) { ctx.ui.notify("/gpt-account add requires an interactive UI.", "error"); return; } try { - await ctx.modelRegistry.modelRuntime.login(OPENAI_CODEX_PROVIDER_ID, "oauth", { - signal: ctx.signal, - prompt: async (prompt) => { - const answer = await ctx.ui.input(prompt.message); - if (answer === undefined) throw new Error(LOGIN_CANCELLED_MESSAGE); - return answer; - }, - notify: (event) => ctx.ui.notify(authEventMessage(event), "info"), - }); + await ctx.modelRegistry.modelRuntime.login( + OPENAI_CODEX_PROVIDER_ID, + "oauth", + createExtensionLoginInteraction(ctx, { + providerLabel: OPENAI_CODEX_PROVIDER_LABEL, + openBrowser: deps.openBrowser, + }), + ); emitProviderAccountsChanged(OPENAI_CODEX_PROVIDER_ID); ctx.ui.notify("OpenAI Codex OAuth account added.", "info"); } catch (error) { @@ -83,7 +76,7 @@ async function pinAccount(ctx: ExtensionCommandContext, name: string | undefined ctx.ui.notify(`Pinned OpenAI Codex OAuth account '${name}'.`, "info"); } -export default function gptAccountExtension(pi: ExtensionAPI): void { +export default function gptAccountExtension(pi: ExtensionAPI, deps: GptAccountExtensionDeps = {}): void { pi.registerCommand("gpt-account", { description: "List and manage OpenAI Codex OAuth accounts.", argumentHint: "[add | remove | pin | unpin]", @@ -96,7 +89,7 @@ export default function gptAccountExtension(pi: ExtensionAPI): void { return; } if (action === "add") { - await addAccount(ctx); + await addAccount(ctx, deps); return; } if (action === "remove") { diff --git a/packages/coding-agent/src/core/extensions/builtin/oauth-login-interaction.ts b/packages/coding-agent/src/core/extensions/builtin/oauth-login-interaction.ts new file mode 100644 index 000000000..bd3186b68 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/oauth-login-interaction.ts @@ -0,0 +1,111 @@ +/** + * Relay a provider OAuth login (`modelRuntime.login`) onto the extension UI of + * a slash command such as `/gpt-account add`, mirroring what the `/login` + * dialog and `modes/rpc/login-prompts.ts` do for their surfaces. + * + * Two decisions are not visible from the code alone: every dialog is bound to + * the per-prompt `AuthPrompt.signal` as well as the command signal, so a + * manual-code dialog is released when the provider's local callback server + * wins the race (`loginOpenAICodex`); and `auth_url` opens the browser only in + * the TUI, because an RPC client renders the notice on its own machine. + */ + +import type { AuthEvent, AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai"; +import { openBrowser as openPlatformBrowser } from "../../../utils/open-browser.ts"; +import type { ExtensionCommandContext } from "../types.ts"; + +export const LOGIN_CANCELLED_MESSAGE = "Login cancelled"; + +export interface ExtensionLoginInteractionOptions { + /** Provider name rendered in notices, e.g. "OpenAI Codex OAuth". */ + readonly providerLabel: string; + /** Browser launcher for `auth_url` events in the TUI; tests inject a recorder. */ + readonly openBrowser?: ((url: string) => void) | undefined; +} + +type LoginCommandContext = Pick; + +export function createExtensionLoginInteraction( + ctx: LoginCommandContext, + options: ExtensionLoginInteractionOptions, +): AuthInteraction { + const openBrowser = options.openBrowser ?? openPlatformBrowser; + return { + signal: ctx.signal, + prompt: (prompt) => relayPrompt(ctx, prompt), + notify: (event) => relayEvent(ctx, event, options.providerLabel, openBrowser), + }; +} + +function dialogSignal( + commandSignal: AbortSignal | undefined, + promptSignal: AbortSignal | undefined, +): AbortSignal | undefined { + if (commandSignal && promptSignal) return AbortSignal.any([commandSignal, promptSignal]); + return commandSignal ?? promptSignal; +} + +async function relayPrompt(ctx: LoginCommandContext, prompt: AuthPrompt): Promise { + const signal = dialogSignal(ctx.signal, prompt.signal); + if (signal?.aborted) throw new Error(LOGIN_CANCELLED_MESSAGE); + const dialogOptions = signal ? { signal } : undefined; + const answer = await answerPrompt(ctx, prompt, dialogOptions); + if (answer === undefined || signal?.aborted) throw new Error(LOGIN_CANCELLED_MESSAGE); + return answer; +} + +async function answerPrompt( + ctx: LoginCommandContext, + prompt: AuthPrompt, + dialogOptions: { signal: AbortSignal } | undefined, +): Promise { + switch (prompt.type) { + case "select": { + const label = await ctx.ui.select( + prompt.message, + prompt.options.map((option) => option.label), + dialogOptions, + ); + return prompt.options.find((option) => option.label === label)?.id; + } + case "text": + case "secret": + case "manual_code": + return ctx.ui.input(prompt.message, prompt.placeholder, dialogOptions); + } +} + +function relayEvent( + ctx: LoginCommandContext, + event: AuthEvent, + providerLabel: string, + openBrowser: (url: string) => void, +): void { + switch (event.type) { + case "auth_url": { + if (ctx.mode === "tui") openBrowser(event.url); + const lines = [`Open this URL to authorize ${providerLabel}:`, event.url]; + if (event.instructions) lines.push(event.instructions); + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + case "device_code": + ctx.ui.notify( + [ + `Open this URL to authorize ${providerLabel}:`, + event.verificationUri, + `Enter code: ${event.userCode}`, + ].join("\n"), + "info", + ); + return; + case "info": { + const links = (event.links ?? []).map((link) => (link.label ? `${link.label}: ${link.url}` : link.url)); + ctx.ui.notify([event.message, ...links].join("\n"), "info"); + return; + } + case "progress": + ctx.ui.notify(event.message, "info"); + return; + } +} diff --git a/packages/coding-agent/test/suite/account-command-harness.ts b/packages/coding-agent/test/suite/account-command-harness.ts new file mode 100644 index 000000000..2dd73cfb9 --- /dev/null +++ b/packages/coding-agent/test/suite/account-command-harness.ts @@ -0,0 +1,93 @@ +import type { AuthInteraction } from "@earendil-works/pi-ai"; +import type { AuthStorage } from "../../src/core/auth-storage.ts"; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionMode, + ExtensionUIDialogOptions, + RegisteredCommand, +} from "../../src/core/extensions/types.ts"; + +export type Command = Pick; +export type Notice = { message: string; type: "info" | "warning" | "error" | undefined }; +export type DialogCall = + | { kind: "select"; title: string; options: string[]; signal: AbortSignal | undefined } + | { kind: "input"; title: string; placeholder: string | undefined; signal: AbortSignal | undefined }; +export type DialogAnswers = { + select?: (title: string, options: string[]) => string | undefined; + input?: ( + title: string, + placeholder: string | undefined, + opts: ExtensionUIDialogOptions | undefined, + ) => Promise; +}; +export type ContextOptions = { mode?: ExtensionMode; dialogs?: DialogAnswers }; +export type LoginFn = (provider: string, method: string, interaction: AuthInteraction) => Promise; + +export type AccountCommandContext = { + ctx: ExtensionCommandContext; + notices: Notice[]; + dialogs: DialogCall[]; +}; + +export function registerCommand(name: string, register: (pi: ExtensionAPI) => void): Command { + const commands = new Map(); + const pi = { + registerCommand: (commandName: string, command: Command) => commands.set(commandName, command), + } as unknown as ExtensionAPI; + register(pi); + const registered = commands.get(name); + if (!registered) throw new Error(`/${name} was not registered`); + return registered; +} + +/** Dialog fakes record every call; an unanswered input resolves to "" (Enter on an empty field). */ +export function createAccountCommandContext( + storage: AuthStorage, + cwd: string, + options: ContextOptions = {}, +): AccountCommandContext { + const notices: Notice[] = []; + const dialogs: DialogCall[] = []; + return { + ctx: { + hasUI: true, + mode: options.mode ?? "tui", + cwd, + signal: undefined, + sessionManager: { getSessionId: () => "session-01" }, + modelRegistry: { authStorage: storage }, + ui: { + notify: (message: string, type?: Notice["type"]) => notices.push({ message, type }), + select: async (title: string, choices: string[], opts?: ExtensionUIDialogOptions) => { + dialogs.push({ kind: "select", title, options: choices, signal: opts?.signal }); + return options.dialogs?.select?.(title, choices); + }, + input: async (title: string, placeholder?: string, opts?: ExtensionUIDialogOptions) => { + dialogs.push({ kind: "input", title, placeholder, signal: opts?.signal }); + return options.dialogs?.input ? options.dialogs.input(title, placeholder, opts) : ""; + }, + }, + } as unknown as ExtensionCommandContext, + notices, + dialogs, + }; +} + +export function createLoginCommandContext( + storage: AuthStorage, + cwd: string, + login: LoginFn, + options: ContextOptions = {}, +): AccountCommandContext & { logins: string[] } { + const context = createAccountCommandContext(storage, cwd, options); + const logins: string[] = []; + const runtime = { + login: async (provider: string, method: string, interaction: AuthInteraction) => { + logins.push(`${provider}:${method}`); + await login(provider, method, interaction); + }, + }; + Object.assign(context.ctx.modelRegistry, { modelRuntime: runtime }); + return { ...context, logins }; +} diff --git a/packages/coding-agent/test/suite/account-extension.test.ts b/packages/coding-agent/test/suite/account-extension.test.ts index ed3ce8709..576391575 100644 --- a/packages/coding-agent/test/suite/account-extension.test.ts +++ b/packages/coding-agent/test/suite/account-extension.test.ts @@ -4,12 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../../src/core/auth-storage.ts"; import accountExtension from "../../src/core/extensions/builtin/account/index.ts"; -import { subscribeProviderAccountEvents } from "../../src/core/extensions/builtin/claude-sdk-oauth/account-events.ts"; -import gptAccountExtension from "../../src/core/extensions/builtin/gpt-account.ts"; -import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand } from "../../src/core/extensions/types.ts"; - -type Command = Pick; -type Notice = { message: string; type: "info" | "warning" | "error" | undefined }; +import { type Command, createAccountCommandContext, registerCommand } from "./account-command-harness.ts"; let dir: string; let storage: AuthStorage; @@ -24,31 +19,11 @@ afterEach(() => { }); function registeredCommand(): Command { - const commands = new Map(); - const pi = { - registerCommand: (name: string, command: Command) => commands.set(name, command), - } as unknown as ExtensionAPI; - accountExtension(pi); - const registered = commands.get("account"); - if (!registered) throw new Error("/account was not registered"); - return registered; + return registerCommand("account", accountExtension); } -function createContext(): { ctx: ExtensionCommandContext; notices: Notice[] } { - const notices: Notice[] = []; - return { - ctx: { - hasUI: true, - cwd: dir, - signal: undefined, - sessionManager: { getSessionId: () => "session-01" }, - modelRegistry: { authStorage: storage }, - ui: { - notify: (message: string, type?: Notice["type"]) => notices.push({ message, type }), - }, - } as unknown as ExtensionCommandContext, - notices, - }; +function createContext() { + return createAccountCommandContext(storage, dir); } async function seedPool(provider: string): Promise { @@ -122,180 +97,3 @@ describe("/account command", () => { expect(notices.at(-1)?.message).toContain("Usage: /account"); }); }); - -describe("/gpt-account command", () => { - function registeredGptCommand(): Command { - const commands = new Map(); - const pi = { - registerCommand: (name: string, command: Command) => commands.set(name, command), - } as unknown as ExtensionAPI; - gptAccountExtension(pi); - const registered = commands.get("gpt-account"); - if (!registered) throw new Error("/gpt-account was not registered"); - return registered; - } - - it("lists OpenAI Codex OAuth accounts without leaking tokens", async () => { - await storage.modify("openai-codex", async () => ({ - type: "oauth", - access: "access-secret", - refresh: "refresh-secret", - expires: 1, - accounts: [ - { name: "default", access: "access-secret", refresh: "refresh-secret", expires: 1, source: "login" }, - { name: "work", access: "work-access", refresh: "work-refresh", expires: 1, source: "login" }, - ], - })); - const { ctx, notices } = createContext(); - - await registeredGptCommand().handler("", ctx); - - const output = notices.map((notice) => notice.message).join("\n"); - expect(output).toContain("OpenAI Codex OAuth accounts:"); - expect(output).toContain("default | login | available"); - expect(output).toContain("work | login | available"); - expect(output).not.toContain("access-secret"); - expect(output).not.toContain("work-access"); - }); - - it("pins and unpins an OpenAI Codex OAuth account", async () => { - await storage.modify("openai-codex", async () => ({ - type: "oauth", - access: "access-secret", - refresh: "refresh-secret", - expires: 1, - accounts: [ - { name: "default", access: "access-secret", refresh: "refresh-secret", expires: 1, source: "login" }, - { name: "work", access: "work-access", refresh: "work-refresh", expires: 1, source: "login" }, - ], - })); - const { ctx, notices } = createContext(); - const command = registeredGptCommand(); - - await command.handler("pin work", ctx); - await command.handler("", ctx); - expect(notices[notices.length - 1]?.message).toContain("work | login | available | pinned"); - - await command.handler("unpin", ctx); - expect(storage.get("openai-codex")).not.toHaveProperty("pinned"); - }); - - function createLoginContext(login: (provider: string, method: string) => Promise): { - ctx: ExtensionCommandContext; - notices: Notice[]; - logins: string[]; - } { - const { ctx, notices } = createContext(); - const logins: string[] = []; - const runtime = { - login: async (provider: string, method: string) => { - logins.push(`${provider}:${method}`); - await login(provider, method); - }, - }; - Object.assign(ctx.modelRegistry, { modelRuntime: runtime }); - return { ctx, notices, logins }; - } - - it("add runs an openai-codex oauth login and announces the new account", async () => { - const changed: string[] = []; - const unsubscribe = subscribeProviderAccountEvents((event) => { - if (event.type === "accounts_changed") changed.push(event.provider); - }); - const { ctx, notices, logins } = createLoginContext(async () => {}); - - try { - await registeredGptCommand().handler("add", ctx); - } finally { - unsubscribe(); - } - - expect(logins).toEqual(["openai-codex:oauth"]); - expect(notices.at(-1)).toMatchObject({ message: "OpenAI Codex OAuth account added.", type: "info" }); - expect(changed).toEqual(["openai-codex"]); - }); - - it("add stays silent when the user cancels the login prompt", async () => { - const changed: string[] = []; - const unsubscribe = subscribeProviderAccountEvents((event) => { - if (event.type === "accounts_changed") changed.push(event.provider); - }); - const { ctx, notices } = createLoginContext(async () => { - throw new Error("Login cancelled"); - }); - - try { - await registeredGptCommand().handler("add", ctx); - } finally { - unsubscribe(); - } - - expect(notices).toEqual([]); - expect(changed).toEqual([]); - }); - - it("add surfaces a real login failure as an error notice", async () => { - const { ctx, notices } = createLoginContext(async () => { - throw new Error("authorization server rejected the code"); - }); - - await registeredGptCommand().handler("add", ctx); - - expect(notices.at(-1)).toMatchObject({ message: "authorization server rejected the code", type: "error" }); - }); - - it("remove deletes exactly the named account", async () => { - await storage.modify("openai-codex", async () => ({ - type: "oauth", - access: "access-secret", - refresh: "refresh-secret", - expires: 1, - accounts: [ - { name: "default", access: "access-secret", refresh: "refresh-secret", expires: 1, source: "login" }, - { name: "work", access: "work-access", refresh: "work-refresh", expires: 1, source: "login" }, - ], - })); - const { ctx, notices } = createContext(); - - await registeredGptCommand().handler("remove work", ctx); - - expect(notices.at(-1)?.message).toContain("Removed OpenAI Codex OAuth account 'work'"); - expect(storage.listSlots("openai-codex").map((slot) => slot.name)).toEqual(["default"]); - }); - - it("remove default on a promoted pool leaves the survivor as the stored top-level credential", async () => { - // The shape appendLoginSlot writes when a legacy flat openai-codex credential - // gains a second login: the flat fields still project the legacy `default`. - await storage.modify("openai-codex", async () => ({ - type: "oauth", - access: "legacy-access", - refresh: "legacy-refresh", - expires: 1, - accounts: [ - { name: "default", access: "legacy-access", refresh: "legacy-refresh", expires: 1, source: "login" }, - { name: "login-2", access: "second-access", refresh: "second-refresh", expires: 2, source: "login" }, - ], - })); - const { ctx, notices } = createContext(); - - await registeredGptCommand().handler("remove default", ctx); - - expect(notices.at(-1)?.message).toContain("Removed OpenAI Codex OAuth account 'default'"); - expect(storage.listSlots("openai-codex").map((slot) => slot.name)).toEqual(["login-2"]); - expect(storage.get("openai-codex")).toMatchObject({ - type: "oauth", - access: "second-access", - refresh: "second-refresh", - expires: 2, - }); - }); - - it("remove without a name reports usage instead of removing anything", async () => { - const { ctx, notices } = createContext(); - - await registeredGptCommand().handler("remove", ctx); - - expect(notices.at(-1)?.type).toBe("error"); - expect(notices.at(-1)?.message).toContain("Usage: /gpt-account"); - }); -}); diff --git a/packages/coding-agent/test/suite/gpt-account-add.test.ts b/packages/coding-agent/test/suite/gpt-account-add.test.ts new file mode 100644 index 000000000..637ea7228 --- /dev/null +++ b/packages/coding-agent/test/suite/gpt-account-add.test.ts @@ -0,0 +1,236 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../../src/core/auth-storage.ts"; +import { subscribeProviderAccountEvents } from "../../src/core/extensions/builtin/claude-sdk-oauth/account-events.ts"; +import gptAccountExtension, { type GptAccountExtensionDeps } from "../../src/core/extensions/builtin/gpt-account.ts"; +import { + type Command, + type ContextOptions, + createLoginCommandContext, + type LoginFn, + registerCommand, +} from "./account-command-harness.ts"; + +const CODEX_LOGIN_METHOD_PROMPT = { + type: "select", + message: "Select OpenAI Codex login method:", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device_code", label: "Device code login (headless)" }, + ], +} as const; +const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize?state=abc"; + +let dir: string; +let storage: AuthStorage; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "gpt-account-add-")); + storage = AuthStorage.create(join(dir, "auth.json")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function registeredGptCommand(deps?: GptAccountExtensionDeps): Command { + return registerCommand("gpt-account", (pi) => gptAccountExtension(pi, deps)); +} + +function createLoginContext(login: LoginFn, options: ContextOptions = {}) { + return createLoginCommandContext(storage, dir, login, options); +} + +function collectAccountsChanged(): { changed: string[]; unsubscribe: () => void } { + const changed: string[] = []; + const unsubscribe = subscribeProviderAccountEvents((event) => { + if (event.type === "accounts_changed") changed.push(event.provider); + }); + return { changed, unsubscribe }; +} + +describe("/gpt-account add", () => { + it("shows the login-method choice as a selector and relays the chosen option id", async () => { + const methods: string[] = []; + const { ctx, notices, dialogs } = createLoginContext( + async (_provider, _method, interaction) => { + const method = await interaction.prompt(CODEX_LOGIN_METHOD_PROMPT); + if (method !== "browser" && method !== "device_code") { + throw new Error(`Unknown OpenAI Codex login method: ${method}`); + } + methods.push(method); + }, + { dialogs: { select: () => "Device code login (headless)" } }, + ); + + await registeredGptCommand().handler("add", ctx); + + expect(dialogs).toEqual([ + { + kind: "select", + title: "Select OpenAI Codex login method:", + options: ["Browser login (default)", "Device code login (headless)"], + signal: undefined, + }, + ]); + expect(methods).toEqual(["device_code"]); + expect(notices.at(-1)).toMatchObject({ message: "OpenAI Codex OAuth account added.", type: "info" }); + }); + + it("stays silent when the user dismisses the login-method selector", async () => { + const { changed, unsubscribe } = collectAccountsChanged(); + const { ctx, notices } = createLoginContext( + async (_provider, _method, interaction) => { + await interaction.prompt(CODEX_LOGIN_METHOD_PROMPT); + throw new Error("login must not continue after the selector was dismissed"); + }, + { dialogs: { select: () => undefined } }, + ); + + try { + await registeredGptCommand().handler("add", ctx); + } finally { + unsubscribe(); + } + + expect(notices).toEqual([]); + expect(changed).toEqual([]); + }); + + it("shows the device code beside its verification URL", async () => { + const { ctx, notices } = createLoginContext(async (_provider, _method, interaction) => { + interaction.notify({ + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://auth.openai.com/codex/device", + }); + }); + + await registeredGptCommand().handler("add", ctx); + + const deviceNotice = notices.find((notice) => notice.message.includes("https://auth.openai.com/codex/device")); + expect(deviceNotice?.type).toBe("info"); + expect(deviceNotice?.message).toContain("ABCD-1234"); + }); + + it("passes the manual-code placeholder and releases the dialog when the provider aborts the prompt", async () => { + const outcomes: string[] = []; + const { ctx, notices, dialogs } = createLoginContext( + async (_provider, _method, interaction) => { + const manualAbort = new AbortController(); + const manual = interaction + .prompt({ + type: "manual_code", + message: "Paste the authorization code:", + placeholder: "http://localhost:1455/auth/callback", + signal: manualAbort.signal, + }) + .then( + (value) => `resolved:${value}`, + (error: unknown) => `rejected:${error instanceof Error ? error.message : String(error)}`, + ); + // The local callback server won the race: the provider retires its manual prompt. + manualAbort.abort(); + outcomes.push(await manual); + }, + { + dialogs: { + input: (_title, _placeholder, opts) => + new Promise((resolve) => { + if (!opts?.signal) { + resolve("typed-with-no-way-to-dismiss"); + return; + } + if (opts.signal.aborted) { + resolve(undefined); + return; + } + opts.signal.addEventListener("abort", () => resolve(undefined), { once: true }); + }), + }, + }, + ); + + await registeredGptCommand().handler("add", ctx); + + expect(dialogs).toMatchObject([ + { kind: "input", title: "Paste the authorization code:", placeholder: "http://localhost:1455/auth/callback" }, + ]); + expect(outcomes).toEqual(["rejected:Login cancelled"]); + expect(notices.at(-1)).toMatchObject({ message: "OpenAI Codex OAuth account added.", type: "info" }); + }); + + it("opens the authorize URL in the browser for the TUI and prints it as a fallback", async () => { + const opened: string[] = []; + const { ctx, notices } = createLoginContext(async (_provider, _method, interaction) => { + interaction.notify({ + type: "auth_url", + url: AUTHORIZE_URL, + instructions: "A browser window should open. Complete login to finish.", + }); + }); + + await registeredGptCommand({ openBrowser: (target) => opened.push(target) }).handler("add", ctx); + + expect(opened).toEqual([AUTHORIZE_URL]); + expect(notices[0]?.message).toContain(AUTHORIZE_URL); + }); + + it("leaves browser opening to the client outside the TUI", async () => { + const opened: string[] = []; + const { ctx, notices } = createLoginContext( + async (_provider, _method, interaction) => { + interaction.notify({ type: "auth_url", url: AUTHORIZE_URL }); + }, + { mode: "rpc" }, + ); + + await registeredGptCommand({ openBrowser: (target) => opened.push(target) }).handler("add", ctx); + + expect(opened).toEqual([]); + expect(notices[0]?.message).toContain(AUTHORIZE_URL); + }); + + it("runs an openai-codex oauth login and announces the new account", async () => { + const { changed, unsubscribe } = collectAccountsChanged(); + const { ctx, notices, logins } = createLoginContext(async () => {}); + + try { + await registeredGptCommand().handler("add", ctx); + } finally { + unsubscribe(); + } + + expect(logins).toEqual(["openai-codex:oauth"]); + expect(notices.at(-1)).toMatchObject({ message: "OpenAI Codex OAuth account added.", type: "info" }); + expect(changed).toEqual(["openai-codex"]); + }); + + it("stays silent when the user cancels the login prompt", async () => { + const { changed, unsubscribe } = collectAccountsChanged(); + const { ctx, notices } = createLoginContext(async () => { + throw new Error("Login cancelled"); + }); + + try { + await registeredGptCommand().handler("add", ctx); + } finally { + unsubscribe(); + } + + expect(notices).toEqual([]); + expect(changed).toEqual([]); + }); + + it("surfaces a real login failure as an error notice", async () => { + const { ctx, notices } = createLoginContext(async () => { + throw new Error("authorization server rejected the code"); + }); + + await registeredGptCommand().handler("add", ctx); + + expect(notices.at(-1)).toMatchObject({ message: "authorization server rejected the code", type: "error" }); + }); +}); diff --git a/packages/coding-agent/test/suite/gpt-account-extension.test.ts b/packages/coding-agent/test/suite/gpt-account-extension.test.ts new file mode 100644 index 000000000..84dffd595 --- /dev/null +++ b/packages/coding-agent/test/suite/gpt-account-extension.test.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../../src/core/auth-storage.ts"; +import gptAccountExtension from "../../src/core/extensions/builtin/gpt-account.ts"; +import { type Command, createAccountCommandContext, registerCommand } from "./account-command-harness.ts"; + +let dir: string; +let storage: AuthStorage; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "gpt-account-extension-")); + storage = AuthStorage.create(join(dir, "auth.json")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function registeredGptCommand(): Command { + return registerCommand("gpt-account", gptAccountExtension); +} + +function createContext() { + return createAccountCommandContext(storage, dir); +} + +async function seedCodexPool(): Promise { + await storage.modify("openai-codex", async () => ({ + type: "oauth", + access: "access-secret", + refresh: "refresh-secret", + expires: 1, + accounts: [ + { name: "default", access: "access-secret", refresh: "refresh-secret", expires: 1, source: "login" }, + { name: "work", access: "work-access", refresh: "work-refresh", expires: 1, source: "login" }, + ], + })); +} + +describe("/gpt-account command", () => { + it("lists OpenAI Codex OAuth accounts without leaking tokens", async () => { + await seedCodexPool(); + const { ctx, notices } = createContext(); + + await registeredGptCommand().handler("", ctx); + + const output = notices.map((notice) => notice.message).join("\n"); + expect(output).toContain("OpenAI Codex OAuth accounts:"); + expect(output).toContain("default | login | available"); + expect(output).toContain("work | login | available"); + expect(output).not.toContain("access-secret"); + expect(output).not.toContain("work-access"); + }); + + it("pins and unpins an OpenAI Codex OAuth account", async () => { + await seedCodexPool(); + const { ctx, notices } = createContext(); + const command = registeredGptCommand(); + + await command.handler("pin work", ctx); + await command.handler("", ctx); + expect(notices[notices.length - 1]?.message).toContain("work | login | available | pinned"); + + await command.handler("unpin", ctx); + expect(storage.get("openai-codex")).not.toHaveProperty("pinned"); + }); + + it("remove deletes exactly the named account", async () => { + await seedCodexPool(); + const { ctx, notices } = createContext(); + + await registeredGptCommand().handler("remove work", ctx); + + expect(notices.at(-1)?.message).toContain("Removed OpenAI Codex OAuth account 'work'"); + expect(storage.listSlots("openai-codex").map((slot) => slot.name)).toEqual(["default"]); + }); + + it("remove default on a promoted pool leaves the survivor as the stored top-level credential", async () => { + // The shape appendLoginSlot writes when a legacy flat openai-codex credential + // gains a second login: the flat fields still project the legacy `default`. + await storage.modify("openai-codex", async () => ({ + type: "oauth", + access: "legacy-access", + refresh: "legacy-refresh", + expires: 1, + accounts: [ + { name: "default", access: "legacy-access", refresh: "legacy-refresh", expires: 1, source: "login" }, + { name: "login-2", access: "second-access", refresh: "second-refresh", expires: 2, source: "login" }, + ], + })); + const { ctx, notices } = createContext(); + + await registeredGptCommand().handler("remove default", ctx); + + expect(notices.at(-1)?.message).toContain("Removed OpenAI Codex OAuth account 'default'"); + expect(storage.listSlots("openai-codex").map((slot) => slot.name)).toEqual(["login-2"]); + expect(storage.get("openai-codex")).toMatchObject({ + type: "oauth", + access: "second-access", + refresh: "second-refresh", + expires: 2, + }); + }); + + it("remove without a name reports usage instead of removing anything", async () => { + const { ctx, notices } = createContext(); + + await registeredGptCommand().handler("remove", ctx); + + expect(notices.at(-1)?.type).toBe("error"); + expect(notices.at(-1)?.message).toContain("Usage: /gpt-account"); + }); +});