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 {
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
Reference in New Issue
Block a user