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
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

- Provider-owned OAuth account pools are preserved during login instead of being appended again as a duplicate slot
from their flat compatibility credential.

### Removed

## [2026.8.30-3] - 2026-08-30
Expand Down
4 changes: 4 additions & 0 deletions packages/ai/src/auth/pool/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ function nextLoginSlotName(credential: PooledCredential): string {
* user's stored bytes change until a second credential actually exists.
*/
export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential {
const providerOwned = flat as PooledCredential;
if (Array.isArray(providerOwned.accounts) && providerOwned.accounts.length > 0) {
return flat;
}
if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) {
return flat;
}
Expand Down
26 changes: 26 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
## Preserve provider-owned credential pools during login (2026-08-30)

### What changed

- `packages/ai/src/auth/pool/slots.ts`: `appendLoginSlot` now accepts an OAuth credential whose provider already
returned a populated `accounts` pool as the complete post-login credential instead of appending its flat
compatibility fields as another generated slot.
- `packages/ai/test/credential-pool-resolve-slot.test.ts`: covers a provider-owned named account pool and rejects the
duplicate `login-2` slot that previously copied the flat compatibility sentinel.

### Why

- The shared login layer automatically appends ordinary flat credentials. The Claude SDK OAuth provider already
returns its current pool plus the newly named account, so applying the generic append a second time stored the
provider's managed top-level sentinel as a fake account. Session affinity could select that fake account and fail
otherwise valid requests as `Provider is not configured`.

### Why an extension could not handle it

- The double append happens after the provider login returns, inside the shared credential-pool write path. Providers
cannot prevent the runtime from reinterpreting their completed pool as a flat credential.

### Expected merge conflict zones

- LOW: `auth/pool/slots.ts` at the start of `appendLoginSlot`.

## Stop replaying the Anthropic server-side fallback marker (2026-08-30)

### What changed
Expand Down
15 changes: 14 additions & 1 deletion packages/ai/test/credential-pool-resolve-slot.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import { listSlots, type PooledCredential } from "../src/auth/pool/slots.ts";
import { appendLoginSlot, listSlots, type PooledCredential } from "../src/auth/pool/slots.ts";
import { resolveProviderAuth } from "../src/auth/resolve.ts";
import type { OAuthCredential } from "../src/auth/types.ts";
import { createProvider, type Provider } from "../src/models.ts";
Expand Down Expand Up @@ -63,6 +63,19 @@ function oauthProvider(refreshed: (credential: OAuthCredential) => OAuthCredenti
}

describe("slot-scoped auth resolution", () => {
test("login preserves a provider-owned pooled credential instead of double-appending its flat sentinel", () => {
const current = pooledOAuthEntry();
const providerOwned: PooledCredential = {
...current,
accounts: [
...(current.accounts ?? []),
{ name: "named", access: "named-access", refresh: "r-named", expires: FUTURE, source: "login" },
],
};

expect(appendLoginSlot(current, providerOwned)).toEqual(providerOwned);
});

test("slotName resolves the named api_key slot instead of the flat projection", async () => {
const store = new InMemoryCredentialStore();
await store.modify("slottest", async () => pooledApiKeyEntry());
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@

### Fixed

- Claude SDK OAuth no longer treats DNS, connection, timeout, socket, or fetch failures during token refresh as a
rejected token that blocks the account until re-login; real OAuth rejection signals remain persistent auth errors.

- Compiled standalone binaries can now start the shared interactive RPC host. The host launch re-enters the executable through the internal supervisor route instead of a script path, which compiled entrypoints parse as CLI arguments; previously every interactive launch stalled for the full 10s readiness budget, printed `Error: Unknown option: --socket`, and fell back to a local session.

- A compaction started right after `switch_session`, `new_session`, or `fork` is no longer cancelled by the replacement's own extension binding. Binding deactivates tools for the active model, and that tool change aborted the in-flight compaction with "Compaction cancelled".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ async function prepareSlot(
Object.assign(slot, updated);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`authentication_failed: ${detail}`);
const classification = classifySdkError(error);
const code =
classification.kind === "other" && classification.retryable ? "server_error" : "authentication_failed";
throw new Error(`${code}: ${detail}`);
}
}
const access = slot.source === "env" ? envSlotToken((name) => environment[name], slot.name) : slot.access;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
# claude-sdk-oauth

## 2026-08-30 - Keep transient OAuth refresh failures temporary

### What changed

- `errors.ts`: DNS, connection, timeout, socket, and fetch transport failures classify as retryable transient errors
before the generic `authentication_failed` SDK code is considered. Explicit `invalid_grant`, `invalid_token`,
revoked-token, HTTP 401, and unauthorized signals remain persistent `auth_error` classifications.
- `auth-lane.ts`: recognized transient refresh failures surface as `server_error` instead of claiming that the OAuth
token was rejected.
- `guidance.ts`: all-account guidance now names transient provider failures among temporary block causes.
- `test/claude-sdk-oauth-failover.test.ts`: locks the transient/auth boundary with the observed
`ENOTFOUND platform.claude.com`, Undici timeout, connection-reset, invalid-grant, and HTTP 401 shapes.

### Why

- A temporary DNS outage made the Anthropic refresh request fail before the server could inspect the token. The auth
lane wrapped every refresh exception as `authentication_failed`; failover therefore persisted `auth_error`, which
never expires and incorrectly required re-login. Other external APIs failed DNS in the same window, confirming a
transport outage rather than token rejection.

### Why an extension could not handle it

- Refresh executes inside this builtin's managed account lane before the Claude subprocess starts. Only this layer
can preserve the distinction between transport failure and an OAuth server rejection before failover persists the
account state.

### Expected merge conflict zones

- LOW in `errors.ts` before SDK-code matching, `auth-lane.ts` refresh error wrapping, and `guidance.ts` blocked text.

## 2026-08-30 - Preserve selected OAuth slots during provider preflight

### What changed

- `oauth-login.ts`: readiness now recognizes a concrete OAuth credential selected by the shared credential-rotation
layer, while still excluding the provider's synthetic managed sentinel.
- `test/suite/claude-sdk-oauth-extension.test.ts`: the stream preflight coverage now uses two stored logins so it
exercises the selected-slot request path.

### Why

- With two or more Claude logins, shared credential rotation passes one selected OAuth slot to provider auth
resolution. The Claude readiness predicate counted only the parent credential's `accounts` array, so the selected
slot appeared empty and the request failed with `Provider is not configured: claude-sdk-oauth` even though both
accounts were valid.

### Why an extension could not handle it

- The predicate is part of the builtin provider's private OAuth configuration and runs after the shared runtime has
selected a credential slot. An external extension cannot repair that credential interpretation without replacing
the provider registration.

### Expected merge conflict zones

- LOW in `oauth-login.ts` around `configuredFor` account counting.
- LOW in `test/suite/claude-sdk-oauth-extension.test.ts` around the stored-login stream preflight.

## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const SDK_ERROR_CLASSIFICATIONS: Partial<Record<SDKAssistantMessageError, SdkErr
};

const OTHER_ERROR: SdkErrorClassification = { kind: "other", retryable: false };
const TRANSIENT_NETWORK_ERROR: SdkErrorClassification = { kind: "other", retryable: true };
const AUTH_ERROR: SdkErrorClassification = { kind: "auth_error", retryable: true };

function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
Expand All @@ -40,9 +42,24 @@ function errorText(error: unknown): string {
/** Classifies Claude SDK OAuth error codes and HTTP-shaped fallback text in one place. */
export function classifySdkError(error: unknown): SdkErrorClassification {
const text = errorText(error).toLowerCase();
// Transport failures during token refresh do not say anything about token
// validity. Match them before `authentication_failed` because the auth lane
// wraps every refresh exception with that SDK-compatible prefix.
if (
/\b(?:enotfound|eai_again|econnreset|econnrefused|etimedout|enetworkdown|enetunreach|ehostunreach|und_err_connect_timeout|und_err_socket)\b|fetch failed|network(?: request)? (?:failed|error)|socket hang up|connection reset by peer/.test(
text,
)
) {
return TRANSIENT_NETWORK_ERROR;
}
for (const [code, classification] of Object.entries(SDK_ERROR_CLASSIFICATIONS)) {
if (new RegExp(`\\b${code}\\b`).test(text)) return classification;
}
if (
/\binvalid_grant\b|\binvalid_token\b|\btoken\b[^.]*\brevoked\b|\b(?:http\s*)?401\b|\bunauthorized\b/.test(text)
) {
return AUTH_ERROR;
}
if (/\b(?:http\s*)?429\b|too many requests|rate[ _-]?limit/.test(text)) {
return { kind: "rate_limit", retryable: true };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function allAccountsBlockedGuidance(soonestUnblockAt: number | undefined)
? new Date(soonestUnblockAt).toISOString()
: "after re-login";
return [
`All Claude accounts for ${PROVIDER} are currently blocked (rate limit or auth errors).`,
`All Claude accounts for ${PROVIDER} are currently blocked (rate limit, transient provider, or auth errors).`,
` Soonest automatic retry: ${eta}.`,
` /claude-account list - inspect account states`,
` /login ${PROVIDER} - add another account`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ export function createOAuthConfig(deps: {
environment?: Record<string, string>,
): Promise<boolean> => {
const storedAccounts = stored?.type === "oauth" && Array.isArray(stored.accounts) ? stored.accounts : [];
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
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export async function createInteractiveHostRuntime(
client,
opened.state,
options.onWarning,
(cause) => {
(cause) => {
if (remoteRuntime?.isReconnecting) warnReconnect(cause);
else warnFallback(cause);
},
Expand Down Expand Up @@ -460,7 +460,7 @@ export function createRemoteSessionProxy(
client: RpcClient,
initialState: ReturnType<typeof stateFromRpc>,
onWarning?: (warning: InteractiveHostWarning) => void,
onTransportGone?: (cause: unknown) => void,
onTransportGone?: (cause: unknown) => void,
startupEvents: readonly import("../rpc/rpc-client.ts").RpcClientEvent[] = [],
): RemoteSessionProxy {
// Fire-and-forget setters keep the sync AgentSession signature, but their RPC
Expand Down
5 changes: 1 addition & 4 deletions packages/coding-agent/src/modes/rpc/host-ensure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,7 @@ const REQUIRED_CAPABILITIES = ["multi_session", EXTENSION_EVENTS_CAPABILITY] as
* the first caller. In particular, extension_events must remain available when
* a terminal client starts the shared host before the desktop connects.
*/
export const PINNED_HOST_CLIENT_CAPABILITIES = [
EXTENSION_EVENTS_CAPABILITY,
CUSTOM_UNSUPPORTED_CAPABILITY,
] as const;
export const PINNED_HOST_CLIENT_CAPABILITIES = [EXTENSION_EVENTS_CAPABILITY, CUSTOM_UNSUPPORTED_CAPABILITY] as const;

export function createHostDaemonPaths(agentDir = getAgentDir()): HostDaemonPaths {
const dir = join(agentDir, "rpc-host-daemon");
Expand Down
13 changes: 8 additions & 5 deletions packages/coding-agent/src/modes/rpc/session-event-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ export class SessionEventWriter {
sessions.add(sessionId);
this.connectionSessions.set(id, sessions);
if (this.connectionCapabilities.get(id)?.has("rendered_components"))
for (const record of this.sessionSnapshots.get(sessionId) ?? []) if (record.rendered) this.connections.get(id)!.actor.enqueue(record.line);
for (const record of this.sessionSnapshots.get(sessionId) ?? [])
if (record.rendered) this.connections.get(id)!.actor.enqueue(record.line);
}

detachConnectionFromSession(id: string, sessionId: string): void {
Expand All @@ -174,7 +175,8 @@ export class SessionEventWriter {
this.registeredCapabilityConnections.add(id);
if (!wasCapable && capabilities.includes("rendered_components"))
for (const sessionId of this.connectionSessions.get(id) ?? [])
for (const record of this.sessionSnapshots.get(sessionId) ?? []) if (record.rendered) registered.actor.enqueue(record.line);
for (const record of this.sessionSnapshots.get(sessionId) ?? [])
if (record.rendered) registered.actor.enqueue(record.line);
}

clearConnectionCapabilities(id: string): void {
Expand Down Expand Up @@ -226,9 +228,10 @@ export class SessionEventWriter {
const targets = isTargeted
? [targetId]
: record[RENDERED_COMPONENT_RECORD] && this.connections.size > 0
? [...this.connections.keys()].filter((id) =>
this.connectionCapabilities.get(id)?.has("rendered_components") &&
this.connectionSessions.get(id)?.has(sessionId),
? [...this.connections.keys()].filter(
(id) =>
this.connectionCapabilities.get(id)?.has("rendered_components") &&
this.connectionSessions.get(id)?.has(sessionId),
)
: this.connections.size > 0
? [...this.connections.keys()]
Expand Down
14 changes: 12 additions & 2 deletions packages/coding-agent/test/claude-sdk-oauth-failover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ describe("Claude SDK OAuth failover", () => {
expect(classifySdkError("HTTP 529 overloaded")).toEqual({ kind: "overloaded", retryable: true });
});

it.each([
["fetch failed; getaddrinfo ENOTFOUND platform.claude.com", "other", true],
["authentication_failed: getaddrinfo EAI_AGAIN platform.claude.com", "other", true],
["fetch failed; UND_ERR_CONNECT_TIMEOUT", "other", true],
["authentication_failed: invalid_grant token revoked", "auth_error", true],
["OAuth refresh HTTP 401 unauthorized", "auth_error", true],
] as const)("classifies OAuth refresh failure %s", (message, kind, retryable) => {
expect(classifySdkError(message)).toEqual({ kind, retryable });
});

it("treats Claude Code's prose subscription limits as rate limits", () => {
// Real message from the CLI on an exhausted Pro/Max plan: no SDK error code
// and no HTTP status, so without prose matching it classified as
Expand Down Expand Up @@ -85,13 +95,13 @@ describe("Claude SDK OAuth failover", () => {
});
});

it("still treats unrelated errors as non-retryable", () => {
it("keeps unrelated errors non-retryable while treating connection resets as transient", () => {
// The prose matcher must not swallow ordinary failures into the retry path.
expect(classifySdkError("context window exceeded for this request")).toEqual({
kind: "other",
retryable: false,
});
expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: false });
expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: true });
});

it("walks HRW order after a rate limit, persists the cooldown, and emits failover", async () => {
Expand Down
14 changes: 7 additions & 7 deletions packages/coding-agent/test/interactive-host-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import { FooterComponent } from "../src/modes/interactive/components/footer.ts";
import {
createInteractiveHostRuntime,
createRemoteSessionProxy,
RemoteInteractiveRuntime,
INTERACTIVE_HOST_FALLBACK_WARNING,
RemoteInteractiveRuntime,
} from "../src/modes/interactive/interactive-host-runtime.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { RpcClient } from "../src/modes/rpc/rpc-client.ts";
Expand Down Expand Up @@ -160,11 +160,7 @@ async function waitForHost(child: ChildProcessWithoutNullStreams, socket: string
describe("interactive host runtime", () => {
it("re-registers rendered capability and last width after reconnect", async () => {
const setClientInfo = vi.fn(async () => {});
const runtime = new RemoteInteractiveRuntime(
{} as AgentSessionRuntime,
{} as never,
{ setClientInfo } as never,
);
const runtime = new RemoteInteractiveRuntime({} as AgentSessionRuntime, {} as never, { setClientInfo } as never);
runtime.setClientInfo(117);
await Promise.resolve();
await runtime.reRegisterClientInfo();
Expand Down Expand Up @@ -1853,7 +1849,11 @@ describe("interactive host runtime", () => {
ensureHost: async () => undefined,
});
try {
await runtime.switchSession(target.getSessionFile()!);
await runtime.switchSession(target.getSessionFile()!, {
withSession: async (ctx) => {
expect(ctx.sessionManager.getSessionFile()).toBe(target.getSessionFile());
},
});
await runtime.session.compact();
expect(runtime.session.messages).toContainEqual({
role: "user",
Expand Down
Loading
Loading