fix(usage): scope Muse and agent logins, show every failed host login (#6156)

* fix(muse): show usage only for the agent account

Usage discovery ignored session scope and exposed all remembered accounts to unrelated agents. Resolve session accounts from their own launch environment while preserving global discovery.

* test(muse): own the usage scope regression in the plugin suite

* fix(usage): scope agent reports to their own logins

Account grouping also merged fetch inputs and cache entries across agent and host queries, hiding the agent login failure behind another harness. Preserve scoped login sets, collect every host failure with source-owned harness labels, and keep refresh results within their requesting view.

* test(usage): expect harness labels in discovered accounts
This commit is contained in:
Mohamed Boudra
2026-10-05 18:41:54 +01:00
committed by GitHub
parent 18412604dd
commit 3e2d77723e
22 changed files with 592 additions and 87 deletions
@@ -141,6 +141,7 @@ describe("Claude credential routes", () => {
expect(accounts).toEqual([
{
key: "fixture-account.fixture-org",
harness: "Claude",
input: { route: { store: "claude", path: join(dir, ".credentials.json") } },
},
]);
@@ -237,6 +237,7 @@ test("keychain identity belongs to its token even when Claude Code metadata name
);
expect(identity).toEqual({
key: "pi-account.pi-org",
harness: "Claude",
input: {
route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" },
},
@@ -915,6 +915,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc
expect(accounts).toEqual([
{
key: expect.stringMatching(/^[a-f0-9]{64}$/),
harness: "Claude",
input: {
route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" },
},
@@ -960,7 +961,11 @@ it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account",
const api: typeof fetch = async () => new Response(null, { status });
const accounts = await discover({ kind: "global" }, lookup, api);
expect(accounts).toEqual([
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) },
{
key: expect.stringMatching(/^[a-f0-9]{64}$/),
harness: "Claude",
input: credentialInput(lookup.claudeHome),
},
]);
let fetches = 0;
expect(
@@ -992,7 +997,9 @@ it.each([
return new Response(null, { status: 500 });
};
const accounts = await discover({ kind: "global" }, lookup, api);
expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]);
expect(accounts).toEqual([
{ key: hashAccountKey(JSON.stringify(input.route)), harness: "Claude", input },
]);
await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error);
},
);
@@ -1071,7 +1078,12 @@ it("session discovery reads only its Claude home and excludes foreign sessions",
const lookup = { platform: "linux" as const, home: "/unused-default-home", env: {} };
const accounts = await discover(scope, lookup, api);
expect(accounts).toEqual([
{ key: "session-account.org", label: "session@example.test", input: credentialInput(home) },
{
key: "session-account.org",
label: "session@example.test",
harness: "Claude",
input: credentialInput(home),
},
]);
await discover(scope, lookup, api);
expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]);
+5 -2
View File
@@ -380,7 +380,10 @@ export async function discover(
for (const input of candidates) {
const credentials = await resolveClaudeCredentials(input, lookup);
if (!credentials) continue;
const fallback = { key: hashAccountKey(JSON.stringify(input.route)), input };
const harness = { pi: "Pi", omp: "OMP", claude: "Claude", keychain: "Claude" }[
input.route.store
];
const fallback = { key: hashAccountKey(JSON.stringify(input.route)), harness, input };
if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) {
accounts.push(fallback);
continue;
@@ -391,7 +394,7 @@ export async function discover(
fetchApi,
lookup.now ?? Date.now,
);
accounts.push("status" in profile ? fallback : { ...profile, input });
accounts.push("status" in profile ? fallback : { ...profile, harness, input });
} catch {
// Keep the login visible; fetching usage reports the vendor failure.
accounts.push(fallback);
@@ -1,3 +1,4 @@
import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -412,3 +413,55 @@ test("session discovery isolates CODEX_HOME and excludes foreign routes", async
await rm(home, { recursive: true, force: true });
}
});
test("#6155: real Codex and OpenCode stores share an account but keep agent failures separate", async () => {
await writeAuth("fixture-codex", "same-account");
const data = join(fixtureHome, "data");
await mkdir(join(data, "opencode"), { recursive: true });
await writeFile(
join(data, "opencode", "auth.json"),
JSON.stringify({
openai: { type: "oauth", access: "fixture-opencode", accountId: "same-account", expires: 0 },
}),
);
const env = { HOME: fixtureHome, CODEX_HOME: fixtureHome, XDG_DATA_HOME: data };
const lookup = { home: fixtureHome, env, platform: "linux" as const, now: () => 1_000 };
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: (id) => id === "agent",
usageSession: () => ({ provider: "codex", env, sessionKey: "launch" }),
});
registry.register({
id: "codex",
label: "Codex",
discover: (scope) => discover(scope, lookup),
fetch: (input) =>
fetchUsage(inputSchema.parse(input), async () => new Response(null, { status: 401 }), lookup),
});
const [host] = await registry.listReports();
expect(host?.loginErrors).toEqual([
{
harness: "Codex",
report: {
status: "unavailable",
problem: { kind: "rejected", status: 401, refreshedBy: "codex" },
},
},
{
harness: "OpenCode",
report: {
status: "unavailable",
problem: {
kind: "expired",
expiresAt: "1970-01-01T00:00:00.000Z",
refreshedBy: "opencode",
},
},
},
]);
const [agent] = await registry.listReports({ agentId: "agent" });
expect(agent?.report).toEqual({
status: "unavailable",
problem: { kind: "rejected", status: 401, refreshedBy: "codex" },
});
expect(agent?.loginErrors).toEqual([host!.loginErrors![0]]);
});
+7 -1
View File
@@ -72,7 +72,8 @@ export async function discover(
const present: UsageAccount[] = [];
for (const input of candidates) {
const auth = await readAuth(input, lookup);
if (auth) present.push({ ...accountIdentity(auth, input), input });
if (auth)
present.push({ ...accountIdentity(auth, input), harness: harnessLabel(input), input });
}
return present;
}
@@ -294,3 +295,8 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab
claimString(id?.["email"]);
return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) };
}
function harnessLabel(input: CodexUsageInput): string {
const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" };
return labels[input.route.store];
}
+20 -7
View File
@@ -16,6 +16,13 @@ import { usageSchema } from "./wire.js";
const inputSchema = z.object({ account: z.string() }).strict();
type Observation = z.infer<typeof usageSchema>;
function accountKey(env: ProviderLaunch["env"]): string {
// MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID.
const configHome = env.XDG_CONFIG_HOME ?? path.join(env.HOME ?? homedir(), ".config");
return hashAccountKey(path.resolve(configHome, "muse"));
}
export class Usage {
private readonly launches = new Map<string, ProviderLaunch>();
private readonly sessions = new Map<
@@ -23,10 +30,7 @@ export class Usage {
{ account: string; read: () => Promise<Observation> }
>();
remember(launch: ProviderLaunch): { account: string } {
// MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID.
const configHome =
launch.env.XDG_CONFIG_HOME ?? path.join(launch.env.HOME ?? homedir(), ".config");
const key = hashAccountKey(path.resolve(configHome, "muse"));
const key = accountKey(launch.env);
this.launches.set(key, launch);
return { account: key };
}
@@ -60,12 +64,21 @@ export class Usage {
label: "Muse Code",
icon: "icon.svg",
input: inputSchema,
discover: async () =>
[...this.launches.keys()].map((account) => ({
discover: async (scope) => {
let accounts: string[];
if (scope.kind === "global") {
accounts = [...this.launches.keys()];
} else {
if (scope.provider !== "muse") return [];
const account = accountKey(scope.env);
accounts = this.launches.has(account) ? [account] : [];
}
return accounts.map((account) => ({
key: account,
label: "Muse Code",
input: { account },
})),
}));
},
fetch: async (input) => {
const { account } = inputSchema.parse(input);
const launch = this.launches.get(account);
+47 -4
View File
@@ -1,4 +1,6 @@
import { execFile } from "node:child_process";
import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js";
import { Usage as MuseUsage } from "../server/usage.js";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import os from "node:os";
@@ -1415,7 +1417,7 @@ for (const usage of [
test(`usage source presents ${"usage" in usage ? "subscription windows" : "real route absence"} from discovered accounts`, async () => {
const h = await harness("catalog-controls", { MUSE_TEST_USAGE: JSON.stringify(usage) });
await h.open();
const inputs = await h.usageSource.discover();
const inputs = await h.usageSource.discover({ kind: "global" });
expect(inputs).toHaveLength(1);
const account = inputs[0]!;
expect(account).toEqual({
@@ -1566,7 +1568,7 @@ test("usage identity follows the resolved config directory across credential and
META_BASE_URL: route!,
});
await h.open();
const inputs = await h.usageSource.discover();
const inputs = await h.usageSource.discover({ kind: "global" });
expect(inputs).toHaveLength(1);
identities.push(inputs[0]!.key);
}
@@ -1576,7 +1578,7 @@ test("usage identity follows the resolved config directory across credential and
test.each(["no sessions", "unrelated environment"])("Muse discovery is empty with %s", async () => {
const { Usage } = await import("../server/usage.js");
expect(await new Usage().registration().discover()).toEqual([]);
expect(await new Usage().registration().discover({ kind: "global" })).toEqual([]);
});
test("Muse window identity follows the reported duration instead of assuming five hours", async () => {
@@ -1591,7 +1593,7 @@ test("Muse window identity follows the reported duration instead of assuming fiv
}),
});
await h.open();
const [account] = await h.usageSource.discover();
const [account] = await h.usageSource.discover({ kind: "global" });
expect(await h.usageSource.fetch(account!.input)).toMatchObject({
status: "available",
windows: [
@@ -1687,3 +1689,44 @@ async function cleanupStubbornHosts(h: Awaited<ReturnType<typeof harness>>) {
}
}
}
test("Muse reports belong only to the session's account and remain available globally", async () => {
const usage = new MuseUsage();
const homeEnv = { HOME: "/usage/home" };
const configEnv = { HOME: "/usage/home", XDG_CONFIG_HOME: "/usage/work" };
const accountIds = [homeEnv, configEnv].map((env, index) => {
const launch = { command: "muse", args: [], env };
usage.attach(`session-${index}`, launch, async () => ({
usage: {
observedAtMs: 1700000000000,
tier: "Pro",
window: { usedPercent: 11, resetsAtMs: 1700018000000, windowDurationMins: 120 },
weekly: { usedPercent: 22, resetsAtMs: 1700604800000 },
},
}));
return `muse:${usage.remember(launch).account}`;
});
const sessions = new Map([
["claude", { provider: "claude", env: homeEnv, sessionKey: "claude" }],
["codex", { provider: "codex", env: configEnv, sessionKey: "codex" }],
["muse-home", { provider: "muse", env: homeEnv, sessionKey: "muse-home" }],
["muse-work", { provider: "muse", env: configEnv, sessionKey: "muse-work" }],
["muse-unknown", { provider: "muse", env: { HOME: "/usage/unknown" }, sessionKey: "unknown" }],
]);
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: (id) => sessions.has(id),
usageSession: (id) => sessions.get(id) ?? null,
});
registry.register(usage.registration());
expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds);
expect(await registry.listReports({ agentId: "claude" })).toEqual([]);
expect(await registry.listReports({ agentId: "codex" })).toEqual([]);
for (const [index, agentId] of ["muse-home", "muse-work"].entries()) {
const reports = await registry.listReports({ agentId });
expect(reports.map((report) => report.id)).toEqual([accountIds[index]]);
expect(reports[0]!.report.status).toBe("available");
}
expect(await registry.listReports({ agentId: "muse-unknown" })).toEqual([]);
expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds);
});