Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/ai/src/auth/pool/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
25 changes: 25 additions & 0 deletions packages/ai/test/credential-pool-mutations.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
appendLoginSlot,
type Credential,
type CredentialSlot,
listSlots,
Expand Down Expand Up @@ -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"]);
});
Expand Down
30 changes: 30 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/changes.md
Original file line number Diff line number Diff line change
@@ -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 <name>`, `pin <name>`,
`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
Expand Down
126 changes: 126 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/gpt-account.ts
Original file line number Diff line number Diff line change
@@ -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 <name> | pin <name> | unpin]", "error");
}

function authEventMessage(event: unknown): string {
if (event === null || typeof event !== "object") return "OpenAI Codex OAuth authentication update.";
const value = event as Record<string, unknown>;
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<void> {
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<void> {
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<void> {
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<void> {
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 <name> | pin <name> | 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");
}
},
});
}
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand Down
95 changes: 94 additions & 1 deletion packages/coding-agent/test/suite/account-extension.test.ts
Original file line number Diff line number Diff line change
@@ -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<RegisteredCommand, "handler">;
Expand Down Expand Up @@ -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<string, Command>();
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<string> },
) => {
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);
});
});