Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,13 @@ function formatProfileUsage(entry: ProfileUsageEntry): string {
const state = [entry.active ? "active" : "", entry.applied ? "applied" : ""]
.filter(Boolean)
.join(", ") || "inactive";
const identity =
entry.identity.owner &&
entry.identity.occupant &&
entry.identity.owner.accountUuid !== entry.identity.occupant.accountUuid
? ` · owner ${entry.identity.owner.email ?? entry.identity.owner.accountUuid}` +
` · occupant ${entry.identity.occupant.email ?? entry.identity.occupant.accountUuid}`
: "";
const launch =
entry.launchable.status === "yes"
? chalk.green(`yes (${entry.launchable.reason})`)
Expand All @@ -1318,7 +1325,7 @@ function formatProfileUsage(entry: ProfileUsageEntry): string {
: chalk.yellow(`unknown (${entry.launchable.reason})`);
return (
`${chalk.cyan(entry.tool)}/${chalk.bold(entry.name)} ${usage}\n` +
chalk.dim(` last switch ${last} · ${occupancy} · ${state} · launchable `) +
chalk.dim(` last switch ${last} · ${occupancy}${identity} · ${state} · launchable `) +
launch
);
}
Expand Down
24 changes: 18 additions & 6 deletions src/lib/claude-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,23 @@ export function planParkedRecovery(
}

const layers = profileCredentialLayers(profileDir, tool);
const own = readOAuthSnapshot(profileDir) ?? centralOAuthRecordForProfile(profileDir, tool);
const ownUuid = typeof own?.accountUuid === "string" ? own.accountUuid.toLowerCase() : undefined;
const liveUuidRaw = readOAuthFromPaths(profileAccountJsonPaths(profileDir, tool))?.accountUuid;
const liveUuid = typeof liveUuidRaw === "string" ? liveUuidRaw.toLowerCase() : undefined;
if (isRestorableState(layers.live.state) && ownUuid && liveUuid && ownUuid !== liveUuid) {
const attached = listDirLiveSessions(profileDir).filter((s) => s.alive).length;
return {
outcome: "identity-would-change",
detail:
`the dir currently carries a different account after an in-place switch or login, and its credential is ` +
`${describeCredentialState(layers.live.state)}. Restoring "${profileName ?? "this profile"}"'s own parked ` +
`credential would change which account the dir presents` +
`${attached > 0 ? `, with ${attached} live session(s) attached` : ""}, so it is left to an explicit ` +
`\`accounts switch-account\`.`,
layers,
};
}
if (isRestorableState(layers.live.state)) {
return {
outcome: "live-credential-usable",
Expand Down Expand Up @@ -835,10 +852,6 @@ export function planParkedRecovery(
// pairing one account's identity with another account's token, which is the
// precise failure the identity-index layering exists to prevent. Unknown
// identity is a refusal, not a free pass.
const own = readOAuthSnapshot(profileDir) ?? centralOAuthRecordForProfile(profileDir, tool);
const ownUuid = typeof own?.accountUuid === "string" ? own.accountUuid.toLowerCase() : undefined;
const liveUuidRaw = readOAuthFromPaths(profileAccountJsonPaths(profileDir, tool))?.accountUuid;
const liveUuid = typeof liveUuidRaw === "string" ? liveUuidRaw.toLowerCase() : undefined;
if (!ownUuid) {
return {
outcome: "identity-unknown",
Expand All @@ -855,15 +868,14 @@ export function planParkedRecovery(
return {
outcome: "identity-would-change",
detail:
`the dir currently carries a different account after an in-place switch, and its credential is ` +
`the dir currently carries a different account after an in-place switch or login, and its credential is ` +
`${describeCredentialState(layers.live.state)}. Restoring "${profileName ?? "this profile"}"'s own parked ` +
`credential would change which account the dir presents` +
`${attached > 0 ? `, with ${attached} live session(s) attached` : ""}, so it is left to an explicit ` +
`\`accounts switch-account\`.`,
layers,
};
}

// CROSS-DIRECTORY GATE (defect bb267228). Everything above reasons about THIS
// directory only, and the destructive case does not live in this directory:
// the parked copy can be a SUPERSEDED PREDECESSOR of an account whose current
Expand Down
52 changes: 50 additions & 2 deletions src/lib/usage-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type AccountStatus,
} from "./identity-index.js";
import {
DEFAULT_CLAIM_WINDOW_MS,
readRecentTargetClaims,
readUsageCache,
selectHealthiestAccount,
writeUsageCache,
Expand Down Expand Up @@ -232,6 +234,20 @@ export type ProfileLaunchability = {
| "profile-readiness-unavailable";
};

export interface ProfileAccountIdentity {
accountUuid: string;
email?: string;
status: AccountStatus;
}

export interface ProfileIdentityUsage {
/** The account this profile is labelled/snapshotted to own. */
owner?: ProfileAccountIdentity;
/** The account currently occupying the profile's live config dir. */
occupant?: ProfileAccountIdentity;
occupiedByAnotherAccount: boolean;
}

/**
* Safe, operator-facing row for the cross-tool usage view. Deliberately omits
* config paths, process commands, auth payloads, and provider responses: the
Expand All @@ -244,6 +260,7 @@ export interface ProfileUsageEntry {
lastSwitchAt: string | null;
lastSwitchSource: "profile-selection" | "in-place-switch" | "unknown";
occupancy: ProfileOccupancy;
identity: ProfileIdentityUsage;
active: boolean;
applied: boolean;
launchable: ProfileLaunchability;
Expand Down Expand Up @@ -382,6 +399,30 @@ function profileOccupancy(
};
}

function profileAccountIdentity(account: AccountUsageEntry | undefined): ProfileAccountIdentity | undefined {
if (!account) return undefined;
return {
accountUuid: account.accountUuid,
...(account.email ? { email: account.email } : {}),
status: account.status,
};
}

function profileIdentityUsage(
owner: AccountUsageEntry | undefined,
occupant: AccountUsageEntry | undefined,
): ProfileIdentityUsage {
const ownerIdentity = profileAccountIdentity(owner);
const occupantIdentity = profileAccountIdentity(occupant);
return {
...(ownerIdentity ? { owner: ownerIdentity } : {}),
...(occupantIdentity ? { occupant: occupantIdentity } : {}),
occupiedByAnotherAccount: Boolean(
ownerIdentity && occupantIdentity && ownerIdentity.accountUuid !== occupantIdentity.accountUuid,
),
};
}

/**
* Build one fast cross-tool view. The default path is cache-only for provider
* usage and runs one generic process scan per represented tool; it never
Expand Down Expand Up @@ -418,8 +459,10 @@ export async function collectProfilesUsage(
readiness.providers.map((provider) => [provider.id, provider.available]),
);
const claudeAccountByProfile = new Map<string, AccountUsageEntry>();
const claudeOccupantByProfile = new Map<string, AccountUsageEntry>();
for (const account of accounts) {
for (const name of account.profiles) claudeAccountByProfile.set(name, account);
for (const name of account.occupies) claudeOccupantByProfile.set(name, account);
}

const profilesByTool = new Map<string, Profile[]>();
Expand Down Expand Up @@ -453,13 +496,16 @@ export async function collectProfilesUsage(
.sort((a, b) => a.tool.localeCompare(b.tool) || a.name.localeCompare(b.name))
.map((profile) => {
const profileReadiness = readinessByProfile.get(readinessKey(profile.tool, profile.name));
const ownerAccount = profile.tool === "claude" ? claudeAccountByProfile.get(profile.name) : undefined;
const occupantAccount =
profile.tool === "claude" ? claudeOccupantByProfile.get(profile.name) : undefined;
return {
name: profile.name,
tool: profile.tool,
usage: profileUsageAvailability(
profile,
profileReadiness,
profile.tool === "claude" ? claudeAccountByProfile.get(profile.name) : undefined,
ownerAccount,
now,
),
...latestSwitch(profile),
Expand All @@ -469,12 +515,13 @@ export async function collectProfilesUsage(
profilesByTool.get(profile.tool) ?? [],
!scanFailedTools.has(profile.tool),
),
identity: profileIdentityUsage(ownerAccount, occupantAccount),
active: profileReadiness?.active ?? false,
applied: profileReadiness?.applied ?? false,
launchable: profileLaunchability(
profileReadiness,
providerAvailableByTool.get(profile.tool),
profile.tool === "claude" ? claudeAccountByProfile.get(profile.name) : undefined,
ownerAccount,
opts.refresh === true,
),
};
Expand Down Expand Up @@ -590,6 +637,7 @@ export async function pickHealthiestAccount(
// `accounts pick --healthiest` happily recommends the account the hook
// just fled, and the CLI and the hook disagree about the same pool.
cooldowns: opts.cooldowns ?? activeCooldowns(readExhaustionLedger(), now),
recentClaims: opts.recentClaims ?? readRecentTargetClaims(DEFAULT_CLAIM_WINDOW_MS, now),
},
);

Expand Down
56 changes: 56 additions & 0 deletions src/orphan-occupant-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { loadStore, profilesDir, saveStore } from "./storage.js";
import { getTool } from "./lib/tools.js";
import { resolveStore } from "./lib/store.js";
import { collectAccountsUsage, pickHealthiestAccount } from "./lib/usage-report.js";
import { writeAutoSwitchState, writeUsageCache } from "./lib/auto-switch.js";
import { parseUsageResponse } from "./lib/usage.js";
import {
dirCredentialsFile,
profileAuthDir,
Expand Down Expand Up @@ -102,6 +104,21 @@ function usageFetch(percentUsedByToken: Record<string, number>): typeof fetch {
}) as unknown as typeof fetch;
}

function seedUsage(uuid: string, percentUsed: number): void {
const fetchedAt = new Date().toISOString();
const resets = new Date(Date.now() + 3_600_000).toISOString();
writeUsageCache({
accountUuid: uuid,
fetchedAt,
usage: parseUsageResponse({
limits: [
{ kind: "session", group: "session", percent: percentUsed, resets_at: resets },
{ kind: "weekly_all", group: "weekly", percent: percentUsed, resets_at: resets },
],
}),
});
}

/**
* account006: own identity HOST, live files GUEST (the in-session `/login`
* residue). spare: a plain, wholly self-consistent profile.
Expand Down Expand Up @@ -188,6 +205,45 @@ test("POSITIVE CONTROL: a healthiest account that DOES have a door is still chos
expect(picked.doorless).toBe(0);
});

test("pickHealthiestAccount applies the recent-claim damper when distinct reachable targets exist", async () => {
const now = new Date("2026-08-10T05:12:00Z");
const first = profileDir("account101");
const second = profileDir("account102");
park(first, "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", "first@example.test", "first");
occupy(first, "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", "first@example.test", "first");
park(second, "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb", "second@example.test", "second");
occupy(second, "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb", "second@example.test", "second");
seedUsage("aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", 1); // 99% headroom, but just claimed
seedUsage("bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb", 5); // 95% headroom, still eligible
writeAutoSwitchState(
{
lastSwitchAt: now.toISOString(),
fromUuid: "exhausted",
toUuid: "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa",
outcome: "switched",
},
profileDir("already-switched"),
);

const picked = await pickHealthiestAccount(
{
tool: "claude",
currentUuid: "exhausted",
cachedOnly: true,
maxAgeMs: 60_000,
now,
},
resolveStore(),
);

expect(picked.profileName).toBe("account102");
expect(picked.candidate?.accountUuid).toBe("bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb");
expect(picked.selection.ranked.map((candidate) => candidate.accountUuid)).toEqual([
"bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb",
"aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa",
]);
});

test("POSITIVE CONTROL: when NO ranked candidate has a door, none is invented", async () => {
const occupied = profileDir("account006");
park(occupied, UUID_HOST, "host@example.com", "host");
Expand Down
24 changes: 24 additions & 0 deletions src/repair-auth-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,30 @@ test(
CLI_TIMEOUT_MS,
);

test(
"a healthy foreign live occupant is shown as an identity/occupancy block, not hidden as usable",
() => {
const dir = addProfileDir("account037", UUID_OWN, "lance");
parkAll();
writeFileSync(join(dir, ".claude.json"), identityJson(UUID_GUEST, "lara"));
writeFileSync(join(dir, ".credentials.json"), credentialJson("lara"));

const dry = rowFor(repairRows("--dry-run"), "account037");
const real = rowFor(repairRows(), "account037");

expect(dry.outcome).toBe(real.outcome);
expect(dry.outcome).toBe("identity-would-change");
expect(dry.detail).toContain("different account");
expect(dry.detail).toContain("accounts switch-account");

const human = runCli("repair-auth", "--dry-run");
expect(human.status, human.stderr).toBe(0);
expect(human.stdout).toContain("identity-would-change");
expect(human.stdout).toContain("account037");
},
CLI_TIMEOUT_MS,
);

test(
"a refusal is reported to the operator, not filtered out of the human output",
() => {
Expand Down
16 changes: 16 additions & 0 deletions src/repair-auth-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ const SHAPE_UUID = {
recoverable: "d0000000-0002-4002-8002-000000000002",
nothingParked: "d0000000-0003-4003-8003-000000000003",
switchedAway: "d0000000-0004-4004-8004-000000000004",
healthyForeign: "d0000000-0005-4005-8005-000000000005",
} as const;

/**
Expand Down Expand Up @@ -447,6 +448,21 @@ test("the planner can reach every outcome — the agreement matrix is discrimina
expect(seen.size).toBe(shapes().length);
}, MATRIX_TIMEOUT_MS);

test("a healthy foreign live occupant reaches identity-would-change before live-credential-usable", () => {
const dir = makeProfile("occupiedhealthy", SHAPE_UUID.healthyForeign, "lance");
writeFileSync(join(dir, ".claude.json"), identityJson(UUID_Y, "lara"));
writeFileSync(join(dir, ".credentials.json"), credentialJson("lara"));

const plan = planParkedRecovery(dir, tool(), "occupiedhealthy");
const executed = recoverParkedCredential(dir, tool(), "occupiedhealthy");

expect(plan.outcome).toBe("identity-would-change");
expect(plan.detail).toContain("different account");
expect(plan.detail).toContain("accounts switch-account");
expect(executed.outcome).toBe("identity-would-change");
expect(executed.detail).toBe(plan.detail);
});

test("dry-run and the real run reach the SAME verdict for every shape", () => {
// The defect: the dry-run branch computed `parkedCredentialVerdict` and never
// applied any identity gate, so it said `would-recover` for shapes the real
Expand Down
20 changes: 18 additions & 2 deletions src/usage-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ function runUsageCli(args: string[]) {
});
}

const CLI_TIMEOUT_MS = 60_000;

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "accounts-usage-profiles-home-"));
root = mkdtempSync(join(tmpdir(), "accounts-usage-profiles-root-"));
Expand Down Expand Up @@ -313,6 +315,11 @@ test("default profile usage covers every registered tool, stays cache-only, and
expect(byTool.get("codewith")?.active).toBe(true);
expect(byTool.get("codewith")?.applied).toBe(false);
expect(byTool.get("claude")?.launchable.status).toBe("yes");
expect((byTool.get("claude") as any)?.identity).toMatchObject({
owner: { accountUuid: ACCOUNT_UUID, email: "claude@example.test", status: "ok" },
occupant: { accountUuid: ACCOUNT_UUID, email: "claude@example.test", status: "ok" },
occupiedByAnotherAccount: false,
});
expect(byTool.get("opencode")?.launchable).toEqual({
status: "unknown",
reason: "auth-not-locally-verifiable",
Expand Down Expand Up @@ -531,10 +538,19 @@ test("missing and foreign-occupied Claude directories remain non-launchable", as
{ env: fixtureEnv(), processScanner: () => [] },
resolveStore(fixtureEnv()),
);
expect(occupied.profiles.find((profile) => profile.tool === "claude")?.launchable).toEqual({
const occupiedProfile = occupied.profiles.find((profile) => profile.tool === "claude");
expect(occupiedProfile?.launchable).toEqual({
status: "no",
reason: "profile-directory-occupied",
});
expect((occupiedProfile as any)?.identity).toMatchObject({
owner: { accountUuid: ACCOUNT_UUID, email: "claude@example.test" },
occupant: {
accountUuid: "22222222-2222-4222-8222-222222222222",
email: "occupant@example.test",
},
occupiedByAnotherAccount: true,
});
});

test("usage CLI emits versioned backwards-compatible JSON and safe cross-tool human output", () => {
Expand Down Expand Up @@ -572,4 +588,4 @@ test("usage CLI emits versioned backwards-compatible JSON and safe cross-tool hu
expect(rendered).not.toContain(forbidden);
}
}
});
}, CLI_TIMEOUT_MS);