fix(auth): write Claude session-refreshed token back to canonical creds

Root cause of recurring "Refresh token expired / invalid_grant" logouts: the
main refresh loop and each agent session's Claude CLI share one OAuth token
family but refresh independently. Anthropic rotates refresh tokens and revokes
the whole family if an already-rotated token is reused, so when a session
refreshed mid-turn the canonical copy went stale and its next refresh was
rejected — forcing a manual re-login.

Add syncClaudeSessionAuthBack (mirrors the existing Codex syncCodexSessionAuthBack):
after each Claude turn, if the session's CLAUDE_CONFIG_DIR credentials are
strictly newer than canonical (and same subscription), adopt them into the
canonical file. writeCredentials then fans the current token out to every
session dir, so no stale copy lingers to trigger family revocation. Decision
logic extracted to the pure, tested shouldAdoptSessionOAuth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-22 10:24:57 +09:00
parent 3f73197e6c
commit d8abcf0621
3 changed files with 519 additions and 43 deletions

View File

@@ -2,9 +2,22 @@ import { describe, expect, it } from 'vitest';
import {
applyUpdatedTokensToEnvContent,
pickFreshestOAuth,
shouldAdoptSessionOAuth,
shouldStartTokenRefreshLoop,
} from './token-refresh.js';
function creds(expiresAt: number, refreshToken = 'rt') {
return {
claudeAiOauth: {
accessToken: `at-${expiresAt}`,
refreshToken,
expiresAt,
scopes: [] as string[],
},
};
}
describe('shouldStartTokenRefreshLoop', () => {
it('starts refresh for the Claude service', () => {
expect(shouldStartTokenRefreshLoop('claude-code')).toBe(true);
@@ -42,3 +55,82 @@ describe('shouldStartTokenRefreshLoop', () => {
);
});
});
describe('pickFreshestOAuth', () => {
it('adopts a session copy that expires later than the source', () => {
const best = pickFreshestOAuth([
{ label: 'source', creds: creds(1000) },
{ label: 'sessionA', creds: creds(5000) },
{ label: 'sessionB', creds: creds(3000) },
]);
expect(best?.label).toBe('sessionA');
});
it('keeps the source on ties so we do not churn copies needlessly', () => {
const best = pickFreshestOAuth([
{ label: 'source', creds: creds(5000) },
{ label: 'sessionA', creds: creds(5000) },
]);
expect(best?.label).toBe('source');
});
it('ignores candidates without a refresh token or numeric expiry', () => {
const best = pickFreshestOAuth([
{ label: 'source', creds: creds(1000) },
{ label: 'noRefresh', creds: creds(9000, '') },
{ label: 'missing', creds: null },
]);
expect(best?.label).toBe('source');
});
it('returns null when no candidate is usable', () => {
expect(
pickFreshestOAuth([
{ label: 'source', creds: null },
{ label: 'noRefresh', creds: creds(9000, '') },
]),
).toBeNull();
});
});
describe('shouldAdoptSessionOAuth (session→canonical write-back)', () => {
const oauth = (expiresAt: number, extra: Record<string, unknown> = {}) => ({
accessToken: `at-${expiresAt}`,
refreshToken: `rt-${expiresAt}`,
expiresAt,
scopes: [] as string[],
...extra,
});
it('adopts a session token strictly newer than canonical', () => {
expect(shouldAdoptSessionOAuth(oauth(2000), oauth(1000))).toBe(true);
});
it('does not regress to an older or equal session token', () => {
expect(shouldAdoptSessionOAuth(oauth(1000), oauth(2000))).toBe(false);
expect(shouldAdoptSessionOAuth(oauth(2000), oauth(2000))).toBe(false);
});
it('adopts when canonical is missing/invalid', () => {
expect(shouldAdoptSessionOAuth(oauth(1000), null)).toBe(true);
});
it('rejects an invalid session token', () => {
expect(shouldAdoptSessionOAuth(null, oauth(1000))).toBe(false);
expect(
shouldAdoptSessionOAuth(
{ accessToken: '', refreshToken: '', expiresAt: 5000, scopes: [] },
oauth(1000),
),
).toBe(false);
});
it('refuses to cross subscription types', () => {
expect(
shouldAdoptSessionOAuth(
oauth(2000, { subscriptionType: 'pro' }),
oauth(1000, { subscriptionType: 'max' }),
),
).toBe(false);
});
});