#3894 · Claude expiry recovery guidance and credential refresh
2026-09-18 · Trusted origin/main: 8d35c2776bc7fd33f41a64e4c6dd7ebd2f021887
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium
1. TL;DR
The reporter describes repeated Claude authentication failures after opening its terminal interface and refreshing BB usage. BB’s usage refresh rereads credentials; it does not perform an OAuth renewal, so unchanged expired credentials produce the same expired result. The provider health response includes an explicit login command, but the displayed guidance comes from separate strings that only describe launching Claude. Two clean-checkout tests confirmed this maintenance behavior and recovery when the selected credential changes. The reporter’s actual terminal login, account storage, and chat failure were not reproduced with a live account, so this is not a claim that successful reauthentication is universally ignored.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| BB provides insufficiently specific sign-in guidance. | Verified in source | The provider strings omit the explicit login action present in its health result; the usage component renders those strings. |
| Refreshing usage leaves an expired state unchanged. | Verified under synthetic expired credentials | Two consecutive calls return expired, with no network request. |
| A completed terminal sign-in is ignored by BB. | Unverified; maintenance caching ruled out in the tested case | Replacing the selected fixture credential makes the next call return ok without restarting the module. Merely opening a CLI is not evidence of credential replacement. |
| Chat continues to fail on the reporter’s installation. | Unverified live | No real account, expired OAuth session, or Claude process was used. |
| A particular working directory is necessary. | Not established | The inspected maintenance reader uses the OS Keychain or a home-relative credential file, not the project directory. Host, OS user, and credential source are more relevant; full session configuration was not reproduced. |
3. Environment
- Trusted public repository get-bb/bb at the commit above, obtained from its main branch.
- Darwin arm64; Node 22.22.3; pnpm 9.15.0 via Corepack; Vitest 4.1.1.
- Separate temporary clones named base and verify at the same commit. The second clone was clean before copying the reproduction test.
- Frozen installs in both clones; the first full Turbo build passed all 58 tasks. The second run built its test prerequisites through Turbo.
- No app instance, ports, database, real credentials, or live provider used. Filesystem credential reads, Keychain process calls, executable probing, and fetch were mocked. The fixture CLI version is synthetic, not an installed version measurement.
4. Minimal reproduction
- Clone get-bb/bb and check out the recorded main commit. Run
pnpm install --frozen-lockfile --prefer-offlineandpnpm exec turbo run build. - Save the reproduction test as
plugins/provider-claude-code/src/bridge/issue-3894.repro.test.ts. - Run:
pnpm exec turbo run test --filter=bb-plugin-provider-claude-code --force -- issue-3894.repro.test.ts provider-maintenance.credentials.test.ts
Expected by the user: completing sign-in should allow chat to resume and usage to refresh. This harness tests only maintenance credential selection and rereading, not completed interactive sign-in or chat.
Observed assertions: two expired reads stay expired without fetch; replacing the selected credential yields ok and health ready; a parseable expired Keychain credential hides a fresh file until that Keychain entry is absent. These are characterization assertions that pass on main, not a failing end-to-end regression test.
Test Files 2 passed (2)
Tests 6 passed (6)
The three new characterization tests and three existing credential tests passed in the second checkout. The exact result excerpts appear below; raw logs remain in the local evidence backup.
Full reproduction test
import { afterAll, beforeAll, beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ keychain: "", file: "", fileReads: 0 }));
vi.mock("node:child_process", () => ({
execFile: (
_file: string,
_args: readonly string[],
_options: object,
callback: (error: Error | null, result: { stdout: string; stderr: string }) => void,
) => callback(null, { stdout: state.keychain, stderr: "" }),
}));
vi.mock("node:fs/promises", () => ({
default: {
readFile: (file: string) => {
if (file.endsWith(".credentials.json")) {
state.fileReads += 1;
return Promise.resolve(state.file);
}
return Promise.resolve(JSON.stringify({ oauthAccount: { emailAddress: null } }));
},
},
}));
vi.mock("@get-bb/plugin-sdk/provider-bridge", async (importOriginal) => ({
...(await importOriginal<typeof import("@get-bb/plugin-sdk/provider-bridge")>()),
experimental_resolveExecutablePath: () => Promise.resolve("/test/claude"),
experimental_readCliVersion: () => Promise.resolve("2.1.276"),
}));
import { getClaudeProviderHealth, getClaudeProviderUsage } from "./provider-maintenance.js";
const originalPlatform = process.platform;
const credential = (expiresAt: number) => JSON.stringify({
claudeAiOauth: { accessToken: "synthetic-test-value", expiresAt, subscriptionType: "pro" },
});
beforeAll(() => Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }));
afterAll(() => {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
vi.unstubAllGlobals();
});
beforeEach(() => {
state.keychain = credential(Date.now() - 60_000);
state.file = state.keychain;
state.fileReads = 0;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true, status: 200, json: () => Promise.resolve({ limits: [] }),
}));
});
it("reloading usage repeatedly preserves expired status without attempting a network request", async () => {
expect(await getClaudeProviderUsage()).toEqual({ supported: true, usage: { status: "expired" } });
expect(await getClaudeProviderUsage()).toEqual({ supported: true, usage: { status: "expired" } });
expect(fetch).not.toHaveBeenCalled();
expect(await getClaudeProviderHealth()).toMatchObject({
health: { status: "expired", loginCommand: "claude /login" },
});
});
it("rereads updated credentials and recovers usage without restarting the module", async () => {
expect(await getClaudeProviderUsage()).toMatchObject({ usage: { status: "expired" } });
state.keychain = credential(Date.now() + 3_600_000);
expect(await getClaudeProviderUsage()).toMatchObject({ usage: { status: "ok" } });
expect(fetch).toHaveBeenCalledTimes(1);
expect(await getClaudeProviderHealth()).toMatchObject({ health: { status: "ready" } });
});
it("a stale parseable keychain entry wins over a fresh credential file on macOS", async () => {
state.file = credential(Date.now() + 3_600_000);
expect(await getClaudeProviderUsage()).toMatchObject({ usage: { status: "expired" } });
expect(state.fileReads).toBe(0);
state.keychain = "";
expect(await getClaudeProviderUsage()).toMatchObject({ usage: { status: "ok" } });
expect(state.fileReads).toBe(1);
});
5. Root cause and limits
Confirmed mechanism: plugins/provider-claude-code/src/bridge/provider-maintenance.ts:495–524 loads credentials afresh, returns expired before any HTTP request when their timestamp has passed, and also maps HTTP 401 to expired. Usage refresh therefore observes authentication state; it cannot itself repair that state. The UI refresh calls usage RPC at plugins/provider-usage/app.tsx:97–127.
Guidance mismatch: plugins/provider-claude-code/server.ts:42–50 supplies generic startup hints, while plugins/provider-claude-code/src/bridge/provider-maintenance.ts:323–343 supplies an explicit login command in the health object. The usage UI renders the provider strings for unauthenticated and expired states at plugins/provider-usage/app.tsx:253–259. This explains why the recovery guidance does not describe the distinct login operation. It does not prove how the external CLI behaves merely on startup.
Conditional contributing cause: plugins/provider-claude-code/src/bridge/provider-maintenance.ts:278–294 accepts the first parseable Keychain credential even when expired and never reads the fallback file in that case. The synthetic macOS case reproduces an expired usage badge despite a fresh file. The reporter did not provide evidence of two conflicting credential stores, so this must remain a hypothesis, not the assigned cause of their chat failure.
Ruled out locally: permanent credential caching inside the maintenance module. Updating the selected Keychain fixture immediately restores usage on the next call. The exact failure after terminal startup, including whether a login was completed under the same host/user/configuration, remains unverified.
6. Proposed next step and fix eligibility
Align the displayed guidance with the provider’s explicit login action and identify the execution host and OS user. A follow-up reproduction should verify completed login on that host, then check whether the selected credential changes and a real chat turn succeeds, without recording credential values. If credentials disagree between stores, establish the provider’s intended selection semantics before changing precedence.
No pull request was opened. The full reported chat failure has not been reproduced with a real login, no failing-before/passing-after regression has established a production fix, and changing credential selection or OAuth renewal would alter authentication behavior excluded by the simple-fix rule. A copy-only change cannot be claimed to repair the unverified authentication failure.
7. Related issues
#3337 tracks Claude expiry recovery and #3607 tracks maintenance credential-directory mismatch. These were read as untrusted context; their scripts, tests, and linked branches were not executed. No linked open pull request was found through cross-reference metadata or the issue-number PR search.
8. Verification
The same investigator repeated the test in a second clean temporary clone named verify at 8d35c2776bc7fd33f41a64e4c6dd7ebd2f021887. After a separate frozen install, the test above and existing credential tests ran with --force, preventing a cached test result. All six assertions passed across two files. The first run had three passing tests; the second added the existing three-test credential suite. Both runs support the limited verdict. No report correction was needed. This is a repeated check by the same agent, not an independent review.
9. Appendix
first-run.log
bb-plugin-provider-claude-code:test: > vitest run --config vitest.config.ts "issue-3894.repro.test.ts" bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: RUN v4.1.1 <temporary-root>/base/plugins/provider-claude-code bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: ✓ |bb-plugin-provider-claude-code:isolated| src/bridge/issue-3894.repro.test.ts (3 tests) 10ms bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: Test Files 1 passed (1) bb-plugin-provider-claude-code:test: Tests 3 passed (3) bb-plugin-provider-claude-code:test: Start at 01:03:18 bb-plugin-provider-claude-code:test: Duration 1.02s (transform 539ms, setup 0ms, import 816ms, tests 10ms, environment 0ms) bb-plugin-provider-claude-code:test: Tasks: 5 successful, 5 total Cached: 0 cached, 5 total Time: 2.449s
second-run.log
bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: RUN v4.1.1 <temporary-root>/verify/plugins/provider-claude-code bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: ✓ |bb-plugin-provider-claude-code:isolated| src/bridge/provider-maintenance.credentials.test.ts (3 tests) 6ms bb-plugin-provider-claude-code:test: ✓ |bb-plugin-provider-claude-code:isolated| src/bridge/issue-3894.repro.test.ts (3 tests) 7ms bb-plugin-provider-claude-code:test: bb-plugin-provider-claude-code:test: Test Files 2 passed (2) bb-plugin-provider-claude-code:test: Tests 6 passed (6) bb-plugin-provider-claude-code:test: Start at 01:03:50 bb-plugin-provider-claude-code:test: Duration 489ms (transform 487ms, setup 0ms, import 763ms, tests 13ms, environment 0ms) bb-plugin-provider-claude-code:test: Tasks: 5 successful, 5 total Cached: 0 cached, 5 total Time: 2.399s
build.log
Tasks: 58 successful, 58 total Cached: 4 cached, 58 total Time: 56.332s
Repository commands: fetch trusted main; clone base and verify; detached checkout at the recorded SHA; frozen pnpm installs; full Turbo build in base; focused Turbo tests in each clone; inspect source with rg and numbered excerpts; git diff --check. The ordinary pnpm shim on this host referenced a missing installation, so a temporary PATH shim routed pnpm through Corepack 9.15.0. No dependency or lockfile was changed.
Output excerpts are path-sanitized; test output otherwise preserved. Repository publication policy keeps scripts and raw logs out of git; the full test is embedded above, and local evidence files remain in the backup. No servers or provider processes were started, so no runtime ports or data directories required cleanup. Both checkouts contain only the added reproduction test as a source change.
Trust boundary: issue content was treated solely as untrusted claims. Shell-like text in the issue was not executed. The fixture and test were authored from trusted main source, with all credential and network boundaries mocked.