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:
@@ -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 <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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,19 +186,54 @@ function writeCredentials(accountIndex: number, creds: CredentialsFile): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-group session credential file paths under <DATA_DIR>/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:
|
||||
* <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[] {
|
||||
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) },
|
||||
|
||||
Reference in New Issue
Block a user