Skip to content
3 changes: 3 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

### Fixed

- Adding a second account to a provider whose stored credential predates credential pools (a flat entry with no `accounts` array, which is what `openai-codex` OAuth login writes) no longer overwrites the first one. `appendLoginSlot` now promotes that legacy credential into the pool as the `default` slot and stores the new login beside it as `login-2`, so both accounts remain usable and the flat top-level fields still authenticate a build predating pools. First login (no stored credential) still writes the flat credential as-is, and a provider that returns its own populated `accounts` array is still written through untouched.
- Removing the account whose material the flat top-level credential fields projected no longer leaves the pool authenticating as the deleted account. `removeSlot` now re-projects those fields from the first surviving slot, so a pool left with a single account (which does not enter credential rotation and therefore resolves through the flat projection) immediately uses the account that remains. `accounts` is kept, removing a non-projected slot still leaves the flat fields untouched, and removing the last slot still drops the credential entirely.

### Removed

## [2026.9.3-2] - 2026-09-03
Expand Down
39 changes: 33 additions & 6 deletions packages/ai/src/auth/pool/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,42 @@ export function upsertSlot(credential: PooledCredential | undefined, slot: Crede
return { ...base, accounts };
}

/** Whether the flat top-level fields are this slot's material rather than a sibling's. */
function slotMirrorsFlat(credential: PooledCredential, slot: CredentialSlot): boolean {
if (credential.type === "oauth") return slot.access === credential.access || slot.refresh === credential.refresh;
return slot.key === credential.key;
}

/** Rewrites the flat top-level projection to carry the given slot's material. */
function projectFlatFields(credential: PooledCredential, slot: CredentialSlot): PooledCredential {
if (credential.type === "oauth") {
if (slot.access === undefined || slot.refresh === undefined || slot.expires === undefined) return credential;
return { ...credential, access: slot.access, refresh: slot.refresh, expires: slot.expires };
}
return { ...credential, key: slot.key };
}

/**
* Removes one slot. The credential is dropped entirely once its last slot is gone,
* and a pin naming the removed slot is cleared so selection never points at a slot
* that no longer exists.
*
* When the removed slot was the one the flat top-level fields projected, those
* fields are re-projected from the first survivor. Without that the pool keeps
* authenticating with the deleted account's material: a credential with a single
* remaining slot does not enter the rotation path, so ordinary requests resolve
* the flat projection and would keep using exactly the account the user removed.
* `accounts` is preserved either way, so the entry stays a pool.
*/
export function removeSlot(credential: PooledCredential | undefined, name: string): PooledCredential | undefined {
if (!credential) return undefined;
const accounts = listSlots(credential).filter((slot) => slot.name !== name);
const existing = listSlots(credential);
const removed = existing.find((slot) => slot.name === name);
const accounts = existing.filter((slot) => slot.name !== name);
if (accounts.length === 0) return undefined;
const next: PooledCredential = { ...credential, accounts };
const reprojected =
removed && slotMirrorsFlat(credential, removed) ? projectFlatFields(credential, accounts[0]) : credential;
const next: PooledCredential = { ...reprojected, accounts };
if (next.pinned === name) delete next.pinned;
return next;
}
Expand Down Expand Up @@ -141,9 +167,10 @@ 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 stays reachable as `default`
* instead of being overwritten by the second login.
*
* A login result that already carries its own populated `accounts` array is a
* provider-owned pool: it IS the complete post-login credential, so it is
Expand All @@ -154,7 +181,7 @@ export function appendLoginSlot(current: PooledCredential | undefined, flat: Cre
if ("accounts" in flat && Array.isArray(flat.accounts) && flat.accounts.length > 0) {
return flat;
}
if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) {
if (!current) {
return flat;
}
return upsertSlot(current, slotFromFlatCredentialNamed(flat, nextLoginSlotName(current)));
Expand Down
48 changes: 48 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,51 @@
## A legacy flat credential is promoted, not overwritten, by a second login (2026-09-03)

### What changed

- `packages/ai/src/auth/pool/slots.ts`: `appendLoginSlot` whole-writes the login result only when there is no stored
credential at all (`if (!current)`), instead of also whole-writing whenever the stored credential is flat. A flat
`current` now takes the `upsertSlot` path, so `listSlots` synthesizes its `default` slot from the flat fields and the
fresh login is appended as the next generated `login-N`. The provider-owned pool guard added for senpi#1279 keeps its
place ahead of both branches and is unchanged, as is the pooled-`current` append.
- `packages/ai/src/auth/pool/slots.ts`: `removeSlot` re-projects the flat top-level fields from the first surviving slot
when the removed slot was the one those fields mirrored (matched by `access`/`refresh` for OAuth, by `key` for an API
key). Removing a slot whose material the flat fields never carried still leaves them byte-identical, the last-slot
removal still returns `undefined`, and a pin naming the removed slot is still cleared. `accounts` is preserved in every
surviving case, so a one-slot pool stays a pool rather than collapsing to a bare flat credential; only its projection
moves to the survivor.

### Why

- `openai-codex` OAuth `login` returns a plain flat `OAuthCredential` with no `accounts` array, so the #1279 guard never
fires for it and the old flat-current disjunct did. A second `/login openai-codex` (or the coding-agent `AuthStorage.set`
RPC path) therefore replaced the first account's tokens outright: the user lost the credential they were already using
and the pool they were trying to build never came into existence (senpi LAB-109). Promotion is the same transition
`setSlot` already performs, and `upsertSlot` keeps the pre-existing flat fields as the top-level projection, so a build
that ignores `accounts` still authenticates with exactly the bytes it authenticated with before.
- Promotion alone made removal unsafe. After promotion the flat fields are the legacy `default`'s material, so removing
`default` used to leave the pool listing only `login-2` while the flat projection still held the deleted account's
tokens. That is not cosmetic: `mightHoldCredentialPool` in the coding-agent model runtime only routes through
credential rotation when `accounts.length > 1`, so a pool with one slot left resolves through `resolveProviderAuth`'s
flat branch and kept authenticating as exactly the account the user had just removed, with no way to pin around it.
Re-projecting from the survivor makes the remaining account the effective credential the moment the removal lands.
- This supersedes the sentence in the 2026-09-03 senpi#1279 entry below that says a flat `current` still stores the flat
credential as-is; that branch is what this pass changes. Every other branch it describes is still accurate.

### Why an extension could not handle it

- `appendLoginSlot` is the shared write step inside `ModelsImpl.login` and the coding-agent auth storage `set`, running
after the provider's `login` resolves and before the credential is persisted. No provider or extension seam exists
between producing the credential and the write that was discarding the previous account.
- `removeSlot` is the shared slot algebra behind `ModelsImpl.logout({ slotId })`, `AuthStorage.removeSlot` and
`removeCredentialAccount`. Every removal caller reaches the flat projection only through it, so nothing above it can
keep the projection and the surviving slot in agreement.

### Expected merge conflict zones

- LOW: the second condition of `appendLoginSlot` and its JSDoc in `auth/pool/slots.ts`, immediately below the senpi#1279
guard that the open PRs #1304 and #1196 also touch.
- LOW: the `removeSlot` body and the two projection helpers added directly above it in `auth/pool/slots.ts`.

## OAuth prompt types carry the provider's cancellation signal (2026-09-03)

### What changed
Expand Down
116 changes: 116 additions & 0 deletions packages/ai/test/credential-pool-mutations.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, test } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import {
appendLoginSlot,
type Credential,
Expand All @@ -8,6 +10,8 @@ import {
removeSlot,
upsertSlot,
} from "../src/auth/pool/slots.ts";
import { resolveProviderAuth } from "../src/auth/resolve.ts";
import { createProvider, type Provider } from "../src/models.ts";

describe("credential pool slot algebra", () => {
function pooledApiKey(): PooledCredential {
Expand Down Expand Up @@ -60,6 +64,24 @@ 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("removeSlot deletes only the named slot", () => {
expect(names(removeSlot(pooledApiKey(), "default"))).toEqual(["work"]);
});
Expand All @@ -77,6 +99,49 @@ describe("credential pool slot algebra", () => {
expect(removeSlot(single, "default")).toBeUndefined();
});

test("removeSlot re-projects the flat fields from the survivor when the projected slot is removed", () => {
const promoted = appendLoginSlot(
{ type: "oauth", access: "legacy-access", refresh: "legacy-refresh", expires: 1 },
{ type: "oauth", access: "second-access", refresh: "second-refresh", expires: 2 },
) as PooledCredential;

const next = removeSlot(promoted, "default");

expect(names(next)).toEqual(["login-2"]);
expect(next).toMatchObject({
type: "oauth",
access: "second-access",
refresh: "second-refresh",
expires: 2,
});
});

test("removeSlot leaves the flat projection alone when a non-projected slot is removed", () => {
const promoted = appendLoginSlot(
{ type: "oauth", access: "legacy-access", refresh: "legacy-refresh", expires: 1 },
{ type: "oauth", access: "second-access", refresh: "second-refresh", expires: 2 },
) as PooledCredential;

const next = removeSlot(promoted, "login-2");

expect(names(next)).toEqual(["default"]);
expect(next).toMatchObject({
type: "oauth",
access: "legacy-access",
refresh: "legacy-refresh",
expires: 1,
});
});

test("removeSlot re-projects an api_key survivor when the projected slot is removed", () => {
const promoted = appendLoginSlot({ type: "api_key", key: "legacy-key" }, { type: "api_key", key: "second-key" });

const next = removeSlot(promoted, "default");

expect(names(next)).toEqual(["login-2"]);
expect(next).toMatchObject({ type: "api_key", key: "second-key" });
});

test("upsertSlot rejects a slot name that could collide with path syntax", () => {
const slot = { name: "../escape", key: "k", source: "login" } as CredentialSlot;
expect(() => upsertSlot(pooledApiKey(), slot)).toThrow(/Invalid account name/);
Expand Down Expand Up @@ -131,3 +196,54 @@ describe("credential pool slot algebra", () => {
expect(appendLoginSlot(undefined, flat)).toBe(flat);
});
});

describe("ordinary auth resolution after removing a promoted account", () => {
const authContext = { env: async () => undefined, fileExists: async () => false };
const FUTURE = 4_102_444_800_000;

const provider: Provider = createProvider({
id: "removeoauth",
name: "Remove OAuth",
baseUrl: "https://removeoauth.example",
auth: {
apiKey: envApiKeyAuth("Remove OAuth API key", ["REMOVEOAUTH_API_KEY"]),
oauth: {
name: "Remove OAuth",
login: async () => ({ type: "oauth", access: "a", refresh: "r", expires: FUTURE }),
refresh: async (credential) => credential,
toAuth: async (credential) => ({ apiKey: credential.access }),
},
},
models: [],
api: "openai-responses" as never,
});

function promotedPool(): PooledCredential {
return appendLoginSlot(
{ type: "oauth", access: "legacy-access", refresh: "legacy-refresh", expires: FUTURE },
{ type: "oauth", access: "second-access", refresh: "second-refresh", expires: FUTURE },
) as PooledCredential;
}

test("removing the promoted default makes login-2 the credential ordinary requests use", async () => {
const store = new InMemoryCredentialStore();
const remaining = removeSlot(promotedPool(), "default");
if (!remaining) throw new Error("removeSlot dropped a pool that still had a slot");
await store.modify("removeoauth", async () => remaining);

const resolved = await resolveProviderAuth(provider, store, authContext);

expect(resolved?.auth.apiKey).toBe("second-access");
});

test("removing login-2 keeps the promoted default the credential ordinary requests use", async () => {
const store = new InMemoryCredentialStore();
const remaining = removeSlot(promotedPool(), "login-2");
if (!remaining) throw new Error("removeSlot dropped a pool that still had a slot");
await store.modify("removeoauth", async () => remaining);

const resolved = await resolveProviderAuth(provider, store, authContext);

expect(resolved?.auth.apiKey).toBe("legacy-access");
});
});
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

### Added

- `/gpt-account` manages OpenAI Codex OAuth accounts the way `/claude-account` manages claude-sdk-oauth ones: `add` runs an interactive Codex login and stores the new account beside the existing ones, `remove <name>`, `pin <name>` and `unpin` select which account is used, and the bare command lists every stored account with its source, availability and pin state without printing token material.

### Changed

### Fixed

- `/gpt-account remove` (and every other account-removal path) now leaves the remaining account actually in use. Removing the account the stored credential's top-level fields projected used to keep those fields pointing at the deleted account's tokens, and a provider left with one account does not enter credential rotation, so requests kept authenticating as the removed account; the surviving account's material is now projected onto those fields.
- Claude SDK OAuth classifies Fable-style "requires usage credits" failures as a non-retryable entitlement instead of a rate limit, so the account is not blocked for 60s and AgentSession can fall through to the next model ([#709](https://github.com/code-yeongyu/senpi/issues/709)).
- Print mode (`-p` / `--mode json`) now writes a stderr notice when retry fallback substitutes a model, so silent wrong-model answers are visible without corrupting JSON stdout ([oh-my-openagent#7626](https://github.com/code-yeongyu/oh-my-openagent/issues/7626)).
### New Features
Expand Down
33 changes: 33 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,38 @@
# Builtin extensions changes

## OpenAI Codex OAuth account command (2026-09-03)

### What changed

- `gpt-account.ts` (new): `/gpt-account` is the dedicated OpenAI Codex OAuth account manager, mirroring the
`/claude-account` action set. `add` runs an interactive `openai-codex` oauth login through
`ctx.modelRegistry.modelRuntime.login` and emits `emitProviderAccountsChanged` so subscribed clients re-read the pool;
`remove <name>`, `pin <name>` and `unpin` go through `credential-accounts.ts` (which emits on its own); the
no-argument form lists every stored slot as `name | source | available|blocked` with the pin marked. Only names,
sources and health are rendered, never key or token material.
- `index.ts`: registers `{ id: "gpt-account", factory: gptAccountExtension }` immediately after the provider-neutral
`account` builtin, so the Codex lane keeps its own command name the way `claude-sdk-oauth` and `cursor-cli-oauth` do.

### Why

- The provider-neutral `/account` command lists, pins, unpins and removes accounts for any provider but has no `add`, so
the only way to put a second `openai-codex` account into the pool was `/login openai-codex` - the shared write path
that this same pass fixes for LAB-109. Codex users need the add/remove/pin surface that claude-sdk-oauth users already
have from `/claude-account`, and keeping it in its own command leaves the Codex-specific login wiring (interactive
prompt relay, auth-url notices) out of the provider-neutral command.

### Why an extension could not handle it

- The command has to exist for every session, which means being present in the `builtinExtensions` registry in
`index.ts`; a user extension cannot insert itself there. It also drives `modelRuntime.login` and the coding-agent auth
storage pool directly, and that login/persist seam is core state with no extension-visible hook between producing a
credential and writing it.

### Expected merge conflict zones

- LOW: the import block and the `builtinExtensions` array in `index.ts`, where every new provider lane adds a line.
`gpt-account.ts` itself is new and fork-only.

## Shared eval-only routing predicate for prompt surfaces (2026-09-03)

### What changed
Expand Down
Loading