From 04cfe97a1434d6d9d77d918daf9227c5bc0dc7ac Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 10:31:42 +0900 Subject: [PATCH] fix(auth): scan real session credential paths for fan-out/stale detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listSessionCredentialPaths scanned //.claude/.credentials.json, but real session creds live at //services//.claude/.credentials.json (and under tasks//...). The mismatch meant writeCredentials' fan-out and loadFreshestCredentials' stale-copy scan silently missed every real session file — so old refresh-token copies (family-revocation landmines) were never overwritten and the session→canonical write-back could not converge dormant sessions. Rewrite it via the pure, tested collectSessionCredentialPaths that walks the actual services//.claude and services//tasks//.claude layout. Verified on live data: now matches all 24 real session credential files (was 0). Co-Authored-By: Claude Opus 4.7 --- src/token-refresh.test.ts | 29 ++++++++++++++++++++++ src/token-refresh.ts | 51 +++++++++++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/token-refresh.test.ts b/src/token-refresh.test.ts index 66d0384..1c81bcc 100644 --- a/src/token-refresh.test.ts +++ b/src/token-refresh.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { applyUpdatedTokensToEnvContent, + collectSessionCredentialPaths, pickFreshestOAuth, shouldAdoptSessionOAuth, shouldStartTokenRefreshLoop, @@ -134,3 +135,31 @@ describe('shouldAdoptSessionOAuth (session→canonical write-back)', () => { ).toBe(false); }); }); + +describe('collectSessionCredentialPaths (real session layout)', () => { + it('builds service group + task credential paths, not /.claude', () => { + const tree: Record = { + '/s': ['iger', 'javis_bot'], + '/s/iger/services': ['claude'], + '/s/iger/services/claude/tasks': ['task-1'], + '/s/javis_bot/services': ['claude'], + '/s/javis_bot/services/claude/tasks': [], + }; + const paths = collectSessionCredentialPaths('/s', (dir) => tree[dir] ?? []); + expect(paths).toContain( + '/s/iger/services/claude/.claude/.credentials.json', + ); + expect(paths).toContain( + '/s/iger/services/claude/tasks/task-1/.claude/.credentials.json', + ); + expect(paths).toContain( + '/s/javis_bot/services/claude/.claude/.credentials.json', + ); + // Must NOT use the old broken /.claude path that matched nothing. + expect(paths).not.toContain('/s/iger/.claude/.credentials.json'); + }); + + it('returns nothing when there are no session folders', () => { + expect(collectSessionCredentialPaths('/s', () => [])).toEqual([]); + }); +}); diff --git a/src/token-refresh.ts b/src/token-refresh.ts index 7ed8ec0..efb0cd1 100644 --- a/src/token-refresh.ts +++ b/src/token-refresh.ts @@ -186,19 +186,54 @@ function writeCredentials(accountIndex: number, creds: CredentialsFile): void { } /** - * Per-group session credential file paths under /sessions. Spawned - * agents run with CLAUDE_CONFIG_DIR pointing at one of these dirs and hold their - * own copy of the credentials, which Claude Code may refresh (rotate) on its own. + * Pure builder for the on-disk session credential paths. Spawned agents run with + * CLAUDE_CONFIG_DIR pointing at their session `.claude` dir and keep their own + * copy of the credentials, which Claude Code may refresh (rotate) on its own. + * + * The real layout is: + * //services//.claude/.credentials.json (group session) + * //services//tasks//.claude/.credentials.json (task session) + * + * (An earlier version scanned //.claude/... which never + * matched, so the fan-out and stale-copy scan silently missed every real + * session file.) `listDirs` is injected so this is unit-testable. */ +export function collectSessionCredentialPaths( + sessionsDir: string, + listDirs: (dir: string) => string[], +): string[] { + const CRED = ['.claude', '.credentials.json'] as const; + const paths: string[] = []; + for (const folder of listDirs(sessionsDir)) { + const servicesDir = path.join(sessionsDir, folder, 'services'); + for (const serviceId of listDirs(servicesDir)) { + const serviceDir = path.join(servicesDir, serviceId); + paths.push(path.join(serviceDir, ...CRED)); + const tasksDir = path.join(serviceDir, 'tasks'); + for (const taskId of listDirs(tasksDir)) { + paths.push(path.join(tasksDir, taskId, ...CRED)); + } + } + } + return paths; +} + +function listSubdirectories(dir: string): string[] { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + return []; + } +} + function listSessionCredentialPaths(): string[] { const sessionsDir = path.join(DATA_DIR, 'sessions'); try { if (!fs.existsSync(sessionsDir)) return []; - return fs - .readdirSync(sessionsDir) - .map((group) => - path.join(sessionsDir, group, '.claude', '.credentials.json'), - ); + return collectSessionCredentialPaths(sessionsDir, listSubdirectories); } catch (err) { logger.warn( { err: getErrorMessage(err) },