fix(auth): scan real session credential paths for fan-out/stale detection

listSessionCredentialPaths scanned <sessions>/<folder>/.claude/.credentials.json,
but real session creds live at
<sessions>/<folder>/services/<serviceId>/.claude/.credentials.json (and under
tasks/<taskId>/...). 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/<id>/.claude and services/<id>/tasks/<id>/.claude layout.
Verified on live data: now matches all 24 real session credential files (was 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-22 10:31:42 +09:00
parent d8abcf0621
commit 04cfe97a14
2 changed files with 72 additions and 8 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
applyUpdatedTokensToEnvContent, applyUpdatedTokensToEnvContent,
collectSessionCredentialPaths,
pickFreshestOAuth, pickFreshestOAuth,
shouldAdoptSessionOAuth, shouldAdoptSessionOAuth,
shouldStartTokenRefreshLoop, shouldStartTokenRefreshLoop,
@@ -134,3 +135,31 @@ describe('shouldAdoptSessionOAuth (session→canonical write-back)', () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe('collectSessionCredentialPaths (real session layout)', () => {
it('builds service group + task credential paths, not <group>/.claude', () => {
const tree: Record<string, string[]> = {
'/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 <group>/.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([]);
});
});

View File

@@ -186,19 +186,54 @@ function writeCredentials(accountIndex: number, creds: CredentialsFile): void {
} }
/** /**
* Per-group session credential file paths under <DATA_DIR>/sessions. Spawned * Pure builder for the on-disk session credential paths. Spawned agents run with
* agents run with CLAUDE_CONFIG_DIR pointing at one of these dirs and hold their * CLAUDE_CONFIG_DIR pointing at their session `.claude` dir and keep their own
* own copy of the credentials, which Claude Code may refresh (rotate) on its own. * copy of the credentials, which Claude Code may refresh (rotate) on its own.
*
* The real layout is:
* <sessions>/<folder>/services/<serviceId>/.claude/.credentials.json (group session)
* <sessions>/<folder>/services/<serviceId>/tasks/<taskId>/.claude/.credentials.json (task session)
*
* (An earlier version scanned <sessions>/<folder>/.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[] { function listSessionCredentialPaths(): string[] {
const sessionsDir = path.join(DATA_DIR, 'sessions'); const sessionsDir = path.join(DATA_DIR, 'sessions');
try { try {
if (!fs.existsSync(sessionsDir)) return []; if (!fs.existsSync(sessionsDir)) return [];
return fs return collectSessionCredentialPaths(sessionsDir, listSubdirectories);
.readdirSync(sessionsDir)
.map((group) =>
path.join(sessionsDir, group, '.claude', '.credentials.json'),
);
} catch (err) { } catch (err) {
logger.warn( logger.warn(
{ err: getErrorMessage(err) }, { err: getErrorMessage(err) },