mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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"]);
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user