Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/coding-agent/docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The `claude-sdk-oauth` provider routes LLM calls through the official [Claude Ag
- Run `/login claude-sdk-oauth` to sign in with your Claude Pro/Max subscription (PKCE, same OAuth client as the Claude Code CLI). An existing Anthropic OAuth credential is offered as an import.
- Multiple accounts: each `/login claude-sdk-oauth` adds another named account. `CLAUDE_CODE_OAUTH_TOKEN` (and `_2`..`_N`) are honored as read-only env accounts. `/claude-account` lists, adds, removes, and pins accounts; `--claude-account <name>` pins one for the session; `claudeSdkOauthProvider.pinnedAccount` pins one in settings.
- Session affinity: one senpi session sticks to one account (rendezvous hashing), which keeps Anthropic's prompt cache warm - accounts never rotate mid-session except on automatic failover. Rate limits and auth errors block the account (with cooldown) and retry on the next account, before any visible output; once output has started, the error surfaces instead of replaying.
- Default lane: **ambient** - with no `tokenInjection` setting the provider inherits the environment like the upstream extension (Claude Code CLI login or `ANTHROPIC_API_KEY`). Managed lanes (`oauth-slots`, `config-dir`) are opt-in via one settings line until the live subscription spike proves a managed default.
- Default lane: **oauth-slots** - with no `tokenInjection` setting the provider uses accounts added by `/login claude-sdk-oauth` plus read-only `CLAUDE_CODE_OAUTH_TOKEN` env accounts. Set `tokenInjection: "ambient"` explicitly to inherit Claude Code CLI credentials or other upstream environment authentication. An empty managed pool retains the existing compatibility fallback to ambient authentication.
- Settings (`claudeSdkOauthProvider`):
- `systemPromptMode` — controls how the system prompt is delivered. **`full`** (default) sends senpi's composed system prompt verbatim; the lane no longer rebuilds from the SDK `claude_code` preset, so all prompt regions (project rules, response-language instructions, etc.) reach the model. **`preset-append`** is the previous behaviour (deprecated, kept for one release; emits a one-time warning). **`override`** loads the system prompt from a file (`systemPromptFile`). The legacy `appendSystemPrompt` key still works: `false` → `preset-append`, `true`/unset → `full`; setting both keys makes `systemPromptMode` win and warns.
- In `full` and `override` modes, `settingSources` defaults to `[]` on every lane because senpi's prompt already carries project context — loading the SDK's own CLAUDE.md would double-inject it. The CLI always prepends its own `"You are a Claude agent, built on Anthropic's Claude Agent SDK."` block, which senpi cannot suppress; `full` means the prompt is delivered intact, not that it is the only system-prompt text.
Expand Down Expand Up @@ -86,7 +86,7 @@ ls -lt ~/.claude/projects/*/ | head
If your Claude Pro/Max subscription usage through `claude-sdk-oauth` feels unexpectedly high, check these in order:

1. **Upgrade to v2026.8.3 or later.** Resume-first session continuity (#634-637) landed on 2026-08-03. On older builds, every turn after a divergence (compaction, abort, model switch, restart, failover) re-sends the entire conversation, which is the dominant token-burn mechanism.
2. **Check which lane you are on.** The `ambient` lane (default) inherits the environment. `oauth-slots` and `config-dir` are managed lanes set via `SENPI_CLAUDE_SDK_OAUTH_TOKEN_INJECTION`. The `config-dir` lane keeps each account's credentials in its own `CLAUDE_CONFIG_DIR`; no official SDK API moves a transcript across roots, so account failover on that lane always flattens (re-sends the full history) — this is a declared residual, not a bug.
2. **Check which lane you are on.** The `oauth-slots` lane is the default and uses accounts added by `/login claude-sdk-oauth` plus read-only token env slots. `config-dir` is the alternate managed lane, and `ambient` must normally be selected explicitly to inherit Claude Code CLI credentials or other upstream environment authentication. The exception is an empty managed pool, which retains the existing compatibility fallback to ambient authentication. The `config-dir` lane keeps each account's credentials in its own `CLAUDE_CONFIG_DIR`; no official SDK API moves a transcript across roots, so account failover on that lane always flattens (re-sends the full history) — this is a declared residual, not a bug.
3. **Read the continuity observations.** Tail the session log and filter for `flatten` — each `flatten` line means the lane re-sent the whole conversation and lost prompt-cache hits. A healthy conversation shows one `bootstrap` followed by `delta` lines. Common flatten reasons: `transcript_missing`, `registry_miss`, `resume_initialization_failed`, `cross_root_unsupported` (config-dir only).
4. **Prompt-cache retention.** Effective cache TTL depends on which lane you are on:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function writeConfigCredentials(directory: string, slot: AccountSlot, access: st
}

async function managedPool(settings: ClaudeSdkOauthProviderSettings): Promise<ManagedPool | undefined> {
const lane = settings.tokenInjection ?? "ambient";
const lane = settings.tokenInjection ?? "oauth-slots";
if (lane === "ambient") return undefined;
const store = activeBoundary.createStore();
let credential = await store.read(CLAUDE_SDK_OAUTH_PROVIDER_ID);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# claude-sdk-oauth extension changes

## 2026-08-11 - Default stored OAuth accounts to managed slot injection

- Changed the managed auth pool fallback from `ambient` to `oauth-slots`, matching the query-options default. A
successful `/login claude-sdk-oauth` now supplies its stored account token without requiring an explicit
`claudeSdkOauthProvider.tokenInjection` setting.
- Explicit `tokenInjection: "ambient"` remains unchanged and continues to rely on upstream credential sources. The
existing empty-pool compatibility fallback to ambient authentication is also unchanged.
- Added deterministic regression coverage for default stored-slot selection and explicit ambient preservation using
the real provider stream with in-memory credentials and a fake SDK query.
- This cannot be implemented by an external extension: lane selection and credential preparation are private to the
builtin provider's stream path. Replacing the provider wholesale would also replace its account affinity, failover,
and session-continuity integration.
- Expected conflict zones: `auth-lane.ts` managed-pool lane selection and Claude OAuth auth-lane regression tests.

## 2026-08-11 - Require a real OAuth login for runtime availability

- Removed the literal `apiKey: "claude-sdk-oauth-managed"` registration placeholder. Provider composition treated
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/test/claude-sdk-oauth-auth-lane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ describe("Claude SDK OAuth auth lanes", () => {
it("preserves the parent environment minus SENPI_* variables in ambient mode", async () => {
const captured: Options[] = [];
const { CLAUDE_CODE_OAUTH_TOKEN: _oauthToken, ...ambientEnvironment } = managedEnvironment();
configureAuth(new InMemoryCredentialStore(), ambientEnvironment);
configureAuth(new InMemoryCredentialStore(), ambientEnvironment, undefined, "ambient");
overrideSdkBoundary({ query: queryCapturing(captured) });

await streamClaudeSdkOauth(model, context).result();
Expand Down Expand Up @@ -349,7 +349,7 @@ describe("Claude SDK OAuth auth lanes", () => {
lane === "ambient" ? new InMemoryCredentialStore() : await storeWith(slot("default", "slot-access"));
const captured: Options[] = [];
const { CLAUDE_CODE_OAUTH_TOKEN: _oauthToken, ...ambientEnvironment } = managedEnvironment();
configureAuth(store, ambientEnvironment, undefined, lane === "ambient" ? undefined : lane);
configureAuth(store, ambientEnvironment, undefined, lane);
overrideSdkBoundary({ query: queryCapturing(captured) });

await streamClaudeSdkOauth(model, context).result();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type Api, type Context, InMemoryCredentialStore, type Model } from "@earendil-works/pi-ai";
import { afterEach, expect, it } from "vitest";
import { addAccount, emptyCredential } from "../../../src/core/extensions/builtin/claude-sdk-oauth/accounts.ts";
import {
overrideAuthLaneBoundary,
resetAuthLaneBoundary,
} from "../../../src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts";
import {
type Options,
overrideSdkBoundary,
resetSdkBoundary,
type SDKMessage,
type SdkQuery,
} from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts";
import { streamClaudeSdkOauth } from "../../../src/core/extensions/builtin/claude-sdk-oauth/stream.ts";

const providerId = "claude-sdk-oauth";
const originalAgentDir = process.env.SENPI_CODING_AGENT_DIR;
const temporaryDirectories: string[] = [];

const model: Model<Api> = {
id: "claude-test",
name: "Claude test",
api: providerId,
provider: providerId,
baseUrl: providerId,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};

const context: Context = { messages: [] };

function captureQuery(captured: Options[]): SdkQuery {
return (input) => {
if (!input.options) throw new Error("SDK query options are required");
captured.push(input.options);
return {
async *[Symbol.asyncIterator](): AsyncGenerator<SDKMessage> {
yield { type: "result", subtype: "success", result: "ok" } as SDKMessage;
},
async interrupt() {},
close() {},
};
};
}

afterEach(() => {
resetSdkBoundary();
resetAuthLaneBoundary();
if (originalAgentDir === undefined) delete process.env.SENPI_CODING_AGENT_DIR;
else process.env.SENPI_CODING_AGENT_DIR = originalAgentDir;
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});

it("uses stored OAuth slots when token injection is not configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "senpi-claude-oauth-default-lane-"));
temporaryDirectories.push(agentDir);
process.env.SENPI_CODING_AGENT_DIR = agentDir;

const store = new InMemoryCredentialStore();
await store.modify(providerId, async () =>
addAccount(emptyCredential(), {
name: "default",
access: "stored-slot-access",
refresh: "stored-slot-refresh",
expires: 4_102_444_800_000,
source: "login",
}),
);

overrideAuthLaneBoundary({
createStore: () => store,
env: () => ({
PATH: "/usr/bin",
CLAUDE_CODE_OAUTH_TOKEN: "ambient-access",
}),
getAgentDir: () => agentDir,
});
const captured: Options[] = [];
overrideSdkBoundary({ query: captureQuery(captured) });

await streamClaudeSdkOauth(model, context).result();

expect(captured).toHaveLength(1);
expect(captured[0]?.env).toMatchObject({
PATH: "/usr/bin",
CLAUDE_CODE_OAUTH_TOKEN: "stored-slot-access",
});
});

it("preserves ambient credentials when ambient injection is explicit", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "senpi-claude-oauth-explicit-ambient-"));
temporaryDirectories.push(agentDir);
process.env.SENPI_CODING_AGENT_DIR = agentDir;
writeFileSync(
join(agentDir, "settings.json"),
JSON.stringify({ claudeSdkOauthProvider: { tokenInjection: "ambient" } }),
);

const store = new InMemoryCredentialStore();
await store.modify(providerId, async () =>
addAccount(emptyCredential(), {
name: "default",
access: "stored-slot-access",
refresh: "stored-slot-refresh",
expires: 4_102_444_800_000,
source: "login",
}),
);

overrideAuthLaneBoundary({
createStore: () => store,
env: () => ({
PATH: "/usr/bin",
CLAUDE_CODE_OAUTH_TOKEN: "ambient-access",
}),
getAgentDir: () => agentDir,
});
const captured: Options[] = [];
overrideSdkBoundary({ query: captureQuery(captured) });

await streamClaudeSdkOauth(model, context).result();

expect(captured).toHaveLength(1);
expect(captured[0]?.env).toMatchObject({
PATH: "/usr/bin",
CLAUDE_CODE_OAUTH_TOKEN: "ambient-access",
});
});