diff --git a/packages/ai/src/auth/pool/slots.ts b/packages/ai/src/auth/pool/slots.ts index a9eda87ff8..55b978c233 100644 --- a/packages/ai/src/auth/pool/slots.ts +++ b/packages/ai/src/auth/pool/slots.ts @@ -141,12 +141,12 @@ function nextLoginSlotName(credential: PooledCredential): string { } /** - * Appends an unnamed flat credential to a pool as a generated `login-N` slot. A - * flat or absent current entry keeps today's whole-write shape so no existing - * user's stored bytes change until a second credential actually exists. + * Appends an unnamed flat credential to a pool as a generated `login-N` slot. + * An absent current entry keeps today's whole-write shape; a flat current entry + * is promoted to a pool so the legacy credential remains the default slot. */ export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential { - if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) { + if (!current) { return flat; } return upsertSlot(current, slotFromFlatCredentialNamed(flat, nextLoginSlotName(current))); diff --git a/packages/ai/test/credential-pool-mutations.test.ts b/packages/ai/test/credential-pool-mutations.test.ts index 6713b31f97..6575b82ed0 100644 --- a/packages/ai/test/credential-pool-mutations.test.ts +++ b/packages/ai/test/credential-pool-mutations.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + appendLoginSlot, type Credential, type CredentialSlot, listSlots, @@ -59,6 +60,30 @@ describe("credential pool slot algebra", () => { expect(names(next)).toEqual(["default", "second"]); }); + test("appendLoginSlot promotes a legacy flat credential before adding a login", () => { + const current: Credential = { type: "oauth", access: "first-access", refresh: "first-refresh", expires: 1 }; + const next = appendLoginSlot(current, { + type: "oauth", + access: "second-access", + refresh: "second-refresh", + expires: 2, + }); + + expect(next).toMatchObject({ type: "oauth", access: "first-access", refresh: "first-refresh", expires: 1 }); + expect(names(next)).toEqual(["default", "login-2"]); + expect(listSlots(next).find((slot) => slot.name === "login-2")).toMatchObject({ + access: "second-access", + refresh: "second-refresh", + expires: 2, + }); + }); + + test("appendLoginSlot preserves existing pooled accounts and chooses the next name", () => { + const next = appendLoginSlot(pooledApiKey(), { type: "api_key", key: "new-key" }); + expect(names(next)).toEqual(["default", "work", "login-2"]); + expect(listSlots(next).find((slot) => slot.name === "login-2")?.key).toBe("new-key"); + }); + test("removeSlot deletes only the named slot", () => { expect(names(removeSlot(pooledApiKey(), "default"))).toEqual(["work"]); }); diff --git a/packages/coding-agent/src/core/extensions/builtin/changes.md b/packages/coding-agent/src/core/extensions/builtin/changes.md index 6028ce9a03..c164404df9 100644 --- a/packages/coding-agent/src/core/extensions/builtin/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/changes.md @@ -1,5 +1,35 @@ # Builtin extensions changes +## OpenAI Codex OAuth account method selector (2026-09-08) + +### What changed + +- `/gpt-account add` now renders OAuth `select` prompts with the extension UI selector and maps the chosen label back to its OAuth option id. + +### Why + +- Sending a select prompt through the text-input adapter displayed an empty field and returned an invalid empty method instead of `browser` or `device_code`. + +### Why an extension could not handle it + +- The `/gpt-account` builtin owns the adapter between the shared OAuth prompt contract and the extension UI. + +### Expected merge conflict zones + +- LOW: `gpt-account.ts` prompt adapter and its neighboring regression coverage. + +## OpenAI Codex OAuth account command (2026-09-03) + +### What changed + +- Added `/gpt-account` as the dedicated OpenAI Codex OAuth account manager. +- It mirrors `/claude-account` actions: `add`, `remove `, `pin `, + `unpin`, and account listing without exposing token material. + +### Expected merge conflict zones + +- NONE: new command module plus the builtin extension registry entry. + ## Hooks trust-state snapshots publish atomically for same-account application state (2026-08-31) ### 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 new file mode 100644 index 0000000000..54e2d86929 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/gpt-account.ts @@ -0,0 +1,126 @@ +import { + getCredentialAccounts, + pinCredentialAccount, + removeCredentialAccount, +} from "../../../core/credential-accounts.ts"; +import type { ExtensionAPI, ExtensionCommandContext } from "../types.ts"; + +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; + +function parseArgs(rawArgs: string): string[] { + return rawArgs.trim().split(/\s+/).filter(Boolean); +} + +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:"]; + if (accounts.length === 0) lines.push(" (none)"); + for (const account of accounts) { + const states = [account.name, account.source, account.blocked ? "blocked" : "available"]; + if (account.pinned) states.push("pinned"); + lines.push(` ${states.join(" | ")}`); + } + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function addAccount(ctx: ExtensionCommandContext): 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) => { + if (prompt.type === "select") { + const label = await ctx.ui.select( + prompt.message, + prompt.options.map((option) => option.label), + ); + if (label === undefined) throw new Error("Login cancelled"); + const option = prompt.options.find((candidate) => candidate.label === label); + if (!option) throw new Error("Login cancelled"); + return option.id; + } + 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"), + }); + ctx.ui.notify("OpenAI Codex OAuth account added.", "info"); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + } +} + +async function removeAccount(ctx: ExtensionCommandContext, name: string | undefined): Promise { + if (!name) { + usage(ctx); + return; + } + await removeCredentialAccount(ctx.modelRegistry.authStorage, OPENAI_CODEX_PROVIDER_ID, name); + ctx.ui.notify(`Removed OpenAI Codex OAuth account '${name}'.`, "info"); +} + +async function pinAccount(ctx: ExtensionCommandContext, name: string | undefined): Promise { + if (!name) { + usage(ctx); + return; + } + await pinCredentialAccount(ctx.modelRegistry.authStorage, OPENAI_CODEX_PROVIDER_ID, name); + ctx.ui.notify(`Pinned OpenAI Codex OAuth account '${name}'.`, "info"); +} + +export default function gptAccountExtension(pi: ExtensionAPI): void { + pi.registerCommand("gpt-account", { + description: "List and manage OpenAI Codex OAuth accounts.", + argumentHint: "[add | remove | pin | unpin]", + handler: async (rawArgs, ctx) => { + const args = parseArgs(rawArgs); + const action = args[0] ?? "list"; + try { + if (action === "list") { + await showAccounts(ctx); + return; + } + if (action === "add") { + await addAccount(ctx); + return; + } + if (action === "remove") { + await removeAccount(ctx, args[1]); + return; + } + if (action === "pin" && args[1] !== "unpin") { + await pinAccount(ctx, args[1]); + return; + } + if (action === "unpin" || (action === "pin" && args[1] === "unpin")) { + await pinCredentialAccount(ctx.modelRegistry.authStorage, OPENAI_CODEX_PROVIDER_ID, null); + ctx.ui.notify("Unpinned OpenAI Codex OAuth account.", "info"); + return; + } + usage(ctx); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + } + }, + }); +} diff --git a/packages/coding-agent/src/core/extensions/builtin/index.ts b/packages/coding-agent/src/core/extensions/builtin/index.ts index 0c02f8b7c9..f71093528b 100644 --- a/packages/coding-agent/src/core/extensions/builtin/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/index.ts @@ -12,6 +12,7 @@ import cursorCliOauthExtension from "./cursor-cli-oauth/index.ts"; import diffExtension from "./diff.ts"; import filesExtension from "./files.ts"; import goalExtension from "./goal/index.ts"; +import gptAccountExtension from "./gpt-account.ts"; import gptApplyPatchExtension from "./gpt-apply-patch/index.ts"; import helpExtension from "./help/index.ts"; import historySearchExtension from "./history-search/index.ts"; @@ -102,6 +103,7 @@ export const builtinExtensions: BuiltinExtensionFactory[] = [ // Provider-neutral account listing; sits before the provider lanes so their // dedicated commands (claude-account, cursor accounts) keep their own names. { id: "account", factory: accountExtension }, + { id: "gpt-account", factory: gptAccountExtension }, { id: "claude-sdk-oauth", factory: claudeSdkOauthExtension }, // Registers unconditionally and reports executable/auth state through its oauth check, so it stays beside the other provider lane. { id: "cursor-cli-oauth", factory: cursorCliOauthExtension }, diff --git a/packages/coding-agent/test/suite/account-extension.test.ts b/packages/coding-agent/test/suite/account-extension.test.ts index 11d429ad5f..588cd23659 100644 --- a/packages/coding-agent/test/suite/account-extension.test.ts +++ b/packages/coding-agent/test/suite/account-extension.test.ts @@ -1,9 +1,10 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../../src/core/auth-storage.ts"; import accountExtension from "../../src/core/extensions/builtin/account/index.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; @@ -120,3 +121,95 @@ 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"); + }); + + it.each([ + ["Browser login (default)", "browser"], + ["Device code login (headless)", "device_code"], + ])("maps the selected %s label to the %s OAuth method id", async (label, expectedMethod) => { + const { ctx } = createContext(); + let selectedMethod: string | undefined; + const login = vi.fn( + async ( + _providerId: string, + _method: string, + interaction: { prompt: (prompt: unknown) => Promise }, + ) => { + selectedMethod = await interaction.prompt({ + type: "select", + message: "Select OpenAI Codex login method:", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device_code", label: "Device code login (headless)" }, + ], + }); + }, + ); + ctx.modelRegistry = { ...ctx.modelRegistry, modelRuntime: { login } } as unknown as typeof ctx.modelRegistry; + ctx.ui.select = vi.fn(async () => label); + + await registeredGptCommand().handler("add", ctx); + + expect(ctx.ui.select).toHaveBeenCalledWith("Select OpenAI Codex login method:", [ + "Browser login (default)", + "Device code login (headless)", + ]); + expect(selectedMethod).toBe(expectedMethod); + }); +});