diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml index e2f1fafb3c..c30eea7da4 100644 --- a/.github/workflows/desktop-packages.yml +++ b/.github/workflows/desktop-packages.yml @@ -9,6 +9,7 @@ on: branches: [main] paths: - "packages/desktop/**" + - "plugins/**" - "nix/**" - ".github/workflows/desktop-packages.yml" diff --git a/docs/glossary.md b/docs/glossary.md index 512068ece8..67730885fb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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: `:`. 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`). diff --git a/docs/plugins.md b/docs/plugins.md index b4471d10e7..ab12fe7dd2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -93,6 +93,15 @@ Built-in plugins ship from `plugins//`, 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. diff --git a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts index b36ea6ba98..49c781f84d 100644 --- a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -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"); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index ae7919e952..499b67e0bf 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -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 }, diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index c7de5261aa..6512a8bd8f 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -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$/), diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 124eaca5f6..0b1f04fed7 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -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); diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index 6631d839cb..c3ed13ea67 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -163,7 +163,6 @@ async function expectVerticalOrder( /** 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}`; diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 56d4455099..7f73ec7897 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -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 { - 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 { + 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". */ diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 8095497ec7..0737634a1b 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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(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 ( <> diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index c94fbb01f9..021c627e31 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -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"; diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 753fb17af5..f5a3fe1ed6 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -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(); diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index ee8f6f657b..a0bd6a7e15 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -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 ; - } - return ( - - ); +/** 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 ; +/** 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 ; } /** 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 ? ( - - ) : ( - - ); - if (!usesSheet) return trigger; + const trigger = ; + if (!isCompact) return trigger; return ( {trigger} diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 385c99fa09..b1b299494a 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -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) { diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index fb01c7b194..6ab5a9b691 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -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 diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index eb6aab147e..c14c027853 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -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"), diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index c414b20557..df81a8fbb2 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 41fa0ef2cb..64fd87c566 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -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((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]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index ef8b4bd75f..bdc5c344d3 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -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(); private readonly known = new Map(); @@ -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 { + 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(); + 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; diff --git a/plugins/claude-usage-source/index.server.ts b/plugins/claude-usage-source/index.server.ts index ce5b16c4f7..fae161db58 100644 --- a/plugins/claude-usage-source/index.server.ts +++ b/plugins/claude-usage-source/index.server.ts @@ -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, }); diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index 710e3b9cb4..8f5ee0bd99 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -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, diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts new file mode 100644 index 0000000000..7e0392b88b --- /dev/null +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -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[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") } }, + ]); +}); diff --git a/plugins/claude-usage-source/server/stores.ts b/plugins/claude-usage-source/server/stores.ts new file mode 100644 index 0000000000..0c41c1478c --- /dev/null +++ b/plugins/claude-usage-source/server/stores.ts @@ -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; + +export async function readJson(path: string): Promise { + 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((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, lookup: StoreLookup) { + return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null; +} + +export async function readHarness( + route: Extract, + 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; +} diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index b9eb4b6ecf..f282a25ae9 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -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) { 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 }); + } +}); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 70ba4f9273..34464d8b53 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -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; const SCOPED_WEEKLY_KIND = "weekly_scoped"; interface ClaudeCredentialRecord { + expires?: number; oauth: { accessToken: string } & NonNullable; } @@ -332,21 +334,21 @@ export async function readClaudeKeychainCredentials( return null; } -interface ClaudeCredentialLookup { - platform?: NodeJS.Platform; +interface ClaudeCredentialLookup extends StoreLookup { readKeychainCredentials?: () => Promise; 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 { - 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 { + 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 { + 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 { 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; diff --git a/plugins/claude-usage-source/shared/input.ts b/plugins/claude-usage-source/shared/input.ts index 9d9a5f432e..76ff6a8795 100644 --- a/plugins/claude-usage-source/shared/input.ts +++ b/plugins/claude-usage-source/shared/input.ts @@ -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; diff --git a/plugins/codex-usage-source/index.server.ts b/plugins/codex-usage-source/index.server.ts index 3a7c472784..665860aa5a 100644 --- a/plugins/codex-usage-source/index.server.ts +++ b/plugins/codex-usage-source/index.server.ts @@ -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, }); diff --git a/plugins/codex-usage-source/server/stores.test.ts b/plugins/codex-usage-source/server/stores.test.ts new file mode 100644 index 0000000000..191c92d25f --- /dev/null +++ b/plugins/codex-usage-source/server/stores.test.ts @@ -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[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"); +}); diff --git a/plugins/codex-usage-source/server/stores.ts b/plugins/codex-usage-source/server/stores.ts new file mode 100644 index 0000000000..2f20beb24e --- /dev/null +++ b/plugins/codex-usage-source/server/stores.ts @@ -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; + +export async function readJson(path: string): Promise { + 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((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, lookup: StoreLookup) { + return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null; +} + +export async function readHarness( + route: Extract, + 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; +} diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index e7572f4efe..27aab6e6ac 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -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 { ); } -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("Login", { 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 }); + } +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 8620484b6e..1feda550c0 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -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 { + 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 { + 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 { - 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 = { 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); diff --git a/plugins/codex-usage-source/shared/input.ts b/plugins/codex-usage-source/shared/input.ts index baa8ee7ca6..864af1c194 100644 --- a/plugins/codex-usage-source/shared/input.ts +++ b/plugins/codex-usage-source/shared/input.ts @@ -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; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index d96b18f9d6..80c21f4e8b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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 `:`. 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