diff --git a/.agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs b/.agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs new file mode 100644 index 000000000..58e335d9c --- /dev/null +++ b/.agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs @@ -0,0 +1,297 @@ +#!/usr/bin/env node +/** Real-CLI OAuth pin regression: --self-test --expect red|green. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { EventEmitter, once } from "node:events"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { cliEntry, guardRealAuth, makeSandbox, repoRoot, tsxEntry } from "./lib/common.mjs"; +import { hermeticEnv } from "./lib/mock-loop-support.mjs"; + +const root = repoRoot(); +const evidence = join(root, "local-ignore/qa-evidence/20260910-pinned-compaction"); +const provider = "pinned-qa"; +const model = "pinned-model"; +const summary = "PINNED_QA_SUMMARY_OK"; +const exhausted = "QA_DEFAULT_USAGE_LIMIT_EXHAUSTED"; +const deadline = () => AbortSignal.timeout(25_000); +const payloads = [ + { id: "state", type: "get_state" }, + { id: "prompt-before", type: "prompt", message: "PINNED_QA_BEFORE" }, + { id: "text-before", type: "get_last_assistant_text" }, + { id: "compact", type: "compact", customInstructions: "PINNED_QA_COMPACT" }, + { id: "messages-compact", type: "get_messages" }, + { id: "prompt-after", type: "prompt", message: "PINNED_QA_AFTER" }, + { id: "text-after", type: "get_last_assistant_text" }, +]; + +function sourceReceipt() { + const source = readFileSync(join(root, "packages/ai/src/auth/resolve.ts"), "utf8"); + return { + path: "packages/ai/src/auth/resolve.ts", + sha256: createHash("sha256").update(source).digest("hex"), + implicitPinExpression: /overrides\?\.slotName\s*\?\?\s*stored\?\.pinned/.test(source), + }; +} + +function writeSse(res, text) { + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta, finishReason = null, usage) => res.write(`data: ${JSON.stringify({ + id: "pinned-qa-response", object: "chat.completion.chunk", created: 1, model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}), + })}\n\n`); + chunk({ role: "assistant", content: text }); + chunk({}, "stop", { prompt_tokens: 2000, completion_tokens: 100, total_tokens: 2100 }); + res.end("data: [DONE]\n\n"); +} + +async function run(expectation) { + mkdirSync(evidence, { recursive: true }); + const guard = guardRealAuth(); + const box = makeSandbox("pinned-compaction-qa"); + const credentials = { default: randomUUID(), "login-6": randomUUID() }; + const receipt = { + command: `node .agents/skills/senpi-qa/scripts/pinned-compaction-qa.mjs --self-test --expect ${expectation}`, + expectation, startedAt: new Date().toISOString(), sourceBefore: sourceReceipt(), + rpcInputs: [], requests: [], checks: {}, cleanup: {}, + }; + let phase = "startup"; + let child; + let childClosed = false; + let server; + let lines; + let failure; + const bus = new EventEmitter(); + const lifetime = new AbortController(); + const onSignal = () => lifetime.abort(new Error("QA interrupted")); + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + const signal = () => AbortSignal.any([deadline(), lifetime.signal]); + const safe = (value) => { + let text = String(value); + for (const secret of Object.values(credentials)) text = text.replaceAll(secret, "[redacted]"); + return text.replace(/(?:Bearer|Authorization|x-api-key)\s*[:=]?\s*[^\s,}\]]+/gi, "[redacted-header]"); + }; + const check = (name, condition) => { + receipt.checks[name] = Boolean(condition); + assert.ok(condition, name); + }; + try { + server = createServer((req, res) => { + const chunks = []; + req.on("error", () => lifetime.abort(new Error("local HTTP request failed"))); + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + try { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const label = Object.keys(credentials).find((name) => req.headers.authorization === `Bearer ${credentials[name]}`) ?? "unknown"; + const status = label === "login-6" ? 200 : label === "default" ? 402 : 401; + receipt.requests.push({ + phase, account: label, method: req.method, path: req.url, status, + summaryReplayed: JSON.stringify(body.messages).includes(summary), + }); + assert.equal(req.url, "/v1/chat/completions"); + assert.equal(body.model, model); + if (status !== 200) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { + message: label === "default" ? `${exhausted}: account usage limit exhausted` : "QA_UNKNOWN_ACCOUNT", + type: "insufficient_quota", code: "insufficient_quota", + } })); + return; + } + const text = phase === "compact" ? summary : phase === "after" ? "PINNED_QA_AFTER_OK" : "PINNED_QA_BEFORE_OK"; + writeSse(res, text); + } catch (error) { + lifetime.abort(error); + res.destroy(); + } + }); + }); + const listening = once(server, "listening", { signal: signal() }); + server.listen(0, "127.0.0.1"); + await listening; + const baseUrl = `http://127.0.0.1:${server.address().port}/v1`; + const expires = Date.now() + 86_400_000; + writeFileSync(join(box.agentDir, "auth.json"), JSON.stringify({ [provider]: { + type: "oauth", access: credentials.default, refresh: randomUUID(), expires, + accounts: Object.entries(credentials).map(([name, access]) => ({ name, access, refresh: randomUUID(), expires, source: "login" })), + pinned: "login-6", + } }), { mode: 0o600 }); + writeFileSync(join(box.agentDir, "settings.json"), JSON.stringify({ + compaction: { enabled: false, idleCompactionEnabled: false, keepRecentTokens: 40 }, + retry: { enabled: false }, disabledBuiltinExtensions: ["cache-keepalive"], + })); + const sessionPath = join(box.sessionDir, "history.jsonl"); + const timestamp = new Date().toISOString(); + const history = [{ type: "session", version: 3, id: randomUUID(), timestamp, cwd: box.cwd }]; + let parentId = null; + for (let index = 0; index < 4; index++) { + const id = randomUUID().slice(0, 8); + const text = Array.from({ length: 64 }, (_, part) => + `Archived record ${index}, detail ${part}: ${createHash("sha256").update(`${index}:${part}`).digest("hex")}`).join("\n"); + const message = index % 2 === 0 + ? { role: "user", content: [{ type: "text", text }], timestamp: Date.now() } + : { role: "assistant", content: [{ type: "text", text }], timestamp: Date.now(), + api: "openai-completions", provider, model, stopReason: "stop", + usage: { input: 1000, output: 1000, cacheRead: 0, cacheWrite: 0, totalTokens: 2000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; + history.push({ type: "message", id, parentId, timestamp, message }); + parentId = id; + } + writeFileSync(sessionPath, `${history.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + const extension = join(box.dir, "oauth-fixture.mjs"); + writeFileSync(extension, `export default function(pi) { + pi.registerProvider(${JSON.stringify(provider)}, { + baseUrl: ${JSON.stringify(baseUrl)}, api: "openai-completions", + models: [{ id: ${JSON.stringify(model)}, name: "Pinned QA", reasoning: false, input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 }], + oauth: { + name: "Synthetic QA OAuth", + async login() { throw new Error("QA_LOGIN_FORBIDDEN"); }, + async refreshToken() { throw new Error("QA_REFRESH_UNEXPECTED"); }, + getApiKey(credential) { return credential.access; } + } + }); +} +`); + const sandboxEnv = hermeticEnv(box.env); + // Allowlist prevents unknown provider keys, parent branding, proxies, NODE_OPTIONS, + // or shell hooks from crossing the sandbox boundary. + const env = Object.fromEntries([ + "PATH", "TMPDIR", "LANG", "LC_ALL", "HOME", "USERPROFILE", + "SENPI_CODING_AGENT_DIR", "SENPI_CODING_AGENT_SESSION_DIR", "SENPI_OMO_LOCAL_UPDATE", + "PI_OFFLINE", "PI_TELEMETRY", + ].filter((name) => sandboxEnv[name] !== undefined).map((name) => [name, sandboxEnv[name]])); + Object.assign(env, { XDG_CONFIG_HOME: box.dir, XDG_CACHE_HOME: box.dir, TSX_DISABLE_CACHE: "1" }); + const argv = [tsxEntry(root), "--tsconfig", join(root, "tsconfig.json"), cliEntry(root), + "--mode", "rpc", "--session", sessionPath, "--no-context-files", "--no-extensions", "--no-skills", + "--extension", extension, "--provider", provider, "--model", model]; + receipt.childCommand = [process.execPath, ...argv]; + child = spawn(process.execPath, argv, { cwd: box.cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"] }); + child.on("error", (error) => lifetime.abort(error)); + child.on("close", (code, exitSignal) => { + childClosed = true; + receipt.childExit = { code, signal: exitSignal }; + lifetime.abort(new Error("CLI closed")); + }); + receipt.stderrBytes = 0; + child.stderr.on("data", (chunk) => { receipt.stderrBytes += chunk.length; }); + lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => { + if (!line.startsWith("{")) return; + try { + const message = JSON.parse(line); + bus.emit(message.type === "response" ? `response:${message.id}` : message.type, message); + } catch (error) { lifetime.abort(error); } + }); + const send = async (payload) => { + const response = once(bus, `response:${payload.id}`, { signal: signal() }); + receipt.rpcInputs.push(payload); + child.stdin.write(`${JSON.stringify(payload)}\n`); + return (await response)[0]; + }; + const prompt = async (payload) => { + const ended = once(bus, "agent_end", { signal: signal() }); + const [response, [event]] = await Promise.all([send(payload), ended]); + check(`${phase} prompt accepted`, response.success === true); + const assistants = (event.messages ?? []).filter((message) => message.role === "assistant"); + receipt[`${phase}Turn`] = { + aborted: event.aborted === true, + outcomes: assistants.map((message) => ({ stopReason: message.stopReason, error: message.errorMessage ? safe(message.errorMessage) : null })), + }; + check(`${phase} prompt finished without error`, assistants.length > 0 && assistants.every((message) => message.stopReason === "stop")); + }; + const state = await send(payloads[0]); + check("worktree CLI selected fixture model", state.success && state.data?.model?.provider === provider && state.data.model.id === model); + phase = "before"; + await prompt(payloads[1]); + const beforeText = await send(payloads[2]); + check("ordinary pinned prompt succeeded", beforeText.success && beforeText.data?.text?.includes("PINNED_QA_BEFORE_OK")); + phase = "compact"; + const compact = await send(payloads[3]); + receipt.compactResponse = JSON.parse(safe(JSON.stringify(compact))); + const messages = await send(payloads[4]); + const committed = (messages.data?.messages ?? []).some((message) => message.role === "compactionSummary" && message.summary?.includes(summary)); + receipt.compactionCommitted = committed; + check("compacted messages RPC succeeded", messages.success === true); + phase = "after"; + await prompt(payloads[5]); + const afterText = await send(payloads[6]); + check("resumed pinned prompt succeeded", afterText.success && afterText.data?.text === "PINNED_QA_AFTER_OK"); + const ordinary = receipt.requests.filter((request) => request.phase === "before" || request.phase === "after"); + check("ordinary requests only used login-6", ordinary.length === 2 && ordinary.every((request) => request.account === "login-6" && request.status === 200)); + const compactions = receipt.requests.filter((request) => request.phase === "compact"); + check("explicit compact reached localhost", compactions.length > 0); + if (expectation === "red") { + check("RED compaction used exhausted default", compactions.length === 1 && compactions[0].account === "default" && compactions[0].status === 402); + // Core deliberately collapses an extension's rejection reason into a + // generic RPC error. The HTTP receipt above proves quota exhaustion; + // assert the machine-consumed failure flag, not the error prose. + check("RED explicit compact failed after usage exhaustion", compact.success === false); + check("RED did not commit compaction", !committed); + } else { + check("GREEN compaction only used login-6", compactions.every((request) => request.account === "login-6" && request.status === 200)); + check("GREEN explicit compact succeeded", compact.success === true && compact.data?.details?.schema === "senpi.compaction.summary.v1" && typeof compact.data?.firstKeptEntryId === "string"); + check("GREEN compaction committed", committed); + check("GREEN compaction removed old context", compact.data.details.structuralYield.savedTokens > 0); + check("GREEN resumed request replayed summary", ordinary.at(-1).summaryReplayed); + } + } catch (error) { + failure = error; + receipt.failure = { name: error.name, message: safe(error.message) }; + } finally { + // Never leave the tsx supervisor or its CLI descendant alive. + if (child?.pid) { + try { + const closed = childClosed ? Promise.resolve() : once(child, "close", { signal: deadline() }); + try { process.kill(-child.pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + await closed; + receipt.cleanup.cliClosed = childClosed; + try { process.kill(-child.pid, 0); receipt.cleanup.processGroupGone = false; } + catch (error) { if (error.code !== "ESRCH") throw error; receipt.cleanup.processGroupGone = true; } + } catch (error) { receipt.cleanup.cliError = error.name; failure ??= error; } + } else receipt.cleanup.cliClosed = receipt.cleanup.processGroupGone = true; + lines?.close(); + if (server?.listening) { + try { + const closed = once(server, "close", { signal: deadline() }); + server.closeAllConnections(); + server.close(); + await closed; + } catch (error) { receipt.cleanup.serverError = error.name; failure ??= error; } + } + receipt.cleanup.serverClosed = !server?.listening; + box.cleanup(); + receipt.cleanup.sandboxRemoved = !existsSync(box.dir); + try { receipt.cleanup.realAuthUntouched = guard.assertUnchanged(); } + catch (error) { receipt.cleanup.realAuthUntouched = false; failure ??= error; } + receipt.sourceAfter = sourceReceipt(); + receipt.checks["resolver source unchanged during run"] = receipt.sourceBefore.sha256 === receipt.sourceAfter.sha256; + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + receipt.passed = !failure && Object.values(receipt.checks).every(Boolean) && + ["cliClosed", "processGroupGone", "serverClosed", "sandboxRemoved", "realAuthUntouched"].every((key) => receipt.cleanup[key] === true); + receipt.finishedAt = new Date().toISOString(); + writeFileSync(join(evidence, `cli-${expectation}.json`), `${JSON.stringify(receipt, null, 2)}\n`); + } + for (const [name, pass] of Object.entries(receipt.checks)) console.log(`[${pass ? "PASS" : "FAIL"}] ${name}`); + console.log(JSON.stringify({ expectation, passed: receipt.passed, cleanup: receipt.cleanup, failure: receipt.failure ?? null })); + return receipt.passed; +} + +const args = process.argv.slice(2); +const expectation = args.includes("--expect") ? args[args.indexOf("--expect") + 1] : "green"; +if (!args.includes("--self-test") || !["red", "green"].includes(expectation) || + args.some((arg, index) => !["--self-test", "--expect"].includes(arg) && args[index - 1] !== "--expect")) { + console.error("usage: node pinned-compaction-qa.mjs --self-test [--expect red|green]"); + process.exitCode = 2; +} else { + try { process.exitCode = await run(expectation) ? 0 : 1; } + catch (error) { console.error(`QA setup failed (${error.name})`); process.exitCode = 1; } +} diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 747818cff..0f3399ae8 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,8 @@ ### Changed ### Fixed +- Direct authentication resolution now honors the stored pinned account when no slot is explicitly requested. Explicit request keys and slots retain precedence, and OAuth refresh updates only the selected slot. + - A provider-owned OAuth account pool is merged onto the stored pool at commit time instead of overwriting it with the pre-login snapshot, so a sibling account that rotated its refresh token or earned a rate-limit block during an interactive login is never rewound; pool slots carrying the provider's managed sentinel marker are recognized and dropped so they can never dead-end a request. - The auth-miss wording `Provider is not configured: ` is now a shared exported helper used by every throw site, so consumers keying recovery decisions off it cannot drift from the generators. - Claude Agent SDK `Lock file is already being held` is classified as a transient retryable error instead of an unknown/terminal failure. diff --git a/packages/ai/src/auth/resolve.ts b/packages/ai/src/auth/resolve.ts index e9e884c4c..be0c4e3ef 100644 --- a/packages/ai/src/auth/resolve.ts +++ b/packages/ai/src/auth/resolve.ts @@ -7,7 +7,7 @@ import { projectOAuthSlot, refreshOAuthCredential, } from "./oauth-refresh.ts"; -import { projectSlot } from "./pool/slots.ts"; +import { type PooledCredential, projectSlot } from "./pool/slots.ts"; import type { ApiKeyAuth, ApiKeyCredential, @@ -110,8 +110,8 @@ async function resolveProviderAuthWithSignal( ); } - const stored = await readCredential(credentials, provider.id, signal); - const slotName = overrides?.slotName; + const stored: PooledCredential | undefined = await readCredential(credentials, provider.id, signal); + const slotName = overrides?.slotName ?? stored?.pinned; if (slotName !== undefined) { const projected = stored === undefined ? undefined : projectSlot(stored, slotName); if (!projected) return undefined; diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index fefe5b89b..9ac3b1619 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,21 @@ +## 2026-09-10 - Honor pinned accounts during direct auth resolution + +### What changed + +- `packages/ai/src/auth/resolve.ts`: resolve an explicit slot first, otherwise the stored pinned slot, before using the flat credential. Explicit request API keys retain precedence. Missing pinned slots do not fall back to another identity, and OAuth refresh targets the selected slot. + +### Why + +- Compaction resolves auth directly rather than through the ordinary request pool. It used the flat default account even when another usable account was pinned, so an exhausted default account blocked compaction while ordinary conversation succeeded. + +### Why an extension could not handle it + +- `packages/ai/src/auth/resolve.ts` owns the shared credential resolution below `ModelRegistry.getApiKeyAndHeaders()` and the text/image model collections. An extension cannot correct this account choice for every direct caller. + +### Expected merge conflict zones + +- LOW: the pooled-credential import and slot selection in `packages/ai/src/auth/resolve.ts`. + ## PR #1304 review fixes: shared auth-miss prefix, login merge, sentinel repair (2026-09-10) ### What changed diff --git a/packages/ai/test/auth-pinned-resolve.test.ts b/packages/ai/test/auth-pinned-resolve.test.ts new file mode 100644 index 000000000..5c714d692 --- /dev/null +++ b/packages/ai/test/auth-pinned-resolve.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } 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 { resolveProviderAuth } from "../src/auth/resolve.ts"; +import type { OAuthCredential, ProviderAuth } from "../src/auth/types.ts"; + +const NOW = 1_800_000_000_000; +const FUTURE = NOW + 3_600_000; +const authContext = { env: async () => "ambient-key", fileExists: async () => false }; +const provider = { + id: "pinned-test", + auth: { + apiKey: envApiKeyAuth("Test key", ["TEST_KEY"]), + oauth: { + name: "Test OAuth", + login: async () => { + throw new Error("Login is not part of auth resolution"); + }, + refresh: async (credential: OAuthCredential) => ({ + ...credential, + access: `${credential.access}-renewed`, + refresh: `${credential.refresh}-renewed`, + expires: FUTURE, + }), + toAuth: async (credential: OAuthCredential) => ({ apiKey: credential.access }), + }, + } satisfies ProviderAuth, +}; + +function pooledCredential(type: "oauth" | "api_key"): PooledCredential { + if (type === "api_key") { + return { + type, + key: "default-key", + pinned: "login-6", + accounts: [ + { name: "default", key: "default-key" }, + { name: "login-6", key: "selected-key" }, + ], + }; + } + return { + type, + access: "default-key", + refresh: "default-refresh", + expires: FUTURE, + pinned: "login-6", + accounts: [ + { name: "default", access: "default-key", refresh: "default-refresh", expires: FUTURE }, + { name: "login-6", access: "selected-key", refresh: "selected-refresh", expires: FUTURE }, + ], + }; +} + +describe("stored pinned account auth resolution", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => vi.useRealTimers()); + + for (const type of ["oauth", "api_key"] as const) { + describe(type, () => { + it("uses the pin when the caller does not select a slot", async () => { + // given + const store = new InMemoryCredentialStore(); + const entry = pooledCredential(type); + await store.modify(provider.id, async () => entry); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved?.auth.apiKey).toBe("selected-key"); + expect(await store.read(provider.id)).toEqual(entry); + }); + + it("lets an explicit slot override the pin", async () => { + // given + const store = new InMemoryCredentialStore(); + await store.modify(provider.id, async () => pooledCredential(type)); + // when + const resolved = await resolveProviderAuth(provider, store, authContext, { slotName: "default" }); + // then + expect(resolved?.auth.apiKey).toBe("default-key"); + }); + + it("lets an explicit request key override the pin and slot", async () => { + // given + const store = new InMemoryCredentialStore(); + await store.modify(provider.id, async () => pooledCredential(type)); + // when + const resolved = await resolveProviderAuth(provider, store, authContext, { + apiKey: "request-key", + slotName: "default", + }); + // then + expect(resolved?.auth.apiKey).toBe("request-key"); + }); + + it("keeps the flat default when no pin is saved", async () => { + // given + const store = new InMemoryCredentialStore(); + const { pinned: _pinned, ...entry } = pooledCredential(type); + await store.modify(provider.id, async () => entry); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved?.auth.apiKey).toBe("default-key"); + }); + + it("does not fall back to default or ambient auth for a missing pin", async () => { + // given + const store = new InMemoryCredentialStore(); + await store.modify(provider.id, async () => ({ ...pooledCredential(type), pinned: "missing" })); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved).toBeUndefined(); + }); + }); + } + + it("refreshes the pinned OAuth slot without changing the default account", async () => { + // given + const store = new InMemoryCredentialStore(); + const entry = pooledCredential("oauth"); + await store.modify(provider.id, async () => ({ + ...entry, + accounts: entry.accounts?.map((slot) => (slot.name === "login-6" ? { ...slot, expires: NOW - 1 } : slot)), + })); + // when + const resolved = await resolveProviderAuth(provider, store, authContext); + // then + expect(resolved?.auth.apiKey).toBe("selected-key-renewed"); + const stored: PooledCredential | undefined = await store.read(provider.id); + expect(stored).toMatchObject({ + access: "default-key", + refresh: "default-refresh", + expires: FUTURE, + pinned: "login-6", + }); + expect(listSlots(stored).find((slot) => slot.name === "default")).toEqual(entry.accounts?.[0]); + expect(listSlots(stored).find((slot) => slot.name === "login-6")).toMatchObject({ + access: "selected-key-renewed", + refresh: "selected-refresh-renewed", + expires: FUTURE, + }); + }); +}); diff --git a/packages/ai/test/credential-pool-write-paths.test.ts b/packages/ai/test/credential-pool-write-paths.test.ts index 7a0f80fa2..a1724c57f 100644 --- a/packages/ai/test/credential-pool-write-paths.test.ts +++ b/packages/ai/test/credential-pool-write-paths.test.ts @@ -112,12 +112,15 @@ describe("OAuth refresh keeps sibling slots", () => { api: "openai-responses" as never, }); - test("resolveProviderAuth refreshes the matching slot and leaves siblings byte-identical", async () => { + test("resolveProviderAuth refreshes an explicitly selected default slot and leaves siblings byte-identical", async () => { + // given const store = new InMemoryCredentialStore(); await store.modify("pooloauth", async () => pooledOAuthEntry()); - const resolved = await resolveProviderAuth(oauthProvider, store, authContext); + // when + const resolved = await resolveProviderAuth(oauthProvider, store, authContext, { slotName: "default" }); + // then expect(resolved?.auth.apiKey).toBe("refreshed-r1"); const stored = (await store.read("pooloauth")) as PooledCredential; expect(listSlots(stored).find((slot) => slot.name === "work")).toMatchObject({ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ed90ad831..1bcadcdfa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,8 @@ ### Fixed +- Context compaction now uses the pinned account instead of the flat default credential, preventing an exhausted default account from blocking compaction while ordinary conversation uses another account. + - The Windows RPC host supervisor now creates its internal socket directory recursively and provisions a missing public socket secret while reusing an existing valid one, so launching `--internal-rpc-host-supervisor` directly on a fresh profile reaches its listener instead of crashing with `ENOENT ... mkdir '\rpc-host-daemon\internal-'` and then `ENOENT ... open '.secret'` ([#1370](https://github.com/code-yeongyu/senpi/issues/1370)) - A pool slot holding a provider's managed sentinel (`claude-sdk-oauth-managed`) is healed the moment auth.json is read and the repair is written back once, so a second login on an affected build no longer leaves a dead `login-N` entry that hard-errors every request whose affinity picks it; the rotation classifier also treats an unconfigured-slot auth miss as a per-credential failure, so a single bad slot can never dead-end a healthy multi-account pool.