Restore desktop usage and subscription discovery across harnesses (#5844)

* fix(app): hide the sidebar Usage item while it has no summary

Without a summary window with data, the item fell back to a plain "Usage"
row that duplicated the footer's Usage icon. It now renders nothing, and the
footer rows leave it out so an otherwise empty rows block and its separator
disappear with it.

* fix(desktop): restore built-in usage in packaged apps

The external plugin compiler cannot traverse app.asar, and dependency packaging drops declarations needed by import validation. Ship complete built-in resources beside the archive and verify registration in the packaged desktop smoke check.

* fix(usage): restore login discovery across harnesses

Discover subscription accounts independently of agent sessions and retain each account’s logins in preference order so an unusable credential can fall back to another login for the same account.
This commit is contained in:
Mohamed Boudra
2026-10-01 14:51:11 +01:00
committed by GitHub
parent e10f6d2e2c
commit 70df09daeb
33 changed files with 1580 additions and 223 deletions
+1
View File
@@ -9,6 +9,7 @@ on:
branches: [main]
paths:
- "packages/desktop/**"
- "plugins/**"
- "nix/**"
- ".github/workflows/desktop-packages.yml"
+1 -1
View File
@@ -34,7 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
+10 -1
View File
@@ -93,6 +93,15 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
Built-ins can import only host modules (`@getpaseo/plugin/*`, `zod`) and Node built-ins:
outside the archive, nothing resolves an npm dependency, and the dist build test cannot catch
one because it resolves through the repository's `node_modules`.
The packaged-app smoke check requires every listed built-in to start without relying on
account credentials.
Built-ins run in process and remain active independently of `pluginsEnabled`. They are
absent from the installed plugin list and source configuration; their client bundles
appear in the plugin catalog. Editing one in development requires a daemon restart.
@@ -464,7 +473,7 @@ SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
@@ -24,6 +24,8 @@ import {
} from "../support/helpers/sidebar-nav-settings";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -152,6 +154,8 @@ test.describe("Plugin sidebar items", () => {
page,
}) => {
test.setTimeout(180_000);
// The Usage item shows only with summary data.
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
@@ -184,6 +184,8 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
test.setTimeout(120_000);
// The Usage item shows only with summary data.
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
// Keys for the fixed footer icon buttons are ignored.
await seedSidebarFooterPreferences(page, [
{ key: "help", visible: false },
@@ -184,7 +184,8 @@ test.describe("usage screen", () => {
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
// Without reports the footer has no Usage item; its Usage icon opens the screen.
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
@@ -5,6 +5,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterSeparator,
leaveSettings,
openSidebarNavSettings,
setFooterItemVisible,
@@ -12,6 +13,7 @@ import {
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectNoUsageItem,
expectOnUsageScreen,
expectPinnedUsage,
leaveUsageScreen,
@@ -248,6 +250,36 @@ test.describe("Usage item", () => {
});
});
test("without summary data the footer drops the Usage item and keeps the Usage icon", async ({
page,
}) => {
await installUsageReportsFixture(page, {
lists: [
[
{
id: "alpha:a",
account: {},
fetchedAt: new Date().toISOString(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { status: "unavailable", windows: [] },
},
],
],
});
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible({
timeout: 30_000,
});
await expectNoUsageItem(page);
await expectFooterSeparator(page, false);
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
await expectOnUsageScreen(page);
});
test("the Usage options show and hide the sidebar Usage item", async ({ page }) => {
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
@@ -163,7 +163,6 @@ async function expectVerticalOrder<Key extends string>(
/** Persisted footer row key -> the testID the app shell renders that row with. */
function shellFooterTestID(key: string): string {
// With nothing pinned the Usage item is the plain Usage row.
if (key === "usage") return "sidebar-usage";
const [, pluginId, itemId] = key.split(":");
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
@@ -137,7 +137,7 @@ function visible(page: Page, testID: string): Locator {
return page.locator(`[data-testid="${testID}"]:visible`).first();
}
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
/** The sidebar footer's Usage item: each summary window with data. */
export function usageItem(page: Page): Locator {
return visible(page, "sidebar-usage");
}
@@ -157,10 +157,10 @@ export async function expectPinnedUsage(page: Page, windows: string[]): Promise<
await expect(pinned).toHaveText(windows);
}
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
export async function expectNoPinnedUsage(page: Page): Promise<void> {
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
await expect(usageItem(page)).toHaveText("Usage");
/** Without a summary window with data the footer has no Usage item, only the Usage icon. */
export async function expectNoUsageItem(page: Page): Promise<void> {
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible();
}
/** A window row, which is itself the pin toggle: "Claude", "Session". */
+9 -3
View File
@@ -52,7 +52,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, useOpenUsageScreen } from "@/usage";
import { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -457,11 +457,17 @@ function SidebarFooter({
);
}
/** The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. */
/**
* The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. The
* Usage item is left out while it has no summary to show.
*/
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const hasUsageSummary = useHasUsageSummary();
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter((item) => item.visible);
const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
);
if (visibleItems.length === 0) return null;
return (
<>
+1 -1
View File
@@ -1,3 +1,3 @@
export { HostUsageSection } from "./host-usage-section";
export { UsageScreen } from "./usage-screen";
export { UsageSidebarItem, useOpenUsageScreen } from "./sidebar-item";
export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item";
+17 -1
View File
@@ -1,6 +1,6 @@
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
import { useCallback, useMemo } from "react";
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { skipToken, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useShallow } from "zustand/shallow";
import { useFetchQuery } from "@/data/query";
import {
@@ -95,6 +95,22 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
return { view, refresh };
}
const NO_REPORTS: UsageReportEntry[] = [];
/**
* The reports of the sidebar's usage host, which is connected and reports usage; none until they
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId ?? ""),
queryFn: serverId ? () => listReports(serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return query.data ?? NO_REPORTS;
}
/** Every host with whether it is connected and reports usage, in host order. */
export function useUsageHosts(): UsageHost[] {
const hosts = useHosts();
+25 -59
View File
@@ -1,5 +1,4 @@
import { router } from "expo-router";
import { CircleGauge } from "lucide-react-native";
import { Fragment, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -10,7 +9,6 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
import { useIsCompactFormFactor } from "@/constants/layout";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
@@ -19,8 +17,7 @@ import { buildUsageRoute } from "@/utils/host-routes";
import { useHostUsageWithControls } from "./controls";
import { useUsagePreferences, type UsageDisplay } from "./display";
import { useUsageHostId, useUsageHostSelection } from "./hosts";
import type { UsagePreferences } from "./preferences";
import { useHostUsage } from "./queries";
import { useUsageHostReports } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import { UsageMeter } from "./meter";
import {
@@ -31,47 +28,31 @@ import {
type PinnedUsageLayout,
type PinnedUsageSource,
} from "./pinned";
import type { UsageReportEntry } from "./types";
import type { UsageHost } from "./model";
import { UsageBody } from "./usage-section";
const NO_REPORTS: UsageReportEntry[] = [];
const NO_SOURCES: PinnedUsageSource[] = [];
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, or a plain
* "Usage" row while no summary window has data. Pressing it opens the Usage screen; on compact
* layouts it opens the usage sheet instead.
*/
export function UsageSidebarItem() {
const { preferences, display } = useUsagePreferences();
const serverId = useUsageHostId();
if (!serverId) {
return <UsageEntry serverId={serverId} sources={NO_SOURCES} display={display} />;
}
return (
<PinnedUsageItem
key={serverId}
serverId={serverId}
preferences={preferences}
display={display}
/>
);
/** Each summary window with data on the usage host, under its source; empty while none has. */
function useUsageSummary(): readonly PinnedUsageSource[] {
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
}
function PinnedUsageItem({
serverId,
preferences,
display,
}: {
serverId: string;
preferences: UsagePreferences;
display: UsageDisplay;
}) {
const { view } = useHostUsage(serverId);
const reports = view.kind === "ready" ? view.reports : NO_REPORTS;
const sources = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return <UsageEntry serverId={serverId} sources={sources} display={display} />;
/** Whether the sidebar Usage item has anything to show. */
export function useHasUsageSummary(): boolean {
return useUsageSummary().length > 0;
}
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
* while no summary window has data, since the footer's Usage icon already opens the screen.
* Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead.
*/
export function UsageSidebarItem() {
const { display } = useUsagePreferences();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <UsageEntry sources={sources} display={display} />;
}
/** Opens the Usage screen, over the sidebar on compact layouts. */
@@ -85,11 +66,9 @@ export function useOpenUsageScreen(): () => void {
}
function UsageEntry({
serverId,
sources,
display,
}: {
serverId: string | null;
sources: readonly PinnedUsageSource[];
display: UsageDisplay;
}) {
@@ -100,30 +79,17 @@ function UsageEntry({
const [open, setOpen] = useState(false);
// The sheet mounts on first open; the summary already owns the report query.
const [sheetMounted, setSheetMounted] = useState(false);
// Without a host there are no reports to show, so compact goes to the screen, which says so.
const usesSheet = isCompact && serverId !== null;
const handlePress = useCallback(() => {
if (!usesSheet) {
if (!isCompact) {
openUsageScreen();
return;
}
setSheetMounted(true);
setOpen(true);
}, [openUsageScreen, usesSheet]);
}, [isCompact, openUsageScreen]);
const trigger =
sources.length > 0 ? (
<PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />
) : (
<SidebarHeaderRow
variant="inline"
icon={CircleGauge}
label={label}
onPress={handlePress}
testID="sidebar-usage"
/>
);
if (!usesSheet) return trigger;
const trigger = <PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />;
if (!isCompact) return trigger;
return (
<SidebarPopoverRoot open={open} onOpenChange={setOpen}>
{trigger}
@@ -6,6 +6,8 @@ const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
const { chromium } = require("playwright");
const { extractFile } = require("@electron/asar");
const { WebSocket } = require("ws");
const assert = require("node:assert/strict");
const EXECUTABLE_NAME = "Paseo";
const SMOKE_TIMEOUT_MS = 60_000;
@@ -31,6 +33,29 @@ function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
async function assertBuiltinPluginsStarted(listen) {
const { DaemonClient } = await import("@getpaseo/client/internal/daemon-client");
const { builtinPlugins } =
await import("../../server/dist/server/server/plugins/builtin/index.js");
const client = new DaemonClient({
url: `ws://${listen}/ws`,
clientId: "packaged-builtin-smoke",
webSocketFactory: (url, options) =>
new WebSocket(url, options?.protocols, { headers: options?.headers }),
});
try {
await client.connect();
const catalog = await client.getPluginCatalog();
assert.deepEqual(
catalog.map(({ id }) => id).sort(),
[...builtinPlugins].sort(),
"Every built-in plugin must start in the packaged desktop daemon",
);
} finally {
await client.close();
}
}
function assertExecutable(filePath, label) {
if (!fs.existsSync(filePath)) {
throw new Error(`${label} does not exist: ${filePath}`);
@@ -915,6 +940,8 @@ async function smokePackagedDesktopApp({
deadline,
});
console.log("Packaged desktop smoke: renderer-started desktop daemon reported running");
await assertBuiltinPluginsStarted(listen);
console.log("Packaged desktop smoke: every built-in plugin started");
await smokeCliShim({ appPath, env });
await smokeCliTerminal({ appPath, env });
if (expectedSandbox !== undefined) {
+4
View File
@@ -17,6 +17,7 @@ files:
- "!node_modules/@getpaseo/**/*.test.*"
- "!node_modules/@getpaseo/**/*.spec.*"
- "!node_modules/@getpaseo/server/dist/server/web-ui/**"
- "!node_modules/@getpaseo/server/dist/server/builtin-plugins/**"
# Workspace packages are symlinked, so anything a dev generates inside them
# lands in the asar. CI never has these; a local .debug run adds gigabytes.
- "!node_modules/@getpaseo/*/.debug/**"
@@ -27,6 +28,9 @@ asarUnpack:
- dist/daemon/node-entrypoint-runner.js
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*
extraResources:
# Preserve physical compiler inputs, including .d.ts files omitted from packaged node_modules.
- from: ../server/dist/server/builtin-plugins
to: builtin-plugins
- from: ../app/dist
to: app-dist
- from: assets/editor-targets
@@ -18,6 +18,12 @@ export const builtinPlugins = [
export function resolveBuiltinPluginsRoot(moduleUrl: string | URL = import.meta.url): string {
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
const archiveSegment = `${path.sep}app.asar${path.sep}`;
const archiveIndex = moduleDir.indexOf(archiveSegment);
if (archiveIndex !== -1) {
// esbuild runs outside Electron's filesystem shim and cannot read archive entries.
return path.join(moduleDir.slice(0, archiveIndex), "builtin-plugins");
}
const candidates = [
path.resolve(moduleDir, "..", "..", "..", "builtin-plugins"),
path.resolve(moduleDir, "..", "..", "..", "..", "..", "..", "plugins"),
@@ -1,8 +1,11 @@
import { fileURLToPath } from "node:url";
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "./builtin/index.js";
const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url));
const subprocessDirectory = fileURLToPath(
@@ -49,3 +52,219 @@ test("lists built-in and subprocess usage; validates input and isolates fetch er
await daemon.close();
}
}, 60_000);
test("a packaged daemon serves usage from external built-in resources", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-asar-"));
const packagePath = path.join("node_modules", "@getpaseo", "server", "dist", "server");
const resourceRoot = path.join(root, "builtin-plugins");
// An archive is a file, so the external compiler cannot traverse paths beneath it.
// Actual Electron archive packaging is covered by the packaged-app smoke check.
await writeFile(path.join(root, "app.asar"), "opaque archive fixture");
const directory = path.join(resourceRoot, "listed");
await mkdir(path.join(directory, "server"), { recursive: true });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "listed", requirements: { paseo: ">=0.9.2" } }),
);
await writeFile(
path.join(directory, "server", "contract.d.ts"),
"export interface Account { key: string; label: string; }",
);
await writeFile(
path.join(directory, "server", "account.ts"),
"import type { Account } from './contract.js'; export const account: Account = { key: 'one', label: 'Test account' };",
);
await writeFile(
path.join(directory, "index.server.ts"),
`import { z } from 'zod';
import { account } from './server/account.js';
export default function contribute(server) {
server.registerUsageSource({
id: 'fixture', label: 'Fixture', input: z.object({}),
discover: async () => [{}],
identify: async () => account,
fetch: async () => ({ status: 'available', windows: [{ id: 'session', label: 'Session', usedPct: 37 }] }),
});
return () => {};
}`,
);
const moduleUrl = pathToFileURL(
path.join(root, "app.asar", packagePath, "server", "plugins", "builtin", "index.js"),
);
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.9.2",
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(resolveBuiltinPluginsRoot(moduleUrl), ["listed"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
try {
await client.connect();
const { reports } = await client.listUsageReports();
expect(
reports.map(({ id, sourceId, sourceLabel, account, report }) => ({
id,
sourceId,
sourceLabel,
account,
report,
})),
).toEqual([
{
id: "fixture:one",
sourceId: "fixture",
sourceLabel: "Fixture",
account: { label: "Test account" },
report: {
status: "available",
windows: [{ id: "session", label: "Session", usedPct: 37 }],
},
},
]);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
}
}, 60_000);
test("built-in sources deduplicate cross-harness accounts and fall back without session wiring", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "usage-cross-harness-"));
const home = path.join(root, "home");
const plugins = path.join(root, "plugins");
const codexHome = path.join(home, ".codex");
const env = { CODEX_HOME: codexHome };
async function json(file: string, value: unknown) {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify(value));
}
async function codexToken(token: string) {
await json(path.join(codexHome, "auth.json"), {
tokens: { access_token: token, account_id: "chatgpt-one" },
});
}
await codexToken("fixture-codex");
const openCodePath = path.join(home, ".local", "share", "opencode", "auth.json");
await json(openCodePath, {
openai: { type: "oauth", access: "fixture-opencode", accountId: "chatgpt-one" },
});
const piPath = path.join(home, ".pi", "agent", "auth.json");
await json(piPath, {
"openai-codex": { type: "oauth", access: "fixture-pi-codex", accountId: "chatgpt-one" },
anthropic: { type: "oauth", access: "fixture-pi-claude" },
});
await json(path.join(home, ".claude", ".credentials.json"), {
claudeAiOauth: { accessToken: "fixture-claude" },
});
const dbPath = path.join(home, ".omp", "agent", "agent.db");
await mkdir(path.dirname(dbPath), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(dbPath);
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
);
const insert = db.prepare("INSERT INTO auth_credentials VALUES (?, ?, 'oauth', ?, NULL)");
insert.run(
1,
"openai-codex",
JSON.stringify({
access: "fixture-omp-codex",
accountId: "chatgpt-one",
expires: Date.now() + 60_000,
}),
);
insert.run(
2,
"anthropic",
JSON.stringify({ access: "fixture-omp-claude", expires: Date.now() + 60_000 }),
);
db.close();
for (const source of ["codex", "claude"]) {
const directory = path.join(plugins, `${source}-usage-source`);
await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, {
recursive: true,
});
const identifyCall =
source === "codex"
? "identify(inputSchema.parse(input), lookup)"
: "identify(inputSchema.parse(input), fetchApi, Date.now, lookup)";
// Inject home/env/HTTP at the source boundary; compile and register the real readers.
await writeFile(
path.join(directory, "index.server.ts"),
`
import { inputSchema } from './shared/input.js';
import { discover, identify, fetchUsage } from './server/usage.js';
const lookup = { home: ${JSON.stringify(home)}, env: ${JSON.stringify(env)}, platform: 'linux' };
async function fetchApi(url, init) {
const token = new Headers(init.headers).get('Authorization');
if (String(url).endsWith('/profile')) {
const second = token === 'Bearer fixture-pi-claude';
return new Response(JSON.stringify({ account: { uuid: second ? 'claude-two' : 'claude-one' }, organization: { uuid: second ? 'org-two' : 'org-one' } }));
}
if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 });
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } }));
}
export default function contribute(server) {
server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema,
discover: () => discover(lookup),
identify: (input) => ${identifyCall},
fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup),
});
return () => {};
}
`,
);
}
const daemon = await createTestPaseoDaemon({
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(plugins, ["codex-usage-source", "claude-usage-source"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const first = (await client.listUsageReports()).reports;
expect(first.map((entry) => entry.id).sort()).toEqual([
"claude:claude-one.org-one",
"claude:claude-two.org-two",
"codex:chatgpt-one",
]);
expect(first.find((entry) => entry.sourceId === "codex")?.report.windows[0]?.usedPct).toBe(11);
expect(
first.find((entry) => entry.id === "claude:claude-two.org-two")?.report.windows[0]?.usedPct,
).toBe(77);
expect(JSON.stringify(first)).not.toContain("fixture-");
await json(path.join(home, ".claude", ".credentials.json"), {
claudeAiOauth: { accessToken: "fixture-revoked" },
});
const claudeFallback = (
await client.listUsageReports({
reportIds: ["claude:claude-one.org-one"],
forceRefresh: true,
})
).reports;
expect(claudeFallback[0]?.report.windows[0]?.usedPct).toBe(66);
await codexToken("fixture-revoked");
const refresh = async () =>
(await client.listUsageReports({ reportIds: ["codex:chatgpt-one"], forceRefresh: true }))
.reports[0];
expect((await refresh())?.report.windows[0]?.usedPct).toBe(22);
await rm(openCodePath);
expect((await refresh())?.report.windows[0]?.usedPct).toBe(33);
await json(piPath, {
"openai-codex": {
type: "oauth",
access: "fixture-pi-codex",
accountId: "chatgpt-one",
expires: 1,
},
anthropic: { type: "oauth", access: "fixture-pi-claude" },
});
expect((await refresh())?.report.windows[0]?.usedPct).toBe(44);
await codexToken("fixture-codex");
expect((await refresh())?.report.windows[0]?.usedPct).toBe(11);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
}
}, 60_000);
@@ -191,3 +191,89 @@ test("legacy listing distinguishes labeled accounts and preserves unlabeled name
(await registry.listLegacyUsage()).providers.map((provider) => provider.displayName),
).toEqual(["claude (work)", "claude (personal)", "claude"]);
});
test("duplicate logins follow discovery order and fall back on unavailable, error and throws", async () => {
for (const failure of ["unavailable", "error", "throw"]) {
const registry = new UsageSourceRegistry();
const calls: number[] = [];
registry.register(
source({
id: "codex",
discover: async () => [0, 1, 2],
identify: async (input) => {
if (input === 0) await new Promise((resolve) => setTimeout(resolve, 10));
return { key: "same" };
},
fetch: async (input) => {
calls.push(Number(input));
if (input === 0) {
if (failure === "throw") throw new Error("revoked");
return { status: failure, windows: [] };
}
return {
status: "available",
windows: [{ id: "session", label: "Session", usedPct: 42 }],
};
},
}),
);
const reports = await registry.listReports();
expect(reports).toHaveLength(1);
expect(reports[0]?.report.status).toBe("available");
expect(calls).toEqual([0, 1]);
}
});
test("targeted refresh skips a login that changed accounts and preserves fallback", async () => {
let firstAccount = "original";
const fetched: unknown[] = [];
const registry = new UsageSourceRegistry();
registry.register(
source({
id: "source",
discover: async () => [0, 1],
identify: async (input) => ({ key: input === 0 ? firstAccount : "original" }),
fetch: async (input) => {
fetched.push(input);
return { status: "available", windows: [] };
},
}),
);
await registry.listReports();
firstAccount = "different";
const reports = await registry.listReports({
reportIds: ["source:original"],
forceRefresh: true,
});
expect(reports[0]?.id).toBe("source:original");
expect(reports[0]?.report.status).toBe("available");
expect(fetched).toEqual([0, 1]);
});
test("discovery identifies a source's logins concurrently and keeps their order", async () => {
const registry = new UsageSourceRegistry();
const fetched: unknown[] = [];
let release!: () => void;
const allStarted = new Promise<void>((resolve) => (release = resolve));
let started = 0;
registry.register(
source({
id: "claude",
discover: async () => [0, 1, 2],
// Each identification waits until all three started; serial identification never finishes.
identify: async (input) => {
if (++started === 3) release();
await allStarted;
if (input === 0) await new Promise((resolve) => setTimeout(resolve, 10));
return { key: "same" };
},
fetch: async (input) => {
fetched.push(input);
return { status: "unavailable", windows: [] };
},
}),
);
const reports = await registry.listReports();
expect(reports.map((entry) => entry.id)).toEqual(["claude:same"]);
expect(fetched).toEqual([0, 1, 2]);
});
@@ -15,11 +15,11 @@ export interface UsageSource {
interface KnownReport {
source: UsageSource;
input: unknown;
inputs: unknown[];
label?: string;
}
/** Owns account identity, the latest input for each report, and the five-minute fetch cache. */
/** Owns account identity, ordered logins for each account, and the five-minute fetch cache. */
export class UsageSourceRegistry {
private readonly sources = new Map<string, UsageSource>();
private readonly known = new Map<string, KnownReport>();
@@ -42,7 +42,10 @@ export class UsageSourceRegistry {
for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key);
}
private async identify(source: UsageSource, input: unknown): Promise<string | null> {
private async identify(
source: UsageSource,
input: unknown,
): Promise<{ id: string; label?: string; error?: true } | null> {
try {
const account = await source.identify(input);
if (account === null) return null;
@@ -53,13 +56,11 @@ export class UsageSourceRegistry {
throw new Error(`Invalid account identity from ${source.id}`);
if (typeof account.key !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(account.key))
throw new Error(`Invalid account key from ${source.id}`);
const id = `${source.id}:${account.key}`;
this.known.set(id, { source, input, label: account.label });
return id;
return { id: `${source.id}:${account.key}`, label: account.label };
} catch (error) {
const id = `${source.id}:!error`;
this.writeCache(id, this.errorEntry(source, id, error));
return id;
return { id, error: true };
}
}
@@ -80,9 +81,28 @@ export class UsageSourceRegistry {
try {
const inputs = await source.discover();
if (!Array.isArray(inputs)) throw new Error("Usage discovery must return an array");
return (await Promise.all(inputs.map((input) => this.identify(source, input)))).filter(
(id): id is string => id !== null,
);
const accounts = await Promise.all(inputs.map((input) => this.identify(source, input)));
// Group in discovery order so an account's logins keep the source's preference.
const knownReports = new Map<string, KnownReport>();
const ids: string[] = [];
accounts.forEach((account, index) => {
if (account === null) return;
ids.push(account.id);
if (account.error) return;
const known = knownReports.get(account.id);
if (known) known.inputs.push(inputs[index]);
else
knownReports.set(account.id, {
source,
inputs: [inputs[index]],
label: account.label,
});
});
for (const key of this.known.keys()) {
if (key.startsWith(`${source.id}:`)) this.known.delete(key);
}
for (const [id, known] of knownReports) this.known.set(id, known);
return ids;
} catch (error) {
const id = `${source.id}:!error`;
this.writeCache(id, this.errorEntry(source, id, error));
@@ -127,20 +147,26 @@ export class UsageSourceRegistry {
const pending = this.pending.get(id);
if (pending) return pending;
const request = (async () => {
let entry: UsageReportEntry;
try {
const report = UsageReportSchema.parse(await known.source.fetch(known.input));
entry = {
id,
sourceId: known.source.id,
sourceLabel: known.source.label,
icon: known.source.icon,
account: { label: known.label },
fetchedAt: new Date(this.now()).toISOString(),
report,
};
} catch (error) {
entry = this.errorEntry(known.source, id, error, known.label);
let entry: UsageReportEntry = {
id,
sourceId: known.source.id,
sourceLabel: known.source.label,
icon: known.source.icon,
account: { label: known.label },
fetchedAt: new Date(this.now()).toISOString(),
report: { status: "unavailable", windows: [] },
};
for (const input of known.inputs) {
try {
// A harness can switch accounts between discovery and a targeted refresh.
const account = await known.source.identify(input);
if (!account || `${known.source.id}:${account.key}` !== id) continue;
const report = UsageReportSchema.parse(await known.source.fetch(input));
entry.report = report;
if (report.status === "available") break;
} catch (error) {
entry = this.errorEntry(known.source, id, error, known.label);
}
}
this.writeCache(id, entry);
return entry;
+2 -2
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { discover, fetchUsage, identify } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "Claude",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
discover: () => discover(),
identify,
fetch: fetchUsage,
});
@@ -89,7 +89,7 @@ describe("readClaudeKeychainCredentials", () => {
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fetchUsage } from "./usage.js";
import { discover, fetchUsage } from "./usage.js";
function fixtureFetch(expectedToken: string): typeof fetch {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
@@ -98,41 +98,57 @@ function fixtureFetch(expectedToken: string): typeof fetch {
}) as unknown as typeof fetch;
}
describe("Claude default credential lookup", () => {
it("reads the configured Claude home file", async () => {
describe("Claude credential routes", () => {
it("reads the explicit Claude credential file", async () => {
const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-"));
try {
writeFileSync(
join(dir, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }),
);
expect((await fetchUsage({}, fixtureFetch("fixture-file"), { claudeHome: dir })).status).toBe(
"available",
);
expect(
(
await fetchUsage(
{ route: { store: "claude", path: join(dir, ".credentials.json") } },
fixtureFetch("fixture-file"),
)
).status,
).toBe("available");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("default discovery reads the credential file", async () => {
it("discovery reads the configured credential file", async () => {
const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-"));
try {
writeFileSync(
join(dir, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }),
);
vi.stubEnv("CLAUDE_HOME", dir);
expect((await fetchUsage({}, fixtureFetch("fixture-default"))).status).toBe("available");
const [input] = await discover({
home: dir,
env: { CLAUDE_CONFIG_DIR: dir },
platform: "linux",
});
expect(input).toEqual({ route: { store: "claude", path: join(dir, ".credentials.json") } });
expect((await fetchUsage(input!, fixtureFetch("fixture-default"))).status).toBe("available");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("stays unavailable when the configured home has no credentials", async () => {
it("stays unavailable when the routed file has no credentials", async () => {
const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-"));
try {
const fetchApi = vi.fn() as unknown as typeof fetch;
expect((await fetchUsage({}, fetchApi, { claudeHome: dir, platform: "linux" })).status).toBe(
"unavailable",
);
expect(
(
await fetchUsage(
{ route: { store: "claude", path: join(dir, ".credentials.json") } },
fetchApi,
{ platform: "linux" },
)
).status,
).toBe("unavailable");
expect(fetchApi).not.toHaveBeenCalled();
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -151,7 +167,7 @@ describe("Claude Keychain token usage", () => {
const fetchApi = vi.fn(
async () => new Response(null, { status: 401 }),
) as unknown as typeof fetch;
const report = await fetchUsage({}, fetchApi, {
const report = await fetchUsage({ route: { store: "keychain" } }, fetchApi, {
platform: "darwin",
claudeHome: "/nonexistent-paseo-test-home",
readKeychainCredentials: async () => credentials,
@@ -0,0 +1,277 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { discover, fetchUsage, identify } from "./usage.js";
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), "usage-stores-"));
});
afterEach(async () => {
await rm(home, { recursive: true, force: true });
});
function lookup(env: NodeJS.ProcessEnv = {}) {
return { home, env, platform: "linux" as const, now: () => 1000 };
}
async function json(path: string, value: unknown) {
const { dirname } = await import("node:path");
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(value));
}
async function profile(_url: RequestInfo | URL, init?: RequestInit) {
expect(new Headers(init?.headers).get("Authorization")).toMatch(/^Bearer fixture-/);
return new Response(
JSON.stringify({ account: { uuid: "pi-account" }, organization: { uuid: "pi-org" } }),
);
}
async function account(input: Parameters<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}) {
return identify(input, profile, () => 1000, lookup(env));
}
async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
);
const insert = db.prepare("INSERT INTO auth_credentials VALUES (?, ?, ?, ?, ?)");
insert.run(
1,
"anthropic",
"oauth",
JSON.stringify({ access: "fixture-omp", accountId: "omp-account", expires: 2000 }),
null,
);
insert.run(
2,
"anthropic",
"oauth",
JSON.stringify({ access: "fixture-expired", expires: 500 }),
null,
);
insert.run(
3,
"anthropic",
"oauth",
JSON.stringify({ access: "fixture-disabled", expires: 2000 }),
"revoked",
);
insert.run(
4,
"anthropic",
"api_key",
JSON.stringify({ access: "fixture-key", expires: 2000 }),
null,
);
insert.run(5, "anthropic", "oauth", "invalid", null);
db.close();
}
test("Pi uses its own OAuth identity and re-reads rotated tokens without writing", async () => {
const path = join(home, ".pi", "agent", "auth.json");
await json(path, {
anthropic: { type: "oauth", access: "fixture-first", accountId: "pi-account" },
});
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "other", organizationUuid: "other-org" },
});
const inputs = await discover(lookup());
expect(inputs).toHaveLength(1);
expect(await account(inputs[0]!)).toEqual({ key: "pi-account.pi-org" });
await json(path, {
anthropic: { type: "oauth", access: "fixture-rotated", accountId: "pi-account" },
});
const before = await readFile(path, "utf8");
expect(
(
await fetchUsage(
inputs[0]!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-rotated");
return new Response("{}", { status: 401 });
},
lookup(),
)
).status,
).toBe("unavailable");
expect(await readFile(path, "utf8")).toBe(before);
});
test("Pi honors directory override and ignores API keys and malformed files", async () => {
const directory = join(home, "custom-pi");
const env = { PI_CODING_AGENT_DIR: directory };
const path = join(directory, "auth.json");
await json(path, { anthropic: { type: "oauth", access: "fixture-pi", accountId: "pi-account" } });
expect(await discover(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
await json(path, { anthropic: { type: "api_key", key: "fixture-key" } });
expect(await discover(lookup(env))).toEqual([]);
await writeFile(path, "broken");
expect(await discover(lookup(env))).toEqual([]);
});
test("expired Pi tokens are unavailable without calling usage or refreshing", async () => {
await json(join(home, ".pi", "agent", "auth.json"), {
anthropic: { type: "oauth", access: "fixture-expired", accountId: "pi-account", expires: 500 },
});
const [input] = await discover(lookup());
expect(
(
await fetchUsage(
input!,
async () => {
throw new Error("must not fetch expired token");
},
lookup(),
)
).status,
).toBe("unavailable");
});
for (const location of ["default", "profile", "xdg", "override", "config"]) {
test(`OMP discovers enabled live OAuth rows in ${location} store read-only`, async () => {
let path = join(home, ".omp", "agent", "agent.db");
let env: NodeJS.ProcessEnv = { OMP_AUTH_BROKER_URL: "https://broker.test" };
if (location === "profile") {
env.OMP_PROFILE = "work";
path = join(home, ".omp", "profiles", "work", "agent", "agent.db");
}
if (location === "xdg") {
env.XDG_DATA_HOME = join(home, "data");
env.PI_PROFILE = "work";
path = join(home, "data", "omp", "profiles", "work", "agent.db");
}
if (location === "override") {
env.PI_CODING_AGENT_DIR = join(home, "custom");
path = join(home, "custom", "agent.db");
}
if (location === "config") {
env.PI_CONFIG_DIR = "custom-config";
path = join(home, "custom-config", "agent", "agent.db");
}
await database(path);
const before = await readFile(path);
const inputs = await discover(lookup(env));
expect(inputs).toEqual([{ route: { store: "omp", path, credentialId: 1 } }]);
expect(await account(inputs[0]!, env)).toEqual({ key: "pi-account.pi-org" });
expect(
(
await fetchUsage(
inputs[0]!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-omp");
return new Response("{}", { status: 401 });
},
lookup(env),
)
).status,
).toBe("unavailable");
expect(await readFile(path)).toEqual(before);
});
}
test("missing SQLite skips only OMP", async () => {
await database(join(home, ".omp", "agent", "agent.db"));
await json(join(home, ".pi", "agent", "auth.json"), {
anthropic: { type: "oauth", access: "fixture-pi" },
});
const inputs = await discover({ ...lookup(), sqlite: () => undefined });
expect(inputs).toEqual([
{ route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } },
]);
});
test("Claude file and keychain are independent discovery candidates", async () => {
const path = join(home, ".claude", ".credentials.json");
await json(path, { claudeAiOauth: { accessToken: "fixture-file" } });
const inputs = await discover({
...lookup(),
platform: "darwin",
readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }),
});
expect(inputs).toEqual([{ route: { store: "claude", path } }, { route: { store: "keychain" } }]);
});
test("Claude account metadata alone does not discover a login", async () => {
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "old", organizationUuid: "old-org" },
});
expect(await discover(lookup())).toEqual([]);
expect(
await identify(
{ route: { store: "claude", path: join(home, ".claude", ".credentials.json") } },
profile,
Date.now,
lookup(),
),
).toBeNull();
});
test("keychain identity belongs to its token even when Claude Code metadata names another account", async () => {
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "stale", organizationUuid: "stale-org" },
});
const identity = await identify({ route: { store: "keychain" } }, profile, () => 1000, {
...lookup(),
platform: "darwin",
readKeychainCredentials: async () => ({
claudeAiOauth: { accessToken: "fixture-keychain-other" },
}),
});
expect(identity).toEqual({ key: "pi-account.pi-org" });
});
test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => {
const path = join(home, ".omp", "agent", "agent.db");
await database(path);
const [input] = await discover(lookup());
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
JSON.stringify({ access: "fixture-rotated-omp", accountId: "omp-account", expires: 2000 }),
);
expect(
(
await fetchUsage(
input!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe(
"Bearer fixture-rotated-omp",
);
return new Response("{}", { status: 401 });
},
lookup(),
)
).status,
).toBe("unavailable");
db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run();
db.close();
expect(
(
await fetchUsage(
input!,
async () => {
throw new Error("disabled login must not fetch");
},
lookup(),
)
).status,
).toBe("unavailable");
});
test("Claude discovery honors CLAUDE_CONFIG_DIR", async () => {
const directory = join(home, "configured-claude");
const ignoredDirectory = join(home, "other-home");
await json(join(directory, ".credentials.json"), {
claudeAiOauth: { accessToken: "fixture-configured" },
});
await json(join(ignoredDirectory, ".credentials.json"), {
claudeAiOauth: { accessToken: "fixture-other" },
});
const inputs = await discover(
lookup({ CLAUDE_CONFIG_DIR: directory, CLAUDE_HOME: ignoredDirectory }),
);
expect(inputs).toEqual([
{ route: { store: "claude", path: join(directory, ".credentials.json") } },
]);
});
@@ -0,0 +1,132 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { z } from "zod";
import { routeSchema } from "../shared/input.js";
export interface StoreLookup {
home?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
now?: () => number;
sqlite?: () => Sqlite | undefined;
}
interface Database {
prepare(sql: string): { all(...args: string[]): unknown[] };
close(): void;
}
export interface Sqlite {
DatabaseSync: new (path: string, options: { readOnly: boolean }) => Database;
}
export const oauthSchema = z.object({
access: z.string().min(1),
accountId: z.string().optional(),
expires: z.number().optional(),
});
const rowSchema = z.object({ id: z.number().int().positive(), data: z.string() });
export type Route = z.infer<typeof routeSchema>;
export async function readJson(path: string): Promise<unknown> {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
return null;
}
}
export function piAuthPath(lookup: StoreLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
const configured = env.PI_CODING_AGENT_DIR?.trim();
if (!configured) return join(home, ".pi", "agent", "auth.json");
if (configured === "~") return join(home, "auth.json");
const directory = configured.startsWith("~/")
? join(home, configured.slice(2))
: resolve(configured);
return join(directory, "auth.json");
}
function ompDatabasePath(lookup: StoreLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
const platform = lookup.platform ?? process.platform;
const profile = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim() || "default";
const base = join(home, env.PI_CONFIG_DIR || ".omp");
const configRoot = profile === "default" ? base : join(base, "profiles", profile);
const defaultAgentDir = join(configRoot, "agent");
const agentDir =
profile === "default" && env.PI_CODING_AGENT_DIR
? resolve(env.PI_CODING_AGENT_DIR)
: defaultAgentDir;
if (
(platform === "linux" || platform === "darwin") &&
agentDir === defaultAgentDir &&
env.XDG_DATA_HOME
) {
const xdgBase = join(env.XDG_DATA_HOME, "omp");
const xdgRoot = profile === "default" ? xdgBase : join(xdgBase, "profiles", profile);
if (existsSync(xdgRoot)) return join(xdgRoot, "agent.db");
}
return join(agentDir, "agent.db");
}
function builtinSqlite(): Sqlite | undefined {
// Node 20 must still compile/load the plugin; only OMP needs this newer builtin.
const parsed = z
.object({
DatabaseSync: z.custom<Sqlite["DatabaseSync"]>((value) => typeof value === "function"),
})
.safeParse(process.getBuiltinModule?.("node:sqlite"));
return parsed.success ? parsed.data : undefined;
}
function ompRows(path: string, lookup: StoreLookup) {
let db: Database | undefined;
try {
const sqlite = (lookup.sqlite ?? builtinSqlite)();
if (!sqlite) return [];
db = new sqlite.DatabaseSync(path, { readOnly: true });
const rows = db
.prepare(
"SELECT id, data FROM auth_credentials WHERE provider = ? AND credential_type = 'oauth' AND disabled_cause IS NULL ORDER BY id",
)
.all("anthropic");
return rows.flatMap((value) => {
const row = rowSchema.safeParse(value);
if (!row.success) return [];
try {
const oauth = oauthSchema.parse(JSON.parse(row.data.data));
if (oauth.expires === undefined || oauth.expires <= (lookup.now ?? Date.now)()) return [];
return [{ id: row.data.id, oauth }];
} catch {
return [];
}
});
} catch {
return [];
} finally {
db?.close();
}
}
export function discoverOmp(lookup: StoreLookup): Route[] {
const path = ompDatabasePath(lookup);
return ompRows(path, lookup).map(({ id }) => ({ store: "omp", path, credentialId: id }));
}
export function readOmp(route: Extract<Route, { store: "omp" }>, lookup: StoreLookup) {
return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null;
}
export async function readHarness(
route: Extract<Route, { path: string }>,
lookup: StoreLookup = {},
) {
if (route.store === "omp") return readOmp(route, lookup);
const provider = "anthropic";
const parsed = z.record(z.string(), z.unknown()).safeParse(await readJson(route.path));
if (!parsed.success) return null;
const oauth = oauthSchema.extend({ type: z.literal("oauth") }).safeParse(parsed.data[provider]);
return oauth.success ? oauth.data : null;
}
@@ -3,9 +3,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchUsage } from "./usage.js";
import { inputSchema } from "../shared/input.js";
import { inputSchema, type UsageInput } from "../shared/input.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
function credentialInput(directory: string): UsageInput {
return { route: { store: "claude", path: join(directory, ".credentials.json") } };
}
function writeClaudeCredentials(
dir: string,
accessToken: string,
@@ -63,7 +67,7 @@ describe("claude usage source", () => {
originalEnv = { ...process.env };
process.env["HOME"] = homeDir;
process.env["USERPROFILE"] = homeDir;
process.env["CLAUDE_HOME"] = claudeHome;
process.env["CLAUDE_CONFIG_DIR"] = claudeHome;
for (const key of [
"APPDATA",
"COPILOT_TOKEN",
@@ -100,7 +104,9 @@ describe("claude usage source", () => {
) {
return {
listUsage: async () => {
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
const report = await fetchUsage(credentialInput(claudeHome), (url, init) =>
fetchApi(url, init),
);
return {
providers: [
{
@@ -230,9 +236,9 @@ describe("usage bars escalate as they fill", () => {
function claudeAt(utilization: number) {
writeClaudeCredentials(claudeHome, "at_valid");
process.env["CLAUDE_HOME"] = claudeHome;
process.env["CLAUDE_CONFIG_DIR"] = claudeHome;
return fetchUsage(
{},
credentialInput(claudeHome),
mockFetch(
new Map([
[
@@ -286,11 +292,11 @@ describe("Claude usage source scoped weekly limits", () => {
function claudeProvider(body: unknown) {
writeClaudeCredentials(claudeHome, "at_valid");
process.env["CLAUDE_HOME"] = claudeHome;
process.env["CLAUDE_CONFIG_DIR"] = claudeHome;
const fetchApi = mockFetch(
new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]),
);
return { provider: { fetchUsage: () => fetchUsage({}, fetchApi) } };
return { provider: { fetchUsage: () => fetchUsage(credentialInput(claudeHome), fetchApi) } };
}
it("renders a scoped weekly limit as its own window", async () => {
@@ -467,9 +473,9 @@ describe("Claude usage source scoped limit reconciliation", () => {
async function windowsFor(body: Record<string, unknown>) {
writeClaudeCredentials(claudeHome, "at_valid");
process.env["CLAUDE_HOME"] = claudeHome;
process.env["CLAUDE_CONFIG_DIR"] = claudeHome;
const usage = await fetchUsage(
{},
credentialInput(claudeHome),
mockFetch(new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]])),
);
return usage.windows;
@@ -633,7 +639,7 @@ describe("Claude usage source scoped limit reconciliation", () => {
});
});
it("identify reads account and organization from the selected Claude config", async () => {
it("identify uses the credential token profile instead of stale Claude config", async () => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { identify } = await import("./usage.js");
const directory = await mkdtemp(join(tmpdir(), "claude-identity-"));
@@ -648,9 +654,21 @@ it("identify reads account and organization from the selected Claude config", as
},
}),
);
expect(await identify({}, undefined, undefined, { accountHome: directory })).toEqual({
key: "account-uuid.org-uuid",
label: "test@example.com",
writeClaudeCredentials(directory, "fixture-local-account");
expect(
await identify(
credentialInput(directory),
async () =>
jsonResponse({
account: { uuid: "token-account", email: "token@example.test" },
organization: { uuid: "token-org" },
}),
Date.now,
{ home: directory, claudeHome: directory },
),
).toEqual({
key: "token-account.token-org",
label: "token@example.test",
});
} finally {
await rm(directory, { recursive: true, force: true });
@@ -670,18 +688,23 @@ it("Claude credentials without account metadata use a cached OAuth profile ident
{ status: 200 },
);
};
expect(await identify({}, fetchProfile, Date.now, tokenLookup("fixture-claude-token"))).toEqual({
const lookup = tokenLookup("fixture-claude-token");
expect(
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup),
).toEqual({
key: "account-uuid.org-uuid",
label: "test@example.com",
});
expect(await identify({}, fetchProfile, Date.now, tokenLookup("fixture-claude-token"))).toEqual({
expect(
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup),
).toEqual({
key: "account-uuid.org-uuid",
label: "test@example.com",
});
expect(calls).toBe(1);
});
it("identifies the same default credentials that fetch uses when oauthAccount is absent", async () => {
it("identifies the same credential route that fetch uses when oauthAccount is absent", async () => {
const { identify } = await import("./usage.js");
const directory = mkdtempSync(join(tmpdir(), "claude-credential-identity-"));
try {
@@ -689,7 +712,7 @@ it("identifies the same default credentials that fetch uses when oauthAccount is
writeFileSync(join(directory, ".claude.json"), JSON.stringify({}));
let usageRequested = false;
await fetchUsage(
{},
credentialInput(directory),
async () => {
usageRequested = true;
return new Response(null, { status: 401 });
@@ -699,7 +722,7 @@ it("identifies the same default credentials that fetch uses when oauthAccount is
expect(usageRequested).toBe(true);
let profileRequested = false;
const account = await identify(
{},
credentialInput(directory),
async () => {
profileRequested = true;
return jsonResponse({
@@ -708,7 +731,7 @@ it("identifies the same default credentials that fetch uses when oauthAccount is
});
},
Date.now,
{ claudeHome: directory, accountHome: directory },
{ claudeHome: directory, home: directory },
);
expect(profileRequested).toBe(true);
expect(account).toEqual({ key: "account-uuid.org-uuid", label: "owner@example.test" });
@@ -725,8 +748,8 @@ it("expires cached token profiles after five minutes", async () => {
calls++;
return jsonResponse({ account: { uuid: "account-uuid" }, organization: { uuid: "org-uuid" } });
};
const input = {};
const lookup = tokenLookup("ttl-fixture-token");
const input = credentialInput(lookup.claudeHome);
await identify(input, fetchProfile, () => now, lookup);
await identify(input, fetchProfile, () => now, lookup);
expect(calls).toBe(1);
@@ -735,21 +758,21 @@ it("expires cached token profiles after five minutes", async () => {
expect(calls).toBe(2);
});
it("identify and fetch share the macOS keychain credential fallback", async () => {
it("identify and fetch share the explicit macOS keychain route", async () => {
const { identify } = await import("./usage.js");
const directory = mkdtempSync(join(tmpdir(), "claude-keychain-identity-"));
try {
const lookup = {
platform: "darwin" as const,
claudeHome: directory,
accountHome: directory,
home: directory,
readKeychainCredentials: async () => ({
claudeAiOauth: { accessToken: "keychain-fixture-token" },
}),
};
let usageRequested = false;
await fetchUsage(
{},
{ route: { store: "keychain" } },
async () => {
usageRequested = true;
return new Response(null, { status: 401 });
@@ -758,7 +781,7 @@ it("identify and fetch share the macOS keychain credential fallback", async () =
);
expect(usageRequested).toBe(true);
const account = await identify(
{},
{ route: { store: "keychain" } },
async () =>
jsonResponse({
account: { uuid: "keychain-account" },
@@ -773,15 +796,15 @@ it("identify and fetch share the macOS keychain credential fallback", async () =
}
});
it("identify and fetch share the default Claude home credentials", async () => {
it("identify and fetch share the explicit Claude credential route", async () => {
const { identify } = await import("./usage.js");
const directory = mkdtempSync(join(tmpdir(), "claude-home-identity-"));
try {
writeClaudeCredentials(directory, "home-fixture-token");
const lookup = { claudeHome: directory, accountHome: directory };
const lookup = { claudeHome: directory, home: directory };
let usageRequested = false;
await fetchUsage(
{},
credentialInput(directory),
async () => {
usageRequested = true;
return new Response(null, { status: 401 });
@@ -790,7 +813,7 @@ it("identify and fetch share the default Claude home credentials", async () => {
);
expect(usageRequested).toBe(true);
const identity = await identify(
{},
credentialInput(directory),
async () =>
jsonResponse({
account: { uuid: "home-account" },
@@ -813,16 +836,18 @@ it("bounds cached profiles across many token rotations", async () => {
return jsonResponse({ account: { uuid: "account" }, organization: { uuid: "org" } });
};
for (let index = 0; index < 129; index++) {
await identify({}, fetchProfile, Date.now, tokenLookup(`rotation-fixture-${index}`));
const lookup = tokenLookup(`rotation-fixture-${index}`);
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup);
}
await identify({}, fetchProfile, Date.now, tokenLookup("rotation-fixture-0"));
const lookup = tokenLookup("rotation-fixture-0");
await identify(credentialInput(lookup.claudeHome), fetchProfile, Date.now, lookup);
expect(calls).toBe(130);
});
it("discovery input rejects explicit credential routes", () => {
it("discovery input requires a route and rejects raw credentials", () => {
expect(inputSchema.safeParse({ accessToken: "unused" }).success).toBe(false);
expect(inputSchema.safeParse({ configDir: "/unused" }).success).toBe(false);
expect(inputSchema.parse({})).toEqual({});
expect(() => inputSchema.parse({})).toThrow();
});
const tokenDirectories: string[] = [];
@@ -834,5 +859,26 @@ function tokenLookup(accessToken: string) {
const directory = mkdtempSync(join(tmpdir(), "claude-profile-token-"));
tokenDirectories.push(directory);
writeClaudeCredentials(directory, accessToken);
return { claudeHome: directory, accountHome: directory };
return { claudeHome: directory, home: directory };
}
it("discovers a Pi OAuth login independently of the CLI login", async () => {
const { discover } = await import("./usage.js");
const { mkdir, mkdtemp, writeFile, rm } = await import("node:fs/promises");
const directory = await mkdtemp(join(tmpdir(), "usage-pi-discovery-"));
try {
await mkdir(join(directory, ".pi", "agent"), { recursive: true });
await writeFile(
join(directory, ".pi", "agent", "auth.json"),
JSON.stringify({
anthropic: { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
}),
);
const inputs = await discover({ home: directory, env: {}, platform: "linux" });
expect(inputs).toContainEqual({
route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") },
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
+45 -29
View File
@@ -1,3 +1,4 @@
import { discoverOmp, piAuthPath, readHarness, type StoreLookup } from "./stores.js";
import type { UsageInput } from "../shared/input.js";
import { execFile } from "node:child_process";
import { existsSync, promises as fs } from "node:fs";
@@ -75,6 +76,7 @@ type ClaudeLimit = z.infer<typeof ClaudeLimitSchema>;
const SCOPED_WEEKLY_KIND = "weekly_scoped";
interface ClaudeCredentialRecord {
expires?: number;
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
}
@@ -332,21 +334,21 @@ export async function readClaudeKeychainCredentials(
return null;
}
interface ClaudeCredentialLookup {
platform?: NodeJS.Platform;
interface ClaudeCredentialLookup extends StoreLookup {
readKeychainCredentials?: () => Promise<unknown | null>;
claudeHome?: string;
accountHome?: string;
}
/** Shared credential lookup for usage fetches and account identification. */
export async function resolveClaudeCredentials(
_input: UsageInput,
lookup: ClaudeCredentialLookup = {},
function claudeCredentialPath(lookup: ClaudeCredentialLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
const claudeHome = lookup.claudeHome ?? env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
return join(claudeHome, ".credentials.json");
}
async function keychainCredentialRecord(
lookup: ClaudeCredentialLookup,
): Promise<ClaudeCredentialRecord | null> {
const claudeHome = lookup.claudeHome ?? process.env["CLAUDE_HOME"] ?? join(homedir(), ".claude");
const fileCredentials = await readCredentialFile(join(claudeHome, ".credentials.json"));
if (fileCredentials) return fileCredentials;
if ((lookup.platform ?? process.platform) !== "darwin") return null;
const parsed = ClaudeCredentialsSchema.safeParse(
await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(),
@@ -354,6 +356,34 @@ export async function resolveClaudeCredentials(
return parsed.success ? toCredentialRecord(parsed.data) : null;
}
export async function discover(lookup: ClaudeCredentialLookup = {}): Promise<UsageInput[]> {
const candidates: UsageInput[] = [
{ route: { store: "claude", path: claudeCredentialPath(lookup) } },
];
if ((lookup.platform ?? process.platform) === "darwin")
candidates.push({ route: { store: "keychain" } });
candidates.push(
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ route })),
);
const present: UsageInput[] = [];
for (const input of candidates)
if (await resolveClaudeCredentials(input, lookup)) present.push(input);
return present;
}
/** Re-read the selected login; the harness owns token refresh. */
export async function resolveClaudeCredentials(
input: UsageInput,
lookup: ClaudeCredentialLookup = {},
): Promise<ClaudeCredentialRecord | null> {
const route = input.route;
if (route.store === "claude") return readCredentialFile(route.path);
if (route.store === "keychain") return keychainCredentialRecord(lookup);
const oauth = await readHarness(route, lookup);
return oauth ? { oauth: { accessToken: oauth.access }, expires: oauth.expires } : null;
}
async function readCredentialFile(path: string): Promise<ClaudeCredentialRecord | null> {
if (!existsSync(path)) return null;
try {
@@ -418,7 +448,11 @@ export async function fetchUsage(
}
const credentials = await resolveClaudeCredentials(input, credentialLookup);
if (!credentials) {
if (
!credentials ||
(credentials.expires !== undefined &&
credentials.expires <= (credentialLookup.now ?? Date.now)())
) {
return unavailableUsage();
}
@@ -464,11 +498,6 @@ export async function fetchUsage(
}
// The OAuth usage endpoint meters the active organization selected by the token.
const ClaudeAccountSchema = z.object({
accountUuid: z.string().min(1),
organizationUuid: z.string().min(1),
emailAddress: z.string().optional(),
});
type ClaudeIdentity = { key: string; label?: string } | null;
const PROFILE_TTL_MS = 300_000;
const PROFILE_CACHE_LIMIT = 128;
@@ -486,19 +515,6 @@ export async function identify(
now: () => number = Date.now,
credentialLookup: ClaudeCredentialLookup = {},
) {
const directory = credentialLookup.accountHome ?? homedir();
try {
const config = z
.object({ oauthAccount: ClaudeAccountSchema })
.parse(JSON.parse(await fs.readFile(join(directory, ".claude.json"), "utf8")));
const account = config.oauthAccount;
return {
key: `${account.accountUuid}.${account.organizationUuid}`,
...(account.emailAddress ? { label: account.emailAddress } : {}),
};
} catch {
// Credentials may still be present even when account metadata is absent.
}
const credentials = await resolveClaudeCredentials(input, credentialLookup);
if (!credentials) return null;
const token = credentials.oauth.accessToken;
+13 -1
View File
@@ -1,3 +1,15 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const routeSchema = z.discriminatedUnion("store", [
z.object({ store: z.enum(["claude", "pi"]), path: z.string().min(1) }).strict(),
z
.object({
store: z.literal("omp"),
path: z.string().min(1),
credentialId: z.number().int().positive(),
})
.strict(),
z.object({ store: z.literal("keychain") }).strict(),
]);
export const inputSchema = z.object({ route: routeSchema }).strict();
export type UsageInput = z.infer<typeof inputSchema>;
+2 -2
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, identify } from "./server/usage.js";
import { discover, fetchUsage, identify } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "Codex",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
discover: () => discover(),
identify,
fetch: fetchUsage,
});
@@ -0,0 +1,245 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { discover, fetchUsage, identify } from "./usage.js";
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), "usage-stores-"));
});
afterEach(async () => {
await rm(home, { recursive: true, force: true });
});
function lookup(env: NodeJS.ProcessEnv = {}) {
return { home, env, platform: "linux" as const, now: () => 1000 };
}
async function json(path: string, value: unknown) {
const { dirname } = await import("node:path");
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(value));
}
async function account(input: Parameters<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}) {
return identify(input, lookup(env));
}
async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
);
const insert = db.prepare("INSERT INTO auth_credentials VALUES (?, ?, ?, ?, ?)");
insert.run(
1,
"openai-codex",
"oauth",
JSON.stringify({ access: "fixture-omp", accountId: "omp-account", expires: 2000 }),
null,
);
insert.run(
2,
"openai-codex",
"oauth",
JSON.stringify({ access: "fixture-expired", expires: 500 }),
null,
);
insert.run(
3,
"openai-codex",
"oauth",
JSON.stringify({ access: "fixture-disabled", expires: 2000 }),
"revoked",
);
insert.run(
4,
"openai-codex",
"api_key",
JSON.stringify({ access: "fixture-key", expires: 2000 }),
null,
);
insert.run(5, "openai-codex", "oauth", "invalid", null);
db.close();
}
test("Pi uses its own OAuth identity and re-reads rotated tokens without writing", async () => {
const path = join(home, ".pi", "agent", "auth.json");
await json(path, {
"openai-codex": { type: "oauth", access: "fixture-first", accountId: "pi-account" },
});
await json(join(home, ".claude.json"), {
oauthAccount: { accountUuid: "other", organizationUuid: "other-org" },
});
const inputs = await discover(lookup());
expect(inputs).toHaveLength(1);
expect(await account(inputs[0]!)).toEqual({ key: "pi-account" });
await json(path, {
"openai-codex": { type: "oauth", access: "fixture-rotated", accountId: "pi-account" },
});
const before = await readFile(path, "utf8");
expect(
(
await fetchUsage(
inputs[0]!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-rotated");
return new Response("{}", { status: 401 });
},
lookup(),
)
).status,
).toBe("unavailable");
expect(await readFile(path, "utf8")).toBe(before);
});
test("Pi honors directory override and ignores API keys and malformed files", async () => {
const directory = join(home, "custom-pi");
const env = { PI_CODING_AGENT_DIR: directory };
const path = join(directory, "auth.json");
await json(path, {
"openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
});
expect(await discover(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
await json(path, { "openai-codex": { type: "api_key", key: "fixture-key" } });
expect(await discover(lookup(env))).toEqual([]);
await writeFile(path, "broken");
expect(await discover(lookup(env))).toEqual([]);
});
test("expired Pi tokens are unavailable without calling usage or refreshing", async () => {
await json(join(home, ".pi", "agent", "auth.json"), {
"openai-codex": {
type: "oauth",
access: "fixture-expired",
accountId: "pi-account",
expires: 500,
},
});
const [input] = await discover(lookup());
expect(
(
await fetchUsage(
input!,
async () => {
throw new Error("must not fetch expired token");
},
lookup(),
)
).status,
).toBe("unavailable");
});
for (const location of ["default", "profile", "xdg", "override", "config"]) {
test(`OMP discovers enabled live OAuth rows in ${location} store read-only`, async () => {
let path = join(home, ".omp", "agent", "agent.db");
let env: NodeJS.ProcessEnv = { OMP_AUTH_BROKER_URL: "https://broker.test" };
if (location === "profile") {
env.OMP_PROFILE = "work";
path = join(home, ".omp", "profiles", "work", "agent", "agent.db");
}
if (location === "xdg") {
env.XDG_DATA_HOME = join(home, "data");
env.PI_PROFILE = "work";
path = join(home, "data", "omp", "profiles", "work", "agent.db");
}
if (location === "override") {
env.PI_CODING_AGENT_DIR = join(home, "custom");
path = join(home, "custom", "agent.db");
}
if (location === "config") {
env.PI_CONFIG_DIR = "custom-config";
path = join(home, "custom-config", "agent", "agent.db");
}
await database(path);
const before = await readFile(path);
const inputs = await discover(lookup(env));
expect(inputs).toEqual([{ route: { store: "omp", path, credentialId: 1 } }]);
expect(await account(inputs[0]!, env)).toEqual({ key: "omp-account" });
expect(
(
await fetchUsage(
inputs[0]!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-omp");
return new Response("{}", { status: 401 });
},
lookup(env),
)
).status,
).toBe("unavailable");
expect(await readFile(path)).toEqual(before);
});
}
test("missing SQLite skips only OMP", async () => {
await database(join(home, ".omp", "agent", "agent.db"));
await json(join(home, ".pi", "agent", "auth.json"), {
"openai-codex": { type: "oauth", access: "fixture-pi" },
});
const inputs = await discover({ ...lookup(), sqlite: () => undefined });
expect(inputs).toEqual([
{ route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } },
]);
});
test("Codex and OpenCode discover accounts in preference order with XDG and CODEX_HOME", async () => {
const codex = join(home, "codex");
const data = join(home, "data");
const env = { CODEX_HOME: codex, XDG_DATA_HOME: data };
await json(join(codex, "auth.json"), {
tokens: { access_token: "fixture-codex", account_id: "same" },
});
// Codex CLI never reads ~/.config/codex.
await json(join(home, ".config", "codex", "auth.json"), {
tokens: { access_token: "fixture-config", account_id: "same" },
});
await json(join(home, ".codex", "auth.json"), {
tokens: { access_token: "fixture-legacy", account_id: "same" },
});
await json(join(data, "opencode", "auth.json"), {
openai: { type: "oauth", access: "fixture-open", accountId: "same" },
});
const inputs = await discover(lookup(env));
expect(inputs.map((input) => input.route.store)).toEqual(["codex", "codex", "opencode"]);
for (const input of inputs) expect(await account(input, env)).toEqual({ key: "same" });
await json(join(data, "opencode", "auth.json"), { openai: { type: "api", key: "fixture-key" } });
expect(await discover(lookup(env))).toHaveLength(2);
});
test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => {
const path = join(home, ".omp", "agent", "agent.db");
await database(path);
const [input] = await discover(lookup());
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
JSON.stringify({ access: "fixture-rotated-omp", accountId: "omp-account", expires: 2000 }),
);
expect(
(
await fetchUsage(
input!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe(
"Bearer fixture-rotated-omp",
);
return new Response("{}", { status: 401 });
},
lookup(),
)
).status,
).toBe("unavailable");
db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run();
db.close();
expect(
(
await fetchUsage(
input!,
async () => {
throw new Error("disabled login must not fetch");
},
lookup(),
)
).status,
).toBe("unavailable");
});
+132
View File
@@ -0,0 +1,132 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { z } from "zod";
import { routeSchema } from "../shared/input.js";
export interface StoreLookup {
home?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
now?: () => number;
sqlite?: () => Sqlite | undefined;
}
interface Database {
prepare(sql: string): { all(...args: string[]): unknown[] };
close(): void;
}
export interface Sqlite {
DatabaseSync: new (path: string, options: { readOnly: boolean }) => Database;
}
export const oauthSchema = z.object({
access: z.string().min(1),
accountId: z.string().optional(),
expires: z.number().optional(),
});
const rowSchema = z.object({ id: z.number().int().positive(), data: z.string() });
export type Route = z.infer<typeof routeSchema>;
export async function readJson(path: string): Promise<unknown> {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
return null;
}
}
export function piAuthPath(lookup: StoreLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
const configured = env.PI_CODING_AGENT_DIR?.trim();
if (!configured) return join(home, ".pi", "agent", "auth.json");
if (configured === "~") return join(home, "auth.json");
const directory = configured.startsWith("~/")
? join(home, configured.slice(2))
: resolve(configured);
return join(directory, "auth.json");
}
function ompDatabasePath(lookup: StoreLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
const platform = lookup.platform ?? process.platform;
const profile = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim() || "default";
const base = join(home, env.PI_CONFIG_DIR || ".omp");
const configRoot = profile === "default" ? base : join(base, "profiles", profile);
const defaultAgentDir = join(configRoot, "agent");
const agentDir =
profile === "default" && env.PI_CODING_AGENT_DIR
? resolve(env.PI_CODING_AGENT_DIR)
: defaultAgentDir;
if (
(platform === "linux" || platform === "darwin") &&
agentDir === defaultAgentDir &&
env.XDG_DATA_HOME
) {
const xdgBase = join(env.XDG_DATA_HOME, "omp");
const xdgRoot = profile === "default" ? xdgBase : join(xdgBase, "profiles", profile);
if (existsSync(xdgRoot)) return join(xdgRoot, "agent.db");
}
return join(agentDir, "agent.db");
}
function builtinSqlite(): Sqlite | undefined {
// Node 20 must still compile/load the plugin; only OMP needs this newer builtin.
const parsed = z
.object({
DatabaseSync: z.custom<Sqlite["DatabaseSync"]>((value) => typeof value === "function"),
})
.safeParse(process.getBuiltinModule?.("node:sqlite"));
return parsed.success ? parsed.data : undefined;
}
function ompRows(path: string, lookup: StoreLookup) {
let db: Database | undefined;
try {
const sqlite = (lookup.sqlite ?? builtinSqlite)();
if (!sqlite) return [];
db = new sqlite.DatabaseSync(path, { readOnly: true });
const rows = db
.prepare(
"SELECT id, data FROM auth_credentials WHERE provider = ? AND credential_type = 'oauth' AND disabled_cause IS NULL ORDER BY id",
)
.all("openai-codex");
return rows.flatMap((value) => {
const row = rowSchema.safeParse(value);
if (!row.success) return [];
try {
const oauth = oauthSchema.parse(JSON.parse(row.data.data));
if (oauth.expires === undefined || oauth.expires <= (lookup.now ?? Date.now)()) return [];
return [{ id: row.data.id, oauth }];
} catch {
return [];
}
});
} catch {
return [];
} finally {
db?.close();
}
}
export function discoverOmp(lookup: StoreLookup): Route[] {
const path = ompDatabasePath(lookup);
return ompRows(path, lookup).map(({ id }) => ({ store: "omp", path, credentialId: id }));
}
export function readOmp(route: Extract<Route, { store: "omp" }>, lookup: StoreLookup) {
return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null;
}
export async function readHarness(
route: Extract<Route, { path: string }>,
lookup: StoreLookup = {},
) {
if (route.store === "omp") return readOmp(route, lookup);
const provider = route.store === "opencode" ? "openai" : "openai-codex";
const parsed = z.record(z.string(), z.unknown()).safeParse(await readJson(route.path));
if (!parsed.success) return null;
const oauth = oauthSchema.extend({ type: z.literal("oauth") }).safeParse(parsed.data[provider]);
return oauth.success ? oauth.data : null;
}
+43 -17
View File
@@ -1,10 +1,14 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { fetchUsage, identify } from "./usage.js";
import { inputSchema } from "../shared/input.js";
import { inputSchema, type CodexUsageInput } from "../shared/input.js";
function authInput(directory: string): CodexUsageInput {
return { route: { store: "codex", path: join(directory, "auth.json") } };
}
let fixtureHome: string;
beforeEach(async () => {
@@ -42,7 +46,7 @@ function response(headers: HeadersInit, accountId?: string): Promise<Response> {
);
}
test("default input reads CODEX_HOME auth and preserves the usage request", async () => {
test("explicit route reads Codex auth and preserves the usage request", async () => {
const home = await mkdtemp(join(tmpdir(), "usage-codex-"));
try {
process.env["CODEX_HOME"] = home;
@@ -52,7 +56,7 @@ test("default input reads CODEX_HOME auth and preserves the usage request", asyn
tokens: { access_token: "fixture-default", account_id: "account-default" },
}),
);
const report = await fetchUsage({}, (_url, init) =>
const report = await fetchUsage(authInput(home), (_url, init) =>
response(init?.headers ?? {}, "account-default"),
);
expect(report).toMatchObject({
@@ -65,7 +69,7 @@ test("default input reads CODEX_HOME auth and preserves the usage request", asyn
}
});
test("discovery input rejects explicit credential routes", () => {
test("discovery input requires a route and rejects raw credentials", () => {
for (const input of [
{ codexHome: "/unused" },
{ accessToken: "unused" },
@@ -73,18 +77,20 @@ test("discovery input rejects explicit credential routes", () => {
]) {
expect(inputSchema.safeParse(input).success).toBe(false);
}
expect(inputSchema.parse({})).toEqual({});
expect(() => inputSchema.parse({})).toThrow();
});
test("identify returns a key when fetch finds Codex token credentials", async () => {
await writeAuth("fixture-supplied", "account-supplied");
await fetchUsage({}, (_url, init) => response(init?.headers ?? {}, "account-supplied"));
expect(await identify({})).toEqual({ key: "account-supplied" });
await fetchUsage(authInput(fixtureHome), (_url, init) =>
response(init?.headers ?? {}, "account-supplied"),
);
expect(await identify(authInput(fixtureHome))).toEqual({ key: "account-supplied" });
});
test("coerces credit balance and marks a 96 percent window dangerous", async () => {
const report = await fetchUsage(
{},
authInput(fixtureHome),
async () =>
new Response(
JSON.stringify({
@@ -110,7 +116,7 @@ test("coerces credit balance and marks a 96 percent window dangerous", async ()
test("summarizes the session and weekly windows by default, not code review", async () => {
const report = await fetchUsage(
{},
authInput(fixtureHome),
async () =>
new Response(
JSON.stringify({
@@ -132,7 +138,7 @@ test("summarizes the session and weekly windows by default, not code review", as
test("HTML usage body is unavailable", async () => {
const report = await fetchUsage(
{},
authInput(fixtureHome),
async () => new Response("<html>Login</html>", { status: 200 }),
);
expect(report.status).toBe("unavailable");
@@ -155,7 +161,7 @@ test("401 leaves auth.json byte for byte unchanged and makes no refresh request"
});
await writeFile(authPath, before);
let calls = 0;
const report = await fetchUsage({}, async (url) => {
const report = await fetchUsage(authInput(home), async (url) => {
expect(String(url)).toBe("https://chatgpt.com/backend-api/wham/usage");
calls++;
return new Response(null, { status: 401 });
@@ -168,15 +174,15 @@ test("401 leaves auth.json byte for byte unchanged and makes no refresh request"
}
});
test("default auth account claim survives token rotation", async () => {
test("auth account claim survives token rotation", async () => {
const token = (suffix: string) =>
`header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "work-id" }, suffix })).toString("base64url")}.signature`;
await writeAuth(token("first"));
expect(await identify({})).toEqual({ key: "work-id" });
expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" });
await writeAuth(token("second"));
expect(await identify({})).toEqual({ key: "work-id" });
expect(await identify(authInput(fixtureHome))).toEqual({ key: "work-id" });
await writeAuth("opaque-token");
expect(await identify({})).toBeNull();
expect(await identify(authInput(fixtureHome))).toBeNull();
});
test("identify reads an email label from auth id_token when access token has no profile", async () => {
@@ -190,7 +196,7 @@ test("identify reads an email label from auth id_token when access token has no
tokens: { account_id: "account-id", access_token: "opaque-token", id_token: idToken },
}),
);
expect(await identify({})).toEqual({
expect(await identify(authInput(home))).toEqual({
key: "account-id",
label: "id-owner@example.test",
});
@@ -198,3 +204,23 @@ test("identify reads an email label from auth id_token when access token has no
await rm(home, { recursive: true, force: true });
}
});
test("discovers a Pi OAuth login independently of the CLI login", async () => {
const { discover } = await import("./usage.js");
const directory = await mkdtemp(join(tmpdir(), "usage-pi-discovery-"));
try {
await mkdir(join(directory, ".pi", "agent"), { recursive: true });
await writeFile(
join(directory, ".pi", "agent", "auth.json"),
JSON.stringify({
"openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
}),
);
const inputs = await discover({ home: directory, env: {}, platform: "linux" });
expect(inputs).toContainEqual({
route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") },
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
+55 -25
View File
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises";
import { discoverOmp, piAuthPath, readHarness, readJson, type StoreLookup } from "./stores.js";
import { homedir } from "node:os";
import { join } from "node:path";
import {
@@ -32,28 +32,56 @@ const responseSchema = z.object({
credits: z.object({ balance: number.optional() }).nullish(),
});
export async function readAuth(
_input: CodexUsageInput,
): Promise<{ token: string; accountId?: string; idToken?: string } | null> {
const candidates = [
...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []),
join(homedir(), ".config", "codex", "auth.json"),
join(homedir(), ".codex", "auth.json"),
interface Auth {
token: string;
accountId?: string;
idToken?: string;
expires?: number;
}
export async function discover(lookup: StoreLookup = {}): Promise<CodexUsageInput[]> {
const env = lookup.env ?? process.env;
const home = lookup.home ?? homedir();
const paths = [
...(env.CODEX_HOME ? [join(env.CODEX_HOME, "auth.json")] : []),
join(home, ".codex", "auth.json"),
];
for (const path of candidates) {
try {
const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8")));
if (auth.tokens?.access_token)
return {
token: auth.tokens.access_token,
accountId: auth.tokens.account_id,
idToken: auth.tokens.id_token,
};
} catch {
continue;
}
const candidates: CodexUsageInput[] = [...new Set(paths)].map((path) => ({
route: { store: "codex", path },
}));
candidates.push(
{
route: {
store: "opencode",
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
},
},
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ route })),
);
const present: CodexUsageInput[] = [];
for (const input of candidates) if (await readAuth(input, lookup)) present.push(input);
return present;
}
export async function readAuth(
input: CodexUsageInput,
lookup: StoreLookup = {},
): Promise<Auth | null> {
const route = input.route;
if (route.store !== "codex") {
const oauth = await readHarness(route, lookup);
return oauth
? { token: oauth.access, accountId: oauth.accountId, expires: oauth.expires }
: null;
}
return null;
const auth = authSchema.safeParse(await readJson(route.path));
if (!auth.success || !auth.data.tokens?.access_token) return null;
return {
token: auth.data.tokens.access_token,
accountId: auth.data.tokens.account_id,
idToken: auth.data.tokens.id_token,
};
}
function usageWindow(
@@ -73,9 +101,11 @@ function usageWindow(
export async function fetchUsage(
input: CodexUsageInput,
fetchApi: typeof fetch = fetch,
lookup: StoreLookup = {},
): Promise<UsageReport> {
const auth = await readAuth(input);
if (!auth) return { status: "unavailable", windows: [] };
const auth = await readAuth(input, lookup);
if (!auth || (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)()))
return { status: "unavailable", windows: [] };
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.token}`,
Accept: "application/json",
@@ -151,8 +181,8 @@ function claimString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
export async function identify(input: CodexUsageInput) {
const auth = await readAuth(input);
export async function identify(input: CodexUsageInput, lookup: StoreLookup = {}) {
const auth = await readAuth(input, lookup);
if (!auth) return null;
const access = jwtClaims(auth.token);
const id = jwtClaims(auth.idToken);
+11 -1
View File
@@ -1,4 +1,14 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
export const routeSchema = z.discriminatedUnion("store", [
z.object({ store: z.enum(["codex", "opencode", "pi"]), path: z.string().min(1) }).strict(),
z
.object({
store: z.literal("omp"),
path: z.string().min(1),
credentialId: z.number().int().positive(),
})
.strict(),
]);
export const inputSchema = z.object({ route: routeSchema }).strict();
export type CodexUsageInput = z.infer<typeof inputSchema>;
+11
View File
@@ -336,6 +336,17 @@ export default function contribute(server: PluginServerContext) {
`discover()` is required and supplies configured inputs; return `[]` when no account is configured. `identify(input)` returns a stable account key and optional display label without fetching usage, or `null` when there are no credentials. The daemon combines the source ID and key as `<sourceId>:<accountKey>`. The key must be 1–128 characters from `[A-Za-z0-9._-]`, remain stable across token rotation, and identify the account or organization whose quota is metered. Never use a credential or raw email as the key; use `hashAccountKey(value)` when the only stable identity is sensitive.
Return credential-store locators in preference order from `discover()`. The daemon groups inputs with the same source and account key into one card, keeps that order, and tries each login until `fetch()` returns `available`. An unavailable or error report, thrown failure, or missing login falls through to the next input. If none succeeds, the card carries the last attempted report (or `unavailable` if no login remains). Before fetching, the daemon identifies the login again and skips it if its account changed. Re-read credentials in `fetch()` so the harness's token rotations take effect; never refresh or write its tokens. Keep raw credentials out of discovery inputs and reports.
Built-in subscription sources discover logins independently of agents and provider names:
| Source | Login preference order | Account key |
| ------ | -------------------------------------------------------- | ----------------------------------------------------- |
| Codex | Codex CLI (`$CODEX_HOME`, `~/.codex`), OpenCode, Pi, OMP | ChatGPT account ID from stored metadata or JWT claims |
| Claude | Claude Code credential file, macOS keychain, Pi, OMP | OAuth account UUID and organization UUID |
OpenCode honors `XDG_DATA_HOME`; Pi honors `PI_CODING_AGENT_DIR`. OMP honors its directory, profile, and XDG settings, reads enabled unexpired OAuth rows by credential ID, and is skipped when the host lacks `node:sqlite`. Claude Code honors `CLAUDE_CONFIG_DIR`. Each Claude login's OAuth profile supplies its identity.
`usage.list_reports` discovers reports when called without IDs, or reads only the requested known IDs. It caches each report for five minutes and `forceRefresh` refreshes only the returned IDs. Each entry carries `id`, `account.label`, and `fetchedAt`; `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above.
## Entry point and cleanup