Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Fixed

- Adding a second account to a provider that manages its own credential pool no longer stored the provider's placeholder tokens as an extra `login-2` slot; the pooled login result is now written through untouched ([#1279](https://github.com/code-yeongyu/senpi/issues/1279)).

### Removed

## [2026.9.3] - 2026-09-03
Expand Down
8 changes: 8 additions & 0 deletions packages/ai/src/auth/pool/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,16 @@ 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.
*
* 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
* written through untouched. Reading its top-level fields as a flat credential
* would append the provider's placeholder material as a second slot.
*/
export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential {
if ("accounts" in flat && Array.isArray(flat.accounts) && flat.accounts.length > 0) {
return flat;
}
if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) {
return flat;
}
Expand Down
18 changes: 18 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## Login keeps a provider-owned credential pool intact (2026-09-03)

### What changed

- `packages/ai/src/auth/pool/slots.ts`: `appendLoginSlot` returns the login result untouched when that result already carries a populated `accounts` array. Every other branch is unchanged: an absent or flat `current` still stores the flat credential as-is, and an unnamed flat credential against a pooled `current` still becomes the next generated `login-N` slot with its own material.

### Why

- A provider whose own `login` returns the complete pooled credential (claude-sdk-oauth builds it with `addAccount`) was double-pooled: the shared login path read that result's top-level fields as if they were a flat credential and appended them as a second slot. For claude-sdk-oauth those top-level fields are the managed sentinel, so a second account produced a `login-2` slot holding `claude-sdk-oauth-managed` instead of the newly issued tokens, and selecting that slot failed authentication (senpi#1279).

### Why an extension could not handle it

- `appendLoginSlot` is the shared write step inside `ModelsImpl.login` and the coding-agent auth storage `set`; it runs after the provider's `login` returns and before the credential is persisted, so no provider or extension seam exists between producing the pool and mangling it.

### Expected merge conflict zones

- LOW: the guard at the top of `appendLoginSlot` and its JSDoc in `auth/pool/slots.ts`. The same hunk appears in the open PRs #1304 and #1196.

## Anthropic OAuth advertises Claude Code 2.1.251 (2026-09-02)

### What changed
Expand Down
50 changes: 50 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 @@ -80,4 +81,53 @@ describe("credential pool slot algebra", () => {
const slot = { name: "../escape", key: "k", source: "login" } as CredentialSlot;
expect(() => upsertSlot(pooledApiKey(), slot)).toThrow(/Invalid account name/);
});

function sentinelPool(): PooledCredential {
return {
type: "oauth",
access: "claude-sdk-oauth-managed",
refresh: "claude-sdk-oauth-managed",
expires: 4_102_444_800_000,
accounts: [{ name: "default", source: "login", access: "real-a", refresh: "refresh-a", expires: 999 }],
};
}

test("appendLoginSlot returns a provider-owned pooled login result unchanged", () => {
const current = sentinelPool();
const providerLoginResult: PooledCredential = {
...current,
accounts: [
...listSlots(current),
{ name: "account-2", source: "login", access: "real-b", refresh: "refresh-b", expires: 999 },
],
};

const next = appendLoginSlot(current, providerLoginResult);

expect(next).toEqual(providerLoginResult);
expect(names(next)).toEqual(["default", "account-2"]);
expect(listSlots(next).at(-1)?.access).toBe("real-b");
});

test("appendLoginSlot still appends an unnamed flat oauth credential as login-2", () => {
const flat: Credential = { type: "oauth", access: "fresh-access", refresh: "fresh-refresh", expires: 999 };

const next = appendLoginSlot(sentinelPool(), flat);

expect(names(next)).toEqual(["default", "login-2"]);
expect(listSlots(next).at(-1)).toMatchObject({ access: "fresh-access", refresh: "fresh-refresh" });
});

test("appendLoginSlot still appends an unnamed flat api_key credential", () => {
const next = appendLoginSlot(pooledApiKey(), { type: "api_key", key: "third-key" });

expect(names(next)).toEqual(["default", "work", "login-2"]);
expect(listSlots(next).at(-1)?.key).toBe("third-key");
});

test("appendLoginSlot without a current credential writes the flat credential as-is", () => {
const flat: Credential = { type: "api_key", key: "only-key" };

expect(appendLoginSlot(undefined, flat)).toBe(flat);
});
});
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

- Bundled Claude Code is now 2.1.259 via `@anthropic-ai/claude-agent-sdk` 0.3.259, so `claude-sdk-oauth` sessions on `claude-fable-5-1` no longer fail with the API 400 that required version 2.1.251 or newer ([#1298](https://github.com/code-yeongyu/senpi/issues/1298)).
- Claude SDK OAuth maps malformed or raw-string content entries to text (or an omission placeholder) instead of image blocks with undefined `media_type`/`data`, which made Claude Code abort the next query ([oh-my-openagent#7660](https://github.com/code-yeongyu/oh-my-openagent/issues/7660)).
- A second `claude-sdk-oauth` login now stores the newly issued OAuth tokens instead of a broken slot holding the managed placeholder, and no longer fails with `Provider is not configured: claude-sdk-oauth` when account rotation has selected a single account ([#1279](https://github.com/code-yeongyu/senpi/issues/1279)).
### New Features

### Breaking Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,23 @@
- LOW in `prompt-bridge.ts` around `appendContentBlocks`.
- LOW in `session-sync.ts` around `appendContent`.
- NEW file `content-blocks.ts`.
## 2026-09-03 - Accept a rotation-projected OAuth slot as configured

### What changed

- `oauth-login.ts`: the `configuredFor` predicate behind `check` and `resolveAmbient` counts a stored credential whose top-level OAuth fields are concrete (neither `access` nor `refresh` is the managed sentinel) as one account, in addition to the `accounts` array and environment tokens. A projected sentinel still counts as zero, so the ambient opt-in path is unchanged.

### Why

- Shared credential rotation projects one named slot onto the flat credential shape and strips `accounts` before handing the credential to the provider. The predicate only counted `accounts`, so a projected concrete slot counted as zero accounts and the provider reported "Provider is not configured: claude-sdk-oauth" — which a user hit as a failing second login.

### Why an extension could not handle it

- This IS the extension side: the availability predicate lives in this builtin provider's auth config and runs before any request-scoped hook.

### Expected merge conflict zones

- LOW: `oauth-login.ts` around the `accountCount` computation in `configuredFor`. The same hunk appears in the open PRs #1304 and #1196.
## 2026-09-02 - Honor tool-less summarization requests

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,15 @@ export function createOAuthConfig(deps: {
environment?: Record<string, string>,
): Promise<boolean> => {
const storedAccounts = stored?.type === "oauth" && Array.isArray(stored.accounts) ? stored.accounts : [];
// Slot-scoped resolution projects one account onto the flat credential shape,
// stripping `accounts`; its concrete (non-sentinel) tokens are that account.
const selectedStoredAccount =
stored?.type === "oauth" &&
stored.access !== SENTINEL_OAUTH_FIELDS.access &&
stored.refresh !== SENTINEL_OAUTH_FIELDS.refresh;
const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx));
const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length;
const accountCount = storedAccounts.length + environmentTokenCount;
const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount;
const settings = deps.readSettings?.();
const lane = settings?.tokenInjection ?? (accountCount > 0 ? "oauth-slots" : "ambient");
if (lane === "ambient") {
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/test/claude-sdk-oauth-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OAuthAuth } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { listAccounts, SENTINEL_OAUTH_FIELDS } from "../src/core/extensions/builtin/claude-sdk-oauth/accounts.ts";
import { createOAuthConfig } from "../src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts";
import { authContext } from "./support/claude-sdk-oauth-provider.ts";

function fakeFlow(credential: { access: string; refresh: string; expires: number }): OAuthAuth {
return {
Expand Down Expand Up @@ -101,4 +102,23 @@ describe("claude-sdk-oauth oauth login config", () => {
const credential = await config.login({});
expect(await config.refreshToken(credential)).toBe(credential);
});

it("check accepts a concrete oauth slot projected by credential rotation", async () => {
const config = createOAuthConfig({ readCurrent: async () => undefined, loginFlow: fakeFlow(fresh) });

const check = await config.check({
ctx: authContext(),
credential: { type: "oauth", access: "slot-access", refresh: "slot-refresh", expires: Date.now() + 60_000 },
});

expect(check).toEqual({ source: "Claude SDK OAuth", type: "oauth" });
});

it("check still rejects a projected sentinel that names no account", async () => {
const config = createOAuthConfig({ readCurrent: async () => undefined, loginFlow: fakeFlow(fresh) });

const check = await config.check({ ctx: authContext(), credential: { type: "oauth", ...SENTINEL_OAUTH_FIELDS } });

expect(check).toBeUndefined();
});
});