From eec75b7d41359542d2cb613c90d3c46587e2f935 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Wed, 15 Jul 2026 20:17:17 +0900 Subject: [PATCH 01/28] feat: support multiple Grok accounts --- README.md | 38 ++- src/config.ts | 149 ++++++++++- src/imagine/auth.ts | 8 +- src/provider/accounts.ts | 392 +++++++++++++++++++++++++++ src/provider/register.ts | 111 ++++---- src/provider/rotation.ts | 144 ++++++++++ src/provider/toolScope.ts | 3 +- src/provider/usage.ts | 11 +- src/vision/describe.ts | 12 +- tests/config.test.ts | 102 +++++-- tests/imagine/register.test.ts | 18 +- tests/provider/accounts.test.ts | 444 +++++++++++++++++++++++++++++++ tests/provider/package.test.ts | 2 + tests/provider/register.test.ts | 80 +++++- tests/provider/rotation.test.ts | 277 +++++++++++++++++++ tests/provider/toolScope.test.ts | 9 +- tests/vision/describe.test.ts | 17 ++ tests/vision/helpers.ts | 20 ++ 18 files changed, 1733 insertions(+), 104 deletions(-) create mode 100644 src/provider/accounts.ts create mode 100644 src/provider/rotation.ts create mode 100644 tests/provider/accounts.test.ts create mode 100644 tests/provider/rotation.test.ts diff --git a/README.md b/README.md index 88cd780..2a148a0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Use your X Premium or SuperGrok subscription in [pi](https://pi.dev/) with a cle - **Vision for text-only models** — automatically describe images with a vision-capable Grok model and cache descriptions locally. - **Grok Imagine** — generate JPEGs from the TUI or let Grok call the `image_gen` tool, with inline previews. - **Subscription OAuth** — sign in through a browser or device code or reuse an official Grok Build login; tokens refresh automatically. +- **Multiple accounts** — keep independent Pi logins, switch from a TUI, and continue automatically on an exact exhausted-balance error. - **Model-scoped compatibility** — Cursor-style tool names only for Grok Build and Composer 2.5. All other models keep Pi's native tools. - **Usage tracking** — check account limits, remaining credits, and reset times from pi. @@ -41,6 +42,8 @@ Choose **Grok CLI**, then select one of these methods: - **Device code login (headless)** — displays a URL and short code for SSH, containers, and other headless environments. - **Use existing Grok Build login** — appears when a valid official `~/.grok/auth.json` entry is available. The file is read-only to this extension. +To add another login, run `/grok-cli-accounts`, choose **+ Add account**, and optionally label it. The extension adds a stable provider alias and pre-fills `/login `; press Enter to complete Pi's native login flow. Use only accounts you own or are authorized to access. + ### 3. Select a model ```text @@ -84,6 +87,22 @@ The table describes metadata bundled with this extension, not live model discove Use `/login` to authenticate through a browser or device code, or reuse an official Grok Build login. Pi stores and refreshes OAuth credentials automatically. For automation, `GROK_CLI_OAUTH_TOKEN` supplies a direct token without automatic refresh. +Run `/grok-cli-accounts` to add, switch, rename, relogin, log out, or remove accounts. Each account is registered as an independent Pi provider (`grok-cli`, `grok-cli-2`, and so on), so Pi keeps its OAuth credential separately in `auth.json`. The base `grok-cli` slot is permanent; aliases can be removed. Alias numbers are stable and are not reused. + +`GROK_CLI_OAUTH_TOKEN` applies only to the permanent base account. When it is set, the accounts UI identifies the environment token and tells you to unset it instead of presenting a logout action that cannot remove it. + +#### Automatic exhaustion rotation + +When at least two configured accounts are logged in, pi-grok-cli rotates after a final Grok assistant error whose trimmed message is exactly: + +```text +OpenAI API error (402): 402 "Grok Build usage balance exhausted" +``` + +Rotation waits until Pi has finished retries, compaction, and tool activity. It selects the next authenticated account in circular configuration order, preserves the current model when available, and automatically continues the interrupted request once. Accounts already exhausted during that continuation chain are skipped. If every logged-in account returns the exact error, the extension stops without wrapping and reports that all accounts are exhausted. + +The attempted-account set is in memory and applies only to that continuation chain. A new user request starts fresh, so recovered quota can be detected naturally. Similar 402 messages, 401s, 429s, non-Grok errors, and non-final errors do not trigger rotation. A manual model change before failover cancels the pending rotation. + ### Model-scoped Cursor compatibility Pi's native tool vocabulary remains the default. Cursor-compatible tool names are exposed only when one of these exact models is selected: @@ -91,6 +110,8 @@ Pi's native tool vocabulary remains the default. Cursor-compatible tool names ar - `grok-cli/grok-build` - `grok-cli/grok-composer-2.5-fast` +Managed account aliases expose the same two model IDs with the same compatibility behavior. + Only these two models get Cursor-compatible tool names. Grok 4.5, future Grok models, and other providers continue to use Pi's native tools. No extra shims. | Compatibility tools | Implementation | @@ -117,7 +138,7 @@ When `read` or `Read` returns an image to a text-only model, pi-grok-cli describ ### Grok Imagine image generation -Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any active model call the `image_gen` tool. Both use the current Grok CLI OAuth token and save the result under the current session or a requested output path. The command supports `--aspect`, `--out`, and `--resolution 1k`. +Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any active model call the `image_gen` tool. Both use the current or last successfully selected Grok account and save the result under the current session or a requested output path. The command supports `--aspect`, `--out`, and `--resolution 1k`. `image_gen` is enabled by default across providers and is independent of Cursor compatibility. Use `/grok-cli-imagine:tool [on|off|status]` to manage its persisted availability without disabling the direct command. @@ -125,6 +146,7 @@ Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any acti | Command | Description | | --- | --- | +| `/grok-cli-accounts` | Add, switch, rename, relogin, log out, or remove Grok accounts. | | `/grok-cli-usage` | Fetch current quota, remaining credits, and reset times. | | `/grok-cli-imagine ` | Generate and preview an image. Supports `--aspect`, `--out`, and `--resolution 1k`. | | `/grok-cli-imagine:tool [on\|off\|status]` | Toggle, set, or report persistent model-callable `image_gen` availability. | @@ -138,7 +160,17 @@ All extension settings are read from `~/.pi/grok-cli.json`. The file is created ```json { - "version": 1, + "version": 2, + "accounts": { + "nextAccountNumber": 2, + "selectedProvider": "grok-cli", + "items": [ + { + "provider": "grok-cli", + "label": "Account 1" + } + ] + }, "imagine": { "enabled": true }, @@ -152,7 +184,7 @@ All extension settings are read from `~/.pi/grok-cli.json`. The file is created } ``` -Legacy Imagine and vision settings are migrated automatically. The vision cache remains separate at `~/.pi/grok-cli-vision-cache.json`. +Version 1 configuration and legacy Imagine and vision settings are migrated atomically while preserving feature settings. OAuth credentials remain in Pi's auth storage and are not rewritten. The vision cache remains separate at `~/.pi/grok-cli-vision-cache.json`. `model` must identify an image-capable model. Invalid values are reported and replaced with safe defaults. Manual changes apply on the next image read. diff --git a/src/config.ts b/src/config.ts index 5fa6891..3006932 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,7 +12,8 @@ import { homedir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { resolveModels } from './models/catalog.js'; -export const CONFIG_VERSION = 1 as const; +export const CONFIG_VERSION = 2 as const; +const LEGACY_CONFIG_VERSION = 1; export const DEFAULT_DESCRIBE_MODEL = 'grok-build'; export const DEFAULT_MAX_IMAGES = 4; export const DEFAULT_CACHE_MAX_ENTRIES = 100; @@ -27,8 +28,20 @@ export interface VisionConfig { cacheMaxEntries: number; } +export interface GrokCliAccount { + provider: string; + label: string; +} + +export interface AccountsConfig { + nextAccountNumber: number; + selectedProvider: string; + items: GrokCliAccount[]; +} + export interface GrokCliConfig { version: typeof CONFIG_VERSION; + accounts: AccountsConfig; imagine: ImagineConfig; vision: VisionConfig; } @@ -43,8 +56,15 @@ export const DEFAULT_VISION_CONFIG: VisionConfig = { cacheMaxEntries: DEFAULT_CACHE_MAX_ENTRIES, }; +export const DEFAULT_ACCOUNTS_CONFIG: AccountsConfig = { + nextAccountNumber: 2, + selectedProvider: 'grok-cli', + items: [{ provider: 'grok-cli', label: 'Account 1' }], +}; + export const DEFAULT_CONFIG: GrokCliConfig = { version: CONFIG_VERSION, + accounts: DEFAULT_ACCOUNTS_CONFIG, imagine: DEFAULT_IMAGINE_CONFIG, vision: DEFAULT_VISION_CONFIG, }; @@ -54,7 +74,7 @@ export interface LoadedConfig { warning?: string; } -type ParsedConfig = LoadedConfig & { valid: boolean }; +type ParsedConfig = LoadedConfig & { valid: boolean; needsMigration: boolean }; type LegacyConfig = LoadedConfig & { existingPaths: string[]; @@ -70,6 +90,10 @@ export const getLegacyVisionConfigPath = () => join(homePath(), '.pi', 'grok-cli function defaultConfig(): GrokCliConfig { return { version: CONFIG_VERSION, + accounts: { + ...DEFAULT_ACCOUNTS_CONFIG, + items: DEFAULT_ACCOUNTS_CONFIG.items.map((account) => ({ ...account })), + }, imagine: { ...DEFAULT_IMAGINE_CONFIG }, vision: { ...DEFAULT_VISION_CONFIG }, }; @@ -88,6 +112,95 @@ function combineWarnings(warnings: (string | undefined)[]) { return combined.length ? combined.join(' ') : undefined; } +export function hasTerminalControlCharacters(value: string) { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || (code >= 127 && code <= 159); + }); +} + +const accountNumber = (provider: string) => { + if (provider === 'grok-cli') return 1; + const match = /^grok-cli-((?:[2-9]|[1-9]\d+))$/.exec(provider); + return match ? Number(match[1]) : undefined; +}; + +function normalizeAccountsConfig(raw: unknown, warnings: string[]): AccountsConfig { + if (raw === undefined) return defaultConfig().accounts; + if (!isObject(raw) || !Array.isArray(raw.items)) { + warnings.push('accounts must be an object with an items array. Using defaults.'); + return defaultConfig().accounts; + } + + const invalid: unknown[] = []; + const providers = new Set(); + const labels = new Set(); + const baseIndex = raw.items.findIndex((value) => { + if (!isObject(value) || value.provider !== 'grok-cli' || typeof value.label !== 'string') { + return false; + } + const label = value.label.trim(); + return Boolean(label) && [...label].length <= 40 && !hasTerminalControlCharacters(label); + }); + const accountValues = + baseIndex >= 0 + ? [raw.items[baseIndex], ...raw.items.filter((_value, index) => index !== baseIndex)] + : [{ provider: 'grok-cli', label: 'Account 1' }, ...raw.items]; + const items = accountValues.flatMap((value) => { + if (!isObject(value) || typeof value.provider !== 'string' || typeof value.label !== 'string') { + invalid.push(value); + return []; + } + const label = value.label.trim(); + const normalizedLabel = label.toLocaleLowerCase(); + if ( + accountNumber(value.provider) === undefined || + !label || + [...label].length > 40 || + hasTerminalControlCharacters(label) || + providers.has(value.provider) || + labels.has(normalizedLabel) + ) { + invalid.push(value); + return []; + } + providers.add(value.provider); + labels.add(normalizedLabel); + return [{ provider: value.provider, label }]; + }); + + if (!providers.has('grok-cli')) { + items.unshift({ provider: 'grok-cli', label: 'Account 1' }); + providers.add('grok-cli'); + } else { + items.sort((left, right) => + left.provider === 'grok-cli' ? -1 : right.provider === 'grok-cli' ? 1 : 0, + ); + } + + if (invalid.length) + warnings.push('accounts contains invalid or duplicate entries. Ignoring them.'); + const highestAccountNumber = Math.max( + ...items.map((account) => accountNumber(account.provider) ?? 1), + ); + const requestedNext = + typeof raw.nextAccountNumber === 'number' && + Number.isInteger(raw.nextAccountNumber) && + raw.nextAccountNumber >= 2 + ? raw.nextAccountNumber + : 2; + const selectedProvider = + typeof raw.selectedProvider === 'string' && providers.has(raw.selectedProvider) + ? raw.selectedProvider + : 'grok-cli'; + + return { + nextAccountNumber: Math.max(requestedNext, highestAccountNumber + 1), + selectedProvider, + items, + }; +} + export function describableModels(): string[] { return resolveModels() .filter((model) => model.input.includes('image')) @@ -170,7 +283,7 @@ export function normalizeVisionConfig( } function normalizeConfig( - raw: { imagine?: unknown; vision?: unknown }, + raw: { accounts?: unknown; imagine?: unknown; vision?: unknown }, warnings: string[], ): GrokCliConfig { const vision = raw.vision; @@ -179,6 +292,7 @@ function normalizeConfig( } return { version: CONFIG_VERSION, + accounts: normalizeAccountsConfig(raw.accounts, warnings), imagine: normalizeImagineConfig(raw.imagine, warnings), vision: normalizeVisionConfig(isObject(vision) ? vision : {}, warnings), }; @@ -190,13 +304,15 @@ function parseConfig(configPath: string): ParsedConfig { if (!isObject(parsed)) { return { config: defaultConfig(), + needsMigration: false, valid: false, warning: `Config ${configPath} must be a JSON object. Using legacy settings or defaults.`, }; } - if (parsed.version !== CONFIG_VERSION) { + if (parsed.version !== CONFIG_VERSION && parsed.version !== LEGACY_CONFIG_VERSION) { return { config: defaultConfig(), + needsMigration: false, valid: false, warning: `Unsupported config version ${String(parsed.version)} in ${configPath}. Using legacy settings or defaults.`, }; @@ -204,12 +320,14 @@ function parseConfig(configPath: string): ParsedConfig { const warnings: string[] = []; return { config: normalizeConfig(parsed, warnings), + needsMigration: parsed.version === LEGACY_CONFIG_VERSION, valid: true, warning: warnings.length ? `Invalid ${configPath}: ${warnings.join(' ')}` : undefined, }; } catch (error) { return { config: defaultConfig(), + needsMigration: false, valid: false, warning: `Could not read ${configPath}: ${errorMessage(error)}. Using legacy settings or defaults.`, }; @@ -295,6 +413,7 @@ function loadLegacyConfig(): LegacyConfig { return { config: { version: CONFIG_VERSION, + accounts: defaultConfig().accounts, imagine: imagine?.config ?? { ...DEFAULT_IMAGINE_CONFIG }, vision: vision?.config ?? { ...DEFAULT_VISION_CONFIG }, }, @@ -368,6 +487,28 @@ export function migrateLegacyConfig(): { warning?: string } { if (!loaded.valid) { return { warning: combineWarnings([loaded.warning, legacy.warning]) }; } + if (loaded.needsMigration) { + try { + saveConfig(loaded.config); + const verified = parseConfig(getConfigPath()); + if ( + !verified.valid || + verified.needsMigration || + JSON.stringify(verified.config) !== JSON.stringify(loaded.config) + ) { + return { + warning: combineWarnings([ + verified.warning, + `Could not verify migrated config ${getConfigPath()}. Legacy files were preserved.`, + ]), + }; + } + } catch (error) { + return { + warning: `Could not migrate configuration ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + }; + } + } const cleanupWarning = removeLegacyConfigs(legacy.recognizedPaths); const warning = combineWarnings([loaded.warning, legacy.warning, cleanupWarning]); return warning ? { warning } : {}; diff --git a/src/imagine/auth.ts b/src/imagine/auth.ts index e68d87d..2ee61fe 100644 --- a/src/imagine/auth.ts +++ b/src/imagine/auth.ts @@ -1,12 +1,8 @@ import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { resolveGrokToken } from '../provider/accounts.js'; export async function resolveImagineToken(ctx: ExtensionContext) { - if (process.env.GROK_CLI_OAUTH_TOKEN) return process.env.GROK_CLI_OAUTH_TOKEN; - try { - return await ctx.modelRegistry.getApiKeyForProvider('grok-cli'); - } catch { - return undefined; - } + return resolveGrokToken(ctx); } export const IMAGINE_AUTH_ERROR = diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts new file mode 100644 index 0000000..2be8fb0 --- /dev/null +++ b/src/provider/accounts.ts @@ -0,0 +1,392 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from '@earendil-works/pi-coding-agent'; +import { + type GrokCliAccount, + type GrokCliConfig, + hasTerminalControlCharacters, + loadConfig, + saveConfig, +} from '../config.js'; + +export const GROK_CLI_PROVIDER = 'grok-cli'; +export const DEFAULT_GROK_MODEL = 'grok-build'; + +type RegisterAccount = (account: GrokCliAccount) => void; + +export function isGrokCliProvider(provider: string | undefined): boolean { + return provider === GROK_CLI_PROVIDER || /^grok-cli-(?:[2-9]|[1-9]\d+)$/.test(provider ?? ''); +} + +function copyConfig(): GrokCliConfig { + const config = loadConfig().config; + return { + ...config, + accounts: { + ...config.accounts, + items: config.accounts.items.map((account) => ({ ...account })), + }, + imagine: { ...config.imagine }, + vision: { ...config.vision }, + }; +} + +function accountNumber(provider: string) { + if (provider === GROK_CLI_PROVIDER) return 1; + return Number(provider.slice('grok-cli-'.length)); +} + +function defaultLabel(provider: string) { + return `Account ${accountNumber(provider)}`; +} + +function labelError(config: GrokCliConfig, provider: string, label: string) { + if ([...label].length > 40) return 'Account labels must be 40 characters or fewer.'; + if (hasTerminalControlCharacters(label)) + return 'Account labels cannot contain control characters.'; + if ( + config.accounts.items.some( + (account) => + account.provider !== provider && + account.label.toLocaleLowerCase() === label.toLocaleLowerCase(), + ) + ) { + return `An account named “${label}” already exists.`; + } + return undefined; +} + +async function promptLabel( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + provider: string, + title: string, +) { + while (true) { + const input = await ctx.ui.input(title, defaultLabel(provider)); + if (input === undefined) return undefined; + const label = input.trim() || defaultLabel(provider); + const error = labelError(config, provider, label); + if (!error) return label; + ctx.ui.notify(error, 'error'); + } +} + +function hasStoredAuth(ctx: ExtensionContext, provider: string) { + return ctx.modelRegistry.authStorage.has(provider); +} + +function hasAccountAuth(ctx: ExtensionContext, provider: string) { + return ( + hasStoredAuth(ctx, provider) || + (provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN)) + ); +} + +function accountStatus(ctx: ExtensionContext, config: GrokCliConfig, provider: string) { + const environment = provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN); + if (config.accounts.selectedProvider === provider && hasAccountAuth(ctx, provider)) { + return environment ? 'Active (environment)' : 'Active'; + } + if (environment) return 'Environment token'; + return hasStoredAuth(ctx, provider) ? 'Logged in' : 'Login required'; +} + +function accountRows(ctx: ExtensionContext, config: GrokCliConfig) { + return config.accounts.items.map((account) => ({ + account, + row: `${account.label} — ${accountStatus(ctx, config, account.provider)}`, + })); +} + +function prefillLogin(ctx: ExtensionContext, provider: string) { + ctx.ui.setEditorText(`/login ${provider}`); +} + +async function switchAccount( + pi: ExtensionAPI, + ctx: ExtensionContext, + config: GrokCliConfig, + account: GrokCliAccount, +) { + const modelId = isGrokCliProvider(ctx.model?.provider) ? ctx.model?.id : DEFAULT_GROK_MODEL; + const model = + ctx.modelRegistry.find(account.provider, modelId ?? DEFAULT_GROK_MODEL) ?? + ctx.modelRegistry.find(account.provider, DEFAULT_GROK_MODEL); + if (!model) { + ctx.ui.notify(`Grok CLI model unavailable for “${account.label}”.`, 'error'); + return false; + } + if (!(await pi.setModel(model))) { + ctx.ui.notify( + `Could not switch to “${account.label}”; authentication is unavailable.`, + 'error', + ); + return false; + } + config.accounts.selectedProvider = account.provider; + saveConfig(config); + return true; +} + +async function addAccount( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + registerAccount: RegisterAccount, +) { + const provider = `${GROK_CLI_PROVIDER}-${config.accounts.nextAccountNumber}`; + const label = await promptLabel(ctx, config, provider, 'Label this Grok CLI account:'); + if (!label) return; + const account = { provider, label }; + config.accounts.items.push(account); + config.accounts.nextAccountNumber += 1; + try { + saveConfig(config); + registerAccount(account); + prefillLogin(ctx, provider); + } catch (error) { + ctx.ui.notify( + `Could not add Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function renameAccount( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + account: GrokCliAccount, + registerAccount: RegisterAccount, +) { + const label = await promptLabel(ctx, config, account.provider, `Rename “${account.label}”:`); + if (!label) return; + account.label = label; + try { + saveConfig(config); + registerAccount(account); + } catch (error) { + ctx.ui.notify( + `Could not rename Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function removeBaseAccount( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + account: GrokCliAccount, + registerAccount: RegisterAccount, +) { + if ( + !(await ctx.ui.confirm( + `Log out of “${account.label}”?`, + 'This removes its saved OAuth login from Pi.', + )) + ) { + return; + } + try { + ctx.modelRegistry.authStorage.logout(account.provider); + account.label = 'Account 1'; + saveConfig(config); + registerAccount(account); + } catch (error) { + ctx.ui.notify( + `Could not log out of Grok CLI: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function fallbackBeforeRemoval( + pi: ExtensionAPI, + ctx: ExtensionCommandContext, + config: GrokCliConfig, + removed: GrokCliAccount, +) { + const needsFallback = + config.accounts.selectedProvider === removed.provider || + ctx.model?.provider === removed.provider; + if (!needsFallback) return undefined; + const candidates = config.accounts.items.filter( + (account) => account.provider !== removed.provider && hasAccountAuth(ctx, account.provider), + ); + for (const account of candidates) { + const target = + ctx.modelRegistry.find(account.provider, ctx.model?.id ?? DEFAULT_GROK_MODEL) ?? + ctx.modelRegistry.find(account.provider, DEFAULT_GROK_MODEL); + if (target && (await pi.setModel(target))) return account.provider; + } + return candidates.length ? null : undefined; +} + +async function removeAlias( + pi: ExtensionAPI, + ctx: ExtensionCommandContext, + config: GrokCliConfig, + account: GrokCliAccount, + deferredRemovals: Set, +) { + if ( + !(await ctx.ui.confirm( + `Remove “${account.label}”?`, + 'This removes its saved OAuth login from Pi and deletes the account slot.', + )) + ) { + return; + } + const fallback = await fallbackBeforeRemoval(pi, ctx, config, account); + if (fallback === null) { + ctx.ui.notify('Could not switch to another authenticated Grok CLI account.', 'error'); + return; + } + try { + ctx.modelRegistry.authStorage.logout(account.provider); + config.accounts.items = config.accounts.items.filter( + (candidate) => candidate.provider !== account.provider, + ); + config.accounts.selectedProvider = fallback ?? GROK_CLI_PROVIDER; + saveConfig(config); + if (ctx.model?.provider === account.provider && fallback === undefined) { + deferredRemovals.add(account.provider); + ctx.ui.notify( + 'Grok CLI account removed. The current model now requires login and will disappear after you switch models.', + 'warning', + ); + return; + } + pi.unregisterProvider(account.provider); + } catch (error) { + ctx.ui.notify( + `Could not remove Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function manageAccount( + pi: ExtensionAPI, + ctx: ExtensionCommandContext, + config: GrokCliConfig, + account: GrokCliAccount, + registerAccount: RegisterAccount, + deferredRemovals: Set, +) { + const environment = + account.provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN); + const loginLabel = hasStoredAuth(ctx, account.provider) ? 'Log in again' : 'Log in'; + const removeLabel = account.provider === GROK_CLI_PROVIDER ? 'Log out' : 'Log out and remove'; + const options = environment + ? ['Rename', 'Environment token instructions', 'Back'] + : ['Rename', loginLabel, removeLabel, 'Back']; + const action = await ctx.ui.select(`Manage “${account.label}”:`, options); + if (action === 'Rename') { + await renameAccount(ctx, config, account, registerAccount); + return; + } + if (action === loginLabel) { + prefillLogin(ctx, account.provider); + return; + } + if (action === 'Environment token instructions') { + ctx.ui.notify( + 'Unset GROK_CLI_OAUTH_TOKEN and restart Pi to remove the environment token.', + 'info', + ); + return; + } + if (action === 'Log out') { + await removeBaseAccount(ctx, config, account, registerAccount); + return; + } + if (action === 'Log out and remove') { + await removeAlias(pi, ctx, config, account, deferredRemovals); + } +} + +export function resolveGrokProvider(ctx: Pick) { + const config = loadConfig().config; + if ( + isGrokCliProvider(ctx.model?.provider) && + config.accounts.items.some((account) => account.provider === ctx.model?.provider) + ) { + return ctx.model?.provider ?? GROK_CLI_PROVIDER; + } + return config.accounts.selectedProvider; +} + +export async function resolveGrokToken( + ctx: Pick, +): Promise { + const provider = resolveGrokProvider(ctx); + if (provider === GROK_CLI_PROVIDER && process.env.GROK_CLI_OAUTH_TOKEN) { + return process.env.GROK_CLI_OAUTH_TOKEN; + } + try { + return await ctx.modelRegistry.getApiKeyForProvider(provider); + } catch { + return undefined; + } +} + +export function registerAccountManagement(pi: ExtensionAPI, registerAccount: RegisterAccount) { + const deferredRemovals = new Set(); + + pi.registerCommand('grok-cli-accounts', { + description: 'Add, switch, rename, relogin, or remove Grok CLI accounts', + handler: async (_args, ctx) => { + const config = copyConfig(); + const rows = accountRows(ctx, config); + const choice = await ctx.ui.select('Grok CLI accounts:', [ + ...rows.map(({ row }) => row), + '+ Add account', + 'Manage accounts', + ]); + if (choice === '+ Add account') { + await addAccount(ctx, config, registerAccount); + return; + } + if (choice === 'Manage accounts') { + const latest = copyConfig(); + const managementRows = accountRows(ctx, latest); + const selected = await ctx.ui.select( + 'Manage Grok CLI account:', + managementRows.map(({ row }) => row), + ); + const account = managementRows.find(({ row }) => row === selected)?.account; + if (account) { + await manageAccount(pi, ctx, latest, account, registerAccount, deferredRemovals); + } + return; + } + const account = rows.find(({ row }) => row === choice)?.account; + if (!account) return; + if (!hasAccountAuth(ctx, account.provider)) { + prefillLogin(ctx, account.provider); + return; + } + await switchAccount(pi, ctx, config, account); + }, + }); + + return { + handleModelSelect(event: { + model: { provider: string }; + previousModel?: { provider: string }; + }) { + if (event.previousModel && deferredRemovals.delete(event.previousModel.provider)) { + pi.unregisterProvider(event.previousModel.provider); + } + if (!isGrokCliProvider(event.model.provider)) return; + const config = copyConfig(); + if (!config.accounts.items.some((account) => account.provider === event.model.provider)) + return; + if (config.accounts.selectedProvider === event.model.provider) return; + config.accounts.selectedProvider = event.model.provider; + saveConfig(config); + }, + }; +} diff --git a/src/provider/register.ts b/src/provider/register.ts index 08ea7dd..fae1928 100644 --- a/src/provider/register.ts +++ b/src/provider/register.ts @@ -8,13 +8,15 @@ import type { import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import * as oauth from '../auth/oauth.js'; import { getBaseUrl, type XaiOAuthCredentials } from '../auth/oauth.js'; -import { migrateLegacyConfig } from '../config.js'; +import { type GrokCliAccount, loadConfig, migrateLegacyConfig } from '../config.js'; import { registerImagineFeature } from '../imagine/register.js'; import { type GrokCliModelConfig, resolveModels } from '../models/catalog.js'; import { sanitizePayload } from '../payload/sanitize.js'; import { registerGrokTools } from '../tools/register.js'; import { bindLivePiWebAccess, ensureWebSearchDelegate } from '../tools/webSearchDelegate.js'; import { registerVisionFeature } from '../vision/register.js'; +import { isGrokCliProvider, registerAccountManagement } from './accounts.js'; +import { registerExhaustionRotation } from './rotation.js'; import { grokCliModelHeaders } from './stream.js'; import { handoffGrokTools, restoreGrokTools, syncGrokTools } from './toolScope.js'; import { registerUsageCommand } from './usage.js'; @@ -24,54 +26,60 @@ export default function registerGrokCli(pi: ExtensionAPI) { const baseUrl = getBaseUrl(); const models = resolveModels(); - const oauthProvider = { - name: 'Grok CLI', - usesCallbackServer: true, - - async login(callbacks: OAuthLoginCallbacks): Promise { - return oauth.login(callbacks); - }, - - async refreshToken(credentials: OAuthCredentials): Promise { - return oauth.refresh(credentials); - }, - - getApiKey(credentials: OAuthCredentials): string { - return credentials.access; - }, - - modifyModels(models: Model[], credentials: OAuthCredentials) { - const effectiveBaseUrl = String( - (credentials as XaiOAuthCredentials).baseUrl ?? getBaseUrl(), - ).replace(/\/+$/, ''); - - return models.map((model) => - model.provider === 'grok-cli' ? { ...model, baseUrl: effectiveBaseUrl } : model, - ); - }, - } satisfies Omit; - - pi.registerProvider('grok-cli', { - name: 'Grok CLI', - baseUrl, - apiKey: '$GROK_CLI_OAUTH_TOKEN', - api: 'openai-responses', - models: models.map((m: GrokCliModelConfig) => ({ - id: m.id, - name: m.name, - reasoning: m.reasoning, - thinkingLevelMap: m.thinkingLevelMap, - input: m.input, - cost: m.cost, - contextWindow: m.contextWindow, - maxTokens: m.maxTokens, - // Carried as model.headers so the version-gate headers reach the server on - // every request even when the API-provider registry reverts to pi-ai's - // built-in openai-responses handler (see grokCliModelHeaders). - headers: grokCliModelHeaders(m.id), - })), - oauth: oauthProvider, - }); + const registerAccount = (account: GrokCliAccount) => { + const oauthProvider = { + name: `Grok CLI — ${account.label}`, + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise { + return oauth.login(callbacks); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return oauth.refresh(credentials); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, + + modifyModels(registeredModels: Model[], credentials: OAuthCredentials) { + const effectiveBaseUrl = String( + (credentials as XaiOAuthCredentials).baseUrl ?? getBaseUrl(), + ).replace(/\/+$/, ''); + + return registeredModels.map((model) => + model.provider === account.provider ? { ...model, baseUrl: effectiveBaseUrl } : model, + ); + }, + } satisfies Omit; + + pi.registerProvider(account.provider, { + name: `Grok CLI — ${account.label}`, + baseUrl, + ...(account.provider === 'grok-cli' ? { apiKey: '$GROK_CLI_OAUTH_TOKEN' } : {}), + api: 'openai-responses', + models: models.map((m: GrokCliModelConfig) => ({ + id: m.id, + name: m.name, + reasoning: m.reasoning, + thinkingLevelMap: m.thinkingLevelMap, + input: m.input, + cost: m.cost, + contextWindow: m.contextWindow, + maxTokens: m.maxTokens, + // Carried as model.headers so the version-gate headers reach the server on + // every request even when the API-provider registry reverts to pi-ai's + // built-in openai-responses handler (see grokCliModelHeaders). + headers: grokCliModelHeaders(m.id), + })), + oauth: oauthProvider, + }); + }; + + for (const account of loadConfig().config.accounts.items) registerAccount(account); + const accountManagement = registerAccountManagement(pi, registerAccount); + registerExhaustionRotation(pi); const { webSearchRegistered } = registerGrokTools(pi); registerImagineFeature(pi); @@ -80,6 +88,7 @@ export default function registerGrokCli(pi: ExtensionAPI) { syncGrokTools(pi, model, { captureDelete, webSearchRegistered }); pi.on('model_select', (event) => { + accountManagement.handleModelSelect(event); syncTools(event.model); }); @@ -118,12 +127,12 @@ export default function registerGrokCli(pi: ExtensionAPI) { }); pi.on('before_provider_headers', (event, ctx) => { - if (ctx.model?.provider !== 'grok-cli') return; + if (!isGrokCliProvider(ctx.model?.provider)) return; event.headers['x-grok-conv-id'] = ctx.sessionManager.getSessionId(); }); pi.on('before_provider_request', (event, ctx) => { - if (ctx.model?.provider !== 'grok-cli') return; + if (!isGrokCliProvider(ctx.model?.provider)) return; const modelId = ctx.model?.id ?? ''; const sessionId = ctx.sessionManager?.getSessionId(); diff --git a/src/provider/rotation.ts b/src/provider/rotation.ts new file mode 100644 index 0000000..0d2b866 --- /dev/null +++ b/src/provider/rotation.ts @@ -0,0 +1,144 @@ +import type { AssistantMessage } from '@earendil-works/pi-ai'; +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { loadConfig, saveConfig } from '../config.js'; +import { DEFAULT_GROK_MODEL, GROK_CLI_PROVIDER, isGrokCliProvider } from './accounts.js'; + +export const EXHAUSTED_BALANCE_ERROR = + 'OpenAI API error (402): 402 "Grok Build usage balance exhausted"'; +export const ROTATION_CONTINUATION = + 'Continue the previous request using the newly selected Grok account. Do not repeat completed work.'; + +type FailedResponse = { + model: string; + provider: string; +}; + +function isExhaustionMessage(message: unknown): message is AssistantMessage { + if (!message || typeof message !== 'object') return false; + const candidate = message as Partial; + return ( + candidate.role === 'assistant' && + isGrokCliProvider(candidate.provider) && + candidate.stopReason === 'error' && + candidate.errorMessage?.trim() === EXHAUSTED_BALANCE_ERROR + ); +} + +function hasAccountAuth(ctx: ExtensionContext, provider: string) { + return ( + ctx.modelRegistry.authStorage.has(provider) || + (provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN)) + ); +} + +function circularProviders(providers: string[], current: string) { + const index = providers.indexOf(current); + if (index < 0) return providers; + return [...providers.slice(index + 1), ...providers.slice(0, index)]; +} + +export function registerExhaustionRotation(pi: ExtensionAPI) { + const exhausted = new Set(); + const unavailable = new Set(); + let pending: FailedResponse | undefined; + let awaitingContinuation = false; + + const clearChain = () => { + exhausted.clear(); + unavailable.clear(); + pending = undefined; + awaitingContinuation = false; + }; + + pi.on('input', (event) => { + if (event.source !== 'extension') clearChain(); + }); + + pi.on('model_select', () => { + if (pending) clearChain(); + }); + + pi.on('message_end', (event) => { + if (!isExhaustionMessage(event.message)) return; + const message = event.message; + const config = loadConfig().config; + if (!config.accounts.items.some((account) => account.provider === message.provider)) { + return; + } + pending = { provider: message.provider, model: message.model }; + exhausted.add(message.provider); + awaitingContinuation = false; + }); + + pi.on('agent_settled', async (_event, ctx) => { + if (!pending) { + if (awaitingContinuation) clearChain(); + return; + } + + const failed = pending; + pending = undefined; + if (ctx.model?.provider !== failed.provider || ctx.model.id !== failed.model) { + clearChain(); + return; + } + + const config = loadConfig().config; + const authenticated = config.accounts.items.filter((account) => + hasAccountAuth(ctx, account.provider), + ); + if (authenticated.length < 2) { + clearChain(); + return; + } + + for (const provider of circularProviders( + config.accounts.items.map((account) => account.provider), + failed.provider, + )) { + if ( + provider === failed.provider || + exhausted.has(provider) || + unavailable.has(provider) || + !authenticated.some((account) => account.provider === provider) + ) { + continue; + } + const model = + ctx.modelRegistry.find(provider, failed.model) ?? + ctx.modelRegistry.find(provider, DEFAULT_GROK_MODEL); + if (!model || !(await pi.setModel(model))) { + unavailable.add(provider); + continue; + } + + const failedLabel = config.accounts.items.find( + (account) => account.provider === failed.provider, + )?.label; + const selected = config.accounts.items.find((account) => account.provider === provider); + if (!failedLabel || !selected) { + unavailable.add(provider); + continue; + } + config.accounts.selectedProvider = provider; + saveConfig(config); + ctx.ui.notify( + `Grok CLI: “${failedLabel}” exhausted; switched to “${selected.label}” and continuing.`, + 'info', + ); + awaitingContinuation = true; + pi.sendUserMessage(ROTATION_CONTINUATION); + return; + } + + if (authenticated.every((account) => exhausted.has(account.provider))) { + ctx.ui.notify('Grok CLI: all logged-in accounts are exhausted.', 'warning'); + } else { + ctx.ui.notify( + 'Grok CLI: no other logged-in account is available for automatic rotation.', + 'warning', + ); + } + clearChain(); + }); +} diff --git a/src/provider/toolScope.ts b/src/provider/toolScope.ts index f04ad1e..e6f1daa 100644 --- a/src/provider/toolScope.ts +++ b/src/provider/toolScope.ts @@ -1,5 +1,6 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { loadConfig } from '../config.js'; +import { isGrokCliProvider } from './accounts.js'; export const SHIM_MODEL_IDS = new Set(['grok-build', 'grok-composer-2.5-fast']); @@ -62,7 +63,7 @@ const toolScopeHandoffs: ToolScopeHandoffStore = globalToolScope.__piGrokCliTool globalToolScope.__piGrokCliToolScopeHandoffs = toolScopeHandoffs; function isLegacyModel(model: ToolModel | undefined) { - return model?.provider === 'grok-cli' && SHIM_MODEL_IDS.has(model.id); + return isGrokCliProvider(model?.provider) && SHIM_MODEL_IDS.has(model?.id ?? ''); } function sameTools(currentTools: string[], nextTools: string[]) { diff --git a/src/provider/usage.ts b/src/provider/usage.ts index 2f8f821..b1d450b 100644 --- a/src/provider/usage.ts +++ b/src/provider/usage.ts @@ -1,14 +1,15 @@ import type { Api, Model } from '@earendil-works/pi-ai'; import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { XaiOAuthError } from '../shared/errors.js'; +import { isGrokCliProvider, resolveGrokProvider, resolveGrokToken } from './accounts.js'; import { fetchBillingUsage, formatQuota } from './billing.js'; export function registerUsageCommand(pi: Pick) { pi.registerCommand('grok-cli-usage', { description: 'Show Grok CLI provider status, quota, and token health', handler: async (_args, ctx) => { - const token = process.env.GROK_CLI_OAUTH_TOKEN; - if (token) { + const provider = resolveGrokProvider(ctx); + if (provider === 'grok-cli' && process.env.GROK_CLI_OAUTH_TOKEN) { ctx.ui.notify( '⚠️ Grok CLI: using GROK_CLI_OAUTH_TOKEN env bypass — no auto-refresh available', 'warning', @@ -17,13 +18,15 @@ export function registerUsageCommand(pi: Pick) try { const registry = ctx.modelRegistry; - const grokModels = registry.getAll().filter((m: Model) => m.provider === 'grok-cli'); + const grokModels = registry + .getAll() + .filter((model: Model) => isGrokCliProvider(model.provider)); if (grokModels.length === 0) { ctx.ui.notify('Grok CLI: no models registered. Run /login grok-cli first.', 'warning'); return; } - const apiKey = token ?? (await registry.getApiKeyForProvider?.('grok-cli')); + const apiKey = await resolveGrokToken(ctx); if (!apiKey) { ctx.ui.notify(formatQuota(undefined).join('\n'), 'info'); return; diff --git a/src/vision/describe.ts b/src/vision/describe.ts index e29b136..f830111 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -2,6 +2,7 @@ import type { ImageContent, TextContent } from '@earendil-works/pi-ai'; import type { ExtensionContext, ToolResultEvent } from '@earendil-works/pi-coding-agent'; import { getBaseUrl } from '../auth/oauth.js'; import { loadConfig, type VisionConfig } from '../config.js'; +import { resolveGrokToken } from '../provider/accounts.js'; import { grokCliModelHeaders } from '../provider/stream.js'; import { getCachePath, @@ -240,15 +241,6 @@ function replaceImagesWithText( return skipped ? [...parts, skipped] : parts; } -async function resolveApiKey(ctx: ExtensionContext): Promise { - if (process.env.GROK_CLI_OAUTH_TOKEN) return process.env.GROK_CLI_OAUTH_TOKEN; - try { - return await ctx.modelRegistry.getApiKeyForProvider('grok-cli'); - } catch { - return undefined; - } -} - async function describeSingle( img: ImageContent, index: number, @@ -321,7 +313,7 @@ export async function handleReadResult( const selected = images.slice(0, config.maxImages); const skipped = images.length - selected.length; - const apiKey = await resolveApiKey(ctx); + const apiKey = await resolveGrokToken(ctx); if (!apiKey) { ctx.ui.notify( '[grok-cli-vision] No API key — run /login grok-cli or set GROK_CLI_OAUTH_TOKEN', diff --git a/tests/config.test.ts b/tests/config.test.ts index ee31ad8..2be0475 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -18,6 +18,11 @@ import { import { useTempHome } from './vision/helpers.js'; const setupHome = useTempHome(); +const FEATURE_CONFIG = { + ...DEFAULT_CONFIG, + imagine: { enabled: false }, + vision: { ...DEFAULT_CONFIG.vision, maxImages: 2 }, +}; function paths(home: string) { return { @@ -36,6 +41,11 @@ function writeJson(path: string, value: unknown) { writeFileSync(path, JSON.stringify(value)); } +function writeConsolidatedConfig(home: string, value: unknown) { + mkdirSync(paths(home).pi, { recursive: true }); + writeJson(paths(home).config, value); +} + function withoutDirectoryWrites(directory: string, action: () => T) { chmodSync(directory, 0o500); try { @@ -57,20 +67,10 @@ describe('Grok CLI configuration', () => { it('atomically saves and loads the versioned configuration', () => { const home = setupHome(); - saveConfig({ - ...DEFAULT_CONFIG, - imagine: { enabled: false }, - vision: { ...DEFAULT_CONFIG.vision, maxImages: 2 }, - }); + saveConfig(FEATURE_CONFIG); - expect(loadConfig()).toEqual({ - config: { - ...DEFAULT_CONFIG, - imagine: { enabled: false }, - vision: { ...DEFAULT_CONFIG.vision, maxImages: 2 }, - }, - }); - expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(1); + expect(loadConfig()).toEqual({ config: FEATURE_CONFIG }); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(2); expect(readdirSync(paths(home).pi).filter((name) => name.endsWith('.tmp'))).toEqual([]); }); @@ -78,7 +78,7 @@ describe('Grok CLI configuration', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); writeJson(paths(home).config, { - version: 1, + version: 2, vision: { enabled: 'yes', maxImages: -1, cacheEnabled: false }, }); @@ -96,14 +96,14 @@ describe('Grok CLI configuration', () => { it('falls back to legacy settings without overwriting an unsupported version', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, { version: 2, imagine: { enabled: false } }); + writeJson(paths(home).config, { version: 3, imagine: { enabled: false } }); writeJson(paths(home).imagine, { enabled: false }); const migration = migrateLegacyConfig(); expect(migration.warning).toMatch(/Unsupported.*version/); expect(loadConfig().config.imagine.enabled).toBe(false); - expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(2); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(3); expect(existsSync(paths(home).imagine)).toBe(true); }); @@ -125,7 +125,7 @@ describe('Grok CLI configuration', () => { expect(migrateLegacyConfig()).toEqual({}); expect(loadConfig().config).toEqual({ - version: 1, + ...DEFAULT_CONFIG, imagine: { enabled: false }, vision: { enabled: false, @@ -147,6 +147,74 @@ describe('Grok CLI configuration', () => { } }); + it('migrates version 1 in place while preserving existing feature settings', () => { + const home = setupHome(); + writeConsolidatedConfig(home, { ...FEATURE_CONFIG, version: 1 }); + + expect(loadConfig()).toEqual({ config: FEATURE_CONFIG }); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(1); + + expect(migrateLegacyConfig()).toEqual({}); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8'))).toEqual(FEATURE_CONFIG); + }); + + it('normalizes account metadata and reports discarded entries', () => { + const home = setupHome(); + writeConsolidatedConfig(home, { + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: 2, + selectedProvider: 'missing', + items: [ + { provider: 'grok-cli', label: ' Personal ' }, + { provider: 'grok-cli-2', label: 'Work' }, + { provider: 'grok-cli-2', label: 'Duplicate provider' }, + { provider: 'grok-cli-3', label: 'work' }, + { provider: 'grok-cli-10', label: 'Account 10' }, + { provider: 'other', label: 'Other' }, + { provider: 'grok-cli-4', label: 'bad\nlabel' }, + ], + }, + }); + + const loaded = loadConfig(); + + expect(loaded.config.accounts).toEqual({ + nextAccountNumber: 11, + selectedProvider: 'grok-cli', + items: [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + { provider: 'grok-cli-10', label: 'Account 10' }, + ], + }); + expect(loaded.warning).toContain('accounts'); + }); + + it('reserves the permanent base label when base metadata is missing', () => { + const home = setupHome(); + writeConsolidatedConfig(home, { + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: 2, + selectedProvider: 'grok-cli-2', + items: [ + { provider: 'grok-cli-2', label: 'Account 1' }, + { provider: 'grok-cli-3', label: 'Work' }, + ], + }, + }); + + expect(loadConfig().config.accounts).toEqual({ + nextAccountNumber: 4, + selectedProvider: 'grok-cli', + items: [ + { provider: 'grok-cli', label: 'Account 1' }, + { provider: 'grok-cli-3', label: 'Work' }, + ], + }); + }); + it.each(['grok-cli', 'all'])('migrates released Imagine scope %s to enabled', (scope) => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); diff --git a/tests/imagine/register.test.ts b/tests/imagine/register.test.ts index 6eff825..4e2d0f7 100644 --- a/tests/imagine/register.test.ts +++ b/tests/imagine/register.test.ts @@ -4,7 +4,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; import { registerImagineFeature } from '../../src/imagine/register.js'; -import { useTempHome } from '../vision/helpers.js'; +import { saveTestAccounts, useTempHome } from '../vision/helpers.js'; import { imagineDependencies } from './helpers.js'; const setupHome = useTempHome(); @@ -20,6 +20,7 @@ function setup(token?: string, initialActiveTools: readonly string[] = ['read']) const setActiveTools = vi.fn((toolsToActivate: string[]) => { activeTools = [...toolsToActivate]; }); + const getApiKeyForProvider = vi.fn(async () => token); registerImagineFeature( { registerCommand(name: string, command: unknown) { @@ -46,7 +47,7 @@ function setup(token?: string, initialActiveTools: readonly string[] = ['read']) cwd: '/project', model: { provider: 'openai' }, ui: { notify }, - modelRegistry: { getApiKeyForProvider: vi.fn(async () => token) }, + modelRegistry: { getApiKeyForProvider }, sessionManager: { getSessionDir: () => '/sessions', getSessionId: () => 'id', @@ -67,6 +68,7 @@ function setup(token?: string, initialActiveTools: readonly string[] = ['read']) home, setActiveTools, getActiveTools: () => activeTools, + getApiKeyForProvider, }; } @@ -117,6 +119,18 @@ describe('registerImagineFeature command', () => { ); }); + it('uses the last selected Grok alias while a non-Grok model is active', async () => { + const extension = setup('work-token'); + saveTestAccounts(); + + await extension.commands.get('grok-cli-imagine')?.handler('cat', extension.context); + + expect(extension.getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); + expect(extension.generate).toHaveBeenCalledWith( + expect.objectContaining({ token: 'work-token' }), + ); + }); + it('keeps a successful JPEG when PNG preview conversion fails', async () => { const extension = setup('token'); extension.convert.mockResolvedValueOnce(null); diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts new file mode 100644 index 0000000..ff6642b --- /dev/null +++ b/tests/provider/accounts.test.ts @@ -0,0 +1,444 @@ +import { + AuthStorage, + type ExtensionAPI, + type ExtensionContext, +} from '@earendil-works/pi-coding-agent'; +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import { + isGrokCliProvider, + registerAccountManagement, + resolveGrokProvider, + resolveGrokToken, +} from '../../src/provider/accounts.js'; +import { oauthCredential, TEST_ACCOUNTS, useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); + +function configureAccounts( + selectedProvider = 'grok-cli', + items = TEST_ACCOUNTS, + nextAccountNumber = 3, +) { + setupHome(); + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { nextAccountNumber, selectedProvider, items }, + }); +} + +async function runAccountsCommand(extension: ReturnType) { + await extension.commands.get('grok-cli-accounts')?.handler('', extension.context); +} + +function setup( + options: { + auth?: Record>; + confirms?: boolean[]; + inputs?: (string | undefined)[]; + model?: { provider: string; id: string }; + preserveHome?: boolean; + selections?: (string | undefined)[]; + setModel?: boolean[]; + } = {}, +) { + if (!options.preserveHome) setupHome(); + const commands = new Map Promise }>(); + const registerAccount = vi.fn(); + const unregisterProvider = vi.fn(); + const setModelResults = [...(options.setModel ?? [true])]; + const setModel = vi.fn(async () => setModelResults.shift() ?? true); + const pi = { + registerCommand(name: string, command: unknown) { + commands.set(name, command as { handler: (args: string, ctx: unknown) => Promise }); + }, + setModel, + unregisterProvider, + } as unknown as ExtensionAPI; + const accountManagement = registerAccountManagement(pi, registerAccount); + + const authStorage = AuthStorage.inMemory(options.auth); + const selections = [...(options.selections ?? [])]; + const inputs = [...(options.inputs ?? [])]; + const confirms = [...(options.confirms ?? [])]; + const notify = vi.fn(); + const setEditorText = vi.fn(); + const models = new Map(); + for (const provider of ['grok-cli', 'grok-cli-2', 'grok-cli-3']) { + for (const id of ['grok-build', 'grok-composer-2.5-fast']) { + models.set(`${provider}/${id}`, { provider, id }); + } + } + const context = { + model: options.model, + modelRegistry: { + authStorage, + find: (provider: string, id: string) => models.get(`${provider}/${id}`), + getApiKeyForProvider: (provider: string) => authStorage.getApiKey(provider), + }, + ui: { + confirm: vi.fn(async () => confirms.shift() ?? false), + input: vi.fn(async () => inputs.shift()), + notify, + select: vi.fn(async () => selections.shift()), + setEditorText, + }, + }; + + return { + authStorage, + accountManagement, + commands, + context, + notify, + registerAccount, + setEditorText, + setModel, + unregisterProvider, + }; +} + +describe('Grok CLI account helpers', () => { + it('recognizes only the base provider and valid numbered aliases', () => { + expect(isGrokCliProvider('grok-cli')).toBe(true); + expect(isGrokCliProvider('grok-cli-2')).toBe(true); + expect(isGrokCliProvider('grok-cli-10')).toBe(true); + expect(isGrokCliProvider('grok-cli-1')).toBe(false); + expect(isGrokCliProvider('grok-cli-work')).toBe(false); + }); + + it('resolves the current Grok alias or the persisted selection for other models', async () => { + configureAccounts('grok-cli-2'); + const getApiKeyForProvider = vi.fn(async (provider: string) => `${provider}-token`); + + expect( + resolveGrokProvider({ + model: { provider: 'grok-cli', id: 'grok-build' }, + } as unknown as Pick), + ).toBe('grok-cli'); + expect( + resolveGrokProvider({ + model: { provider: 'openai', id: 'gpt-5' }, + } as unknown as Pick), + ).toBe('grok-cli-2'); + expect( + await resolveGrokToken({ + model: { provider: 'openai', id: 'gpt-5' }, + modelRegistry: { getApiKeyForProvider }, + } as unknown as Pick), + ).toBe('grok-cli-2-token'); + expect(getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); + }); +}); + +describe('/grok-cli-accounts', () => { + it('adds a labeled alias and pre-fills Pi native login', async () => { + const extension = setup({ selections: ['+ Add account'], inputs: ['Work'] }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts).toEqual({ + nextAccountNumber: 3, + selectedProvider: 'grok-cli', + items: [ + { provider: 'grok-cli', label: 'Account 1' }, + { provider: 'grok-cli-2', label: 'Work' }, + ], + }); + expect(extension.registerAccount).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + label: 'Work', + }); + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + }); + + it('uses a stable default label and never reuses removed numbers', async () => { + configureAccounts('grok-cli', [{ provider: 'grok-cli', label: 'Account 1' }], 4); + const extension = setup({ preserveHome: true, selections: ['+ Add account'], inputs: [''] }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-4', + label: 'Account 4', + }); + expect(loadConfig().config.accounts.nextAccountNumber).toBe(5); + }); + + it('switches a logged-in account while preserving the current Grok model', async () => { + configureAccounts(); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + model: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' }, + preserveHome: true, + selections: ['Work — Logged in'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-composer-2.5-fast', + }); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + }); + + it('uses grok-build when switching from a non-Grok model', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + model: { provider: 'openai', id: 'gpt-5' }, + preserveHome: true, + selections: ['Work — Logged in'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-build', + }); + }); + + it('prefills login instead of switching an unauthenticated account', async () => { + configureAccounts(); + const extension = setup({ preserveHome: true, selections: ['Work — Login required'] }); + + await runAccountsCommand(extension); + + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + expect(extension.setModel).not.toHaveBeenCalled(); + }); + + it('does not persist a switch when Pi rejects the model change', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + preserveHome: true, + selections: ['Work — Logged in'], + setModel: [false], + }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + expect(extension.notify).toHaveBeenCalledWith( + 'Could not switch to “Work”; authentication is unavailable.', + 'error', + ); + }); + + it('rejects duplicate, controlled, and overlong labels before adding an account', async () => { + configureAccounts(); + const extension = setup({ + preserveHome: true, + selections: ['+ Add account'], + inputs: [' work ', 'bad\nlabel', 'bad\u009blabel', 'x'.repeat(41), 'Client'], + }); + + await runAccountsCommand(extension); + + expect(extension.notify.mock.calls.map(([message]) => message)).toEqual([ + 'An account named “work” already exists.', + 'Account labels cannot contain control characters.', + 'Account labels cannot contain control characters.', + 'Account labels must be 40 characters or fewer.', + ]); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-3', + label: 'Client', + }); + }); + + it('renames an account and updates its provider display registration', async () => { + configureAccounts(); + const extension = setup({ + selections: ['Manage accounts', 'Work — Login required', 'Rename'], + inputs: ['Client'], + preserveHome: true, + }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items[1]?.label).toBe('Client'); + expect(extension.registerAccount).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + label: 'Client', + }); + }); + + it('logs out and removes an inactive alias after confirmation', async () => { + configureAccounts(); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + confirms: [true], + model: { provider: 'grok-cli', id: 'grok-build' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Logged in', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.authStorage.has('grok-cli-2')).toBe(false); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + ]); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + }); + + it('leaves an alias untouched when removal confirmation is cancelled', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + confirms: [false], + preserveHome: true, + selections: ['Manage accounts', 'Work — Logged in', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.authStorage.has('grok-cli-2')).toBe(true); + expect(loadConfig().config.accounts.items).toHaveLength(2); + expect(extension.unregisterProvider).not.toHaveBeenCalled(); + }); + + it('switches away before removing the active alias', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + confirms: [true], + model: { provider: 'grok-cli-2', id: 'grok-composer-2.5-fast' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Active', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli', + id: 'grok-composer-2.5-fast', + }); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + }); + + it('defers unregistering an active alias when no authenticated fallback exists', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + confirms: [true], + model: { provider: 'grok-cli-2', id: 'grok-build' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Active', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.unregisterProvider).not.toHaveBeenCalled(); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + ]); + + extension.accountManagement.handleModelSelect({ + model: { provider: 'openai' }, + previousModel: { provider: 'grok-cli-2' }, + }); + + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + }); + + it('prefills native Pi relogin from account management', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Logged in', 'Log in again'], + }); + + await runAccountsCommand(extension); + + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + }); + + it('keeps the base slot and resets its label when logging out', async () => { + configureAccounts('grok-cli', [{ provider: 'grok-cli', label: 'Personal' }], 2); + const extension = setup({ + auth: { 'grok-cli': oauthCredential('personal') }, + confirms: [true], + preserveHome: true, + selections: ['Manage accounts', 'Personal — Active', 'Log out'], + }); + + await runAccountsCommand(extension); + + expect(extension.authStorage.has('grok-cli')).toBe(false); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Account 1' }, + ]); + expect(extension.unregisterProvider).not.toHaveBeenCalled(); + }); + + it('keeps the selected alias when logging out of the inactive base account', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + confirms: [true], + preserveHome: true, + selections: ['Manage accounts', 'Personal — Logged in', 'Log out'], + }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + expect(loadConfig().config.accounts.items[0]?.label).toBe('Account 1'); + }); + + it('explains that a base environment token cannot be logged out from Pi', async () => { + const original = process.env.GROK_CLI_OAUTH_TOKEN; + process.env.GROK_CLI_OAUTH_TOKEN = 'environment-token'; + try { + const extension = setup({ + selections: [ + 'Manage accounts', + 'Account 1 — Active (environment)', + 'Environment token instructions', + ], + }); + + await runAccountsCommand(extension); + + expect(extension.notify).toHaveBeenCalledWith( + 'Unset GROK_CLI_OAUTH_TOKEN and restart Pi to remove the environment token.', + 'info', + ); + expect(extension.authStorage.has('grok-cli')).toBe(false); + } finally { + if (original === undefined) delete process.env.GROK_CLI_OAUTH_TOKEN; + else process.env.GROK_CLI_OAUTH_TOKEN = original; + } + }); + + it('persists a Grok alias selected through Pi model controls', () => { + configureAccounts(); + const extension = setup({ preserveHome: true }); + + extension.accountManagement.handleModelSelect({ + model: { provider: 'grok-cli-2' }, + previousModel: { provider: 'grok-cli' }, + }); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + }); +}); diff --git a/tests/provider/package.test.ts b/tests/provider/package.test.ts index c7461c9..a766e0d 100644 --- a/tests/provider/package.test.ts +++ b/tests/provider/package.test.ts @@ -62,8 +62,10 @@ describe('repository layout', () => { 'src/index.ts', 'src/models/catalog.ts', 'src/payload/sanitize.ts', + 'src/provider/accounts.ts', 'src/provider/billing.ts', 'src/provider/register.ts', + 'src/provider/rotation.ts', 'src/provider/stream.ts', 'src/provider/toolScope.ts', 'src/provider/usage.ts', diff --git a/tests/provider/register.test.ts b/tests/provider/register.test.ts index 2872037..953373a 100644 --- a/tests/provider/register.test.ts +++ b/tests/provider/register.test.ts @@ -4,9 +4,11 @@ import { join } from 'node:path'; import type { Api, Model, OAuthCredentials, OAuthProviderInterface } from '@earendil-works/pi-ai'; import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, saveConfig } from '../../src/config.js'; import { GROK_SHIM_TOOL_NAMES } from '../../src/tools/register.js'; import * as webSearchDelegate from '../../src/tools/webSearchDelegate.js'; import { plainTheme as theme } from '../tools/toolTestHelpers.js'; +import { saveTestAccounts } from '../vision/helpers.js'; const { mockPiWebAccessInstalled } = vi.hoisted(() => ({ mockPiWebAccessInstalled: vi.fn(() => true), @@ -345,21 +347,35 @@ describe('Grok CLI status command', () => { it('uses the registered provider token when no env token is set', async () => { delete process.env.GROK_CLI_OAUTH_TOKEN; setupHome(); + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: 3, + selectedProvider: 'grok-cli-2', + items: [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + ], + }, + }); const fetchMock = vi.fn(async () => billingResponse(4000, 100, '2026-07-01T00:00:00+00:00'), ); globalThis.fetch = fetchMock; const extension = await setupExtension(); const notify = vi.fn(); + const getApiKeyForProvider = vi.fn(async () => 'provider-token'); await extension.commands.get('grok-cli-usage')?.handler([], { ...statusContext(notify), modelRegistry: { ...statusContext(notify).modelRegistry, - getApiKeyForProvider: async () => 'provider-token', + getAll: () => [{ provider: 'grok-cli-2', id: 'grok-build' }], + getApiKeyForProvider, }, }); + expect(getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: 'Bearer provider-token', }); @@ -526,7 +542,7 @@ describe('Grok CLI provider registration', () => { const extension = await setupExtension(); const provider = extension.providers.get('grok-cli'); - expect(provider?.name).toBe('Grok CLI'); + expect(provider?.name).toBe('Grok CLI — Account 1'); expect(provider?.api).toBe('openai-responses'); expect(provider?.apiKey).toBe('$GROK_CLI_OAUTH_TOKEN'); expect(provider?.streamSimple).toBeUndefined(); @@ -558,6 +574,41 @@ describe('Grok CLI provider registration', () => { ]); }); + it('registers configured aliases with independent OAuth keys and labeled names', async () => { + saveTestAccounts(); + + const extension = await setupExtension(); + const base = extension.providers.get('grok-cli'); + const alias = extension.providers.get('grok-cli-2'); + + expect(base?.name).toBe('Grok CLI — Personal'); + expect(base?.apiKey).toBe('$GROK_CLI_OAUTH_TOKEN'); + expect(alias?.name).toBe('Grok CLI — Work'); + expect(alias?.apiKey).toBeUndefined(); + expect(alias?.models?.map((model) => model.id)).toEqual(base?.models?.map((model) => model.id)); + expect( + alias?.oauth?.modifyModels?.( + [ + { provider: 'grok-cli', id: 'grok-build', baseUrl: 'base' } as Model, + { provider: 'grok-cli-2', id: 'grok-build', baseUrl: 'old' } as Model, + ], + { + access: 'access-token', + refresh: 'refresh-token', + expires: 123, + baseUrl: 'https://example.invalid/work///', + } as OAuthCredentials, + ), + ).toEqual([ + { provider: 'grok-cli', id: 'grok-build', baseUrl: 'base' }, + { + provider: 'grok-cli-2', + id: 'grok-build', + baseUrl: 'https://example.invalid/work', + }, + ]); + }); + it('adds conversation affinity headers only for Grok requests', async () => { const extension = await setupExtension(); const grokEvent = { headers: { existing: 'keep' } as Record }; @@ -579,6 +630,14 @@ describe('Grok CLI provider registration', () => { }); expect(openAiEvent.headers).toEqual({ existing: 'keep' }); + + const aliasEvent = { headers: {} as Record }; + extension.handlers.get('before_provider_headers')?.(aliasEvent, { + ...contextForModel('grok-cli-2'), + sessionManager: { getSessionId: () => 'session-alias' }, + }); + + expect(aliasEvent.headers).toEqual({ 'x-grok-conv-id': 'session-alias' }); }); it('sanitizes Grok provider requests with the current session id', async () => { @@ -603,6 +662,23 @@ describe('Grok CLI provider registration', () => { instructions: 'system instruction', prompt_cache_key: 'session-123', }); + + const aliasResult = extension.handlers.get('before_provider_request')?.( + { payload: { input: [{ role: 'system', content: 'alias instruction' }] } }, + { + cwd: process.cwd(), + model: { provider: 'grok-cli-2', id: 'grok-build' }, + modelRegistry: { getAll: () => [] }, + sessionManager: { getSessionId: () => 'session-alias' }, + ui: { notify: vi.fn() }, + }, + ); + + expect(aliasResult).toEqual({ + input: [], + instructions: 'alias instruction', + prompt_cache_key: 'session-alias', + }); }); it('leaves non-Grok provider requests untouched', async () => { diff --git a/tests/provider/rotation.test.ts b/tests/provider/rotation.test.ts new file mode 100644 index 0000000..2210697 --- /dev/null +++ b/tests/provider/rotation.test.ts @@ -0,0 +1,277 @@ +import { AuthStorage, type ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import { + EXHAUSTED_BALANCE_ERROR, + ROTATION_CONTINUATION, + registerExhaustionRotation, +} from '../../src/provider/rotation.js'; +import { oauthCredential, useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); +const THREE_ACCOUNTS = [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + { provider: 'grok-cli-3', label: 'Client' }, +]; + +function setup( + options: { + accounts?: { provider: string; label: string }[]; + auth?: string[]; + current?: { provider: string; id: string }; + missingModels?: string[]; + setModel?: boolean[]; + } = {}, +) { + setupHome(); + const accounts = options.accounts ?? [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + ]; + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: accounts.length + 1, + selectedProvider: options.current?.provider ?? 'grok-cli', + items: accounts, + }, + }); + const handlers = new Map unknown)[]>(); + const authStorage = AuthStorage.inMemory( + Object.fromEntries( + (options.auth ?? accounts.map((account) => account.provider)).map((provider) => [ + provider, + oauthCredential(`${provider}-token`), + ]), + ), + ); + const model = options.current ?? { provider: 'grok-cli', id: 'grok-build' }; + const context = { + model: { ...model }, + modelRegistry: { + authStorage, + find: (provider: string, id: string) => + options.missingModels?.includes(`${provider}/${id}`) ? undefined : { provider, id }, + }, + ui: { notify: vi.fn() }, + }; + const setModelResults = [...(options.setModel ?? [])]; + const setModel = vi.fn(async (nextModel: { provider: string; id: string }) => { + const result = setModelResults.shift() ?? true; + if (result) context.model = nextModel; + return result; + }); + const sendUserMessage = vi.fn(); + const pi = { + on(event: string, handler: (event: unknown, ctx: unknown) => unknown) { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + sendUserMessage, + setModel, + } as unknown as ExtensionAPI; + registerExhaustionRotation(pi); + + return { + context, + handlers, + notify: context.ui.notify, + sendUserMessage, + setModel, + async emit(event: string, data: unknown = { type: event }) { + for (const handler of handlers.get(event) ?? []) await handler(data, context); + }, + }; +} + +function assistant( + provider: string, + errorMessage = EXHAUSTED_BALANCE_ERROR, + model = 'grok-build', + stopReason = 'error', +) { + return { + type: 'message_end', + message: { + role: 'assistant', + provider, + model, + stopReason, + errorMessage, + }, + }; +} + +async function settleExhaustion(extension: ReturnType, provider: string) { + await extension.emit('message_end', assistant(provider)); + await extension.emit('agent_settled'); +} + +async function emitExtensionContinuation(extension: ReturnType) { + await extension.emit('input', { + type: 'input', + source: 'extension', + text: ROTATION_CONTINUATION, + }); +} + +function switchedProviders(extension: ReturnType) { + return extension.setModel.mock.calls.map(([model]) => model.provider); +} + +describe('Grok CLI exhaustion rotation', () => { + it('switches only after agent_settled, preserves the model, and continues once', async () => { + const extension = setup({ current: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' } }); + + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.sendUserMessage).not.toHaveBeenCalled(); + + await extension.emit('agent_settled'); + + expect(extension.setModel).toHaveBeenCalledOnce(); + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-composer-2.5-fast', + }); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + expect(extension.notify).toHaveBeenCalledWith( + 'Grok CLI: “Personal” exhausted; switched to “Work” and continuing.', + 'info', + ); + expect(extension.sendUserMessage).toHaveBeenCalledOnce(); + expect(extension.sendUserMessage).toHaveBeenCalledWith(ROTATION_CONTINUATION); + }); + + it.each([ + ['near match', 'OpenAI API error (402): 402 "Grok Build usage balance exhausted".'], + ['other 402', 'OpenAI API error (402): payment required'], + ['401', 'OpenAI API error (401): unauthorized'], + ['429', 'OpenAI API error (429): rate limited'], + ])('ignores %s errors', async (_name, errorMessage) => { + const extension = setup(); + + await extension.emit('message_end', assistant('grok-cli', errorMessage)); + await extension.emit('agent_settled'); + + expect(extension.setModel).not.toHaveBeenCalled(); + }); + + it('ignores non-Grok providers and non-error assistant messages', async () => { + const extension = setup(); + + await extension.emit('message_end', assistant('openai', EXHAUSTED_BALANCE_ERROR)); + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-build', 'stop'), + ); + await extension.emit('agent_settled'); + + expect(extension.setModel).not.toHaveBeenCalled(); + }); + + it('uses circular account order and skips login-required accounts', async () => { + const extension = setup({ + accounts: THREE_ACCOUNTS, + auth: ['grok-cli-2', 'grok-cli-3'], + current: { provider: 'grok-cli-3', id: 'grok-composer-2.5-fast' }, + }); + + await extension.emit( + 'message_end', + assistant('grok-cli-3', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); + await extension.emit('agent_settled'); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-composer-2.5-fast', + }); + }); + + it('skips failed setModel candidates and falls back to grok-build when needed', async () => { + const extension = setup({ + accounts: THREE_ACCOUNTS, + current: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' }, + missingModels: ['grok-cli-3/grok-composer-2.5-fast'], + setModel: [false, true], + }); + + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); + await extension.emit('agent_settled'); + + expect(extension.setModel.mock.calls).toEqual([ + [{ provider: 'grok-cli-2', id: 'grok-composer-2.5-fast' }], + [{ provider: 'grok-cli-3', id: 'grok-build' }], + ]); + expect(extension.sendUserMessage).toHaveBeenCalledOnce(); + }); + + it('preserves attempted accounts across extension continuations and stops without wrapping', async () => { + const extension = setup({ + accounts: THREE_ACCOUNTS, + }); + + await settleExhaustion(extension, 'grok-cli'); + await emitExtensionContinuation(extension); + await settleExhaustion(extension, 'grok-cli-2'); + await emitExtensionContinuation(extension); + await settleExhaustion(extension, 'grok-cli-3'); + await extension.emit('agent_settled'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli-3']); + expect(extension.sendUserMessage).toHaveBeenCalledTimes(2); + expect(extension.notify).toHaveBeenCalledWith( + 'Grok CLI: all logged-in accounts are exhausted.', + 'warning', + ); + }); + + it('starts a fresh chain for new real user input', async () => { + const extension = setup(); + + await settleExhaustion(extension, 'grok-cli'); + await extension.emit('input', { type: 'input', source: 'interactive', text: 'try again' }); + await settleExhaustion(extension, 'grok-cli-2'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + }); + + it('clears the chain after the continuation settles successfully', async () => { + const extension = setup(); + + await settleExhaustion(extension, 'grok-cli'); + await emitExtensionContinuation(extension); + await extension.emit('message_end', assistant('grok-cli-2', '', 'grok-build', 'stop')); + await extension.emit('agent_settled'); + await extension.emit('message_end', assistant('grok-cli-2')); + await extension.emit('agent_settled'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + }); + + it('cancels a pending rotation after a manual model change', async () => { + const extension = setup(); + + await extension.emit('message_end', assistant('grok-cli')); + await extension.emit('model_select', { + type: 'model_select', + model: { provider: 'openai', id: 'gpt-5' }, + }); + await extension.emit('model_select', { + type: 'model_select', + model: { provider: 'grok-cli', id: 'grok-build' }, + }); + await extension.emit('agent_settled'); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.sendUserMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/provider/toolScope.test.ts b/tests/provider/toolScope.test.ts index 95fe70b..b61ac83 100644 --- a/tests/provider/toolScope.test.ts +++ b/tests/provider/toolScope.test.ts @@ -88,12 +88,13 @@ function sync( describe('syncGrokTools', () => { it.each([ - 'grok-build', - 'grok-composer-2.5-fast', - ])('uses compatibility names for exact legacy model %s', (id) => { + ['grok-cli', 'grok-build'], + ['grok-cli', 'grok-composer-2.5-fast'], + ['grok-cli-2', 'grok-build'], + ])('uses compatibility names for exact legacy model %s/%s', (provider, id) => { const state = toolState(['read', 'write', 'edit', 'grep', 'find', 'ls', 'bash']); - sync(state, 'grok-cli', id, { captureDelete: true }); + sync(state, provider, id, { captureDelete: true }); expect(state.tools()).toEqual([ 'Read', diff --git a/tests/vision/describe.test.ts b/tests/vision/describe.test.ts index d87fdb8..677c8d9 100644 --- a/tests/vision/describe.test.ts +++ b/tests/vision/describe.test.ts @@ -5,6 +5,7 @@ import type { ExtensionContext, ToolResultEvent } from '@earendil-works/pi-codin import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG } from '../../src/config.js'; import { handleReadResult } from '../../src/vision/describe.js'; +import { saveTestAccounts } from './helpers.js'; const BASE_URL = 'https://cli-chat-proxy.grok.com/v1'; const PNG = Buffer.from('fake-png-bytes').toString('base64'); @@ -170,6 +171,22 @@ describe('handleReadResult — image routing', () => { expect(headers.Authorization).toBe('Bearer env-token'); }); + it('uses the last selected Grok alias while a non-Grok model is active', async () => { + saveTestAccounts(); + const getApiKeyForProvider = vi.fn(async () => 'work-token'); + const ctx = { + ...buildCtx(), + model: { provider: 'openai', input: ['text'] }, + modelRegistry: { getApiKeyForProvider }, + } as unknown as ExtensionContext; + + await handleReadResult(readEvent([imageBlock()]), ctx); + + expect(getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); + const headers = (fetchMock.mock.calls[0]?.[1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe('Bearer work-token'); + }); + it('describes multiple images with preserved order and labels', async () => { const result = await handleReadResult( readEvent([ diff --git a/tests/vision/helpers.ts b/tests/vision/helpers.ts index a4fb8f6..c7ae076 100644 --- a/tests/vision/helpers.ts +++ b/tests/vision/helpers.ts @@ -2,6 +2,26 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach } from 'vitest'; +import { DEFAULT_CONFIG, saveConfig } from '../../src/config.js'; + +export const TEST_ACCOUNTS = [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, +]; + +export const oauthCredential = (access: string) => ({ + type: 'oauth' as const, + access, + refresh: `${access}-refresh`, + expires: Date.now() + 60_000, +}); + +export function saveTestAccounts(selectedProvider = 'grok-cli-2') { + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { nextAccountNumber: 3, selectedProvider, items: TEST_ACCOUNTS }, + }); +} /** * Point HOME at a fresh temp dir for the whole test file, restoring it on From 17304501baf9cb533cf05215fb9d233f698e095d Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Wed, 15 Jul 2026 23:05:50 +0900 Subject: [PATCH 02/28] feat(accounts): add cached quota management Why: - Make multi-account quota state visible and refreshable without changing the active model. What: - Group extension config and caches under ~/.pi/grok-cli with verified atomic migration. - Add provider-keyed quota caching and in-dialog refresh for authenticated accounts. - Reuse the lowest free provider alias while reserving deferred active removals. Validation: - bun run check (467 tests) --- README.md | 23 +- src/config.ts | 114 ++++---- src/provider/accounts.ts | 247 ++++++++++++++++-- src/provider/billing.ts | 25 +- src/provider/quotaCache.ts | 143 ++++++++++ src/provider/register.ts | 5 +- src/provider/usage.ts | 20 +- src/storage.ts | 61 +++++ src/vision/cache.ts | 10 +- tests/config.test.ts | 36 ++- tests/imagine/config.test.ts | 6 +- tests/provider/accounts.test.ts | 416 +++++++++++++++++++++++++++++- tests/provider/package.test.ts | 2 + tests/provider/quotaCache.test.ts | 107 ++++++++ tests/provider/register.test.ts | 75 +++++- tests/storage.test.ts | 93 +++++++ tests/vision/config.test.ts | 11 +- tests/vision/describe.test.ts | 10 +- 18 files changed, 1258 insertions(+), 146 deletions(-) create mode 100644 src/provider/quotaCache.ts create mode 100644 src/storage.ts create mode 100644 tests/provider/quotaCache.test.ts create mode 100644 tests/storage.test.ts diff --git a/README.md b/README.md index 2a148a0..8759116 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ The table describes metadata bundled with this extension, not live model discove Use `/login` to authenticate through a browser or device code, or reuse an official Grok Build login. Pi stores and refreshes OAuth credentials automatically. For automation, `GROK_CLI_OAUTH_TOKEN` supplies a direct token without automatic refresh. -Run `/grok-cli-accounts` to add, switch, rename, relogin, log out, or remove accounts. Each account is registered as an independent Pi provider (`grok-cli`, `grok-cli-2`, and so on), so Pi keeps its OAuth credential separately in `auth.json`. The base `grok-cli` slot is permanent; aliases can be removed. Alias numbers are stable and are not reused. +Run `/grok-cli-accounts` to add, switch, rename, relogin, log out, or remove accounts. Each account is registered as an independent Pi provider (`grok-cli`, `grok-cli-2`, and so on), so Pi keeps its OAuth credential separately in `auth.json`. The base `grok-cli` slot is permanent; aliases can be removed. Existing aliases keep their provider IDs, and a removed alias number is reused by the next account when it is the lowest available slot. + +The account selectors show the last cached quota for every logged-in account as consumed monthly and weekly usage. Press `r` to refresh all logged-in accounts without switching models or changing the selected account. Refreshes run three accounts at a time; a failed account keeps its previous cached value and is marked as failed for the current dialog. Values older than 30 minutes are labeled stale. Opening the selector does not refresh automatically. `GROK_CLI_OAUTH_TOKEN` applies only to the permanent base account. When it is set, the accounts UI identifies the environment token and tells you to unset it instead of presenting a logout action that cannot remove it. @@ -101,7 +103,7 @@ OpenAI API error (402): 402 "Grok Build usage balance exhausted" Rotation waits until Pi has finished retries, compaction, and tool activity. It selects the next authenticated account in circular configuration order, preserves the current model when available, and automatically continues the interrupted request once. Accounts already exhausted during that continuation chain are skipped. If every logged-in account returns the exact error, the extension stops without wrapping and reports that all accounts are exhausted. -The attempted-account set is in memory and applies only to that continuation chain. A new user request starts fresh, so recovered quota can be detected naturally. Similar 402 messages, 401s, 429s, non-Grok errors, and non-final errors do not trigger rotation. A manual model change before failover cancels the pending rotation. +The attempted-account set is in memory and applies only to that continuation chain. A new user request starts fresh, so recovered quota can be detected naturally. Similar 402 messages, 401s, 429s, non-Grok errors, and non-final errors do not trigger rotation. A manual model change before failover cancels the pending rotation. Cached quota is informational and never triggers or suppresses rotation. ### Model-scoped Cursor compatibility @@ -147,7 +149,7 @@ Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any acti | Command | Description | | --- | --- | | `/grok-cli-accounts` | Add, switch, rename, relogin, log out, or remove Grok accounts. | -| `/grok-cli-usage` | Fetch current quota, remaining credits, and reset times. | +| `/grok-cli-usage` | Fetch current quota, update its cache, and show cached data if refresh fails. | | `/grok-cli-imagine ` | Generate and preview an image. Supports `--aspect`, `--out`, and `--resolution 1k`. | | `/grok-cli-imagine:tool [on\|off\|status]` | Toggle, set, or report persistent model-callable `image_gen` availability. | | `/grok-cli-vision:status` | Show vision state, describer model, configuration path, and cache statistics. | @@ -156,7 +158,18 @@ Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any acti ## Configuration -All extension settings are read from `~/.pi/grok-cli.json`. The file is created after the first setting change and defaults to: +Extension-owned data is grouped under `~/.pi/grok-cli/`: + +```text +~/.pi/grok-cli/ +├── config.json +├── vision-cache.json +└── quota-cache.json +``` + +`config.json` contains settings and account labels. The two cache files contain derived vision descriptions and quota responses; neither contains OAuth tokens. Pi continues to own OAuth credentials in its `auth.json`, and the official `~/.grok/auth.json` file remains read-only to this extension. + +The configuration file is created after the first setting change and defaults to: ```json { @@ -184,7 +197,7 @@ All extension settings are read from `~/.pi/grok-cli.json`. The file is created } ``` -Version 1 configuration and legacy Imagine and vision settings are migrated atomically while preserving feature settings. OAuth credentials remain in Pi's auth storage and are not rewritten. The vision cache remains separate at `~/.pi/grok-cli-vision-cache.json`. +Existing `~/.pi/grok-cli.json`, `~/.pi/grok-cli-vision-cache.json`, version 1 configuration, and legacy Imagine and vision settings are migrated atomically while preserving feature settings and cache data. A conflicting file at the new location remains authoritative and the legacy file is preserved with a warning instead of being overwritten. `model` must identify an image-capable model. Invalid values are reported and replaced with safe defaults. Manual changes apply on the next image read. diff --git a/src/config.ts b/src/config.ts index 3006932..0380c61 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,16 +1,17 @@ -import { randomUUID } from 'node:crypto'; -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; -import { homedir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { resolveModels } from './models/catalog.js'; +import { + getConfigPath, + getLegacyConfigPath, + getLegacyImagineConfigPath, + getLegacyVisionCachePath, + getLegacyVisionConfigPath, + getVisionCachePath, + migrateStoredFile, + writeFileAtomic, +} from './storage.js'; + +export { getConfigPath, getLegacyImagineConfigPath, getLegacyVisionConfigPath } from './storage.js'; export const CONFIG_VERSION = 2 as const; const LEGACY_CONFIG_VERSION = 1; @@ -81,12 +82,6 @@ type LegacyConfig = LoadedConfig & { recognizedPaths: string[]; }; -const homePath = () => process.env.HOME || homedir(); - -export const getConfigPath = () => join(homePath(), '.pi', 'grok-cli.json'); -export const getLegacyImagineConfigPath = () => join(homePath(), '.pi', 'grok-cli-imagine.json'); -export const getLegacyVisionConfigPath = () => join(homePath(), '.pi', 'grok-cli-vision.json'); - function defaultConfig(): GrokCliConfig { return { version: CONFIG_VERSION, @@ -125,6 +120,16 @@ const accountNumber = (provider: string) => { return match ? Number(match[1]) : undefined; }; +export function findAvailableAccountNumber( + providers: Iterable, + reservedProviders: Iterable = [], +) { + const unavailable = new Set([...providers, ...reservedProviders]); + const find = (number: number): number => + unavailable.has(`grok-cli-${number}`) ? find(number + 1) : number; + return find(2); +} + function normalizeAccountsConfig(raw: unknown, warnings: string[]): AccountsConfig { if (raw === undefined) return defaultConfig().accounts; if (!isObject(raw) || !Array.isArray(raw.items)) { @@ -180,22 +185,13 @@ function normalizeAccountsConfig(raw: unknown, warnings: string[]): AccountsConf if (invalid.length) warnings.push('accounts contains invalid or duplicate entries. Ignoring them.'); - const highestAccountNumber = Math.max( - ...items.map((account) => accountNumber(account.provider) ?? 1), - ); - const requestedNext = - typeof raw.nextAccountNumber === 'number' && - Number.isInteger(raw.nextAccountNumber) && - raw.nextAccountNumber >= 2 - ? raw.nextAccountNumber - : 2; const selectedProvider = typeof raw.selectedProvider === 'string' && providers.has(raw.selectedProvider) ? raw.selectedProvider : 'grok-cli'; return { - nextAccountNumber: Math.max(requestedNext, highestAccountNumber + 1), + nextAccountNumber: findAvailableAccountNumber(providers), selectedProvider, items, }; @@ -429,13 +425,18 @@ function loadLegacyConfig(): LegacyConfig { } export function loadConfig(): LoadedConfig { - if (!existsSync(getConfigPath())) { + const configPath = existsSync(getConfigPath()) + ? getConfigPath() + : existsSync(getLegacyConfigPath()) + ? getLegacyConfigPath() + : undefined; + if (!configPath) { const legacy = loadLegacyConfig(); return legacy.warning ? { config: legacy.config, warning: legacy.warning } : { config: legacy.config }; } - const loaded = parseConfig(getConfigPath()); + const loaded = parseConfig(configPath); if (loaded.valid) { return loaded.warning ? { config: loaded.config, warning: loaded.warning } @@ -449,22 +450,7 @@ export function loadConfig(): LoadedConfig { } export function saveConfig(config: GrokCliConfig) { - const configPath = getConfigPath(); - const tempPath = join( - dirname(configPath), - `.${basename(configPath)}.${process.pid}.${randomUUID()}.tmp`, - ); - mkdirSync(dirname(configPath), { recursive: true }); - try { - writeFileSync(tempPath, `${JSON.stringify(normalizeConfig(config, []), null, 2)}\n`, { - encoding: 'utf8', - flag: 'wx', - }); - renameSync(tempPath, configPath); - } catch (error) { - rmSync(tempPath, { force: true }); - throw error; - } + writeFileAtomic(getConfigPath(), `${JSON.stringify(normalizeConfig(config, []), null, 2)}\n`); } function removeLegacyConfigs(paths: string[]) { @@ -481,11 +467,18 @@ function removeLegacyConfigs(paths: string[]) { } export function migrateLegacyConfig(): { warning?: string } { + const storageWarning = combineWarnings([ + migrateStoredFile(getLegacyConfigPath(), getConfigPath()), + migrateStoredFile(getLegacyVisionCachePath(), getVisionCachePath()), + ]); + if (!existsSync(getConfigPath()) && existsSync(getLegacyConfigPath())) { + return { warning: storageWarning }; + } const legacy = loadLegacyConfig(); if (existsSync(getConfigPath())) { const loaded = parseConfig(getConfigPath()); if (!loaded.valid) { - return { warning: combineWarnings([loaded.warning, legacy.warning]) }; + return { warning: combineWarnings([storageWarning, loaded.warning, legacy.warning]) }; } if (loaded.needsMigration) { try { @@ -498,6 +491,7 @@ export function migrateLegacyConfig(): { warning?: string } { ) { return { warning: combineWarnings([ + storageWarning, verified.warning, `Could not verify migrated config ${getConfigPath()}. Legacy files were preserved.`, ]), @@ -505,17 +499,27 @@ export function migrateLegacyConfig(): { warning?: string } { } } catch (error) { return { - warning: `Could not migrate configuration ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + warning: combineWarnings([ + storageWarning, + `Could not migrate configuration ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + ]), }; } } const cleanupWarning = removeLegacyConfigs(legacy.recognizedPaths); - const warning = combineWarnings([loaded.warning, legacy.warning, cleanupWarning]); + const warning = combineWarnings([ + storageWarning, + loaded.warning, + legacy.warning, + cleanupWarning, + ]); return warning ? { warning } : {}; } - if (legacy.existingPaths.length === 0) return {}; + if (legacy.existingPaths.length === 0) { + return storageWarning ? { warning: storageWarning } : {}; + } if (legacy.recognizedPaths.length !== legacy.existingPaths.length) { - return { warning: legacy.warning }; + return { warning: combineWarnings([storageWarning, legacy.warning]) }; } try { saveConfig(legacy.config); @@ -523,6 +527,7 @@ export function migrateLegacyConfig(): { warning?: string } { if (!verified.valid || JSON.stringify(verified.config) !== JSON.stringify(legacy.config)) { return { warning: combineWarnings([ + storageWarning, verified.warning, `Could not verify migrated config ${getConfigPath()}. Legacy files were preserved.`, ]), @@ -530,9 +535,12 @@ export function migrateLegacyConfig(): { warning?: string } { } } catch (error) { return { - warning: `Could not migrate legacy configuration to ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + warning: combineWarnings([ + storageWarning, + `Could not migrate legacy configuration to ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + ]), }; } - const warning = removeLegacyConfigs(legacy.recognizedPaths); + const warning = combineWarnings([storageWarning, removeLegacyConfigs(legacy.recognizedPaths)]); return warning ? { warning } : {}; } diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts index 2be8fb0..2918161 100644 --- a/src/provider/accounts.ts +++ b/src/provider/accounts.ts @@ -3,13 +3,22 @@ import type { ExtensionCommandContext, ExtensionContext, } from '@earendil-works/pi-coding-agent'; +import { matchesKey, SelectList } from '@earendil-works/pi-tui'; import { + findAvailableAccountNumber, type GrokCliAccount, type GrokCliConfig, hasTerminalControlCharacters, loadConfig, saveConfig, } from '../config.js'; +import { fetchBillingUsage } from './billing.js'; +import { + formatCachedQuota, + loadQuotaCache, + removeQuotaUsage, + saveQuotaUsage, +} from './quotaCache.js'; export const GROK_CLI_PROVIDER = 'grok-cli'; export const DEFAULT_GROK_MODEL = 'grok-build'; @@ -94,17 +103,209 @@ function accountStatus(ctx: ExtensionContext, config: GrokCliConfig, provider: s return hasStoredAuth(ctx, provider) ? 'Logged in' : 'Login required'; } -function accountRows(ctx: ExtensionContext, config: GrokCliConfig) { - return config.accounts.items.map((account) => ({ - account, - row: `${account.label} — ${accountStatus(ctx, config, account.provider)}`, - })); +async function resolveAccountToken(ctx: ExtensionContext, provider: string) { + if (provider === GROK_CLI_PROVIDER && process.env.GROK_CLI_OAUTH_TOKEN) { + return process.env.GROK_CLI_OAUTH_TOKEN; + } + try { + return await ctx.modelRegistry.getApiKeyForProvider(provider); + } catch { + return undefined; + } +} + +async function refreshAccountBatches( + accounts: GrokCliAccount[], + refresh: (account: GrokCliAccount) => Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return; + const batch = accounts.slice(0, 3); + if (!batch.length) return; + await Promise.all(batch.map(refresh)); + await refreshAccountBatches(accounts.slice(3), refresh, signal); +} + +function showAccountSelector( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + mode: 'main' | 'manage', +) { + return ctx.ui.custom((tui, theme, _keybindings, done) => { + const accountItems = new Map( + config.accounts.items.map((account) => [ + account.provider, + { + value: account.provider, + label: `${account.label} — ${accountStatus(ctx, config, account.provider)}`, + description: '', + }, + ]), + ); + const items = [ + ...accountItems.values(), + ...(mode === 'main' + ? [ + { value: 'action:add', label: '+ Add account', description: '' }, + { value: 'action:manage', label: 'Manage accounts', description: '' }, + ] + : []), + ]; + const selectList = new SelectList( + items, + Math.min(items.length, 10), + { + selectedPrefix: (text) => theme.fg('accent', text), + selectedText: (text) => theme.fg('accent', text), + description: (text) => theme.fg('dim', text), + scrollInfo: (text) => theme.fg('dim', text), + noMatch: (text) => theme.fg('warning', text), + }, + { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 40 }, + ); + const refreshingProviders = new Set(); + const failedProviders = new Set(); + const controller = new AbortController(); + let cache = loadQuotaCache(); + let status: string | undefined; + let refreshing = false; + let disposed = false; + + const updateDescriptions = () => { + for (const account of config.accounts.items) { + const item = accountItems.get(account.provider); + if (!item) continue; + const cached = cache.accounts[account.provider]; + item.description = !hasAccountAuth(ctx, account.provider) + ? 'Quota unavailable · login required' + : refreshingProviders.has(account.provider) + ? 'Refreshing…' + : failedProviders.has(account.provider) + ? cached + ? `${formatCachedQuota(cached)} · refresh failed` + : 'Refresh failed · press r to retry' + : cached + ? formatCachedQuota(cached) + : 'Quota not fetched · press r'; + } + selectList.invalidate(); + }; + + const close = (value: string | undefined) => { + if (disposed) return; + disposed = true; + controller.abort(); + done(value); + }; + + const startRefresh = async () => { + if (refreshing || disposed) return; + const accounts = config.accounts.items.filter((account) => + hasAccountAuth(ctx, account.provider), + ); + if (!accounts.length) { + status = 'No logged-in accounts to refresh'; + tui.requestRender(); + return; + } + refreshing = true; + failedProviders.clear(); + for (const account of accounts) refreshingProviders.add(account.provider); + status = `Refreshing quotas… 0/${accounts.length}`; + updateDescriptions(); + tui.requestRender(); + let completed = 0; + let updated = 0; + let failed = 0; + + await refreshAccountBatches( + accounts, + async (account) => { + try { + const token = await resolveAccountToken(ctx, account.provider); + if (!token) throw new Error('authentication unavailable'); + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]); + await saveQuotaUsage(account.provider, await fetchBillingUsage(token, signal)); + updated += 1; + } catch { + if (!controller.signal.aborted) { + failedProviders.add(account.provider); + failed += 1; + } + } finally { + refreshingProviders.delete(account.provider); + completed += 1; + cache = loadQuotaCache(); + status = `Refreshing quotas… ${completed}/${accounts.length}`; + updateDescriptions(); + if (!disposed) tui.requestRender(); + } + }, + controller.signal, + ); + + refreshing = false; + if (disposed) return; + status = `Updated ${updated} accounts; ${failed} failed`; + tui.requestRender(); + }; + + selectList.onSelect = (item) => close(item.value); + selectList.onCancel = () => close(undefined); + updateDescriptions(); + + return { + render(width: number) { + return [ + theme.fg( + 'accent', + theme.bold(mode === 'main' ? 'Grok CLI accounts:' : 'Manage Grok CLI account:'), + ), + '', + ...selectList.render(width), + '', + ...(status ? [theme.fg('dim', status)] : []), + theme.fg( + 'dim', + `r refresh quotas · enter select · esc ${mode === 'main' ? 'close' : 'back'}`, + ), + ]; + }, + invalidate() { + selectList.invalidate(); + }, + handleInput(data: string) { + if (data === 'r' || data === 'R' || matchesKey(data, 'r')) { + void startRefresh(); + return; + } + selectList.handleInput(data); + if (!disposed) tui.requestRender(); + }, + dispose() { + if (disposed) return; + disposed = true; + controller.abort(); + }, + }; + }); } function prefillLogin(ctx: ExtensionContext, provider: string) { ctx.ui.setEditorText(`/login ${provider}`); } +async function clearQuotaUsage(ctx: ExtensionContext, provider: string) { + try { + await removeQuotaUsage(provider); + } catch (error) { + ctx.ui.notify( + `Grok CLI quota cache cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + 'warning', + ); + } +} + async function switchAccount( pi: ExtensionAPI, ctx: ExtensionContext, @@ -135,13 +336,20 @@ async function addAccount( ctx: ExtensionCommandContext, config: GrokCliConfig, registerAccount: RegisterAccount, + reservedProviders: Set, ) { - const provider = `${GROK_CLI_PROVIDER}-${config.accounts.nextAccountNumber}`; + const accountNumber = findAvailableAccountNumber( + config.accounts.items.map((account) => account.provider), + reservedProviders, + ); + const provider = `${GROK_CLI_PROVIDER}-${accountNumber}`; const label = await promptLabel(ctx, config, provider, 'Label this Grok CLI account:'); if (!label) return; const account = { provider, label }; config.accounts.items.push(account); - config.accounts.nextAccountNumber += 1; + config.accounts.nextAccountNumber = findAvailableAccountNumber( + config.accounts.items.map((candidate) => candidate.provider), + ); try { saveConfig(config); registerAccount(account); @@ -193,6 +401,7 @@ async function removeBaseAccount( account.label = 'Account 1'; saveConfig(config); registerAccount(account); + await clearQuotaUsage(ctx, account.provider); } catch (error) { ctx.ui.notify( `Could not log out of Grok CLI: ${error instanceof Error ? error.message : String(error)}`, @@ -250,6 +459,7 @@ async function removeAlias( ); config.accounts.selectedProvider = fallback ?? GROK_CLI_PROVIDER; saveConfig(config); + await clearQuotaUsage(ctx, account.provider); if (ctx.model?.provider === account.provider && fallback === undefined) { deferredRemovals.add(account.provider); ctx.ui.notify( @@ -339,30 +549,21 @@ export function registerAccountManagement(pi: ExtensionAPI, registerAccount: Reg description: 'Add, switch, rename, relogin, or remove Grok CLI accounts', handler: async (_args, ctx) => { const config = copyConfig(); - const rows = accountRows(ctx, config); - const choice = await ctx.ui.select('Grok CLI accounts:', [ - ...rows.map(({ row }) => row), - '+ Add account', - 'Manage accounts', - ]); - if (choice === '+ Add account') { - await addAccount(ctx, config, registerAccount); + const choice = await showAccountSelector(ctx, config, 'main'); + if (choice === 'action:add') { + await addAccount(ctx, config, registerAccount, deferredRemovals); return; } - if (choice === 'Manage accounts') { + if (choice === 'action:manage') { const latest = copyConfig(); - const managementRows = accountRows(ctx, latest); - const selected = await ctx.ui.select( - 'Manage Grok CLI account:', - managementRows.map(({ row }) => row), - ); - const account = managementRows.find(({ row }) => row === selected)?.account; + const selected = await showAccountSelector(ctx, latest, 'manage'); + const account = latest.accounts.items.find(({ provider }) => provider === selected); if (account) { await manageAccount(pi, ctx, latest, account, registerAccount, deferredRemovals); } return; } - const account = rows.find(({ row }) => row === choice)?.account; + const account = config.accounts.items.find(({ provider }) => provider === choice); if (!account) return; if (!hasAccountAuth(ctx, account.provider)) { prefillLogin(ctx, account.provider); diff --git a/src/provider/billing.ts b/src/provider/billing.ts index 0c5e3fb..19accf0 100644 --- a/src/provider/billing.ts +++ b/src/provider/billing.ts @@ -1,17 +1,17 @@ import { getBaseUrl } from '../auth/oauth.js'; -interface MonthlyUsage { +export interface MonthlyUsage { monthlyLimit: number; used: number; billingPeriodEnd: string; } -interface WeeklyUsage { +export interface WeeklyUsage { creditUsagePercent: number; billingPeriodEnd: string; } -interface BillingUsage { +export interface BillingUsage { monthly: MonthlyUsage; weekly?: WeeklyUsage; } @@ -75,18 +75,27 @@ function parseWeeklyUsage(payload: unknown): WeeklyUsage | undefined { return { creditUsagePercent, billingPeriodEnd }; } -export async function fetchBillingUsage(token: string): Promise { +export async function fetchBillingUsage( + token: string, + signal?: AbortSignal, +): Promise { const headers = billingHeaders(token); - const monthlyResponse = await fetch(`${getBaseUrl()}/billing`, { headers }); + const monthlyResponse = await fetch(`${getBaseUrl()}/billing`, { headers, signal }); if (!monthlyResponse.ok) throw new Error(`billing endpoint returned ${monthlyResponse.status}`); const monthly = parseMonthlyUsage(await monthlyResponse.json()); - const weekly = await fetchWeeklyUsage(headers).catch(() => undefined); + const weekly = await fetchWeeklyUsage(headers, signal).catch((error: unknown) => { + if (signal?.aborted) throw error; + return undefined; + }); return { monthly, weekly }; } -async function fetchWeeklyUsage(headers: Record): Promise { - const response = await fetch(`${getBaseUrl()}/billing?format=credits`, { headers }); +async function fetchWeeklyUsage( + headers: Record, + signal?: AbortSignal, +): Promise { + const response = await fetch(`${getBaseUrl()}/billing?format=credits`, { headers, signal }); if (!response.ok) return undefined; return parseWeeklyUsage(await response.json()); } diff --git a/src/provider/quotaCache.ts b/src/provider/quotaCache.ts new file mode 100644 index 0000000..8d05c2a --- /dev/null +++ b/src/provider/quotaCache.ts @@ -0,0 +1,143 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { getQuotaCachePath, writeFileAtomic } from '../storage.js'; +import type { BillingUsage, MonthlyUsage, WeeklyUsage } from './billing.js'; + +export interface CachedQuota extends BillingUsage { + updatedAt: string; +} + +export interface QuotaCache { + version: 1; + accounts: Record; +} + +const emptyCache = (): QuotaCache => ({ version: 1, accounts: {} }); + +function isDate(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(new Date(value).getTime()); +} + +function isMonthlyUsage(value: unknown): value is MonthlyUsage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const monthly = value as Record; + return ( + typeof monthly.monthlyLimit === 'number' && + Number.isFinite(monthly.monthlyLimit) && + typeof monthly.used === 'number' && + Number.isFinite(monthly.used) && + isDate(monthly.billingPeriodEnd) + ); +} + +function isWeeklyUsage(value: unknown): value is WeeklyUsage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const weekly = value as Record; + return ( + typeof weekly.creditUsagePercent === 'number' && + Number.isFinite(weekly.creditUsagePercent) && + isDate(weekly.billingPeriodEnd) + ); +} + +function parseCachedQuota(value: unknown): CachedQuota | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const entry = value as Record; + if (!isDate(entry.updatedAt) || !isMonthlyUsage(entry.monthly)) return undefined; + if (entry.weekly !== undefined && !isWeeklyUsage(entry.weekly)) return undefined; + return { + updatedAt: entry.updatedAt, + monthly: entry.monthly, + ...(entry.weekly ? { weekly: entry.weekly } : {}), + }; +} + +export function loadQuotaCache(path = getQuotaCachePath()): QuotaCache { + try { + const raw: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return emptyCache(); + const cache = raw as Record; + if ( + cache.version !== 1 || + !cache.accounts || + typeof cache.accounts !== 'object' || + Array.isArray(cache.accounts) + ) { + return emptyCache(); + } + return { + version: 1, + accounts: Object.fromEntries( + Object.entries(cache.accounts).flatMap(([provider, value]) => { + const entry = parseCachedQuota(value); + return entry ? [[provider, entry]] : []; + }), + ), + }; + } catch { + return emptyCache(); + } +} + +const cacheUpdates = new Map>(); + +async function updateQuotaCache( + update: (cache: QuotaCache) => boolean, + path = getQuotaCachePath(), +) { + const previous = cacheUpdates.get(path) ?? Promise.resolve(); + const next = previous.then(() => { + const cache = loadQuotaCache(path); + if (!update(cache)) return; + writeFileAtomic(path, `${JSON.stringify(cache, null, 2)}\n`, 0o600); + }); + cacheUpdates.set(path, next); + try { + await next; + } finally { + if (cacheUpdates.get(path) === next) cacheUpdates.delete(path); + } +} + +export function saveQuotaUsage( + provider: string, + usage: BillingUsage, + updatedAt = new Date().toISOString(), + path = getQuotaCachePath(), +) { + return updateQuotaCache((cache) => { + cache.accounts[provider] = { + updatedAt, + monthly: { ...usage.monthly }, + ...(usage.weekly ? { weekly: { ...usage.weekly } } : {}), + }; + return true; + }, path); +} + +export function removeQuotaUsage(provider: string, path = getQuotaCachePath()) { + return updateQuotaCache((cache) => { + if (!existsSync(path) || !cache.accounts[provider]) return false; + delete cache.accounts[provider]; + return true; + }, path); +} + +function formatAge(updatedAt: string, now: number) { + const age = Math.max(0, now - new Date(updatedAt).getTime()); + if (age < 60_000) return 'just now'; + if (age < 3_600_000) return `${Math.floor(age / 60_000)}m ago`; + if (age < 86_400_000) return `${Math.floor(age / 3_600_000)}h ago`; + return `${Math.floor(age / 86_400_000)}d ago`; +} + +export function formatCachedQuota(entry: CachedQuota, now = Date.now()) { + const age = Math.max(0, now - new Date(entry.updatedAt).getTime()); + return [ + `Monthly ${entry.monthly.used.toLocaleString()} / ${entry.monthly.monthlyLimit.toLocaleString()} used`, + entry.weekly + ? `Weekly ${Math.round(entry.weekly.creditUsagePercent)}% used` + : 'Weekly unavailable', + ...(age >= 30 * 60_000 ? ['stale'] : []), + formatAge(entry.updatedAt, now), + ].join(' · '); +} diff --git a/src/provider/register.ts b/src/provider/register.ts index fae1928..c733d2a 100644 --- a/src/provider/register.ts +++ b/src/provider/register.ts @@ -16,6 +16,7 @@ import { registerGrokTools } from '../tools/register.js'; import { bindLivePiWebAccess, ensureWebSearchDelegate } from '../tools/webSearchDelegate.js'; import { registerVisionFeature } from '../vision/register.js'; import { isGrokCliProvider, registerAccountManagement } from './accounts.js'; +import { removeQuotaUsage } from './quotaCache.js'; import { registerExhaustionRotation } from './rotation.js'; import { grokCliModelHeaders } from './stream.js'; import { handoffGrokTools, restoreGrokTools, syncGrokTools } from './toolScope.js'; @@ -32,7 +33,9 @@ export default function registerGrokCli(pi: ExtensionAPI) { usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { - return oauth.login(callbacks); + const credentials = await oauth.login(callbacks); + await removeQuotaUsage(account.provider).catch(() => undefined); + return credentials; }, async refreshToken(credentials: OAuthCredentials): Promise { diff --git a/src/provider/usage.ts b/src/provider/usage.ts index b1d450b..1c1c9a2 100644 --- a/src/provider/usage.ts +++ b/src/provider/usage.ts @@ -3,6 +3,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { XaiOAuthError } from '../shared/errors.js'; import { isGrokCliProvider, resolveGrokProvider, resolveGrokToken } from './accounts.js'; import { fetchBillingUsage, formatQuota } from './billing.js'; +import { loadQuotaCache, saveQuotaUsage } from './quotaCache.js'; export function registerUsageCommand(pi: Pick) { pi.registerCommand('grok-cli-usage', { @@ -34,13 +35,28 @@ export function registerUsageCommand(pi: Pick) try { ctx.ui.notify('Fetching grok cli usage…', 'info'); - ctx.ui.notify(formatQuota(await fetchBillingUsage(apiKey)).join('\n'), 'info'); + const usage = await fetchBillingUsage(apiKey); + try { + await saveQuotaUsage(provider, usage); + } catch (error) { + ctx.ui.notify( + `Grok CLI quota cache update failed: ${error instanceof Error ? error.message : String(error)}`, + 'warning', + ); + } + ctx.ui.notify(formatQuota(usage).join('\n'), 'info'); } catch (err) { ctx.ui.notify( `Grok CLI billing refresh failed: ${err instanceof Error ? err.message : String(err)}`, 'warning', ); - ctx.ui.notify(formatQuota(undefined).join('\n'), 'info'); + const cached = loadQuotaCache().accounts[provider]; + ctx.ui.notify( + cached + ? `Grok CLI cached usage from ${cached.updatedAt}:\n${formatQuota(cached).join('\n')}` + : formatQuota(undefined).join('\n'), + 'info', + ); } } catch (err) { const msg = diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..1d9f224 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,61 @@ +import { randomUUID } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +const homePath = () => process.env.HOME || homedir(); + +export const getGrokCliDirectory = () => join(homePath(), '.pi', 'grok-cli'); +export const getConfigPath = () => join(getGrokCliDirectory(), 'config.json'); +export const getVisionCachePath = () => join(getGrokCliDirectory(), 'vision-cache.json'); +export const getQuotaCachePath = () => join(getGrokCliDirectory(), 'quota-cache.json'); + +export const getLegacyConfigPath = () => join(homePath(), '.pi', 'grok-cli.json'); +export const getLegacyImagineConfigPath = () => join(homePath(), '.pi', 'grok-cli-imagine.json'); +export const getLegacyVisionConfigPath = () => join(homePath(), '.pi', 'grok-cli-vision.json'); +export const getLegacyVisionCachePath = () => join(homePath(), '.pi', 'grok-cli-vision-cache.json'); + +export function writeFileAtomic(path: string, contents: string, mode?: number) { + const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); + mkdirSync(dirname(path), { recursive: true }); + try { + writeFileSync(tempPath, contents, { + encoding: 'utf8', + flag: 'wx', + ...(mode === undefined ? {} : { mode }), + }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + throw error; + } +} + +export function migrateStoredFile(source: string, destination: string) { + if (!existsSync(source)) return undefined; + if (existsSync(destination)) { + return `Could not migrate ${source}: ${destination} already exists. The legacy file was preserved.`; + } + let verified = false; + try { + const contents = readFileSync(source, 'utf8'); + writeFileAtomic(destination, contents); + if (readFileSync(destination, 'utf8') !== contents) { + throw new Error(`could not verify ${destination}`); + } + verified = true; + unlinkSync(source); + return undefined; + } catch (error) { + if (!verified) rmSync(destination, { force: true }); + return `Could not migrate ${source} to ${destination}: ${error instanceof Error ? error.message : String(error)}. The legacy file was preserved.`; + } +} diff --git a/src/vision/cache.ts b/src/vision/cache.ts index f710e35..17930e2 100644 --- a/src/vision/cache.ts +++ b/src/vision/cache.ts @@ -1,10 +1,12 @@ import { createHash } from 'node:crypto'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { getLegacyVisionCachePath, getVisionCachePath } from '../storage.js'; export const getCachePath = () => - join(process.env.HOME || homedir(), '.pi', 'grok-cli-vision-cache.json'); + existsSync(getVisionCachePath()) || !existsSync(getLegacyVisionCachePath()) + ? getVisionCachePath() + : getLegacyVisionCachePath(); export interface CacheEntry { createdAt: string; diff --git a/tests/config.test.ts b/tests/config.test.ts index 2be0475..4eb2e09 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -26,10 +26,12 @@ const FEATURE_CONFIG = { function paths(home: string) { return { - cache: join(home, '.pi', 'grok-cli-vision-cache.json'), - config: join(home, '.pi', 'grok-cli.json'), + cache: join(home, '.pi', 'grok-cli', 'vision-cache.json'), + config: join(home, '.pi', 'grok-cli', 'config.json'), + data: join(home, '.pi', 'grok-cli'), debug: join(home, '.pi', 'grok-cli-vision-debug.log'), imagine: join(home, '.pi', 'grok-cli-imagine.json'), + legacyCache: join(home, '.pi', 'grok-cli-vision-cache.json'), pi: join(home, '.pi'), quota: join(home, '.pi', 'grok-cli-quota.json'), tools: join(home, '.pi', 'grok-cli-tools.json'), @@ -42,7 +44,7 @@ function writeJson(path: string, value: unknown) { } function writeConsolidatedConfig(home: string, value: unknown) { - mkdirSync(paths(home).pi, { recursive: true }); + mkdirSync(paths(home).data, { recursive: true }); writeJson(paths(home).config, value); } @@ -71,13 +73,12 @@ describe('Grok CLI configuration', () => { expect(loadConfig()).toEqual({ config: FEATURE_CONFIG }); expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(2); - expect(readdirSync(paths(home).pi).filter((name) => name.endsWith('.tmp'))).toEqual([]); + expect(readdirSync(paths(home).data).filter((name) => name.endsWith('.tmp'))).toEqual([]); }); it('fills missing sections and normalizes invalid fields with warnings', () => { const home = setupHome(); - mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, { + writeConsolidatedConfig(home, { version: 2, vision: { enabled: 'yes', maxImages: -1, cacheEnabled: false }, }); @@ -96,7 +97,7 @@ describe('Grok CLI configuration', () => { it('falls back to legacy settings without overwriting an unsupported version', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, { version: 3, imagine: { enabled: false } }); + writeConsolidatedConfig(home, { version: 3, imagine: { enabled: false } }); writeJson(paths(home).imagine, { enabled: false }); const migration = migrateLegacyConfig(); @@ -118,7 +119,7 @@ describe('Grok CLI configuration', () => { cacheEnabled: false, cacheMaxEntries: 25, }); - for (const path of [paths(home).cache, paths(home).tools, paths(home).quota]) { + for (const path of [paths(home).legacyCache, paths(home).tools, paths(home).quota]) { writeJson(path, { keep: true }); } writeFileSync(paths(home).debug, 'keep'); @@ -137,12 +138,9 @@ describe('Grok CLI configuration', () => { }); expect(existsSync(paths(home).imagine)).toBe(false); expect(existsSync(paths(home).vision)).toBe(false); - for (const path of [ - paths(home).cache, - paths(home).tools, - paths(home).quota, - paths(home).debug, - ]) { + expect(existsSync(paths(home).legacyCache)).toBe(false); + expect(JSON.parse(readFileSync(paths(home).cache, 'utf8'))).toEqual({ keep: true }); + for (const path of [paths(home).tools, paths(home).quota, paths(home).debug]) { expect(existsSync(path)).toBe(true); } }); @@ -180,7 +178,7 @@ describe('Grok CLI configuration', () => { const loaded = loadConfig(); expect(loaded.config.accounts).toEqual({ - nextAccountNumber: 11, + nextAccountNumber: 3, selectedProvider: 'grok-cli', items: [ { provider: 'grok-cli', label: 'Personal' }, @@ -206,7 +204,7 @@ describe('Grok CLI configuration', () => { }); expect(loadConfig().config.accounts).toEqual({ - nextAccountNumber: 4, + nextAccountNumber: 2, selectedProvider: 'grok-cli', items: [ { provider: 'grok-cli', label: 'Account 1' }, @@ -274,7 +272,7 @@ describe('Grok CLI configuration', () => { it('keeps a valid consolidated file authoritative and cleans recognized legacy files', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, { + writeConsolidatedConfig(home, { ...DEFAULT_CONFIG, imagine: { enabled: false }, }); @@ -292,7 +290,7 @@ describe('Grok CLI configuration', () => { it('preserves malformed legacy files beside a valid consolidated config', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, DEFAULT_CONFIG); + writeConsolidatedConfig(home, DEFAULT_CONFIG); writeFileSync(paths(home).imagine, '{ nope'); expect(migrateLegacyConfig().warning).toMatch(/Could not read/); @@ -303,7 +301,7 @@ describe('Grok CLI configuration', () => { it('preserves a recognized legacy file when cleanup fails and retries later', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, DEFAULT_CONFIG); + writeConsolidatedConfig(home, DEFAULT_CONFIG); writeJson(paths(home).imagine, { enabled: false }); const migration = withoutDirectoryWrites(paths(home).pi, migrateLegacyConfig); diff --git a/tests/imagine/config.test.ts b/tests/imagine/config.test.ts index 80555da..66736b8 100644 --- a/tests/imagine/config.test.ts +++ b/tests/imagine/config.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_CONFIG, getConfigPath, loadConfig, saveConfig } from '../../src/config.js'; import { useTempHome } from '../vision/helpers.js'; @@ -28,8 +28,8 @@ describe('Imagine configuration', () => { }); it('falls back safely for malformed JSON', () => { - const home = setupHome(); - mkdirSync(join(home, '.pi'), { recursive: true }); + setupHome(); + mkdirSync(dirname(getConfigPath()), { recursive: true }); writeFileSync(getConfigPath(), '{ nope'); expect(loadConfig().warning).toContain('Could not read'); }); diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts index ff6642b..d86c587 100644 --- a/tests/provider/accounts.test.ts +++ b/tests/provider/accounts.test.ts @@ -3,7 +3,8 @@ import { type ExtensionAPI, type ExtensionContext, } from '@earendil-works/pi-coding-agent'; -import { describe, expect, it, vi } from 'vitest'; +import type { Component } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; import { isGrokCliProvider, @@ -11,9 +12,19 @@ import { resolveGrokProvider, resolveGrokToken, } from '../../src/provider/accounts.js'; +import { loadQuotaCache, saveQuotaUsage } from '../../src/provider/quotaCache.js'; import { oauthCredential, TEST_ACCOUNTS, useTempHome } from '../vision/helpers.js'; const setupHome = useTempHome(); +const originalFetch = globalThis.fetch; +const originalToken = process.env.GROK_CLI_OAUTH_TOKEN; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + if (originalToken === undefined) delete process.env.GROK_CLI_OAUTH_TOKEN; + else process.env.GROK_CLI_OAUTH_TOKEN = originalToken; +}); function configureAccounts( selectedProvider = 'grok-cli', @@ -27,6 +38,30 @@ function configureAccounts( }); } +const authenticatedAccounts = () => ({ + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), +}); + +const numberedAccounts = (count: number) => + Array.from({ length: count }, (_value, index) => ({ + provider: index === 0 ? 'grok-cli' : `grok-cli-${index + 1}`, + label: `Account ${index + 1}`, + })); + +function setupRefresh( + accounts: { provider: string }[], + action: (component: Component) => Promise, +) { + return setup({ + auth: Object.fromEntries( + accounts.map((account) => [account.provider, oauthCredential(account.provider)]), + ), + preserveHome: true, + customActions: [action], + }); +} + async function runAccountsCommand(extension: ReturnType) { await extension.commands.get('grok-cli-accounts')?.handler('', extension.context); } @@ -35,6 +70,7 @@ function setup( options: { auth?: Record>; confirms?: boolean[]; + customActions?: ((component: Component) => Promise)[]; inputs?: (string | undefined)[]; model?: { provider: string; id: string }; preserveHome?: boolean; @@ -61,8 +97,10 @@ function setup( const selections = [...(options.selections ?? [])]; const inputs = [...(options.inputs ?? [])]; const confirms = [...(options.confirms ?? [])]; + const customActions = [...(options.customActions ?? [])]; const notify = vi.fn(); const setEditorText = vi.fn(); + const customRenders: string[][] = []; const models = new Map(); for (const provider of ['grok-cli', 'grok-cli-2', 'grok-cli-3']) { for (const id of ['grok-build', 'grok-composer-2.5-fast']) { @@ -74,10 +112,60 @@ function setup( modelRegistry: { authStorage, find: (provider: string, id: string) => models.get(`${provider}/${id}`), - getApiKeyForProvider: (provider: string) => authStorage.getApiKey(provider), + getApiKeyForProvider: async (provider: string) => { + const credential = authStorage.get(provider); + return credential?.type === 'oauth' ? credential.access : undefined; + }, }, ui: { confirm: vi.fn(async () => confirms.shift() ?? false), + custom: vi.fn( + async ( + factory: ( + tui: { requestRender: () => void }, + theme: { bold: (text: string) => string; fg: (_color: string, text: string) => string }, + keybindings: unknown, + done: (value: string | undefined) => void, + ) => Component | Promise, + ) => { + let resolveResult = (_value: string | undefined) => {}; + const result = new Promise((resolve) => { + resolveResult = resolve; + }); + let component: Component; + const tui = { + requestRender() { + customRenders.push(component.render(160)); + }, + }; + component = await factory( + tui, + { bold: (text) => text, fg: (_color, text) => text }, + {}, + resolveResult, + ); + customRenders.push(component.render(160)); + const action = customActions.shift(); + if (action) { + await action(component); + return result; + } + const choice = selections.shift(); + if (choice === undefined) { + component.handleInput?.('\u001b'); + return result; + } + for (let index = 0; index < 20; index += 1) { + const selected = component.render(160).find((line) => line.startsWith('→ ')); + if (selected?.includes(choice)) { + component.handleInput?.('\r'); + return result; + } + component.handleInput?.('\u001b[B'); + } + throw new Error(`Could not select custom UI row: ${choice}`); + }, + ), input: vi.fn(async () => inputs.shift()), notify, select: vi.fn(async () => selections.shift()), @@ -90,6 +178,7 @@ function setup( accountManagement, commands, context, + customRenders, notify, registerAccount, setEditorText, @@ -132,6 +221,230 @@ describe('Grok CLI account helpers', () => { }); describe('/grok-cli-accounts', () => { + it('shows cached quota usage in both account selectors', async () => { + configureAccounts(); + await saveQuotaUsage( + 'grok-cli', + { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: 60, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }, + new Date().toISOString(), + ); + const extension = setup({ + auth: authenticatedAccounts(), + preserveHome: true, + selections: ['Manage accounts', 'Work — Logged in', 'Back'], + }); + + await runAccountsCommand(extension); + + expect(extension.context.ui.custom).toHaveBeenCalledTimes(2); + expect(extension.customRenders[0]?.join('\n')).toContain( + 'Monthly 300 / 2,000 used · Weekly 60% used', + ); + expect(extension.customRenders.at(-1)?.join('\n')).toContain('Quota not fetched · press r'); + }); + + it('refreshes every logged-in account with r without changing models and preserves selection', async () => { + configureAccounts(); + process.env.GROK_CLI_OAUTH_TOKEN = 'personal'; + const timeout = vi.spyOn(AbortSignal, 'timeout'); + const fetchMock = vi.fn(async (input, init) => { + const authorization = (init?.headers as Record).authorization; + const used = authorization === 'Bearer personal' ? 300 : 900; + if (String(input).includes('format=credits')) { + return Response.json({ + config: { + currentPeriod: { type: 'USAGE_PERIOD_TYPE_WEEKLY' }, + creditUsagePercent: used === 300 ? 60 : 25, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }); + } + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: used }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + globalThis.fetch = fetchMock; + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + preserveHome: true, + customActions: [ + async (component) => { + component.handleInput?.('\u001b[B'); + component.handleInput?.('r'); + component.handleInput?.('r'); + await vi.waitFor(() => { + expect(Object.keys(loadQuotaCache().accounts)).toHaveLength(2); + }); + expect(component.render(160).find((line) => line.startsWith('→ '))).toContain('Work'); + component.handleInput?.('\u001b'); + }, + ], + }); + + await runAccountsCommand(extension); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(timeout).toHaveBeenCalledTimes(2); + expect(timeout).toHaveBeenCalledWith(30_000); + expect(loadQuotaCache().accounts['grok-cli']?.monthly.used).toBe(300); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(900); + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.customRenders.flat().join('\n')).toContain('Updated 2 accounts; 0 failed'); + }); + + it('keeps cached quota on a partial refresh failure and marks only that row failed', async () => { + configureAccounts(); + await saveQuotaUsage( + 'grok-cli-2', + { + monthly: { + monthlyLimit: 2000, + used: 700, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }, + new Date().toISOString(), + ); + globalThis.fetch = vi.fn(async (input, init) => { + const authorization = (init?.headers as Record).authorization; + if (authorization === 'Bearer work') return new Response('nope', { status: 500 }); + if (String(input).includes('format=credits')) + return new Response('no weekly', { status: 500 }); + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 300 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setup({ + auth: authenticatedAccounts(), + preserveHome: true, + customActions: [ + async (component) => { + component.handleInput?.('r'); + await vi.waitFor(() => { + expect(component.render(160).join('\n')).toContain('Updated 1 accounts; 1 failed'); + }); + expect(component.render(160).join('\n')).toContain( + 'Monthly 700 / 2,000 used · Weekly unavailable', + ); + expect(component.render(160).join('\n')).toContain('refresh failed'); + component.handleInput?.('\u001b'); + }, + ], + }); + + await runAccountsCommand(extension); + + expect(loadQuotaCache().accounts['grok-cli']?.monthly.used).toBe(300); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(700); + }); + + it('handles uppercase R with no authenticated accounts without fetching', async () => { + configureAccounts(); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + const extension = setup({ + preserveHome: true, + customActions: [ + async (component) => { + component.handleInput?.('R'); + expect(component.render(35).join('\n')).toContain('No logged-in accounts to refresh'); + expect(component.render(35).join('\n')).toContain('Personal — Login required'); + component.handleInput?.('\u001b'); + }, + ], + }); + + await runAccountsCommand(extension); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('aborts outstanding quota requests when the selector closes', async () => { + const accounts = numberedAccounts(5); + configureAccounts('grok-cli', accounts, 6); + const aborted = vi.fn(); + globalThis.fetch = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + aborted(); + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + const extension = setupRefresh(accounts, async (component) => { + component.handleInput?.('r'); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(3)); + component.handleInput?.('\u001b'); + }); + + await runAccountsCommand(extension); + await vi.waitFor(() => expect(aborted).toHaveBeenCalledTimes(3)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + expect(loadQuotaCache().accounts).toEqual({}); + }); + + it('runs at most three account refreshes concurrently', async () => { + const accounts = numberedAccounts(5); + configureAccounts('grok-cli', accounts, 6); + const releases: (() => void)[] = []; + let active = 0; + let maximum = 0; + globalThis.fetch = vi.fn(async (input) => { + if (String(input).includes('format=credits')) + return new Response('no weekly', { status: 500 }); + active += 1; + maximum = Math.max(maximum, active); + await new Promise((resolve) => releases.push(resolve)); + active -= 1; + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 300 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setupRefresh(accounts, async (component) => { + component.handleInput?.('r'); + await vi.waitFor(() => expect(releases).toHaveLength(3)); + expect(maximum).toBe(3); + releases.splice(0).forEach((release) => { + release(); + }); + await vi.waitFor(() => expect(releases).toHaveLength(2)); + releases.splice(0).forEach((release) => { + release(); + }); + await vi.waitFor(() => expect(Object.keys(loadQuotaCache().accounts)).toHaveLength(5)); + component.handleInput?.('\u001b'); + }); + + await runAccountsCommand(extension); + + expect(maximum).toBe(3); + }); + it('adds a labeled alias and pre-fills Pi native login', async () => { const extension = setup({ selections: ['+ Add account'], inputs: ['Work'] }); @@ -152,17 +465,17 @@ describe('/grok-cli-accounts', () => { expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); }); - it('uses a stable default label and never reuses removed numbers', async () => { + it('uses the lowest available alias number and its matching default label', async () => { configureAccounts('grok-cli', [{ provider: 'grok-cli', label: 'Account 1' }], 4); const extension = setup({ preserveHome: true, selections: ['+ Add account'], inputs: [''] }); await runAccountsCommand(extension); expect(loadConfig().config.accounts.items.at(-1)).toEqual({ - provider: 'grok-cli-4', - label: 'Account 4', + provider: 'grok-cli-2', + label: 'Account 2', }); - expect(loadConfig().config.accounts.nextAccountNumber).toBe(5); + expect(loadConfig().config.accounts.nextAccountNumber).toBe(3); }); it('switches a logged-in account while preserving the current Grok model', async () => { @@ -255,6 +568,13 @@ describe('/grok-cli-accounts', () => { it('renames an account and updates its provider display registration', async () => { configureAccounts(); + await saveQuotaUsage('grok-cli-2', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); const extension = setup({ selections: ['Manage accounts', 'Work — Login required', 'Rename'], inputs: ['Client'], @@ -268,10 +588,18 @@ describe('/grok-cli-accounts', () => { provider: 'grok-cli-2', label: 'Client', }); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(300); }); it('logs out and removes an inactive alias after confirmation', async () => { configureAccounts(); + await saveQuotaUsage('grok-cli-2', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); const extension = setup({ auth: { 'grok-cli': oauthCredential('personal'), @@ -290,6 +618,74 @@ describe('/grok-cli-accounts', () => { { provider: 'grok-cli', label: 'Personal' }, ]); expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + expect(loadQuotaCache().accounts['grok-cli-2']).toBeUndefined(); + }); + + it('reuses grok-cli-2 for the next account after removing that alias', async () => { + configureAccounts(); + const extension = setup({ + auth: authenticatedAccounts(), + confirms: [true], + inputs: ['Replacement'], + model: { provider: 'grok-cli', id: 'grok-build' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Logged in', 'Log out and remove', '+ Add account'], + }); + + await runAccountsCommand(extension); + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Replacement' }, + ]); + expect(extension.registerAccount).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + label: 'Replacement', + }); + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + }); + + it('does not reuse an active alias until its deferred unregister completes', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + confirms: [true], + inputs: ['Temporary', 'Replacement'], + model: { provider: 'grok-cli-2', id: 'grok-build' }, + preserveHome: true, + selections: [ + 'Manage accounts', + 'Work — Active', + 'Log out and remove', + '+ Add account', + '+ Add account', + ], + }); + + await runAccountsCommand(extension); + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-3', + label: 'Temporary', + }); + + extension.accountManagement.handleModelSelect({ + model: { provider: 'openai' }, + previousModel: { provider: 'grok-cli-2' }, + }); + await runAccountsCommand(extension); + + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-2', + label: 'Replacement', + }); + expect(extension.setEditorText.mock.calls.map(([value]) => value)).toEqual([ + '/login grok-cli-3', + '/login grok-cli-2', + ]); }); it('leaves an alias untouched when removal confirmation is cancelled', async () => { @@ -371,6 +767,13 @@ describe('/grok-cli-accounts', () => { it('keeps the base slot and resets its label when logging out', async () => { configureAccounts('grok-cli', [{ provider: 'grok-cli', label: 'Personal' }], 2); + await saveQuotaUsage('grok-cli', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); const extension = setup({ auth: { 'grok-cli': oauthCredential('personal') }, confirms: [true], @@ -385,6 +788,7 @@ describe('/grok-cli-accounts', () => { { provider: 'grok-cli', label: 'Account 1' }, ]); expect(extension.unregisterProvider).not.toHaveBeenCalled(); + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); }); it('keeps the selected alias when logging out of the inactive base account', async () => { diff --git a/tests/provider/package.test.ts b/tests/provider/package.test.ts index a766e0d..859de64 100644 --- a/tests/provider/package.test.ts +++ b/tests/provider/package.test.ts @@ -64,12 +64,14 @@ describe('repository layout', () => { 'src/payload/sanitize.ts', 'src/provider/accounts.ts', 'src/provider/billing.ts', + 'src/provider/quotaCache.ts', 'src/provider/register.ts', 'src/provider/rotation.ts', 'src/provider/stream.ts', 'src/provider/toolScope.ts', 'src/provider/usage.ts', 'src/shared/errors.ts', + 'src/storage.ts', 'src/tools/files.ts', 'src/tools/glob.ts', 'src/tools/read.ts', diff --git a/tests/provider/quotaCache.test.ts b/tests/provider/quotaCache.test.ts new file mode 100644 index 0000000..1fb4c3b --- /dev/null +++ b/tests/provider/quotaCache.test.ts @@ -0,0 +1,107 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { BillingUsage } from '../../src/provider/billing.js'; +import { + formatCachedQuota, + loadQuotaCache, + removeQuotaUsage, + saveQuotaUsage, +} from '../../src/provider/quotaCache.js'; +import { getQuotaCachePath } from '../../src/storage.js'; +import { useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); +const usage = (used: number, weekly = 60): BillingUsage => ({ + monthly: { + monthlyLimit: 2000, + used, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: weekly, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, +}); + +describe('Grok CLI quota cache', () => { + it('starts empty without creating a file and rejects malformed cache data', () => { + setupHome(); + + expect(loadQuotaCache()).toEqual({ version: 1, accounts: {} }); + expect(existsSync(getQuotaCachePath())).toBe(false); + + mkdirSync(dirname(getQuotaCachePath()), { recursive: true }); + writeFileSync(getQuotaCachePath(), JSON.stringify({ version: 2, accounts: {} })); + expect(loadQuotaCache()).toEqual({ version: 1, accounts: {} }); + + writeFileSync( + getQuotaCachePath(), + JSON.stringify({ + version: 1, + accounts: { + 'grok-cli': { + updatedAt: 'not-a-date', + monthly: { monthlyLimit: 2000, used: '300', billingPeriodEnd: 'bad' }, + }, + }, + }), + ); + expect(loadQuotaCache()).toEqual({ version: 1, accounts: {} }); + }); + + it('atomically stores provider usage and serializes concurrent updates', async () => { + const home = setupHome(); + const updatedAt = '2026-07-15T10:30:00.000Z'; + + await Promise.all([ + saveQuotaUsage('grok-cli', usage(300), updatedAt), + saveQuotaUsage('grok-cli-2', usage(900, 25), updatedAt), + ]); + + expect(loadQuotaCache()).toEqual({ + version: 1, + accounts: { + 'grok-cli': { updatedAt, ...usage(300) }, + 'grok-cli-2': { updatedAt, ...usage(900, 25) }, + }, + }); + expect( + JSON.parse(readFileSync(getQuotaCachePath(), 'utf8')).accounts['grok-cli-2'].monthly.used, + ).toBe(900); + expect( + existsSync(`${getQuotaCachePath()}.${process.pid}.tmp`) || + existsSync(`${getQuotaCachePath()}.tmp`), + ).toBe(false); + expect(getQuotaCachePath()).toContain(`${home}/.pi/grok-cli/`); + }); + + it('removes only the requested provider entry', async () => { + setupHome(); + await saveQuotaUsage('grok-cli', usage(300), '2026-07-15T10:30:00.000Z'); + await saveQuotaUsage('grok-cli-2', usage(900), '2026-07-15T10:30:00.000Z'); + + await removeQuotaUsage('grok-cli-2'); + + expect(Object.keys(loadQuotaCache().accounts)).toEqual(['grok-cli']); + }); + + it('formats fresh, stale, and weekly-unavailable usage explicitly as consumed quota', () => { + expect( + formatCachedQuota( + { updatedAt: '2026-07-15T10:22:00.000Z', ...usage(300) }, + Date.parse('2026-07-15T10:30:00.000Z'), + ), + ).toBe('Monthly 300 / 2,000 used · Weekly 60% used · 8m ago'); + + expect( + formatCachedQuota( + { + updatedAt: '2026-07-15T08:30:00.000Z', + monthly: usage(300).monthly, + }, + Date.parse('2026-07-15T10:30:00.000Z'), + ), + ).toBe('Monthly 300 / 2,000 used · Weekly unavailable · stale · 2h ago'); + }); +}); diff --git a/tests/provider/register.test.ts b/tests/provider/register.test.ts index 953373a..f3b2249 100644 --- a/tests/provider/register.test.ts +++ b/tests/provider/register.test.ts @@ -1,19 +1,33 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { Api, Model, OAuthCredentials, OAuthProviderInterface } from '@earendil-works/pi-ai'; +import type { + Api, + Model, + OAuthCredentials, + OAuthLoginCallbacks, + OAuthProviderInterface, +} from '@earendil-works/pi-ai'; import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, saveConfig } from '../../src/config.js'; +import { loadQuotaCache, saveQuotaUsage } from '../../src/provider/quotaCache.js'; +import { getQuotaCachePath } from '../../src/storage.js'; import { GROK_SHIM_TOOL_NAMES } from '../../src/tools/register.js'; import * as webSearchDelegate from '../../src/tools/webSearchDelegate.js'; import { plainTheme as theme } from '../tools/toolTestHelpers.js'; import { saveTestAccounts } from '../vision/helpers.js'; -const { mockPiWebAccessInstalled } = vi.hoisted(() => ({ +const { mockOauthLogin, mockPiWebAccessInstalled } = vi.hoisted(() => ({ + mockOauthLogin: vi.fn(), mockPiWebAccessInstalled: vi.fn(() => true), })); +vi.mock('../../src/auth/oauth.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, login: mockOauthLogin }; +}); + vi.mock('../../src/tools/webSearchDelegate.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -62,6 +76,12 @@ const originalToken = process.env.GROK_CLI_OAUTH_TOKEN; const tempDirs: string[] = []; beforeEach(() => { + mockOauthLogin.mockReset(); + mockOauthLogin.mockResolvedValue({ + access: 'new-access', + refresh: 'new-refresh', + expires: Date.now() + 60_000, + }); process.env.TZ = 'America/New_York'; const dir = mkdtempSync(join(tmpdir(), 'pi-grok-cli-home-')); mkdirSync(join(dir, '.pi')); @@ -380,6 +400,8 @@ describe('Grok CLI status command', () => { authorization: 'Bearer provider-token', }); expect(notify.mock.calls.at(-1)?.[0]).toContain('100 / 4,000 used 3%'); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(100); + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); }); it('does not fetch billing when no token is available', async () => { @@ -399,28 +421,31 @@ describe('Grok CLI status command', () => { ); }); - it('does not persist billing usage to the global pi config directory', async () => { + it('persists successful billing usage in the selected provider cache', async () => { process.env.GROK_CLI_OAUTH_TOKEN = 'env-token'; - const home = setupHome(); + setupHome(); globalThis.fetch = vi.fn(async () => billingResponse(4000, 1421, '2026-07-01T00:00:00+00:00'), ); const extension = await setupExtension(); await runStatus(extension); - expect(existsSync(join(home, '.pi', 'grok-cli-quota.json'))).toBe(false); + expect(loadQuotaCache().accounts['grok-cli']).toMatchObject({ + monthly: { monthlyLimit: 4000, used: 1421 }, + }); + expect(existsSync(getQuotaCachePath())).toBe(true); }); it('rejects invalid billing payloads instead of caching NaN values', async () => { process.env.GROK_CLI_OAUTH_TOKEN = 'env-token'; - const home = setupHome(); + setupHome(); globalThis.fetch = vi.fn(async () => billingResponse('4000', 1421, '2026-07-01T00:00:00+00:00'), ); const extension = await setupExtension(); const notify = await runStatus(extension); - expect(existsSync(join(home, '.pi', 'grok-cli-quota.json'))).toBe(false); + expect(existsSync(getQuotaCachePath())).toBe(false); expect(notify.mock.calls.at(-1)?.[0]).toBe( [ ' Usage:', @@ -449,9 +474,20 @@ describe('Grok CLI status command', () => { ); }); - it('shows no billing data when refresh fails', async () => { + it('shows the selected provider cached billing data when refresh fails', async () => { process.env.GROK_CLI_OAUTH_TOKEN = 'env-token'; setupHome(); + await saveQuotaUsage( + 'grok-cli', + { + monthly: { + monthlyLimit: 4000, + used: 1421, + billingPeriodEnd: '2026-07-01T00:00:00+00:00', + }, + }, + '2026-06-30T00:00:00.000Z', + ); globalThis.fetch = vi.fn(async () => new Response('nope', { status: 500 })); const extension = await setupExtension(); const notify = await runStatus(extension); @@ -460,9 +496,8 @@ describe('Grok CLI status command', () => { 'Grok CLI billing refresh failed: billing endpoint returned 500', 'warning', ); - expect(notify.mock.calls.at(-1)?.[0]).toContain( - 'no billing data available — run /login grok-cli or set GROK_CLI_OAUTH_TOKEN', - ); + expect(notify.mock.calls.at(-1)?.[0]).toContain('cached usage from'); + expect(notify.mock.calls.at(-1)?.[0]).toContain('1,421 / 4,000 used'); }); it('warns when no Grok models are registered', async () => { @@ -538,6 +573,22 @@ describe('Grok CLI status command', () => { }); describe('Grok CLI provider registration', () => { + it('clears cached quota after a successful OAuth login', async () => { + await saveQuotaUsage('grok-cli', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + const extension = await setupExtension(); + + await extension.providers.get('grok-cli')?.oauth?.login({} as OAuthLoginCallbacks); + + expect(mockOauthLogin).toHaveBeenCalledOnce(); + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); + }); + it('registers provider metadata and OAuth helpers', async () => { const extension = await setupExtension(); const provider = extension.providers.get('grok-cli'); @@ -725,7 +776,7 @@ describe('Grok CLI tool scoping', () => { await setupExtension(); - expect(existsSync(join(piDir, 'grok-cli.json'))).toBe(true); + expect(existsSync(join(piDir, 'grok-cli', 'config.json'))).toBe(true); expect(existsSync(join(piDir, 'grok-cli-imagine.json'))).toBe(false); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..3f17b1b --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,93 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, migrateLegacyConfig } from '../src/config.js'; +import { + getConfigPath, + getGrokCliDirectory, + getLegacyConfigPath, + getLegacyVisionCachePath, + getQuotaCachePath, + getVisionCachePath, +} from '../src/storage.js'; +import { getCachePath } from '../src/vision/cache.js'; +import { useTempHome } from './vision/helpers.js'; + +const setupHome = useTempHome(); + +function writeJson(path: string, value: unknown) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +describe('Grok CLI storage', () => { + it('groups extension-owned files under one directory without creating it on read', () => { + const home = setupHome(); + + expect(getGrokCliDirectory()).toBe(join(home, '.pi', 'grok-cli')); + expect(getConfigPath()).toBe(join(home, '.pi', 'grok-cli', 'config.json')); + expect(getVisionCachePath()).toBe(join(home, '.pi', 'grok-cli', 'vision-cache.json')); + expect(getQuotaCachePath()).toBe(join(home, '.pi', 'grok-cli', 'quota-cache.json')); + expect(loadConfig()).toEqual({ config: DEFAULT_CONFIG }); + expect(existsSync(getGrokCliDirectory())).toBe(false); + }); + + it('migrates the consolidated config and vision cache after verified writes', () => { + const home = setupHome(); + mkdirSync(join(home, '.pi'), { recursive: true }); + writeJson(getLegacyConfigPath(), { ...DEFAULT_CONFIG, imagine: { enabled: false } }); + writeJson(getLegacyVisionCachePath(), { + version: 1, + entries: { cached: { description: 'keep' } }, + }); + + expect(migrateLegacyConfig()).toEqual({}); + + expect(loadConfig().config.imagine.enabled).toBe(false); + expect(JSON.parse(readFileSync(getConfigPath(), 'utf8')).version).toBe(2); + expect(JSON.parse(readFileSync(getVisionCachePath(), 'utf8'))).toEqual({ + version: 1, + entries: { cached: { description: 'keep' } }, + }); + expect(existsSync(getLegacyConfigPath())).toBe(false); + expect(existsSync(getLegacyVisionCachePath())).toBe(false); + expect(getCachePath()).toBe(getVisionCachePath()); + }); + + it('keeps new files authoritative and preserves conflicting legacy files', () => { + setupHome(); + mkdirSync(getGrokCliDirectory(), { recursive: true }); + writeJson(getConfigPath(), DEFAULT_CONFIG); + writeJson(getLegacyConfigPath(), { ...DEFAULT_CONFIG, imagine: { enabled: false } }); + writeJson(getVisionCachePath(), { version: 1, entries: { current: {} } }); + writeJson(getLegacyVisionCachePath(), { version: 1, entries: { legacy: {} } }); + + const migration = migrateLegacyConfig(); + + expect(migration.warning).toContain(getLegacyConfigPath()); + expect(migration.warning).toContain(getLegacyVisionCachePath()); + expect(loadConfig().config).toEqual(DEFAULT_CONFIG); + expect(existsSync(getLegacyConfigPath())).toBe(true); + expect(existsSync(getLegacyVisionCachePath())).toBe(true); + }); + + it('falls back to legacy files when the destination directory cannot be created', () => { + const home = setupHome(); + const piDirectory = join(home, '.pi'); + mkdirSync(piDirectory, { recursive: true }); + writeJson(getLegacyConfigPath(), { ...DEFAULT_CONFIG, imagine: { enabled: false } }); + writeJson(getLegacyVisionCachePath(), { version: 1, entries: {} }); + chmodSync(piDirectory, 0o500); + + try { + const migration = migrateLegacyConfig(); + + expect(migration.warning).toMatch(/Could not migrate/); + expect(loadConfig().config.imagine.enabled).toBe(false); + expect(getCachePath()).toBe(getLegacyVisionCachePath()); + expect(existsSync(getLegacyConfigPath())).toBe(true); + expect(existsSync(getLegacyVisionCachePath())).toBe(true); + } finally { + chmodSync(piDirectory, 0o700); + } + }); +}); diff --git a/tests/vision/config.test.ts b/tests/vision/config.test.ts index dfb72b5..a8604d0 100644 --- a/tests/vision/config.test.ts +++ b/tests/vision/config.test.ts @@ -1,9 +1,10 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_CONFIG, describableModels, + getConfigPath, loadConfig, normalizeVisionConfig, saveConfig, @@ -13,7 +14,7 @@ import { useTempHome } from './helpers.js'; const setupHome = useTempHome(); function writeConfig(config: unknown) { - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, JSON.stringify({ version: 1, vision: config })); } @@ -67,8 +68,8 @@ describe('grok-cli-vision config', () => { }); it('warns when the config file is not a JSON object', () => { - const home = setupHome(); - const configPath = join(home, '.pi', 'grok-cli.json'); + setupHome(); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, JSON.stringify([1, 2, 3])); @@ -79,7 +80,7 @@ describe('grok-cli-vision config', () => { it('warns on invalid JSON', () => { setupHome(); - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, '{ not json'); diff --git a/tests/vision/describe.test.ts b/tests/vision/describe.test.ts index 677c8d9..0639893 100644 --- a/tests/vision/describe.test.ts +++ b/tests/vision/describe.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import type { ExtensionContext, ToolResultEvent } from '@earendil-works/pi-coding-agent'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_CONFIG } from '../../src/config.js'; +import { DEFAULT_CONFIG, getConfigPath } from '../../src/config.js'; import { handleReadResult } from '../../src/vision/describe.js'; import { saveTestAccounts } from './helpers.js'; @@ -110,8 +110,8 @@ describe('handleReadResult — no-op cases', () => { }); it('does nothing when routing is disabled via config', async () => { - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); - mkdirSync(join(process.env.HOME as string, '.pi'), { recursive: true }); + const configPath = getConfigPath(); + mkdirSync(dirname(configPath), { recursive: true }); writeFileSync( configPath, JSON.stringify({ @@ -223,7 +223,7 @@ describe('handleReadResult — image routing', () => { expect.stringMatching(/Describing image/), 'info', ); - const cachePath = join(process.env.HOME as string, '.pi', 'grok-cli-vision-cache.json'); + const cachePath = join(process.env.HOME as string, '.pi', 'grok-cli', 'vision-cache.json'); expect(existsSync(cachePath)).toBe(true); const cache = JSON.parse(readFileSync(cachePath, 'utf-8')); expect(Object.keys(cache.entries)).toHaveLength(1); @@ -402,7 +402,7 @@ describe('handleReadResult — response shapes and resilience', () => { }); it('caps described images at maxImages and notes the skipped remainder', async () => { - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync( configPath, From 1d3cfbfab21c7091e384153c97f73d4a1a3cbeb4 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Thu, 16 Jul 2026 13:24:29 +0900 Subject: [PATCH 03/28] fix: isolate Grok credentials and preserve reasoning --- README.md | 7 +- SECURITY.md | 2 +- src/auth/grokCredentials.ts | 54 ---------- src/auth/oauth.ts | 46 ++------- src/payload/sanitize.ts | 21 ++-- tests/auth/grokCredentials.test.ts | 84 ---------------- tests/auth/oauth.test.ts | 152 ++++++++++------------------- tests/payload/sanitize.test.ts | 90 ++++++++++++++++- tests/provider/package.test.ts | 1 - 9 files changed, 162 insertions(+), 295 deletions(-) delete mode 100644 src/auth/grokCredentials.ts delete mode 100644 tests/auth/grokCredentials.test.ts diff --git a/README.md b/README.md index 8759116..89d3a96 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Use your X Premium or SuperGrok subscription in [pi](https://pi.dev/) with a cle - **Vision for text-only models** — automatically describe images with a vision-capable Grok model and cache descriptions locally. - **Grok Imagine** — generate JPEGs from the TUI or let Grok call the `image_gen` tool, with inline previews. -- **Subscription OAuth** — sign in through a browser or device code or reuse an official Grok Build login; tokens refresh automatically. +- **Subscription OAuth** — sign in through a browser or device code; tokens refresh automatically. - **Multiple accounts** — keep independent Pi logins, switch from a TUI, and continue automatically on an exact exhausted-balance error. - **Model-scoped compatibility** — Cursor-style tool names only for Grok Build and Composer 2.5. All other models keep Pi's native tools. - **Usage tracking** — check account limits, remaining credits, and reset times from pi. @@ -40,7 +40,6 @@ Choose **Grok CLI**, then select one of these methods: - **Browser login (default)** — opens xAI authorization and completes a PKCE exchange through a local loopback callback. If xAI shows a one-time code instead of redirecting to pi, paste that code into pi to complete the active PKCE exchange. - **Device code login (headless)** — displays a URL and short code for SSH, containers, and other headless environments. -- **Use existing Grok Build login** — appears when a valid official `~/.grok/auth.json` entry is available. The file is read-only to this extension. To add another login, run `/grok-cli-accounts`, choose **+ Add account**, and optionally label it. The extension adds a stable provider alias and pre-fills `/login `; press Enter to complete Pi's native login flow. Use only accounts you own or are authorized to access. @@ -85,7 +84,7 @@ The table describes metadata bundled with this extension, not live model discove ### OAuth authentication -Use `/login` to authenticate through a browser or device code, or reuse an official Grok Build login. Pi stores and refreshes OAuth credentials automatically. For automation, `GROK_CLI_OAUTH_TOKEN` supplies a direct token without automatic refresh. +Use `/login` to authenticate through a browser or device code. Pi stores and refreshes OAuth credentials automatically. For automation, `GROK_CLI_OAUTH_TOKEN` supplies a direct token without automatic refresh. Run `/grok-cli-accounts` to add, switch, rename, relogin, log out, or remove accounts. Each account is registered as an independent Pi provider (`grok-cli`, `grok-cli-2`, and so on), so Pi keeps its OAuth credential separately in `auth.json`. The base `grok-cli` slot is permanent; aliases can be removed. Existing aliases keep their provider IDs, and a removed alias number is reused by the next account when it is the lowest available slot. @@ -167,7 +166,7 @@ Extension-owned data is grouped under `~/.pi/grok-cli/`: └── quota-cache.json ``` -`config.json` contains settings and account labels. The two cache files contain derived vision descriptions and quota responses; neither contains OAuth tokens. Pi continues to own OAuth credentials in its `auth.json`, and the official `~/.grok/auth.json` file remains read-only to this extension. +`config.json` contains settings and account labels. The two cache files contain derived vision descriptions and quota responses; neither contains OAuth tokens. Pi owns OAuth credentials in its `auth.json`; this extension does not read Grok Build credentials. The configuration file is created after the first setting change and defaults to: diff --git a/SECURITY.md b/SECURITY.md index 228e4d7..ef28fb8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ The maintainer aims to acknowledge a complete report within 7 calendar days and ## Security boundaries - pi-grok-cli and its tools run with the user's operating-system permissions. File and shell tools can read or modify the workspace and execute commands when the model invokes them. -- OAuth credentials returned by this extension are stored and refreshed by pi. pi-grok-cli may read the verified official Grok CLI entry in `~/.grok/auth.json`, but never writes to that file. +- OAuth credentials returned by this extension are stored and refreshed by pi. pi-grok-cli does not read or modify Grok Build credentials. - Prompts, conversation context, tool definitions, and tool results are sent to the configured Grok CLI proxy. - Images handled by vision routing are sent to the configured describer model. The local vision cache stores descriptions and hashes, not raw images. - Optional web search is delegated to `pi-web-access` and the providers configured there. diff --git a/src/auth/grokCredentials.ts b/src/auth/grokCredentials.ts deleted file mode 100644 index 99ee99b..0000000 --- a/src/auth/grokCredentials.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { getBaseUrl, XAI_ISSUER, XAI_OAUTH_CLIENT_ID, XAI_TOKEN_ENDPOINT } from './config.js'; -import type { XaiOAuthCredentials } from './oauth.js'; - -function expiryMilliseconds(value: unknown) { - if (typeof value === 'number') { - return Number.isInteger(value) && value >= 1_000_000_000_000 && value < 100_000_000_000_000 - ? value - : undefined; - } - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) return undefined; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -export function parseGrokCredentials(value: unknown): XaiOAuthCredentials | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const entry = (value as Record)[`${XAI_ISSUER}::${XAI_OAUTH_CLIENT_ID}`]; - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return undefined; - - const record = entry as Record; - const expires = expiryMilliseconds(record.expires_at); - if ( - typeof record.key !== 'string' || - !record.key || - typeof record.refresh_token !== 'string' || - !record.refresh_token || - record.oidc_issuer !== XAI_ISSUER || - record.oidc_client_id !== XAI_OAUTH_CLIENT_ID || - expires === undefined - ) { - return undefined; - } - - return { - access: record.key, - refresh: record.refresh_token, - expires, - tokenEndpoint: XAI_TOKEN_ENDPOINT, - baseUrl: getBaseUrl(), - }; -} - -export async function readGrokCredentials() { - try { - return parseGrokCredentials( - JSON.parse(await readFile(join(homedir(), '.grok', 'auth.json'), 'utf8')), - ); - } catch { - return undefined; - } -} diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 7dba5c4..caa3d00 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -12,7 +12,6 @@ import { createServer } from 'node:http'; import { XaiErrorCode, XaiOAuthError } from '../shared/errors.js'; import { getBaseUrl, XAI_ISSUER, XAI_OAUTH_CLIENT_ID } from './config.js'; -import { readGrokCredentials } from './grokCredentials.js'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -621,48 +620,21 @@ async function loginWithBrowserCallback() { export async function login( callbacks: import('@earendil-works/pi-ai').OAuthLoginCallbacks, - credentialReader: typeof readGrokCredentials = readGrokCredentials, ): Promise { - const existing = await credentialReader(); const hasDeviceLoginUi = typeof callbacks.onSelect === 'function' && typeof (callbacks as { onDeviceCode?: unknown }).onDeviceCode === 'function'; - const existingMethod = - existing && typeof callbacks.onSelect === 'function' - ? await callbacks.onSelect({ - message: 'Select Grok CLI login method:', - options: [ - { id: 'browser', label: 'Browser login (default)' }, - ...(hasDeviceLoginUi ? [{ id: 'device', label: 'Device code login (headless)' }] : []), - { id: 'existing', label: 'Use existing Grok Build login' }, - ], - }) - : undefined; - if (existing && existingMethod === 'existing') { - if (existing.expires > Date.now()) return existing; - try { - return await refresh(existing); - } catch { - callbacks.onProgress?.( - 'Existing Grok CLI login could not be refreshed. Choose a fresh login method.', - ); - } - } - const discovery = await discover(); const supportsDeviceLogin = Boolean(discovery.device_authorization_endpoint && hasDeviceLoginUi); - const method = - existingMethod === 'browser' || existingMethod === 'device' - ? existingMethod - : supportsDeviceLogin - ? await callbacks.onSelect({ - message: 'Select Grok CLI login method:', - options: [ - { id: 'browser', label: 'Browser login (default)' }, - { id: 'device', label: 'Device code login (headless)' }, - ], - }) - : 'browser'; + const method = supportsDeviceLogin + ? await callbacks.onSelect({ + message: 'Select Grok CLI login method:', + options: [ + { id: 'browser', label: 'Browser login (default)' }, + { id: 'device', label: 'Device code login (headless)' }, + ], + }) + : 'browser'; if (!method) throw new Error('Login cancelled'); if (method === 'device') return loginWithDeviceCode(discovery, callbacks); diff --git a/src/payload/sanitize.ts b/src/payload/sanitize.ts index e24f7ab..9301199 100644 --- a/src/payload/sanitize.ts +++ b/src/payload/sanitize.ts @@ -245,8 +245,16 @@ export function sanitizePayload( if (!item || typeof item !== 'object') return item; const obj = item as Record; - // Strip replayed reasoning items - if (obj.type === 'reasoning') return null; + if (obj.type === 'reasoning') { + delete obj.status; + if (Array.isArray(obj.content)) { + obj.content = obj.content.map((part) => { + if (!part || typeof part !== 'object' || Array.isArray(part)) return part; + if (typeof (part as Record).type === 'string') return part; + return { ...(part as Record), type: 'reasoning_text' }; + }); + } + } // Drop empty string content if (typeof obj.content === 'string' && obj.content.length === 0) return null; @@ -301,13 +309,8 @@ export function sanitizePayload( delete next.reasoningEffort; } - // ── Strip/filter unsupported fields ────────────────────────────────── - if (Array.isArray(next.include)) { - next.include = (next.include as unknown[]).filter( - (item) => item !== 'reasoning.encrypted_content', - ); - if ((next.include as unknown[]).length === 0) delete next.include; - } + // ── Strip unsupported fields ───────────────────────────────────────── + if (Array.isArray(next.include) && next.include.length === 0) delete next.include; delete next.prompt_cache_retention; diff --git a/tests/auth/grokCredentials.test.ts b/tests/auth/grokCredentials.test.ts deleted file mode 100644 index 6ccfc45..0000000 --- a/tests/auth/grokCredentials.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { parseGrokCredentials, readGrokCredentials } from '../../src/auth/grokCredentials.js'; - -const entryKey = 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828'; -const validEntry = { - key: 'official-access', - refresh_token: 'official-refresh', - expires_at: '2030-01-02T03:04:05.000Z', - oidc_issuer: 'https://auth.x.ai', - oidc_client_id: 'b1a00492-073a-47ea-816f-4c329264a828', -}; -const originalHome = process.env.HOME; - -afterEach(() => { - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; -}); - -describe('official Grok CLI credentials', () => { - it('normalizes the verified official credential entry', () => { - expect(parseGrokCredentials({ [entryKey]: validEntry })).toEqual({ - access: 'official-access', - refresh: 'official-refresh', - expires: Date.parse('2030-01-02T03:04:05.000Z'), - tokenEndpoint: 'https://auth.x.ai/oauth2/token', - baseUrl: 'https://cli-chat-proxy.grok.com/v1', - }); - }); - - it('accepts only unambiguous numeric millisecond and ISO string expiry values', () => { - expect( - parseGrokCredentials({ - [entryKey]: { ...validEntry, expires_at: 1_893_459_845_000 }, - })?.expires, - ).toBe(1_893_459_845_000); - expect( - parseGrokCredentials({ - [entryKey]: { ...validEntry, expires_at: 1_893_459_845 }, - }), - ).toBeUndefined(); - expect( - parseGrokCredentials({ - [entryKey]: { ...validEntry, expires_at: '1893459845' }, - }), - ).toBeUndefined(); - }); - - it.each([ - undefined, - null, - {}, - { access_token: validEntry }, - { [entryKey]: { ...validEntry, key: '' } }, - { [entryKey]: { ...validEntry, refresh_token: '' } }, - { [entryKey]: { ...validEntry, oidc_issuer: 'https://example.invalid' } }, - { [entryKey]: { ...validEntry, oidc_client_id: 'legacy-client' } }, - ])('ignores missing, incomplete, or unsupported data: %j', (value) => { - expect(parseGrokCredentials(value)).toBeUndefined(); - }); - - it('returns undefined for a missing or malformed auth file without exposing content', async () => { - const home = await mkdtemp(join(tmpdir(), 'pi-grok-credentials-')); - process.env.HOME = home; - await expect(readGrokCredentials()).resolves.toBeUndefined(); - - await mkdir(join(home, '.grok')); - await writeFile(join(home, '.grok', 'auth.json'), '{"secret":"not-json"'); - await expect(readGrokCredentials()).resolves.toBeUndefined(); - }); - - it('reads the verified entry from ~/.grok/auth.json', async () => { - const home = await mkdtemp(join(tmpdir(), 'pi-grok-credentials-')); - process.env.HOME = home; - await mkdir(join(home, '.grok')); - await writeFile(join(home, '.grok', 'auth.json'), JSON.stringify({ [entryKey]: validEntry })); - await expect(readGrokCredentials()).resolves.toMatchObject({ - access: 'official-access', - refresh: 'official-refresh', - }); - }); -}); diff --git a/tests/auth/oauth.test.ts b/tests/auth/oauth.test.ts index 9e004e6..c95b733 100644 --- a/tests/auth/oauth.test.ts +++ b/tests/auth/oauth.test.ts @@ -1,21 +1,19 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { closeCallbackServer, getBaseUrl, - login as loginWithCredentialReader, + login as oauthLogin, refresh, - type XaiOAuthCredentials, } from '../../src/auth/oauth.js'; import { XaiErrorCode } from '../../src/shared/errors.js'; -type CompleteOAuthLoginCallbacks = Parameters[0]; +type CompleteOAuthLoginCallbacks = Parameters[0]; type OAuthLoginCallbacks = Partial; const login = (callbacks: OAuthLoginCallbacks) => - loginWithCredentialReader(callbacks as CompleteOAuthLoginCallbacks, async () => undefined); -const loginWithCredentialsForTest = ( - callbacks: OAuthLoginCallbacks, - credentials: XaiOAuthCredentials, -) => loginWithCredentialReader(callbacks as CompleteOAuthLoginCallbacks, async () => credentials); + oauthLogin(callbacks as CompleteOAuthLoginCallbacks); const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; @@ -38,16 +36,6 @@ const deviceDiscoveryDocument = { ...discoveryDocument, device_authorization_endpoint: 'https://auth.x.ai/oauth/device/code', }; -function officialCredentials(expires: number): XaiOAuthCredentials { - return { - access: 'official-access', - refresh: 'official-refresh', - expires, - tokenEndpoint: 'https://auth.x.ai/oauth2/token', - baseUrl: 'https://cli-chat-proxy.grok.com/v1', - }; -} - function deviceAuthorizationResponse(overrides: Record = {}) { return Response.json({ device_code: 'device-code', @@ -452,91 +440,53 @@ describe('OAuth helpers without network access', () => { ); }); - it('offers and returns fresh official Grok credentials without a network request', async () => { - const fetchMock = vi.fn(); - const onSelect = vi.fn(async () => 'existing'); - globalThis.fetch = fetchMock; - - await expect( - loginWithCredentialsForTest( - { onSelect, onDeviceCode: vi.fn() }, - officialCredentials(Date.now() + 60_000), - ), - ).resolves.toMatchObject({ - access: 'official-access', - refresh: 'official-refresh', - baseUrl: 'https://cli-chat-proxy.grok.com/v1', - }); - expect(onSelect).toHaveBeenCalledWith({ - message: 'Select Grok CLI login method:', - options: [ - { id: 'browser', label: 'Browser login (default)' }, - { id: 'device', label: 'Device code login (headless)' }, - { id: 'existing', label: 'Use existing Grok Build login' }, - ], - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('refreshes selected expired official Grok credentials through the normal token path', async () => { - const fetchMock = vi.fn(async () => - Response.json({ access_token: 'refreshed-access', refresh_token: 'refreshed-refresh' }), + it('offers only fresh login methods when an official Grok auth file exists', async () => { + const home = await mkdtemp(join(tmpdir(), 'pi-grok-oauth-')); + process.env.HOME = home; + await mkdir(join(home, '.grok')); + await writeFile( + join(home, '.grok', 'auth.json'), + JSON.stringify({ + 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828': { + key: 'official-access', + refresh_token: 'official-refresh', + expires_at: '2030-01-02T03:04:05.000Z', + oidc_issuer: 'https://auth.x.ai', + oidc_client_id: 'b1a00492-073a-47ea-816f-4c329264a828', + }, + }), ); - globalThis.fetch = fetchMock; - - await expect( - loginWithCredentialsForTest({ onSelect: async () => 'existing' }, officialCredentials(0)), - ).resolves.toMatchObject({ - access: 'refreshed-access', - refresh: 'refreshed-refresh', - }); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://auth.x.ai/oauth2/token'); - }); - - it('returns to fresh login after official credential refresh fails without exposing tokens', async () => { - const onProgress = vi.fn(); - const onSelect = vi - .fn() - .mockResolvedValueOnce('existing') - .mockResolvedValueOnce('browser'); - const fetchMock = vi.fn(async (input) => { - if (input === 'https://auth.x.ai/oauth2/token') { - return new Response('revoked official credential', { status: 401 }); - } - if (input === 'https://auth.x.ai/.well-known/openid-configuration') { - return Response.json(deviceDiscoveryDocument); - } - return Response.json({ access_token: 'browser-access', refresh_token: 'browser-refresh' }); - }); - globalThis.fetch = fetchMock; - - await expect( - loginWithCredentialsForTest( - { onSelect, onProgress, onDeviceCode: vi.fn(), onAuth: authorizeCallback }, - officialCredentials(0), - ), - ).resolves.toMatchObject({ access: 'browser-access' }); - expect(onProgress).toHaveBeenCalledWith( - 'Existing Grok CLI login could not be refreshed. Choose a fresh login method.', + const fetchMock = mockBrowserLogin( + { + access_token: 'browser-access', + refresh_token: 'browser-refresh', + }, + deviceDiscoveryDocument, ); - expect(JSON.stringify(onProgress.mock.calls)).not.toContain('official-access'); - expect(JSON.stringify(onProgress.mock.calls)).not.toContain('official-refresh'); - }); - - it('ignores official credentials when browser login is selected', async () => { - const fetchMock = mockBrowserLogin({ - access_token: 'browser-access', - refresh_token: 'browser-refresh', - }); - - await expect( - loginWithCredentialsForTest( - { onSelect: async () => 'browser', onAuth: authorizeCallback }, - officialCredentials(Date.now() + 60_000), - ), - ).resolves.toMatchObject({ access: 'browser-access' }); - expect(fetchMock).toHaveBeenCalledTimes(2); + const onSelect = vi.fn(async () => 'browser'); + + try { + await expect( + login({ + onSelect, + onDeviceCode: vi.fn(), + onAuth: authorizeCallback, + }), + ).resolves.toMatchObject({ access: 'browser-access', refresh: 'browser-refresh' }); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith({ + message: 'Select Grok CLI login method:', + options: [ + { id: 'browser', label: 'Browser login (default)' }, + { id: 'device', label: 'Device code login (headless)' }, + ], + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.stringify(fetchMock.mock.calls)).not.toContain('official-access'); + expect(JSON.stringify(fetchMock.mock.calls)).not.toContain('official-refresh'); + } finally { + await rm(home, { recursive: true, force: true }); + } }); it('ignores an invalid-state HTTP callback and accepts the next valid callback', async () => { diff --git a/tests/payload/sanitize.test.ts b/tests/payload/sanitize.test.ts index 22bcfd3..082e2bf 100644 --- a/tests/payload/sanitize.test.ts +++ b/tests/payload/sanitize.test.ts @@ -18,7 +18,7 @@ describe('payload sanitization', () => { { type: 'output_text', text: 'output text instruction' }, ], }, - { type: 'reasoning', content: 'cached reasoning' }, + { type: 'reasoning', content: 'cached reasoning', status: 'completed' }, { role: 'user', content: '' }, { role: 'user', content: 'hello' }, { role: 'system', content: 'later system instruction' }, @@ -36,8 +36,11 @@ describe('payload sanitization', () => { expect(payload.instructions).toBe( 'existing instruction\n\nsystem instruction\n\ndeveloper instruction\noutput text instruction\n\nlater system instruction', ); - expect(payload.input).toEqual([{ role: 'user', content: 'hello' }]); - expect(payload.include).toEqual(['message.output_text']); + expect(payload.input).toEqual([ + { type: 'reasoning', content: 'cached reasoning' }, + { role: 'user', content: 'hello' }, + ]); + expect(payload.include).toEqual(['reasoning.encrypted_content', 'message.output_text']); expect(payload.prompt_cache_retention).toBeUndefined(); expect(payload.reasoning).toEqual({ effort: 'low' }); expect(payload.text).toEqual({ format: { type: 'json_object' } }); @@ -45,6 +48,85 @@ describe('payload sanitization', () => { expect(payload.prompt_cache_key).toBe('session-123'); }); + it('preserves encrypted reasoning and repairs only missing reasoning-text types', () => { + const payload = sanitizePayload( + { + input: [ + { + type: 'reasoning', + id: 'reasoning-1', + summary: [{ type: 'summary_text', text: 'summary' }], + content: [ + { text: 'missing discriminator' }, + { type: 'future_reasoning_type', text: 'keep discriminator' }, + ], + encrypted_content: 'encrypted-reasoning', + status: 'completed', + future_field: { keep: true }, + }, + ], + include: ['reasoning.encrypted_content'], + }, + 'grok-build', + 'session-123', + process.cwd(), + ); + + expect(payload.input).toEqual([ + { + type: 'reasoning', + id: 'reasoning-1', + summary: [{ type: 'summary_text', text: 'summary' }], + content: [ + { type: 'reasoning_text', text: 'missing discriminator' }, + { type: 'future_reasoning_type', text: 'keep discriminator' }, + ], + encrypted_content: 'encrypted-reasoning', + future_field: { keep: true }, + }, + ]); + expect(payload.include).toEqual(['reasoning.encrypted_content']); + }); + + it('maintains serialized input prefixes across three cumulative turns', () => { + const sanitizeInput = (input: unknown[]) => + sanitizePayload( + { input: structuredClone(input), include: ['reasoning.encrypted_content'] }, + 'grok-build', + 'session-123', + process.cwd(), + ).input as unknown[]; + const firstTurn = [{ role: 'user', content: 'turn one' }]; + const secondTurn = [ + ...firstTurn, + { + type: 'reasoning', + id: 'reasoning-1', + encrypted_content: 'encrypted-1', + status: 'completed', + }, + { role: 'assistant', content: 'answer one' }, + { role: 'user', content: 'turn two' }, + ]; + const thirdTurn = [ + ...secondTurn, + { + type: 'reasoning', + id: 'reasoning-2', + encrypted_content: 'encrypted-2', + status: 'completed', + }, + { role: 'assistant', content: 'answer two' }, + { role: 'user', content: 'turn three' }, + ]; + const first = sanitizeInput(firstTurn); + const second = sanitizeInput(secondTurn); + const third = sanitizeInput(thirdTurn); + + expect(JSON.stringify(second.slice(0, first.length))).toBe(JSON.stringify(first)); + expect(JSON.stringify(third.slice(0, second.length))).toBe(JSON.stringify(second)); + }); + it('preserves existing text while removing response_format', () => { const payload = sanitizePayload( { @@ -78,7 +160,7 @@ describe('payload sanitization', () => { expect(payload.input).toBe('plain prompt'); expect(payload.reasoning).toBeUndefined(); expect(payload.reasoningEffort).toBeUndefined(); - expect(payload.include).toBeUndefined(); + expect(payload.include).toEqual(['reasoning.encrypted_content']); expect(payload.prompt_cache_key).toBe('existing-session'); }); diff --git a/tests/provider/package.test.ts b/tests/provider/package.test.ts index 859de64..a8009c3 100644 --- a/tests/provider/package.test.ts +++ b/tests/provider/package.test.ts @@ -47,7 +47,6 @@ describe('repository layout', () => { it('contains the expected domain source files', () => { expect(globSync('src/**/*.ts').sort()).toEqual([ 'src/auth/config.ts', - 'src/auth/grokCredentials.ts', 'src/auth/oauth.ts', 'src/config.ts', 'src/imagine/aspect.ts', From a64de967a3a7baef835aae8bb7ab380aa0c2eef2 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Thu, 16 Jul 2026 17:04:18 +0900 Subject: [PATCH 04/28] fix: preserve Grok Build reasoning Why: - Keep Grok Build reasoning summaries and replay data without sending unsupported effort controls. What: - Distinguish reasoning support from configurable effort support. - Preserve and deduplicate encrypted reasoning capture for capable models. - Correct the bundled Grok Build context window to 500,000 tokens. Validation: - bun test tests/payload/sanitize.test.ts tests/models/catalog.test.ts - bun run check --- README.md | 2 +- src/models/catalog.ts | 21 ++++++++++---- src/payload/sanitize.ts | 48 +++++++++++++++++++++++------- tests/models/catalog.test.ts | 15 ++++++++-- tests/payload/sanitize.test.ts | 53 ++++++++++++++++++++++++++++++---- 5 files changed, 115 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 89d3a96..803ec36 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The table describes metadata bundled with this extension, not live model discove | Model ID | Registered context | Reasoning | Input | Coding tools | | --- | ---: | --- | --- | --- | | `grok-composer-2.5-fast` | 200K | no | text; images can be described through vision routing | Cursor-compatible names | -| `grok-build` | 512K | yes | text + image | Cursor-compatible names | +| `grok-build` | 500K | yes | text + image | Cursor-compatible names | | `grok-4.3` | 1M | yes | text + image | native pi names | | `grok-4.5` | 500K | yes | text + image | native pi names | | `grok-4.20-0309-reasoning` | 2M | yes | text + image | native pi names | diff --git a/src/models/catalog.ts b/src/models/catalog.ts index 9e5b578..6d2c792 100644 --- a/src/models/catalog.ts +++ b/src/models/catalog.ts @@ -58,7 +58,7 @@ const FALLBACK_MODELS: GrokCliModelConfig[] = [ reasoning: true, input: ['text', 'image'], cost: COST_BUILD, - contextWindow: 512_000, + contextWindow: 500_000, maxTokens: 30_000, }, { @@ -118,11 +118,22 @@ const FALLBACK_MODELS: GrokCliModelConfig[] = [ const EFFORT_CAPABLE_PREFIXES = ['grok-3-mini', 'grok-4.20-multi-agent', 'grok-4.3', 'grok-4.5']; +const normalizedModelName = (modelId: string) => + (modelId.split('/').at(-1) ?? modelId).toLowerCase(); + +const modelConfig = (modelId: string) => { + const name = normalizedModelName(modelId); + return resolveModels().find((entry) => entry.id.toLowerCase() === name); +}; + +export function supportsReasoning(modelId: string): boolean { + return modelConfig(modelId)?.reasoning ?? true; +} + export function supportsReasoningEffort(modelId: string): boolean { - const parts = modelId.split('/'); - const name = parts.at(-1) ?? modelId; - const model = resolveModels().find((entry) => entry.id.toLowerCase() === name.toLowerCase()); - if (!EFFORT_CAPABLE_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) { + const name = normalizedModelName(modelId); + const model = modelConfig(modelId); + if (!EFFORT_CAPABLE_PREFIXES.some((prefix) => name.startsWith(prefix))) { return false; } if (!model?.reasoning) return false; diff --git a/src/payload/sanitize.ts b/src/payload/sanitize.ts index 9301199..8e8c695 100644 --- a/src/payload/sanitize.ts +++ b/src/payload/sanitize.ts @@ -2,7 +2,7 @@ * Payload sanitization for xAI's Responses API via cli-chat-proxy.grok.com. * * xAI's endpoint has quirks compared to stock OpenAI: - * - Replayed `reasoning` items in input cause 400 errors. + * - Replayed `reasoning` items must drop output-only status and carry typed content. * - `reasoning.effort` is only supported on a subset of models. * - Empty-string content items cause validation failures. * - `function_call_output.output` cannot contain image arrays. @@ -22,7 +22,9 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { extname, isAbsolute, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { supportsReasoningEffort } from '../models/catalog.js'; +import { supportsReasoning, supportsReasoningEffort } from '../models/catalog.js'; + +const ENCRYPTED_REASONING_INCLUDE = 'reasoning.encrypted_content'; // ─── Content text extraction ───────────────────────────────────────────────── @@ -297,19 +299,43 @@ export function sanitizePayload( delete next.response_format; } - // ── Reasoning effort ────────────────────────────────────────────────── - if (supportsReasoningEffort(modelId)) { - const reasoning = next.reasoning as Record | undefined; - if (reasoning) { - const effort = reasoning.effort === 'minimal' ? 'low' : reasoning.effort; - next.reasoning = reasoning.summary !== undefined ? { effort } : { ...reasoning, effort }; - } - } else { + // ── Reasoning request configuration ─────────────────────────────────── + const reasoning = + next.reasoning && typeof next.reasoning === 'object' && !Array.isArray(next.reasoning) + ? { ...(next.reasoning as Record) } + : undefined; + const reasoningSupported = supportsReasoning(modelId); + delete next.reasoningEffort; + + if (!reasoningSupported || !reasoning) { delete next.reasoning; - delete next.reasoningEffort; + } else { + if (supportsReasoningEffort(modelId)) { + if (reasoning.effort === 'minimal') reasoning.effort = 'low'; + if (reasoning.effort === undefined) delete reasoning.effort; + } else { + delete reasoning.effort; + } + + if (Object.keys(reasoning).length === 0) delete next.reasoning; + else next.reasoning = reasoning; } // ── Strip unsupported fields ───────────────────────────────────────── + if (Array.isArray(next.include)) { + const hasReasoning = next.reasoning !== undefined; + let keptEncryptedReasoning = false; + const include = next.include.filter((item) => { + if (item !== ENCRYPTED_REASONING_INCLUDE) return true; + if (!reasoningSupported || keptEncryptedReasoning) return false; + keptEncryptedReasoning = true; + return true; + }); + if (hasReasoning && !keptEncryptedReasoning) include.push(ENCRYPTED_REASONING_INCLUDE); + next.include = include; + } else if (next.reasoning !== undefined) { + next.include = [ENCRYPTED_REASONING_INCLUDE]; + } if (Array.isArray(next.include) && next.include.length === 0) delete next.include; delete next.prompt_cache_retention; diff --git a/tests/models/catalog.test.ts b/tests/models/catalog.test.ts index ee1d3e1..642dcad 100644 --- a/tests/models/catalog.test.ts +++ b/tests/models/catalog.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { resolveModels, supportsReasoningEffort } from '../../src/models/catalog.js'; +import { + resolveModels, + supportsReasoning, + supportsReasoningEffort, +} from '../../src/models/catalog.js'; const originalEnv = { ...process.env }; @@ -15,6 +19,12 @@ describe('model catalog', () => { expect(supportsReasoningEffort('grok-4.20-0309-non-reasoning')).toBe(false); }); + it('reports reasoning support by normalized model name', () => { + expect(supportsReasoning('grok-cli/GROK-BUILD')).toBe(true); + expect(supportsReasoning('grok-cli/GROK-COMPOSER-2.5-fast')).toBe(false); + expect(supportsReasoning('grok-4.20-0309-non-reasoning')).toBe(false); + }); + it('uses fallback models when no override is configured', () => { delete process.env.PI_GROK_CLI_MODELS; @@ -34,7 +44,7 @@ describe('model catalog', () => { input: ['text'], }); expect(models.find((model) => model.id === 'grok-build')).toMatchObject({ - contextWindow: 512_000, + contextWindow: 500_000, }); expect(models.find((model) => model.id === 'grok-4.20-0309-reasoning')).toMatchObject({ cost: { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, @@ -61,5 +71,6 @@ describe('model catalog', () => { maxTokens: 30_000, }); expect(models[1].name).toBe('Grok Build'); + expect(supportsReasoning('grok-cli/CUSTOM-MODEL')).toBe(true); }); }); diff --git a/tests/payload/sanitize.test.ts b/tests/payload/sanitize.test.ts index 082e2bf..924fdaf 100644 --- a/tests/payload/sanitize.test.ts +++ b/tests/payload/sanitize.test.ts @@ -4,6 +4,18 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { sanitizePayload } from '../../src/payload/sanitize.js'; +const sanitizeReasoning = ( + modelId: string, + reasoning: Record, + include?: string[], +) => + sanitizePayload( + { input: 'plain prompt', include, reasoning, reasoningEffort: reasoning.effort }, + modelId, + undefined, + process.cwd(), + ); + describe('payload sanitization', () => { it('removes unsupported items and moves all instructions', () => { const payload = sanitizePayload( @@ -42,7 +54,7 @@ describe('payload sanitization', () => { ]); expect(payload.include).toEqual(['reasoning.encrypted_content', 'message.output_text']); expect(payload.prompt_cache_retention).toBeUndefined(); - expect(payload.reasoning).toEqual({ effort: 'low' }); + expect(payload.reasoning).toEqual({ effort: 'low', summary: 'auto' }); expect(payload.text).toEqual({ format: { type: 'json_object' } }); expect(payload.response_format).toBeUndefined(); expect(payload.prompt_cache_key).toBe('session-123'); @@ -143,12 +155,16 @@ describe('payload sanitization', () => { expect(payload.response_format).toBeUndefined(); }); - it('strips reasoning fields for models that do not accept reasoning effort', () => { + it('preserves reasoning summaries for models without effort support', () => { const payload = sanitizePayload( { input: 'plain prompt', - include: ['reasoning.encrypted_content'], - reasoning: { effort: 'high' }, + include: [ + 'message.output_text', + 'reasoning.encrypted_content', + 'reasoning.encrypted_content', + ], + reasoning: { effort: 'high', summary: 'auto', future_option: 'keep' }, reasoningEffort: 'high', prompt_cache_key: 'existing-session', }, @@ -158,10 +174,37 @@ describe('payload sanitization', () => { ); expect(payload.input).toBe('plain prompt'); + expect(payload.reasoning).toEqual({ summary: 'auto', future_option: 'keep' }); + expect(payload.reasoningEffort).toBeUndefined(); + expect(payload.include).toEqual(['message.output_text', 'reasoning.encrypted_content']); + expect(payload.prompt_cache_key).toBe('existing-session'); + }); + + it('removes empty reasoning requests without adding an encrypted-content include', () => { + const payload = sanitizeReasoning('grok-build', { effort: 'none' }, ['message.output_text']); + expect(payload.reasoning).toBeUndefined(); expect(payload.reasoningEffort).toBeUndefined(); + expect(payload.include).toEqual(['message.output_text']); + }); + + it('adds encrypted-content capture when active reasoning has no include list', () => { + const payload = sanitizeReasoning('grok-build', { effort: 'high', summary: 'detailed' }); + + expect(payload.reasoning).toEqual({ summary: 'detailed' }); expect(payload.include).toEqual(['reasoning.encrypted_content']); - expect(payload.prompt_cache_key).toBe('existing-session'); + }); + + it('removes reasoning fields for non-reasoning models', () => { + const payload = sanitizeReasoning( + 'grok-cli/GROK-COMPOSER-2.5-fast', + { effort: 'high', summary: 'auto' }, + ['message.output_text', 'reasoning.encrypted_content'], + ); + + expect(payload.reasoning).toBeUndefined(); + expect(payload.reasoningEffort).toBeUndefined(); + expect(payload.include).toEqual(['message.output_text']); }); it('normalizes image parts and rewrites image tool output', () => { From 96985019d3d7f965aa0214f83b12ad1fa9bf43da Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 00:17:48 +0900 Subject: [PATCH 05/28] feat(accounts): improve exhaustion rotation Why: - Avoid retrying recently exhausted accounts across adjacent requests while preserving sticky normal routing. What: - Keep exact-402 accounts cooling down for five minutes within the Pi session and clear health on session start or successful relogin. - Rank eligible fallbacks by fresh cached tightest-window quota without fetching billing data. - Preserve circular slots for stale, missing, invalid, or tied quota candidates. Validation: - bun run check (470 tests) - autoreview --mode local --- README.md | 4 +- src/provider/quotaCache.ts | 9 +- src/provider/register.ts | 3 +- src/provider/rotation.ts | 82 +++++++++++-- tests/provider/quotaCache.test.ts | 9 ++ tests/provider/register.test.ts | 67 +++++++++++ tests/provider/rotation.test.ts | 188 ++++++++++++++++++++++++++++-- 7 files changed, 337 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 803ec36..8c200e3 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,9 @@ When at least two configured accounts are logged in, pi-grok-cli rotates after a OpenAI API error (402): 402 "Grok Build usage balance exhausted" ``` -Rotation waits until Pi has finished retries, compaction, and tool activity. It selects the next authenticated account in circular configuration order, preserves the current model when available, and automatically continues the interrupted request once. Accounts already exhausted during that continuation chain are skipped. If every logged-in account returns the exact error, the extension stops without wrapping and reports that all accounts are exhausted. +Rotation waits until Pi has finished retries, compaction, and tool activity. It preserves the current model when available and automatically continues the interrupted request once. Accounts that returned the exact error are skipped for five minutes within the current Pi session, including across new requests and successful continuations. A successful relogin makes that account immediately eligible again. If every logged-in account is exhausted or still cooling down, the extension stops without wrapping and reports that all accounts are exhausted. -The attempted-account set is in memory and applies only to that continuation chain. A new user request starts fresh, so recovered quota can be detected naturally. Similar 402 messages, 401s, 429s, non-Grok errors, and non-final errors do not trigger rotation. A manual model change before failover cancels the pending rotation. Cached quota is informational and never triggers or suppresses rotation. +Within the eligible circular fallback order, quota values cached less than 30 minutes ago rank candidates by their tightest remaining monthly or weekly percentage. Candidates with stale, missing, or invalid quota keep their circular slots, and equal scores keep circular order. Failover reads the cache only—it never fetches billing data—and quota ranking never rotates a healthy account or chooses the initial account. Similar 402 messages, 401s, 429s, non-Grok errors, and non-final errors do not trigger rotation. A manual model change before failover cancels the pending rotation. ### Model-scoped Cursor compatibility diff --git a/src/provider/quotaCache.ts b/src/provider/quotaCache.ts index 8d05c2a..3600e73 100644 --- a/src/provider/quotaCache.ts +++ b/src/provider/quotaCache.ts @@ -11,6 +11,8 @@ export interface QuotaCache { accounts: Record; } +const QUOTA_FRESHNESS_MS = 30 * 60_000; + const emptyCache = (): QuotaCache => ({ version: 1, accounts: {} }); function isDate(value: unknown): value is string { @@ -130,14 +132,17 @@ function formatAge(updatedAt: string, now: number) { return `${Math.floor(age / 86_400_000)}d ago`; } +export function isCachedQuotaFresh(entry: CachedQuota, now = Date.now()) { + return Math.max(0, now - new Date(entry.updatedAt).getTime()) < QUOTA_FRESHNESS_MS; +} + export function formatCachedQuota(entry: CachedQuota, now = Date.now()) { - const age = Math.max(0, now - new Date(entry.updatedAt).getTime()); return [ `Monthly ${entry.monthly.used.toLocaleString()} / ${entry.monthly.monthlyLimit.toLocaleString()} used`, entry.weekly ? `Weekly ${Math.round(entry.weekly.creditUsagePercent)}% used` : 'Weekly unavailable', - ...(age >= 30 * 60_000 ? ['stale'] : []), + ...(!isCachedQuotaFresh(entry, now) ? ['stale'] : []), formatAge(entry.updatedAt, now), ].join(' · '); } diff --git a/src/provider/register.ts b/src/provider/register.ts index c733d2a..707a562 100644 --- a/src/provider/register.ts +++ b/src/provider/register.ts @@ -26,6 +26,7 @@ export default function registerGrokCli(pi: ExtensionAPI) { const migration = migrateLegacyConfig(); const baseUrl = getBaseUrl(); const models = resolveModels(); + const exhaustionRotation = registerExhaustionRotation(pi); const registerAccount = (account: GrokCliAccount) => { const oauthProvider = { @@ -35,6 +36,7 @@ export default function registerGrokCli(pi: ExtensionAPI) { async login(callbacks: OAuthLoginCallbacks): Promise { const credentials = await oauth.login(callbacks); await removeQuotaUsage(account.provider).catch(() => undefined); + exhaustionRotation.clearRecentExhaustion(account.provider); return credentials; }, @@ -82,7 +84,6 @@ export default function registerGrokCli(pi: ExtensionAPI) { for (const account of loadConfig().config.accounts.items) registerAccount(account); const accountManagement = registerAccountManagement(pi, registerAccount); - registerExhaustionRotation(pi); const { webSearchRegistered } = registerGrokTools(pi); registerImagineFeature(pi); diff --git a/src/provider/rotation.ts b/src/provider/rotation.ts index 0d2b866..71a9197 100644 --- a/src/provider/rotation.ts +++ b/src/provider/rotation.ts @@ -2,11 +2,13 @@ import type { AssistantMessage } from '@earendil-works/pi-ai'; import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; import { loadConfig, saveConfig } from '../config.js'; import { DEFAULT_GROK_MODEL, GROK_CLI_PROVIDER, isGrokCliProvider } from './accounts.js'; +import { type CachedQuota, isCachedQuotaFresh, loadQuotaCache } from './quotaCache.js'; export const EXHAUSTED_BALANCE_ERROR = 'OpenAI API error (402): 402 "Grok Build usage balance exhausted"'; export const ROTATION_CONTINUATION = 'Continue the previous request using the newly selected Grok account. Do not repeat completed work.'; +const RECENT_EXHAUSTION_COOLDOWN_MS = 5 * 60_000; type FailedResponse = { model: string; @@ -37,9 +39,40 @@ function circularProviders(providers: string[], current: string) { return [...providers.slice(index + 1), ...providers.slice(0, index)]; } +function quotaScore(entry: CachedQuota | undefined, now: number) { + if (!entry || !isCachedQuotaFresh(entry, now) || entry.monthly.monthlyLimit <= 0) { + return undefined; + } + const monthly = Math.min( + 1, + Math.max(0, (entry.monthly.monthlyLimit - entry.monthly.used) / entry.monthly.monthlyLimit), + ); + if (!entry.weekly) return monthly; + return Math.min(monthly, Math.min(1, Math.max(0, 1 - entry.weekly.creditUsagePercent / 100))); +} + +function orderProvidersByQuota( + providers: string[], + accounts: Record, + now: number, +) { + const scored = providers.flatMap((provider, index) => { + const score = quotaScore(accounts[provider], now); + return score === undefined ? [] : [{ provider, index, score }]; + }); + const ranked = [...scored].sort( + (left, right) => right.score - left.score || left.index - right.index, + ); + return providers.map((provider, index) => { + const scoredIndex = scored.findIndex((candidate) => candidate.index === index); + return scoredIndex < 0 ? provider : ranked[scoredIndex].provider; + }); +} + export function registerExhaustionRotation(pi: ExtensionAPI) { const exhausted = new Set(); const unavailable = new Set(); + const recentlyExhausted = new Map(); let pending: FailedResponse | undefined; let awaitingContinuation = false; @@ -50,6 +83,19 @@ export function registerExhaustionRotation(pi: ExtensionAPI) { awaitingContinuation = false; }; + const isRecentlyExhausted = (provider: string, now: number) => { + const exhaustedAt = recentlyExhausted.get(provider); + if (exhaustedAt === undefined) return false; + if (now - exhaustedAt < RECENT_EXHAUSTION_COOLDOWN_MS) return true; + recentlyExhausted.delete(provider); + return false; + }; + + pi.on('session_start', () => { + clearChain(); + recentlyExhausted.clear(); + }); + pi.on('input', (event) => { if (event.source !== 'extension') clearChain(); }); @@ -67,6 +113,7 @@ export function registerExhaustionRotation(pi: ExtensionAPI) { } pending = { provider: message.provider, model: message.model }; exhausted.add(message.provider); + recentlyExhausted.set(message.provider, Date.now()); awaitingContinuation = false; }); @@ -92,18 +139,21 @@ export function registerExhaustionRotation(pi: ExtensionAPI) { return; } - for (const provider of circularProviders( + const now = Date.now(); + const authenticatedProviders = new Set(authenticated.map((account) => account.provider)); + const eligible = circularProviders( config.accounts.items.map((account) => account.provider), failed.provider, - )) { - if ( - provider === failed.provider || - exhausted.has(provider) || - unavailable.has(provider) || - !authenticated.some((account) => account.provider === provider) - ) { - continue; - } + ).filter( + (provider) => + provider !== failed.provider && + !exhausted.has(provider) && + !isRecentlyExhausted(provider, now) && + !unavailable.has(provider) && + authenticatedProviders.has(provider), + ); + + for (const provider of orderProvidersByQuota(eligible, loadQuotaCache().accounts, now)) { const model = ctx.modelRegistry.find(provider, failed.model) ?? ctx.modelRegistry.find(provider, DEFAULT_GROK_MODEL); @@ -131,7 +181,11 @@ export function registerExhaustionRotation(pi: ExtensionAPI) { return; } - if (authenticated.every((account) => exhausted.has(account.provider))) { + if ( + authenticated.every( + (account) => exhausted.has(account.provider) || isRecentlyExhausted(account.provider, now), + ) + ) { ctx.ui.notify('Grok CLI: all logged-in accounts are exhausted.', 'warning'); } else { ctx.ui.notify( @@ -141,4 +195,10 @@ export function registerExhaustionRotation(pi: ExtensionAPI) { } clearChain(); }); + + return { + clearRecentExhaustion(provider: string) { + recentlyExhausted.delete(provider); + }, + }; } diff --git a/tests/provider/quotaCache.test.ts b/tests/provider/quotaCache.test.ts index 1fb4c3b..0e983b3 100644 --- a/tests/provider/quotaCache.test.ts +++ b/tests/provider/quotaCache.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import type { BillingUsage } from '../../src/provider/billing.js'; import { formatCachedQuota, + isCachedQuotaFresh, loadQuotaCache, removeQuotaUsage, saveQuotaUsage, @@ -104,4 +105,12 @@ describe('Grok CLI quota cache', () => { ), ).toBe('Monthly 300 / 2,000 used · Weekly unavailable · stale · 2h ago'); }); + + it('treats quota as stale exactly thirty minutes after its update', () => { + const entry = { updatedAt: '2026-07-15T10:00:00.000Z', ...usage(300) }; + + expect(isCachedQuotaFresh(entry, Date.parse('2026-07-15T10:29:59.999Z'))).toBe(true); + expect(isCachedQuotaFresh(entry, Date.parse('2026-07-15T10:30:00.000Z'))).toBe(false); + expect(formatCachedQuota(entry, Date.parse('2026-07-15T10:30:00.000Z'))).toContain('stale'); + }); }); diff --git a/tests/provider/register.test.ts b/tests/provider/register.test.ts index f3b2249..7e55487 100644 --- a/tests/provider/register.test.ts +++ b/tests/provider/register.test.ts @@ -116,10 +116,13 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess const providers = new Map(); const tools = new Map(); const handlers = new Map(); + const allHandlers = new Map(); let activeTools = initialActiveTools; const setActiveTools = vi.fn((toolNames: string[]) => { activeTools = toolNames; }); + const setModel = vi.fn(async (_model: { provider: string; id: string }) => true); + const sendUserMessage = vi.fn(); const registerGrokCli = (await import('../../src/index.js')).default; registerGrokCli({ registerProvider(name: string, config: ProviderConfig) { @@ -127,6 +130,7 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess }, on(event: string, handler: ExtensionHandler) { handlers.set(event, handler); + allHandlers.set(event, [...(allHandlers.get(event) ?? []), handler]); }, registerCommand(name: string, config: unknown) { commands.set(name, config as CommandConfig); @@ -153,6 +157,8 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess ].map((name) => ({ name })); }, setActiveTools, + setModel, + sendUserMessage, } as unknown as ExtensionAPI); return { commands, @@ -160,6 +166,11 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess tools, handlers, setActiveTools, + setModel, + sendUserMessage, + async emit(event: string, data: unknown, ctx: TestContext) { + for (const handler of allHandlers.get(event) ?? []) await handler(data, ctx); + }, getActiveTools: () => activeTools, replaceActiveTools(nextTools: string[]) { activeTools = [...nextTools]; @@ -589,6 +600,62 @@ describe('Grok CLI provider registration', () => { expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); }); + it('makes a recently exhausted account eligible after successful OAuth login', async () => { + saveTestAccounts('grok-cli'); + const extension = await setupExtension(); + const context = { + model: { provider: 'grok-cli', id: 'grok-build' }, + modelRegistry: { + authStorage: { has: (provider: string) => provider.startsWith('grok-cli') }, + find: (provider: string, id: string) => ({ provider, id }), + getAll: () => [], + }, + ui: { notify: vi.fn() }, + }; + const exhausted = (provider: string) => ({ + type: 'message_end', + message: { + role: 'assistant', + provider, + model: 'grok-build', + stopReason: 'error', + errorMessage: 'OpenAI API error (402): 402 "Grok Build usage balance exhausted"', + }, + }); + + await extension.emit('message_end', exhausted('grok-cli'), context as TestContext); + await extension.emit('agent_settled', { type: 'agent_settled' }, context as TestContext); + context.model = { provider: 'grok-cli-2', id: 'grok-build' }; + await extension.emit( + 'input', + { type: 'input', source: 'extension', text: 'continue after rotation' }, + context as TestContext, + ); + await extension.emit( + 'message_end', + { + type: 'message_end', + message: { + role: 'assistant', + provider: 'grok-cli-2', + model: 'grok-build', + stopReason: 'stop', + }, + }, + context as TestContext, + ); + await extension.emit('agent_settled', { type: 'agent_settled' }, context as TestContext); + + await extension.providers.get('grok-cli')?.oauth?.login({} as OAuthLoginCallbacks); + await extension.emit('message_end', exhausted('grok-cli-2'), context as TestContext); + await extension.emit('agent_settled', { type: 'agent_settled' }, context as TestContext); + + expect(extension.setModel.mock.calls.map(([model]) => model.provider)).toEqual([ + 'grok-cli-2', + 'grok-cli', + ]); + }); + it('registers provider metadata and OAuth helpers', async () => { const extension = await setupExtension(); const provider = extension.providers.get('grok-cli'); diff --git a/tests/provider/rotation.test.ts b/tests/provider/rotation.test.ts index 2210697..9e24a25 100644 --- a/tests/provider/rotation.test.ts +++ b/tests/provider/rotation.test.ts @@ -1,6 +1,8 @@ import { AuthStorage, type ExtensionAPI } from '@earendil-works/pi-coding-agent'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import type { BillingUsage } from '../../src/provider/billing.js'; +import { saveQuotaUsage } from '../../src/provider/quotaCache.js'; import { EXHAUSTED_BALANCE_ERROR, ROTATION_CONTINUATION, @@ -14,6 +16,32 @@ const THREE_ACCOUNTS = [ { provider: 'grok-cli-2', label: 'Work' }, { provider: 'grok-cli-3', label: 'Client' }, ]; +const FOUR_ACCOUNTS = [...THREE_ACCOUNTS, { provider: 'grok-cli-4', label: 'Reserve' }]; +const NOW = Date.parse('2026-07-16T12:00:00.000Z'); +const originalFetch = globalThis.fetch; + +afterEach(() => { + vi.useRealTimers(); + globalThis.fetch = originalFetch; +}); + +function usage(monthlyRemaining: number, weeklyRemaining?: number): BillingUsage { + return { + monthly: { + monthlyLimit: 100, + used: 100 - monthlyRemaining, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + ...(weeklyRemaining === undefined + ? {} + : { + weekly: { + creditUsagePercent: 100 - weeklyRemaining, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }), + }; +} function setup( options: { @@ -70,7 +98,7 @@ function setup( sendUserMessage, setModel, } as unknown as ExtensionAPI; - registerExhaustionRotation(pi); + const rotation = registerExhaustionRotation(pi); return { context, @@ -78,6 +106,7 @@ function setup( notify: context.ui.notify, sendUserMessage, setModel, + rotation, async emit(event: string, data: unknown = { type: event }) { for (const handler of handlers.get(event) ?? []) await handler(data, context); }, @@ -119,6 +148,17 @@ function switchedProviders(extension: ReturnType) { return extension.setModel.mock.calls.map(([model]) => model.provider); } +async function setupAfterSuccessfulContinuation() { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup(); + await settleExhaustion(extension, 'grok-cli'); + await emitExtensionContinuation(extension); + await extension.emit('message_end', assistant('grok-cli-2', '', 'grok-build', 'stop')); + await extension.emit('agent_settled'); + return extension; +} + describe('Grok CLI exhaustion rotation', () => { it('switches only after agent_settled, preserves the model, and continues once', async () => { const extension = setup({ current: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' } }); @@ -234,27 +274,157 @@ describe('Grok CLI exhaustion rotation', () => { ); }); - it('starts a fresh chain for new real user input', async () => { + it('keeps recently exhausted accounts unavailable across new real user input', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); const extension = setup(); await settleExhaustion(extension, 'grok-cli'); await extension.emit('input', { type: 'input', source: 'interactive', text: 'try again' }); await settleExhaustion(extension, 'grok-cli-2'); + expect(switchedProviders(extension)).toEqual(['grok-cli-2']); + expect(extension.notify).toHaveBeenCalledWith( + 'Grok CLI: all logged-in accounts are exhausted.', + 'warning', + ); + }); + + it('keeps recently exhausted accounts unavailable after a successful continuation', async () => { + const extension = await setupAfterSuccessfulContinuation(); + await extension.emit('message_end', assistant('grok-cli-2')); + await extension.emit('agent_settled'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2']); + }); + + it('makes an account eligible again exactly five minutes after exhaustion', async () => { + const extension = await setupAfterSuccessfulContinuation(); + vi.setSystemTime(NOW + 5 * 60_000); + await settleExhaustion(extension, 'grok-cli-2'); + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); }); - it('clears the chain after the continuation settles successfully', async () => { - const extension = setup(); + it('clears recent exhaustion when a new session starts', async () => { + const extension = await setupAfterSuccessfulContinuation(); + await extension.emit('session_start'); + await settleExhaustion(extension, 'grok-cli-2'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + }); + + it('allows successful login to clear one account’s recent exhaustion', async () => { + const extension = await setupAfterSuccessfulContinuation(); + extension.rotation.clearRecentExhaustion('grok-cli'); + await settleExhaustion(extension, 'grok-cli-2'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + }); + + it('orders fresh cached quota by the tightest remaining window without fetching', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ accounts: THREE_ACCOUNTS }); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + await saveQuotaUsage('grok-cli-2', usage(90, 10), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(50), new Date(NOW).toISOString()); await settleExhaustion(extension, 'grok-cli'); - await emitExtensionContinuation(extension); - await extension.emit('message_end', assistant('grok-cli-2', '', 'grok-build', 'stop')); + + expect(switchedProviders(extension)).toEqual(['grok-cli-3']); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('continues through missing models and failed switches in quota-ranked order', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ + accounts: FOUR_ACCOUNTS, + current: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' }, + missingModels: ['grok-cli-3/grok-composer-2.5-fast', 'grok-cli-3/grok-build'], + setModel: [false, true], + }); + await saveQuotaUsage('grok-cli-2', usage(50), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(90), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-4', usage(70), new Date(NOW).toISOString()); + + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); await extension.emit('agent_settled'); - await extension.emit('message_end', assistant('grok-cli-2')); + + expect(extension.setModel.mock.calls).toEqual([ + [{ provider: 'grok-cli-4', id: 'grok-composer-2.5-fast' }], + [{ provider: 'grok-cli-2', id: 'grok-composer-2.5-fast' }], + ]); + expect(extension.sendUserMessage).toHaveBeenCalledOnce(); + }); + + it.each([ + ['missing', undefined], + ['stale', new Date(NOW - 30 * 60_000).toISOString()], + ['invalid', new Date(NOW).toISOString()], + ])('keeps a %s quota candidate in its circular slot', async (_name, updatedAt) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ accounts: FOUR_ACCOUNTS, setModel: [false, true] }); + if (updatedAt) { + await saveQuotaUsage( + 'grok-cli-2', + updatedAt === new Date(NOW).toISOString() + ? { + monthly: { + monthlyLimit: 0, + used: 0, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + } + : usage(50), + updatedAt, + ); + } + await saveQuotaUsage('grok-cli-3', usage(20), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-4', usage(80), new Date(NOW).toISOString()); + + await settleExhaustion(extension, 'grok-cli'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli-4']); + }); + + it('preserves circular order when fresh quota scores tie', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ accounts: THREE_ACCOUNTS }); + await saveQuotaUsage('grok-cli-2', usage(60, 40), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(40), new Date(NOW).toISOString()); + + await settleExhaustion(extension, 'grok-cli'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2']); + }); + + it('excludes a recently exhausted account even when it has the best cached quota', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ + accounts: THREE_ACCOUNTS, + current: { provider: 'grok-cli-2', id: 'grok-build' }, + }); + await saveQuotaUsage('grok-cli', usage(10), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-2', usage(95), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(20), new Date(NOW).toISOString()); + + await settleExhaustion(extension, 'grok-cli-2'); + await emitExtensionContinuation(extension); + await extension.emit('message_end', assistant('grok-cli-3', '', 'grok-build', 'stop')); await extension.emit('agent_settled'); + extension.context.model = { provider: 'grok-cli', id: 'grok-build' }; + await settleExhaustion(extension, 'grok-cli'); - expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + expect(switchedProviders(extension)).toEqual(['grok-cli-3', 'grok-cli-3']); }); it('cancels a pending rotation after a manual model change', async () => { From 503789c9e98a6db299413c651e91ebf118816c7e Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 01:35:40 +0900 Subject: [PATCH 06/28] fix(accounts): preserve active model during removal --- src/provider/accounts.ts | 9 ++++---- tests/provider/accounts.test.ts | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts index 2918161..d797e1f 100644 --- a/src/provider/accounts.ts +++ b/src/provider/accounts.ts @@ -416,13 +416,14 @@ async function fallbackBeforeRemoval( config: GrokCliConfig, removed: GrokCliAccount, ) { - const needsFallback = - config.accounts.selectedProvider === removed.provider || - ctx.model?.provider === removed.provider; - if (!needsFallback) return undefined; const candidates = config.accounts.items.filter( (account) => account.provider !== removed.provider && hasAccountAuth(ctx, account.provider), ); + if (ctx.model?.provider !== removed.provider) { + return config.accounts.selectedProvider === removed.provider + ? (candidates[0]?.provider ?? GROK_CLI_PROVIDER) + : config.accounts.selectedProvider; + } for (const account of candidates) { const target = ctx.modelRegistry.find(account.provider, ctx.model?.id ?? DEFAULT_GROK_MODEL) ?? diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts index d86c587..3cdc12e 100644 --- a/tests/provider/accounts.test.ts +++ b/tests/provider/accounts.test.ts @@ -727,6 +727,47 @@ describe('/grok-cli-accounts', () => { expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); }); + it('does not switch a non-Grok model when removing the selected alias', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: authenticatedAccounts(), + confirms: [true], + model: { provider: 'openai', id: 'gpt-5' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Active', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + }); + + it('keeps the selected account when removing another inactive alias', async () => { + configureAccounts( + 'grok-cli-2', + [...TEST_ACCOUNTS, { provider: 'grok-cli-3', label: 'Client' }], + 4, + ); + const extension = setup({ + auth: { + ...authenticatedAccounts(), + 'grok-cli-3': oauthCredential('client'), + }, + confirms: [true], + model: { provider: 'openai', id: 'gpt-5' }, + preserveHome: true, + selections: ['Manage accounts', 'Client — Logged in', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-3'); + }); + it('defers unregistering an active alias when no authenticated fallback exists', async () => { configureAccounts('grok-cli-2'); const extension = setup({ From cbc9e03c67355ca3b7c1760251831cb45629d712 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 12:43:26 +0900 Subject: [PATCH 07/28] feat(accounts): add local account management dashboard Why: - Give users a local web UI to add, switch, rename, log in/out, and monitor quota across Grok accounts instead of TUI-only flows. What: - Loopback-only server gated by a capability cookie, CSRF header, host check, and strict security headers; browser navigations get an HTML error page instead of raw JSON. - Account grid with quota meters, login progress, and semantic status colors (green ok / amber pending / red error); cyan is reserved for interactive elements. - Add-account modal opens the login popup with the final OAuth ticket URL, so embedded browsers that cannot script an about:blank popup (e.g. WKWebView) still complete login. - Fix danger-button hover contrast where the ghost danger rule bled into the primary variant. Validation: - bun run lint:ci - bun run typecheck - bun run knip - AGENT=1 bun run test (484 passed) Note: depends on the uncommitted account-manager changes in src/provider/accounts.ts; this does not build standalone without them. --- src/provider/dashboard/app.css | 764 ++++++++++++++++++++++++++++++ src/provider/dashboard/app.js | 474 ++++++++++++++++++ src/provider/dashboard/index.html | 64 +++ src/provider/dashboard/server.ts | 522 ++++++++++++++++++++ tests/provider/dashboard.test.ts | 405 ++++++++++++++++ 5 files changed, 2229 insertions(+) create mode 100644 src/provider/dashboard/app.css create mode 100644 src/provider/dashboard/app.js create mode 100644 src/provider/dashboard/index.html create mode 100644 src/provider/dashboard/server.ts create mode 100644 tests/provider/dashboard.test.ts diff --git a/src/provider/dashboard/app.css b/src/provider/dashboard/app.css new file mode 100644 index 0000000..87a9911 --- /dev/null +++ b/src/provider/dashboard/app.css @@ -0,0 +1,764 @@ +:root { + color-scheme: dark; + --bg: #0b0f11; + --panel: #111619; + --panel-raised: #161c20; + --border: #232b30; + --border-strong: #2f383e; + --text: #e9edee; + --muted: #9aa7ab; + --dim: #849298; + --accent: #4fd1e8; + --accent-ink: #052730; + --ok: #4ade80; + --ok-soft: rgb(74 222 128 / 12%); + --ok-ring: rgb(74 222 128 / 38%); + --amber: #f5b453; + --amber-soft: rgb(245 180 83 / 12%); + --red: #f26d64; + --red-soft: rgb(242 109 100 / 10%); + --meter-empty: #1b2327; + --appbar-bg: rgb(11 15 17 / 85%); + --input-bg: #0a0e10; + --accent-hover: #86e5f3; + --danger-ink: #200a08; + --danger-hover: #ff867e; + --ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + background: var(--bg); + color: var(--text); + font-family: var(--ui); + font-size: 14px; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--bg); +} + +body { + min-height: 100vh; + margin: 0; + background: var(--bg); +} + +button, +input { + font: inherit; +} + +button { + border: 0; +} + +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +[hidden] { + display: none; +} + +/* ---------- App bar ---------- */ + +.appbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + border-bottom: 1px solid var(--border); + background: var(--appbar-bg); + backdrop-filter: blur(14px); +} + +.appbar-brand { + display: flex; + min-width: 0; + gap: 10px; + align-items: center; +} + +.brand-mark { + width: 22px; + height: 22px; + flex: none; +} + +.brand-name { + font-size: 13.5px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.brand-meta { + overflow: hidden; + color: var(--dim); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-meta::before { + margin: 0 8px 0 0; + color: var(--border-strong); + content: "·"; +} + +.appbar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.link-pill { + display: inline-flex; + gap: 7px; + align-items: center; + height: 28px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.link-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--dim); +} + +.link-pill.ok .link-dot { + background: var(--ok); +} + +.link-pill.pending .link-dot { + background: var(--amber); + animation: pulse 1.6s ease-in-out infinite; +} + +.link-pill.error { + border-color: rgb(242 109 100 / 35%); + color: var(--red); +} + +.link-pill.error .link-dot { + background: var(--red); +} + +input { + min-width: 0; + height: 34px; + border: 1px solid var(--border); + border-radius: 7px; + padding: 0 11px; + background: var(--input-bg); + color: var(--text); + font-size: 13px; +} + +input::placeholder { + color: var(--dim); +} + +input:hover { + border-color: var(--border-strong); +} + +/* ---------- Buttons ---------- */ + +.button, +.icon-button { + display: inline-flex; + gap: 7px; + align-items: center; + justify-content: center; + height: 34px; + border: 1px solid transparent; + border-radius: 7px; + padding: 0 13px; + cursor: pointer; + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0; + white-space: nowrap; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease; +} + +.button.primary { + background: var(--accent); + color: var(--accent-ink); +} + +.button.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.button.primary.danger { + background: var(--red); + color: var(--danger-ink); +} + +.button.primary.danger:hover:not(:disabled) { + background: var(--danger-hover); + color: var(--danger-ink); +} + +.button.ghost { + border-color: var(--border); + background: transparent; + color: var(--text); +} + +.button.ghost:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--panel-raised); +} + +.button.danger { + border-color: transparent; + background: transparent; + color: var(--muted); +} + +.button.danger:hover:not(:disabled) { + background: var(--red-soft); + color: var(--red); +} + +.button.small { + height: 30px; + padding: 0 10px; + font-size: 12px; +} + +.icon-button { + width: 34px; + padding: 0; + border-color: var(--border); + background: transparent; + color: var(--muted); + font-size: 15px; +} + +.icon-button:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--panel-raised); + color: var(--text); +} + +.refresh-glyph { + display: inline-block; + line-height: 1; +} + +.is-refreshing .refresh-glyph { + animation: spin 900ms linear infinite; +} + +.link-button { + padding: 0; + border: 0; + background: none; + color: var(--muted); + cursor: pointer; + font-size: 12px; + text-align: left; + text-decoration: underline; + text-decoration-color: var(--border-strong); + text-underline-offset: 3px; +} + +.link-button:hover:not(:disabled) { + color: var(--red); + text-decoration-color: var(--red); +} + +/* ---------- Account grid ---------- */ + +main { + width: min(1200px, 100%); + margin: 0 auto; + padding: 20px 20px 72px; +} + +.account-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 370px), 1fr)); + gap: 14px; +} + +.grid-message { + grid-column: 1 / -1; + padding: 56px 24px; + border: 1px dashed var(--border-strong); + border-radius: 10px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; + text-align: center; +} + +/* ---------- Account card ---------- */ + +.account-card { + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + background: var(--panel); + animation: card-enter 220ms ease both; +} + +.account-card.active { + border-color: var(--ok-ring); + background: linear-gradient(160deg, rgb(74 222 128 / 4%), transparent 45%), var(--panel); +} + +.card-head { + display: grid; + gap: 7px; +} + +.card-title-row { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.card-title-row h3 { + overflow: hidden; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-meta-row { + display: flex; + gap: 8px; + align-items: center; +} + +.card-provider { + flex: none; + color: var(--dim); + font-family: var(--mono); + font-size: 11px; +} + +.active-badge { + padding: 3px 8px; + border-radius: 999px; + background: var(--ok-soft); + color: var(--ok); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.status-pill { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 3px 9px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-raised); + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.status-pill span:last-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.status-dot { + width: 6px; + height: 6px; + flex: none; + border-radius: 50%; + background: var(--dim); +} + +.status-pill.ok .status-dot { + background: var(--ok); +} + +.status-pill.pending { + border-color: rgb(245 180 83 / 30%); + color: var(--amber); +} + +.status-pill.pending .status-dot { + background: var(--amber); + animation: pulse 1.6s ease-in-out infinite; +} + +.status-pill.error { + border-color: rgb(242 109 100 / 30%); + color: var(--red); +} + +.status-pill.error .status-dot { + background: var(--red); +} + +/* ---------- Card body: quotas ---------- */ + +.card-body { + display: grid; + gap: 13px; +} + +.quota-head, +.quota-meta { + display: flex; + gap: 10px; + align-items: baseline; + justify-content: space-between; +} + +.quota-head { + margin-bottom: 6px; + font-size: 12px; +} + +.quota-head span:first-child { + color: var(--muted); +} + +.quota-pct { + color: var(--text); + font-family: var(--mono); + font-size: 11.5px; +} + +.quota-meta { + margin-top: 6px; + color: var(--dim); + font-size: 11.5px; +} + +.quota-meta .mono { + font-family: var(--mono); + font-size: 11px; +} + +.meter { + height: 6px; + overflow: hidden; + border-radius: 3px; + background: var(--meter-empty); +} + +.meter-fill { + height: 100%; + width: var(--meter-value); + border-radius: 3px; + background: var(--ok); + transform-origin: left; + animation: meter-rise 500ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.meter.warning .meter-fill { + background: var(--amber); +} + +.meter.danger .meter-fill { + background: var(--red); +} + +.quota-unavailable { + color: var(--dim); + font-size: 11.5px; +} + +.card-empty { + padding: 18px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + color: var(--dim); + font-size: 12.5px; + line-height: 1.55; +} + +.freshness { + display: flex; + gap: 8px; + align-items: center; + color: var(--dim); + font-size: 11.5px; +} + +.tag { + padding: 2px 7px; + border-radius: 999px; + background: var(--amber-soft); + color: var(--amber); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.card-error { + margin: 0; + border: 1px solid rgb(242 109 100 / 28%); + border-radius: 7px; + padding: 9px 11px; + background: var(--red-soft); + color: var(--red); + font-size: 12px; + line-height: 1.5; +} + +/* ---------- Card actions ---------- */ + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; + margin-top: auto; + padding-top: 13px; + border-top: 1px solid var(--border); +} + +.card-actions .push-right { + margin-left: auto; +} + +/* ---------- Login panel ---------- */ + +.login-panel { + display: grid; + gap: 10px; + border: 1px solid rgb(245 180 83 / 25%); + border-radius: 8px; + padding: 12px; + background: rgb(245 180 83 / 6%); +} + +.login-panel p { + color: var(--amber); + font-size: 12px; + line-height: 1.5; +} + +.login-row { + display: flex; + gap: 8px; +} + +.login-row input { + flex: 1; + font-family: var(--mono); + font-size: 12px; +} + +/* ---------- Dialog ---------- */ + +dialog { + width: min(440px, calc(100% - 32px)); + border: 1px solid var(--border-strong); + border-radius: 12px; + padding: 0; + background: var(--panel-raised); + color: var(--text); + box-shadow: 0 24px 64px rgb(0 0 0 / 55%); +} + +dialog::backdrop { + background: rgb(4 6 8 / 70%); + backdrop-filter: blur(4px); +} + +.dialog-panel { + padding: 24px; +} + +.dialog-panel h2 { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.dialog-message { + margin: 10px 0 20px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.dialog-panel label { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; +} + +.dialog-panel input { + width: 100%; +} + +.dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 22px; +} + +/* ---------- Toast ---------- */ + +.toast { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 20; + width: min(400px, calc(100% - 36px)); + border: 1px solid var(--border-strong); + border-radius: 10px; + padding: 13px 15px; + background: var(--panel-raised); + color: var(--text); + box-shadow: 0 16px 40px rgb(0 0 0 / 45%); + font-size: 12.5px; + line-height: 1.5; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} + +.toast.error { + border-color: rgb(242 109 100 / 40%); +} + +/* ---------- Animations ---------- */ + +/* Entrance motion runs on first mount only; poll-driven re-renders stay still. */ +.account-grid.settled .account-card, +.account-grid.settled .meter-fill { + animation: none; +} + +@keyframes card-enter { + from { + opacity: 0; + transform: translateY(8px); + } +} + +@keyframes meter-rise { + from { + transform: scaleX(0); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + 50% { + opacity: 0.4; + } +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 720px) { + .appbar { + padding: 10px 14px; + } + + .appbar-actions { + width: 100%; + } + + main { + padding: 14px 14px 56px; + } +} + +/* ---------- Coarse pointers ---------- */ + +@media (pointer: coarse) { + .button, + .icon-button, + input { + height: 44px; + } + + .icon-button { + width: 44px; + } + + .button.small { + height: 40px; + } +} + +@media (prefers-reduced-motion: reduce) { + .account-card, + .meter-fill, + .link-pill.pending .link-dot, + .status-pill.pending .status-dot, + .is-refreshing .refresh-glyph { + animation: none; + } + + .button, + .icon-button, + .toast { + transition: none; + } +} diff --git a/src/provider/dashboard/app.js b/src/provider/dashboard/app.js new file mode 100644 index 0000000..b5fc6ba --- /dev/null +++ b/src/provider/dashboard/app.js @@ -0,0 +1,474 @@ +const csrf = document.querySelector('meta[name="grok-csrf"]').content; +const accountsRoot = document.querySelector('#accounts'); +const addAccount = document.querySelector('#add-account'); +const refreshButton = document.querySelector('#refresh-quotas'); +const statsSummary = document.querySelector('#stats-summary'); +const linkState = document.querySelector('#link-state'); +const linkText = document.querySelector('#link-text'); +const dialog = document.querySelector('#action-dialog'); +const dialogTitle = document.querySelector('#dialog-title'); +const dialogMessage = document.querySelector('#dialog-message'); +const dialogLabel = document.querySelector('#dialog-label'); +const dialogInput = document.querySelector('#dialog-input'); +const dialogConfirm = document.querySelector('#dialog-confirm'); +const dialogCancel = document.querySelector('#dialog-cancel'); +const toastStatus = document.querySelector('#toast'); +const toastAlert = document.querySelector('#toast-alert'); + +let lastState = ''; +let entranceDone = false; +let timer; + +const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +}; + +const makeToast = (node) => { + let dismiss; + const hide = () => { + clearTimeout(dismiss); + node.classList.remove('visible'); + }; + node.addEventListener('pointerenter', () => clearTimeout(dismiss)); + node.addEventListener('pointerleave', () => { + if (node.classList.contains('visible')) dismiss = setTimeout(hide, 2500); + }); + return { + hide, + show: (message) => { + clearTimeout(dismiss); + node.textContent = message; + node.classList.add('visible'); + dismiss = setTimeout(hide, 4800); + }, + }; +}; + +const statusToast = makeToast(toastStatus); +const alertToast = makeToast(toastAlert); + +const showToast = (message, error = false) => { + (error ? statusToast : alertToast).hide(); + (error ? alertToast : statusToast).show(message); +}; + +const api = async (path, options = {}) => { + const response = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Grok-CSRF': csrf, + ...options.headers, + }, + }); + const body = response.status === 204 ? {} : await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `Dashboard request failed (${response.status})`); + return body; +}; + +const mutation = (path, method, body = {}) => api(path, { method, body: JSON.stringify(body) }); + +const modal = ({ title, message, value, confirm = 'Confirm', danger = false, cancel = true }) => + new Promise((resolve) => { + dialogTitle.textContent = title; + dialogMessage.textContent = message; + dialogConfirm.textContent = confirm; + dialogConfirm.className = danger ? 'button primary danger' : 'button primary'; + dialogCancel.hidden = !cancel; + const hasInput = value !== undefined; + dialogLabel.hidden = !hasInput; + dialogInput.hidden = !hasInput; + dialogInput.value = value ?? ''; + const close = () => { + dialog.removeEventListener('close', close); + resolve(dialog.returnValue === 'confirm' ? (hasInput ? dialogInput.value : true) : undefined); + }; + dialog.addEventListener('close', close); + dialog.showModal(); + if (hasInput) dialogInput.select(); + }); + +dialogCancel.addEventListener('click', () => dialog.close('cancel')); + +const percent = (used, limit) => + !Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0 + ? 0 + : Math.max(0, Math.min(100, (used / limit) * 100)); + +const dateLabel = (value) => + new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); + +const quotaRow = (label, usedLabel, reset, value) => { + const row = element('div', 'quota'); + const header = element('div', 'quota-head'); + header.append(element('span', '', label), element('span', 'quota-pct', `${Math.round(value)}%`)); + const meter = element('div', `meter${value >= 95 ? ' danger' : value >= 75 ? ' warning' : ''}`); + meter.setAttribute('role', 'progressbar'); + meter.setAttribute('aria-label', `${label}: ${Math.round(value)} percent used`); + meter.setAttribute('aria-valuemin', '0'); + meter.setAttribute('aria-valuemax', '100'); + meter.setAttribute('aria-valuenow', String(Math.round(value))); + const fill = element('div', 'meter-fill'); + fill.style.setProperty('--meter-value', `${value}%`); + meter.append(fill); + const meta = element('div', 'quota-meta'); + if (usedLabel) meta.append(element('span', 'mono', usedLabel)); + meta.append(element('span', '', `Resets ${dateLabel(reset)}`)); + row.append(header, meter, meta); + return row; +}; + +const quotaUnavailable = (label, reason) => { + const row = element('div', 'quota'); + const header = element('div', 'quota-head'); + header.append(element('span', '', label)); + row.append(header, element('p', 'quota-unavailable', reason)); + return row; +}; + +const statusPill = (account) => { + const variant = account.login.error + ? 'error' + : account.login.state === 'pending' + ? 'pending' + : account.authenticated + ? 'ok' + : ''; + const pill = element('p', `status-pill${variant ? ` ${variant}` : ''}`); + const dot = element('span', 'status-dot'); + dot.setAttribute('aria-hidden', 'true'); + pill.append(dot, element('span', '', account.status)); + return pill; +}; + +const actionButton = (label, action, kind = 'ghost') => { + const button = element('button', `button small ${kind}`, label); + button.type = 'button'; + button.dataset.action = label; + button.addEventListener('click', action); + return button; +}; + +const startLogin = async (provider) => { + // Open the popup with the final URL instead of scripting a blank one: embedded + // browsers (e.g. WKWebView) hand window.open('') an unusable about:blank view. + try { + const ticket = await mutation(`/api/accounts/${provider}/login-ticket`, 'POST'); + if (!window.open(ticket.path, `grok-login-${provider}`)) { + showToast('Pop-up blocked. Allow pop-ups, then use Log in on the account card.', true); + return; + } + await refreshState(true); + } catch (error) { + showToast(error.message, true); + } +}; + +const loginPanel = (account) => { + const panel = element('form', 'login-panel'); + panel.append(element('p', '', account.login.progress || 'Waiting for browser authorization…')); + const row = element('div', 'login-row'); + const input = element('input'); + input.name = 'code'; + input.autocomplete = 'off'; + input.placeholder = 'One-time code (if shown)'; + input.setAttribute('aria-label', 'One-time authorization code'); + input.dataset.action = 'code'; + const submit = element('button', 'button small primary', 'Submit'); + submit.type = 'submit'; + row.append(input, submit); + const cancel = element('button', 'link-button', 'Cancel login'); + cancel.type = 'button'; + cancel.dataset.action = 'Cancel login'; + cancel.addEventListener('click', async () => { + try { + await mutation(`/api/accounts/${account.provider}/login-cancel`, 'POST'); + await refreshState(true); + } catch (error) { + showToast(error.message, true); + } + }); + panel.append(row, cancel); + panel.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!input.value.trim()) return; + try { + await mutation(`/api/accounts/${account.provider}/login-code`, 'POST', { + code: input.value, + }); + input.value = ''; + showToast('Authorization code submitted.'); + } catch (error) { + showToast(error.message, true); + } + }); + return panel; +}; + +const cardActions = (account) => { + const actions = element('footer', 'card-actions'); + const activate = async () => { + try { + await mutation(`/api/accounts/${account.provider}/activate`, 'POST'); + await refreshState(true); + } catch (error) { + showToast(error.message, true); + } + }; + const rename = async () => { + const label = await modal({ + title: `Rename ${account.label}`, + message: 'Labels are local and identify this provider throughout Pi.', + value: account.label, + confirm: 'Save label', + }); + if (label === undefined) return; + try { + await mutation(`/api/accounts/${account.provider}`, 'PATCH', { label }); + await refreshState(true); + } catch (error) { + showToast(error.message, true); + } + }; + const tokenInstructions = async () => { + await modal({ + title: 'Environment token', + message: + 'Unset GROK_CLI_OAUTH_TOKEN and restart Pi to remove this environment-managed login.', + confirm: 'Close', + cancel: false, + }); + }; + const destructive = async () => { + const confirmed = await modal({ + title: + account.provider === 'grok-cli' ? `Log out ${account.label}?` : `Remove ${account.label}?`, + message: + account.provider === 'grok-cli' + ? 'This removes the saved OAuth login. The permanent base account remains available.' + : 'This removes the saved OAuth login and deletes this account slot.', + confirm: account.provider === 'grok-cli' ? 'Log out' : 'Remove account', + danger: true, + }); + if (!confirmed) return; + try { + await mutation( + account.provider === 'grok-cli' + ? '/api/accounts/grok-cli/logout' + : `/api/accounts/${account.provider}`, + account.provider === 'grok-cli' ? 'POST' : 'DELETE', + ); + await refreshState(true); + } catch (error) { + showToast(error.message, true); + } + }; + + if (!account.authenticated && !account.environment) { + actions.append(actionButton('Log in', () => startLogin(account.provider), 'primary')); + } else if (!account.active && account.authenticated) { + actions.append(actionButton('Switch', activate, 'primary')); + } + if (!account.environment && account.authenticated) { + actions.append(actionButton('Log in again', () => startLogin(account.provider))); + } + if (account.environment) { + actions.append(actionButton('Token instructions', tokenInstructions)); + } + actions.append(actionButton('Rename', rename)); + if (!account.environment) { + const button = actionButton( + account.provider === 'grok-cli' ? 'Log out' : 'Remove', + destructive, + 'danger push-right', + ); + actions.append(button); + } + return actions; +}; + +const accountCard = (account, index) => { + const card = element('article', `account-card${account.active ? ' active' : ''}`); + card.dataset.provider = account.provider; + card.style.animationDelay = `${Math.min(index * 40, 200)}ms`; + + const head = element('header', 'card-head'); + const titleRow = element('div', 'card-title-row'); + titleRow.append(element('h3', '', account.label)); + if (account.active) titleRow.append(element('span', 'active-badge', 'Active')); + const metaRow = element('div', 'card-meta-row'); + metaRow.append(element('span', 'card-provider', account.provider), statusPill(account)); + head.append(titleRow, metaRow); + + const body = element('div', 'card-body'); + if (account.login.state === 'pending') { + body.append(loginPanel(account)); + card.append(head, body); + return card; + } + if (account.quota) { + body.append( + quotaRow( + 'Monthly credits', + `${account.quota.monthly.used.toLocaleString()} / ${account.quota.monthly.monthlyLimit.toLocaleString()} used`, + account.quota.monthly.billingPeriodEnd, + percent(account.quota.monthly.used, account.quota.monthly.monthlyLimit), + ), + ); + body.append( + account.quota.weekly + ? quotaRow( + 'Weekly allowance', + '', + account.quota.weekly.billingPeriodEnd, + Math.max(0, Math.min(100, account.quota.weekly.creditUsagePercent)), + ) + : quotaUnavailable('Weekly allowance', 'Data unavailable'), + ); + const freshness = element('p', 'freshness'); + if (!account.quota.fresh) freshness.append(element('span', 'tag', 'Stale')); + freshness.append(element('span', '', `Updated ${dateLabel(account.quota.updatedAt)}`)); + body.append(freshness); + } else { + body.append( + element( + 'p', + 'card-empty', + account.authenticated + ? 'Quota not fetched — refresh to load usage.' + : 'Quota available once this account is logged in.', + ), + ); + } + if (account.login.error || account.login.quotaError) { + body.append(element('p', 'card-error', account.login.error || account.login.quotaError)); + } + + card.append(head, body, cardActions(account)); + return card; +}; + +const render = (state) => { + const online = state.accounts.filter((account) => account.authenticated).length; + statsSummary.textContent = `${state.accounts.length} account${state.accounts.length === 1 ? '' : 's'} · ${online} usable`; + linkState.className = 'link-pill ok'; + linkText.textContent = 'Synced'; + refreshButton.disabled = state.refreshing; + refreshButton.classList.toggle('is-refreshing', state.refreshing); + accountsRoot.setAttribute('aria-busy', String(state.refreshing)); + const active = document.activeElement; + const refocus = + active instanceof HTMLElement && accountsRoot.contains(active) && active.dataset.action + ? { + provider: active.closest('[data-provider]')?.dataset.provider, + action: active.dataset.action, + } + : undefined; + const codes = new Map( + [...accountsRoot.querySelectorAll('[data-provider] input[name="code"]')] + .map((input) => [input.closest('[data-provider]').dataset.provider, input.value]) + .filter(([, value]) => value), + ); + if (entranceDone) accountsRoot.classList.add('settled'); + accountsRoot.replaceChildren(...state.accounts.map(accountCard)); + entranceDone = true; + for (const [provider, value] of codes) { + const input = accountsRoot.querySelector(`[data-provider="${provider}"] input[name="code"]`); + if (input) input.value = value; + } + if (refocus?.provider) { + accountsRoot + .querySelector(`[data-provider="${refocus.provider}"] [data-action="${refocus.action}"]`) + ?.focus(); + } +}; + +const schedule = (state) => { + clearTimeout(timer); + if (document.hidden) return; + const pending = + state.refreshing || state.accounts.some((account) => account.login.state === 'pending'); + timer = setTimeout(() => refreshState(), pending ? 2000 : 15000); +}; + +async function refreshState(force = false) { + try { + const state = await api('/api/state'); + const serialized = JSON.stringify(state); + if (force || serialized !== lastState) { + lastState = serialized; + render(state); + } + schedule(state); + } catch (error) { + clearTimeout(timer); + linkState.className = 'link-pill error'; + linkText.textContent = 'Offline'; + accountsRoot.setAttribute('aria-busy', 'false'); + accountsRoot.replaceChildren( + element( + 'p', + 'grid-message', + 'Dashboard connection lost. Run /grok-cli-accounts gui to reopen it.', + ), + ); + showToast(error.message, true); + } +} + +addAccount.addEventListener('click', async () => { + const label = await modal({ + title: 'Add account', + message: + 'The label is optional and local to this dashboard. A browser window will open for xAI authorization.', + value: '', + confirm: 'Add + log in', + }); + if (label === undefined) return; + try { + const account = await mutation('/api/accounts', 'POST', { label }); + await refreshState(true); + await startLogin(account.provider); + } catch (error) { + showToast(error.message, true); + } +}); + +refreshButton.addEventListener('click', async () => { + refreshButton.disabled = true; + refreshButton.classList.add('is-refreshing'); + try { + const result = await mutation('/api/quotas/refresh', 'POST'); + showToast( + `Updated ${result.updated} account${result.updated === 1 ? '' : 's'}; ${result.failed.length} failed.`, + ); + await refreshState(true); + } catch (error) { + showToast(error.message, true); + await refreshState(true); + } +}); + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + clearTimeout(timer); + return; + } + void refreshState(true); +}); + +let lastFocusRefresh = 0; +window.addEventListener('focus', () => { + if (Date.now() - lastFocusRefresh < 5000) return; + lastFocusRefresh = Date.now(); + void refreshState(); +}); +void refreshState(true); diff --git a/src/provider/dashboard/index.html b/src/provider/dashboard/index.html new file mode 100644 index 0000000..bdd25dc --- /dev/null +++ b/src/provider/dashboard/index.html @@ -0,0 +1,64 @@ + + + + + + + Grok accounts + + + + + +
+
+ +

Grok accounts

+ Connecting… +
+
+ + Syncing + + + +
+
+ +
+ +
+ + +
+

+

+ + +
+ + +
+
+
+ +
+ + + diff --git a/src/provider/dashboard/server.ts b/src/provider/dashboard/server.ts new file mode 100644 index 0000000..51f5ae8 --- /dev/null +++ b/src/provider/dashboard/server.ts @@ -0,0 +1,522 @@ +import { spawn } from 'node:child_process'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AuthInteraction } from '@earendil-works/pi-ai'; +import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; +import type { AccountManager } from '../accounts.js'; + +const HOST = '127.0.0.1'; +const COOKIE = 'grok_cli_dashboard'; +const MAX_BODY_BYTES = 8 * 1024; +const DEFAULT_IDLE_MS = 15 * 60_000; +const SECURITY_HEADERS = { + 'Cache-Control': 'no-store', + 'Content-Security-Policy': + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', +}; + +class HttpError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + } +} + +export interface AccountDashboardHandle { + origin: string; + bootstrapUrl: string; + csrfToken: string; + isOpen(): boolean; + close(): Promise; +} + +interface DashboardOptions { + idleMs?: number; + refreshAfterLogin?: boolean; +} + +interface AccountDashboardOptions extends DashboardOptions { + launchBrowser?: (url: string) => Promise; +} + +type LoginState = 'pending' | 'success' | 'failed' | 'cancelled'; + +interface LoginJob { + controller: AbortController; + state: LoginState; + progress?: string; + error?: string; + quotaError?: string; + resolveManualCode: (code: string) => void; +} + +function safeEqual(left: string, right: string) { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +function cookieValue(req: IncomingMessage) { + return req.headers.cookie + ?.split(';') + .map((part) => part.trim().split('=')) + .find(([name]) => name === COOKIE)?.[1]; +} + +function send(res: ServerResponse, status: number, body = '', contentType = 'text/plain') { + res.writeHead(status, { + ...SECURITY_HEADERS, + 'Content-Type': `${contentType}; charset=utf-8`, + }); + res.end(body); +} + +function json(res: ServerResponse, status: number, value: unknown) { + send(res, status, JSON.stringify(value), 'application/json'); +} + +async function readJson(req: IncomingMessage) { + if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) { + throw new HttpError(415, 'Expected application/json.'); + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) throw new HttpError(413, 'Request body is too large.'); + chunks.push(buffer); + } + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; + } catch { + throw new HttpError(400, 'Request body must be valid JSON.'); + } +} + +function objectBody(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new HttpError(400, 'Request body must be a JSON object.'); + } + return value as Record; +} + +function publicError(error: unknown) { + if (!(error instanceof Error)) return 'The dashboard action failed.'; + return error.message.replace(/https?:\/\/\S+/g, '[redacted URL]').slice(0, 240); +} + +function escapeHtml(value: string) { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function accountProvider(pathname: string, suffix = '') { + const match = new RegExp(`^/api/accounts/(grok-cli(?:-[2-9][0-9]*)?)${suffix}$`).exec(pathname); + return match?.[1]; +} + +function trustedAuthorizationUrl(value: string) { + const url = new URL(value); + const hostname = url.hostname.toLowerCase(); + if ( + url.protocol !== 'https:' || + (hostname !== 'x.ai' && + hostname !== 'accounts.x.ai' && + hostname !== 'auth.x.ai' && + !hostname.endsWith('.x.ai')) + ) { + throw new Error('Refusing an untrusted OAuth authorization URL.'); + } + return url.toString(); +} + +function launchBrowser(target: string) { + const command: [string, string[]] = + process.platform === 'darwin' + ? ['open', [target]] + : process.platform === 'win32' + ? ['rundll32', ['url.dll,FileProtocolHandler', target]] + : ['xdg-open', [target]]; + return new Promise((resolve) => { + const child = spawn(command[0], command[1], { + detached: true, + stdio: 'ignore', + }); + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + resolve(result); + }; + child.once('spawn', () => finish(true)); + child.once('error', () => finish(false)); + child.unref(); + }); +} + +export function createAccountDashboard( + manager: AccountManager, + options: AccountDashboardOptions = {}, +) { + let dashboard: AccountDashboardHandle | undefined; + return { + async open(ctx: ExtensionContext) { + if (!dashboard?.isOpen()) { + dashboard = await startAccountDashboard(manager, ctx, options); + } + if (!(await (options.launchBrowser ?? launchBrowser)(dashboard.bootstrapUrl))) { + ctx.ui.notify( + `Could not open the account dashboard automatically. Open this private local URL and do not share it: ${dashboard.bootstrapUrl}`, + 'warning', + ); + } + return dashboard; + }, + async close() { + await dashboard?.close(); + dashboard = undefined; + }, + }; +} + +export async function startAccountDashboard( + manager: AccountManager, + ctx: ExtensionContext, + options: DashboardOptions = {}, +): Promise { + const capability = randomBytes(32).toString('base64url'); + const csrfToken = randomBytes(32).toString('base64url'); + const html = readFileSync(new URL('./index.html', import.meta.url), 'utf8').replace( + '__GROK_CSRF_TOKEN__', + csrfToken, + ); + const css = readFileSync(new URL('./app.css', import.meta.url), 'utf8'); + const javascript = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); + let origin = ''; + let expectedHost = ''; + let idleTimer: ReturnType | undefined; + let closing: Promise | undefined; + let refreshController: AbortController | undefined; + const loginTickets = new Map(); + const loginJobs = new Map(); + + const server = createServer((req, res) => { + void handle(req, res).catch((error: unknown) => { + if (res.headersSent) { + res.end(); + return; + } + const status = error instanceof HttpError ? error.status : 500; + if (req.headers.accept?.includes('text/html')) { + const hint = + status === 401 + ? '

Run /grok-cli-accounts gui in Pi to reopen the dashboard.

' + : ''; + send( + res, + status, + `Grok accounts

Grok accounts

${escapeHtml(publicError(error))}

${hint}`, + 'text/html', + ); + return; + } + json(res, status, { error: publicError(error) }); + }); + }); + + const close = () => { + if (closing) return closing; + if (idleTimer) clearTimeout(idleTimer); + refreshController?.abort(); + for (const job of loginJobs.values()) { + if (job.state === 'pending') job.controller.abort(); + job.resolveManualCode(''); + } + closing = new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + }); + return closing; + }; + + const touch = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => void close(), options.idleMs ?? DEFAULT_IDLE_MS); + idleTimer.unref(); + }; + + const requireMutation = (req: IncomingMessage) => { + if (req.headers.origin !== origin || req.headers['x-grok-csrf'] !== csrfToken) { + throw new HttpError(403, 'Dashboard request validation failed.'); + } + }; + + const state = () => { + const snapshot = manager.snapshot(ctx); + return { + refreshing: Boolean(refreshController), + accounts: snapshot.accounts.map((account) => { + const job = loginJobs.get(account.provider); + return { + ...account, + login: job + ? { + state: job.state, + ...(job.progress ? { progress: job.progress } : {}), + ...(job.error ? { error: job.error } : {}), + ...(job.quotaError ? { quotaError: job.quotaError } : {}), + } + : { state: 'idle' }, + }; + }), + }; + }; + + const startLogin = async (provider: string, res: ServerResponse) => { + const account = manager + .snapshot(ctx) + .accounts.find((candidate) => candidate.provider === provider); + if (!account) throw new HttpError(404, 'Grok CLI account not found.'); + if (account.environment) { + throw new HttpError(409, 'The environment-managed account cannot log in from the dashboard.'); + } + if (loginJobs.get(provider)?.state === 'pending') { + throw new HttpError(409, 'A login is already in progress for this account.'); + } + const controller = new AbortController(); + let resolveManualCode = (_code: string) => {}; + const manualCode = new Promise((resolve) => { + resolveManualCode = resolve; + }); + const job: LoginJob = { + controller, + state: 'pending', + progress: 'Waiting for xAI authorization', + resolveManualCode, + }; + loginJobs.set(provider, job); + let settledRedirect = false; + let finishRedirect = () => {}; + const redirected = new Promise((resolve) => { + finishRedirect = resolve; + }); + const interaction: AuthInteraction = { + signal: controller.signal, + notify(event) { + if (event.type === 'progress' || event.type === 'info') { + job.progress = publicError(new Error(event.message)); + return; + } + if (event.type === 'device_code') { + throw new Error('Device-code login remains available in the Pi TUI.'); + } + const location = trustedAuthorizationUrl(event.url); + settledRedirect = true; + res.writeHead(302, { ...SECURITY_HEADERS, Location: location }); + res.end(); + finishRedirect(); + }, + async prompt(prompt) { + if (prompt.type === 'manual_code') return manualCode; + if (prompt.type === 'select') return 'browser'; + throw new Error('Interactive OAuth prompts are not supported in the dashboard.'); + }, + }; + void manager.login(ctx, provider, interaction).then( + async () => { + job.state = 'success'; + job.progress = 'Login complete'; + job.resolveManualCode(''); + if (options.refreshAfterLogin === false) return; + try { + const result = await manager.refreshOne(ctx, provider, controller.signal); + if (result.failed.length) job.quotaError = 'Login succeeded, but quota refresh failed.'; + } catch { + job.quotaError = 'Login succeeded, but quota refresh failed.'; + } + }, + (error: unknown) => { + job.state = controller.signal.aborted ? 'cancelled' : 'failed'; + job.error = controller.signal.aborted ? 'Login cancelled.' : publicError(error); + job.resolveManualCode(''); + if (!settledRedirect) { + send( + res, + 502, + 'Login failed

Grok login failed

You can close this window and retry from the dashboard.

', + 'text/html', + ); + finishRedirect(); + } + }, + ); + await redirected; + }; + + async function handle(req: IncomingMessage, res: ServerResponse) { + touch(); + if (req.headers.host !== expectedHost) throw new HttpError(421, 'Invalid dashboard host.'); + const url = new URL(req.url ?? '/', origin); + if (req.method === 'GET' && url.pathname === `/bootstrap/${capability}`) { + res.writeHead(302, { + ...SECURITY_HEADERS, + Location: '/', + 'Set-Cookie': `${COOKIE}=${capability}; HttpOnly; SameSite=Strict; Path=/`, + }); + res.end(); + return; + } + if (!safeEqual(cookieValue(req) ?? '', capability)) { + throw new HttpError(401, 'Dashboard authorization is required.'); + } + if (req.method === 'GET' && url.pathname === '/') { + send(res, 200, html, 'text/html'); + return; + } + if (req.method === 'GET' && url.pathname === '/app.css') { + send(res, 200, css, 'text/css'); + return; + } + if (req.method === 'GET' && url.pathname === '/app.js') { + send(res, 200, javascript, 'text/javascript'); + return; + } + if (req.method === 'GET' && url.pathname === '/api/state') { + json(res, 200, state()); + return; + } + if (req.method === 'POST' && url.pathname === '/api/accounts') { + requireMutation(req); + const body = objectBody(await readJson(req)); + if (body.label !== undefined && typeof body.label !== 'string') { + throw new HttpError(400, 'Account label must be text.'); + } + json(res, 201, await manager.add(ctx, body.label ?? '')); + return; + } + const renameProvider = accountProvider(url.pathname); + if (req.method === 'PATCH' && renameProvider) { + requireMutation(req); + const body = objectBody(await readJson(req)); + if (typeof body.label !== 'string') { + throw new HttpError(400, 'Account label must be text.'); + } + json(res, 200, await manager.rename(ctx, renameProvider, body.label)); + return; + } + const activateProvider = accountProvider(url.pathname, '/activate'); + if (req.method === 'POST' && activateProvider) { + requireMutation(req); + json(res, 200, await manager.activate(ctx, activateProvider)); + return; + } + const logoutProvider = accountProvider(url.pathname, '/logout'); + if (req.method === 'POST' && logoutProvider) { + requireMutation(req); + json(res, 200, await manager.logout(ctx, logoutProvider)); + return; + } + if (req.method === 'DELETE' && renameProvider) { + requireMutation(req); + json(res, 200, await manager.remove(ctx, renameProvider)); + return; + } + if (req.method === 'POST' && url.pathname === '/api/quotas/refresh') { + requireMutation(req); + if (refreshController) throw new HttpError(409, 'A quota refresh is already running.'); + refreshController = new AbortController(); + try { + json(res, 200, await manager.refresh(ctx, refreshController.signal)); + } finally { + refreshController = undefined; + } + return; + } + const ticketProvider = accountProvider(url.pathname, '/login-ticket'); + if (req.method === 'POST' && ticketProvider) { + requireMutation(req); + const account = manager + .snapshot(ctx) + .accounts.find((candidate) => candidate.provider === ticketProvider); + if (!account) throw new HttpError(404, 'Grok CLI account not found.'); + if (account.environment) { + throw new HttpError( + 409, + 'The environment-managed account cannot log in from the dashboard.', + ); + } + const ticket = randomBytes(24).toString('base64url'); + loginTickets.set(ticket, { provider: ticketProvider, expiresAt: Date.now() + 60_000 }); + json(res, 201, { path: `/oauth/${ticket}` }); + return; + } + const ticket = /^\/oauth\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]; + if (req.method === 'GET' && ticket) { + const loginTicket = loginTickets.get(ticket); + loginTickets.delete(ticket); + if (!loginTicket || loginTicket.expiresAt < Date.now()) { + throw new HttpError(404, 'Login link expired.'); + } + await startLogin(loginTicket.provider, res); + return; + } + const codeProvider = accountProvider(url.pathname, '/login-code'); + if (req.method === 'POST' && codeProvider) { + requireMutation(req); + const body = objectBody(await readJson(req)); + if (typeof body.code !== 'string' || !body.code.trim()) { + throw new HttpError(400, 'Authorization code is required.'); + } + const job = loginJobs.get(codeProvider); + if (job?.state !== 'pending') throw new HttpError(409, 'No login is waiting.'); + job.resolveManualCode(body.code.trim()); + json(res, 202, { accepted: true }); + return; + } + const cancelProvider = accountProvider(url.pathname, '/login-cancel'); + if (req.method === 'POST' && cancelProvider) { + requireMutation(req); + const job = loginJobs.get(cancelProvider); + if (job?.state !== 'pending') throw new HttpError(409, 'No login is running.'); + job.controller.abort(); + job.resolveManualCode(''); + json(res, 202, { cancelled: true }); + return; + } + throw new HttpError(404, 'Dashboard route not found.'); + } + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, HOST, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + await close(); + throw new Error('Could not determine the dashboard address.'); + } + expectedHost = `${HOST}:${address.port}`; + origin = `http://${expectedHost}`; + server.unref(); + touch(); + + return { + origin, + bootstrapUrl: `${origin}/bootstrap/${capability}`, + csrfToken, + isOpen: () => server.listening, + close, + }; +} diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts new file mode 100644 index 0000000..c23e0ef --- /dev/null +++ b/tests/provider/dashboard.test.ts @@ -0,0 +1,405 @@ +import { + InMemoryCredentialStore, + type OAuthCredentials, + type OAuthLoginCallbacks, +} from '@earendil-works/pi-ai'; +import { + type ExtensionAPI, + type ExtensionContext, + ModelRegistry, + ModelRuntime, +} from '@earendil-works/pi-coding-agent'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import { registerAccountManagement } from '../../src/provider/accounts.js'; +import { + type AccountDashboardHandle, + createAccountDashboard, + startAccountDashboard, +} from '../../src/provider/dashboard/server.js'; +import { oauthCredential, useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); +const dashboards: AccountDashboardHandle[] = []; + +afterEach(async () => { + await Promise.all(dashboards.splice(0).map((dashboard) => dashboard.close())); + vi.restoreAllMocks(); +}); + +async function setup() { + setupHome(); + saveConfig(DEFAULT_CONFIG); + const credentials = new InMemoryCredentialStore(); + await credentials.modify('grok-cli', async () => oauthCredential('personal')); + const runtime = await ModelRuntime.create({ + credentials, + modelsPath: null, + allowModelNetwork: false, + }); + const loginFlows = new Map< + string, + (callbacks: OAuthLoginCallbacks) => Promise + >(); + const registerAccount = (account: { provider: string; label: string }) => { + runtime.registerProvider(account.provider, { + name: account.label, + baseUrl: 'https://example.test', + api: 'openai-responses', + models: [ + { + id: 'grok-build', + name: 'Grok Build', + reasoning: true, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + oauth: { + name: account.label, + usesCallbackServer: true, + login: (callbacks) => + loginFlows.get(account.provider)?.(callbacks) ?? + Promise.reject(new Error('No test login flow registered.')), + refreshToken: async (credential) => credential, + getApiKey: (credential) => credential.access, + }, + }); + }; + registerAccount({ provider: 'grok-cli', label: 'Account 1' }); + const modelRegistry = new ModelRegistry(runtime); + const pi = { + registerCommand: vi.fn(), + setModel: vi.fn(async () => true), + unregisterProvider: vi.fn((provider: string) => runtime.unregisterProvider(provider)), + } as unknown as ExtensionAPI; + const accountManagement = registerAccountManagement(pi, registerAccount); + const ctx = { + model: { provider: 'grok-cli', id: 'grok-build' }, + modelRegistry, + ui: { notify: vi.fn() }, + } as unknown as ExtensionContext; + return { + accountManagement, + credentials, + ctx, + loginFlows, + pi, + runtime, + async setCredential(provider: string, credential: ReturnType) { + await credentials.modify(provider, async () => credential); + await runtime.refresh({ allowNetwork: false }); + }, + }; +} + +async function bootstrap(dashboard: AccountDashboardHandle) { + const response = await fetch(dashboard.bootstrapUrl, { redirect: 'manual' }); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/'); + return response.headers.get('set-cookie')?.split(';')[0] ?? ''; +} + +const mutationHeaders = (dashboard: AccountDashboardHandle, cookie: string) => ({ + Cookie: cookie, + Origin: dashboard.origin, + 'Content-Type': 'application/json', + 'X-Grok-CSRF': dashboard.csrfToken, +}); + +async function openDashboard(options?: Parameters[2]) { + const extension = await setup(); + const dashboard = await startAccountDashboard( + extension.accountManagement.manager, + extension.ctx, + options, + ); + dashboards.push(dashboard); + const cookie = await bootstrap(dashboard); + return { + extension, + dashboard, + cookie, + headers: mutationHeaders(dashboard, cookie), + }; +} + +async function waitForAccount( + dashboard: AccountDashboardHandle, + cookie: string, + provider: string, + predicate: (account: Record) => boolean, +) { + await vi.waitFor(async () => { + const state = (await ( + await fetch(`${dashboard.origin}/api/state`, { headers: { Cookie: cookie } }) + ).json()) as { accounts: Record[] }; + expect(predicate(state.accounts.find((account) => account.provider === provider) ?? {})).toBe( + true, + ); + }); +} + +describe('account dashboard loopback server', () => { + it('requires its capability cookie and serves credential-free state with strict headers', async () => { + const session = await openDashboard({ refreshAfterLogin: false }); + + expect((await fetch(`${session.dashboard.origin}/api/state`)).status).toBe(401); + const htmlError = await fetch(`${session.dashboard.origin}/`, { + headers: { Accept: 'text/html' }, + }); + expect(htmlError.status).toBe(401); + expect(htmlError.headers.get('content-type')).toContain('text/html'); + expect(await htmlError.text()).toContain('/grok-cli-accounts gui'); + const page = await fetch(session.dashboard.origin, { + headers: { Cookie: session.cookie }, + }); + const state = await fetch(`${session.dashboard.origin}/api/state`, { + headers: { Cookie: session.cookie }, + }); + + expect(page.status).toBe(200); + expect(page.headers.get('content-security-policy')).toContain("default-src 'self'"); + expect(page.headers.get('cache-control')).toBe('no-store'); + expect(await page.text()).toContain('Grok accounts'); + expect(await state.json()).toMatchObject({ + accounts: [ + { + provider: 'grok-cli', + label: 'Account 1', + authenticated: true, + active: true, + }, + ], + }); + expect( + JSON.stringify( + await ( + await fetch(`${session.dashboard.origin}/api/state`, { + headers: { Cookie: session.cookie }, + }) + ).json(), + ), + ).not.toContain('personal'); + }); + + it('validates origin and csrf before applying account mutations', async () => { + const session = await openDashboard(); + + expect( + ( + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: { ...session.headers, Origin: 'https://evil.example' }, + body: JSON.stringify({ label: 'Work' }), + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: { ...session.headers, 'X-Grok-CSRF': 'wrong' }, + body: JSON.stringify({ label: 'Work' }), + }) + ).status, + ).toBe(403); + + const added = await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: ' Work ' }), + }); + + expect(added.status).toBe(201); + expect(await added.json()).toMatchObject({ provider: 'grok-cli-2', label: 'Work' }); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-2', + label: 'Work', + }); + }); + + it('renames, activates, logs out, and removes accounts through the shared manager', async () => { + const session = await openDashboard(); + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: 'Work' }), + }); + await session.extension.setCredential('grok-cli-2', oauthCredential('work')); + + const renamed = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2`, { + method: 'PATCH', + headers: session.headers, + body: JSON.stringify({ label: 'Client' }), + }); + const activated = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2/activate`, { + method: 'POST', + headers: session.headers, + body: '{}', + }); + const removed = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2`, { + method: 'DELETE', + headers: session.headers, + body: '{}', + }); + const loggedOut = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli/logout`, { + method: 'POST', + headers: session.headers, + body: '{}', + }); + + expect(renamed.status).toBe(200); + expect(activated.status).toBe(200); + expect(session.extension.pi.setModel).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'grok-cli-2', id: 'grok-build' }), + ); + expect(removed.status).toBe(200); + expect(loggedOut.status).toBe(200); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Account 1' }, + ]); + expect(await session.extension.credentials.read('grok-cli')).toBeUndefined(); + }); + + it('redirects browser login without exposing credentials and accepts manual codes', async () => { + const session = await openDashboard({ refreshAfterLogin: false }); + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: 'Work' }), + }); + session.extension.loginFlows.set('grok-cli-2', async (callbacks) => { + callbacks.onAuth({ url: 'https://accounts.x.ai/authorize?state=browser-state' }); + const code = await callbacks.onManualCodeInput?.(); + if (code !== 'manual-code') throw new Error('manual code rejected'); + return oauthCredential('dashboard-access'); + }); + + const ticket = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2/login-ticket`, { + method: 'POST', + headers: session.headers, + body: '{}', + }); + const path = ((await ticket.json()) as { path: string }).path; + const redirect = await fetch(`${session.dashboard.origin}${path}`, { + headers: { Cookie: session.cookie }, + redirect: 'manual', + }); + + expect(ticket.status).toBe(201); + expect(redirect.status).toBe(302); + expect(redirect.headers.get('location')).toBe( + 'https://accounts.x.ai/authorize?state=browser-state', + ); + await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2/login-code`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ code: 'manual-code' }), + }); + await waitForAccount( + session.dashboard, + session.cookie, + 'grok-cli-2', + (account) => account.authenticated === true, + ); + const state = await ( + await fetch(`${session.dashboard.origin}/api/state`, { + headers: { Cookie: session.cookie }, + }) + ).text(); + + expect(state).not.toContain('dashboard-access'); + expect(state).not.toContain('manual-code'); + expect(state).not.toContain('browser-state'); + }); + + it('serves DOM-safe client code and rejects malformed or oversized mutations', async () => { + const session = await openDashboard(); + const script = await ( + await fetch(`${session.dashboard.origin}/app.js`, { + headers: { Cookie: session.cookie }, + }) + ).text(); + const malformed = await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: '{', + }); + const oversized = await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: 'x'.repeat(9000) }), + }); + + expect(script).toContain('textContent'); + expect(script).not.toContain('innerHTML'); + expect(malformed.status).toBe(400); + expect(oversized.status).toBe(413); + }); + + it('serves an accessible page shell with brand-consistent client behavior', async () => { + const session = await openDashboard(); + const page = await ( + await fetch(session.dashboard.origin, { + headers: { Cookie: session.cookie }, + }) + ).text(); + const script = await ( + await fetch(`${session.dashboard.origin}/app.js`, { + headers: { Cookie: session.cookie }, + }) + ).text(); + + // The confirm button stays the dialog's default action (Enter submits, never cancels). + expect(page).toContain('id="dialog-cancel" type="button"'); + // Failures are announced assertively; routine status stays polite. + expect(page).toContain('role="alert"'); + expect(page).toContain('role="status"'); + // The favicon matches the in-app brand mark (Signal Cyan, not mint). + expect(page).toContain('%234fd1e8'); + expect(page).not.toContain('%233fe0c5'); + // Re-renders preserve keyboard focus and typed login codes. + expect(script).toContain('data-action'); + expect(script).toContain('[data-provider]'); + // Entrance motion runs once instead of replaying on every poll. + expect(script).toContain('settled'); + // Toasts pause their dismiss timer on hover. + expect(script).toContain('pointerenter'); + }); + + it('reuses one server, reports browser-launch failures, and closes cleanly', async () => { + const extension = await setup(); + const launchBrowser = vi.fn(async () => false); + const dashboard = createAccountDashboard(extension.accountManagement.manager, { + launchBrowser, + }); + + const first = await dashboard.open(extension.ctx); + const second = await dashboard.open(extension.ctx); + + expect(second.origin).toBe(first.origin); + expect(launchBrowser).toHaveBeenCalledTimes(2); + expect(extension.ctx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining(first.bootstrapUrl), + 'warning', + ); + await dashboard.close(); + await expect(fetch(first.origin)).rejects.toThrow(); + }); + + it('expires an abandoned server after its idle timeout', async () => { + const extension = await setup(); + const dashboard = await startAccountDashboard( + extension.accountManagement.manager, + extension.ctx, + { idleMs: 20 }, + ); + dashboards.push(dashboard); + await new Promise((resolve) => setTimeout(resolve, 50)); + + await expect(fetch(dashboard.origin)).rejects.toThrow(); + }); +}); From c200f549c0ac4e211e93d9e48849500e43feb44c Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 14:04:40 +0900 Subject: [PATCH 08/28] feat(accounts): wire dashboard into /grok-cli-accounts gui Why: - Expose the local account dashboard through the accounts command and share one account-mutation path between the TUI and the dashboard server, completing the dashboard work started in cbc9e03. What: - Extract an AccountManager in accounts.ts that serializes mutations and owns add, rename, activate, login, logout, remove, refresh, and snapshot; the TUI selector and dashboard server both go through it. - Add the /grok-cli-accounts gui subcommand, reject unknown arguments, and close the dashboard on session shutdown. - Adopt pi 0.80.9 APIs: ProviderConfig['oauth'], getProviderAuthStatus, and ModelRuntime login/logout instead of direct authStorage access. - Restyle the dashboard with the Linear-inspired dark theme (indigo accent, inverted primary buttons, subtle inset highlights). - Document the dashboard in README and SECURITY; ignore local agent tooling artifacts in .gitignore. Validation: - bun run check (typecheck, knip, biome lint, 484 tests passed) --- .gitignore | 5 + README.md | 8 +- SECURITY.md | 1 + bun.lock | 22 +- package.json | 14 +- src/provider/accounts.ts | 497 +++++++++++++------ src/provider/dashboard/app.css | 94 ++-- src/provider/dashboard/index.html | 6 +- src/provider/register.ts | 15 +- src/provider/rotation.ts | 2 +- tests/provider/accounts.test.ts | 117 ++++- tests/provider/dashboard.test.ts | 6 +- tests/provider/package.test.ts | 9 +- tests/provider/register.test.ts | 14 +- tests/provider/rotation.test.ts | 15 +- tests/provider/toolScope.integration.test.ts | 19 +- 16 files changed, 579 insertions(+), 265 deletions(-) diff --git a/.gitignore b/.gitignore index b76e3ab..8c4224a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ dist/ docs/ *.tgz .DS_Store +.agents +.impeccable +DESIGN.md +PRODUCT.md +.playwright diff --git a/README.md b/README.md index 8c200e3..a78fdca 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Use your X Premium or SuperGrok subscription in [pi](https://pi.dev/) with a cle - **Model-scoped compatibility** — Cursor-style tool names only for Grok Build and Composer 2.5. All other models keep Pi's native tools. - **Usage tracking** — check account limits, remaining credits, and reset times from pi. -> Requires pi 0.80.0 or newer and an xAI/Grok account with access to the selected model. Model availability varies by account, plan, region, and xAI rollout. The Grok Build executable is not required. +> Requires pi 0.80.9 or newer and an xAI/Grok account with access to the selected model. Model availability varies by account, plan, region, and xAI rollout. The Grok Build executable is not required. > > pi-grok-cli is an unofficial community integration. It does not bypass xAI access controls, quotas, or billing. @@ -41,7 +41,7 @@ Choose **Grok CLI**, then select one of these methods: - **Browser login (default)** — opens xAI authorization and completes a PKCE exchange through a local loopback callback. If xAI shows a one-time code instead of redirecting to pi, paste that code into pi to complete the active PKCE exchange. - **Device code login (headless)** — displays a URL and short code for SSH, containers, and other headless environments. -To add another login, run `/grok-cli-accounts`, choose **+ Add account**, and optionally label it. The extension adds a stable provider alias and pre-fills `/login `; press Enter to complete Pi's native login flow. Use only accounts you own or are authorized to access. +To add another login, run `/grok-cli-accounts`, choose **+ Add account**, and optionally label it. The extension adds a stable provider alias and pre-fills `/login `; press Enter to complete Pi's native login flow. For a visual account and quota dashboard, run `/grok-cli-accounts gui`; adding an account there starts browser login immediately. Use only accounts you own or are authorized to access. ### 3. Select a model @@ -88,6 +88,8 @@ Use `/login` to authenticate through a browser or device code. Pi stores and ref Run `/grok-cli-accounts` to add, switch, rename, relogin, log out, or remove accounts. Each account is registered as an independent Pi provider (`grok-cli`, `grok-cli-2`, and so on), so Pi keeps its OAuth credential separately in `auth.json`. The base `grok-cli` slot is permanent; aliases can be removed. Existing aliases keep their provider IDs, and a removed alias number is reused by the next account when it is the lowest available slot. +Run `/grok-cli-accounts gui` to open the same account controls as a visual local dashboard with monthly and weekly quota meters. The dashboard binds to a random `127.0.0.1` port for the current Pi session, completes browser login without exposing credentials to the page, and closes on Pi session shutdown or after 15 minutes without requests. Its private local URL grants access to account controls while the server is running; do not share it. Browser login and manual one-time-code entry are supported in the dashboard, while device-code login remains available through the TUI for headless environments. + The account selectors show the last cached quota for every logged-in account as consumed monthly and weekly usage. Press `r` to refresh all logged-in accounts without switching models or changing the selected account. Refreshes run three accounts at a time; a failed account keeps its previous cached value and is marked as failed for the current dialog. Values older than 30 minutes are labeled stale. Opening the selector does not refresh automatically. `GROK_CLI_OAUTH_TOKEN` applies only to the permanent base account. When it is set, the accounts UI identifies the environment token and tells you to unset it instead of presenting a logout action that cannot remove it. @@ -147,7 +149,7 @@ Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any acti | Command | Description | | --- | --- | -| `/grok-cli-accounts` | Add, switch, rename, relogin, log out, or remove Grok accounts. | +| `/grok-cli-accounts [gui]` | Manage Grok accounts in the TUI, or add `gui` for the local visual account and quota dashboard. | | `/grok-cli-usage` | Fetch current quota, update its cache, and show cached data if refresh fails. | | `/grok-cli-imagine ` | Generate and preview an image. Supports `--aspect`, `--out`, and `--resolution 1k`. | | `/grok-cli-imagine:tool [on\|off\|status]` | Toggle, set, or report persistent model-callable `image_gen` availability. | diff --git a/SECURITY.md b/SECURITY.md index ef28fb8..c68ef6e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,6 +20,7 @@ The maintainer aims to acknowledge a complete report within 7 calendar days and - pi-grok-cli and its tools run with the user's operating-system permissions. File and shell tools can read or modify the workspace and execute commands when the model invokes them. - OAuth credentials returned by this extension are stored and refreshed by pi. pi-grok-cli does not read or modify Grok Build credentials. +- `/grok-cli-accounts gui` starts a temporary account-management server bound only to an OS-assigned `127.0.0.1` port. A random capability URL bootstraps a session cookie; subsequent mutations require same-origin and CSRF validation. The page receives account labels, status, and quota data, but never OAuth credentials, authorization codes, callback URLs, or environment-token values. Treat the private dashboard URL as sensitive and do not share it while the server is running. - Prompts, conversation context, tool definitions, and tool results are sent to the configured Grok CLI proxy. - Images handled by vision routing are sent to the configured describer model. The local vision cache stores descriptions and hashes, not raw images. - Optional web search is delegated to `pi-web-access` and the providers configured there. diff --git a/bun.lock b/bun.lock index 46a1d4e..305308c 100644 --- a/bun.lock +++ b/bun.lock @@ -10,10 +10,10 @@ }, "devDependencies": { "@biomejs/biome": "2.4.16", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.9", + "@earendil-works/pi-ai": "^0.80.9", + "@earendil-works/pi-coding-agent": "^0.80.9", + "@earendil-works/pi-tui": "^0.80.9", "@vitest/coverage-v8": "^4.1.8", "husky": "^9.1.7", "jscpd": "^4.2.4", @@ -24,9 +24,9 @@ "vitest": "^4.1.8", }, "peerDependencies": { - "@earendil-works/pi-ai": ">=0.80.0", - "@earendil-works/pi-coding-agent": ">=0.80.0", - "@earendil-works/pi-tui": ">=0.80.0", + "@earendil-works/pi-ai": ">=0.80.9", + "@earendil-works/pi-coding-agent": ">=0.80.9", + "@earendil-works/pi-tui": ">=0.80.9", "pi-web-access": ">=0.13.0", }, "optionalPeers": [ @@ -119,13 +119,13 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.6", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.9", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-tObjeOLiw1kYUciBi9R+rRyc4QGK+1akbLLQHvzsn2JrrV2btUdDncJ7jMIR5TKvOYKzKxAwQSl/5k7h3Tjrrg=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.6", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.9", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-kHsH5nO4FU7mbKnskK0BVPVuWzNb2DrZtiN1fb6LamP+6BMI8xEZiAOw2fqs4VudvlMQgOLjtbgErv+kNJRPIg=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.6", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.6", "@earendil-works/pi-ai": "^0.80.6", "@earendil-works/pi-tui": "^0.80.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.9", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.9", "@earendil-works/pi-ai": "^0.80.9", "@earendil-works/pi-tui": "^0.80.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-Clgx2Bg5NbMcCpGxusSDQwE+GC0g/d6sCBluE9aypPgSgtJ6n8VmZIIT6auXObMskpRgkr+XZ77wG5hf+cSDtg=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.6", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-bSuzS4EVSqEPj/Qr/p9eqCESfKsGuDNbl77EGci8Iaqqt/C/XCBZL1MjXaxSWW1NsT5afjp/Cb0NTPzOLv/aPA=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.9", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-unPTW8hRgIHEGjV8mJJ2jqm+fzgnRubes6V2FPk9ay1W9ZLofcpYQ3NDfrODXSci+oKbBpX9JyYUMfQV6jCA/A=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], diff --git a/package.json b/package.json index 2357e61..534328f 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,9 @@ ] }, "peerDependencies": { - "@earendil-works/pi-ai": ">=0.80.0", - "@earendil-works/pi-coding-agent": ">=0.80.0", - "@earendil-works/pi-tui": ">=0.80.0", + "@earendil-works/pi-ai": ">=0.80.9", + "@earendil-works/pi-coding-agent": ">=0.80.9", + "@earendil-works/pi-tui": ">=0.80.9", "pi-web-access": ">=0.13.0" }, "peerDependenciesMeta": { @@ -65,10 +65,10 @@ }, "devDependencies": { "@biomejs/biome": "2.4.16", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.9", + "@earendil-works/pi-ai": "^0.80.9", + "@earendil-works/pi-coding-agent": "^0.80.9", + "@earendil-works/pi-tui": "^0.80.9", "@vitest/coverage-v8": "^4.1.8", "husky": "^9.1.7", "jscpd": "^4.2.4", diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts index d797e1f..de37577 100644 --- a/src/provider/accounts.ts +++ b/src/provider/accounts.ts @@ -1,7 +1,9 @@ +import type { AuthInteraction } from '@earendil-works/pi-ai'; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, + ModelRuntime, } from '@earendil-works/pi-coding-agent'; import { matchesKey, SelectList } from '@earendil-works/pi-tui'; import { @@ -13,8 +15,10 @@ import { saveConfig, } from '../config.js'; import { fetchBillingUsage } from './billing.js'; +import { createAccountDashboard } from './dashboard/server.js'; import { formatCachedQuota, + isCachedQuotaFresh, loadQuotaCache, removeQuotaUsage, saveQuotaUsage, @@ -25,6 +29,32 @@ export const DEFAULT_GROK_MODEL = 'grok-build'; type RegisterAccount = (account: GrokCliAccount) => void; +export interface AccountSnapshot { + provider: string; + label: string; + status: string; + authenticated: boolean; + active: boolean; + environment: boolean; + quota?: { + updatedAt: string; + fresh: boolean; + monthly: { + monthlyLimit: number; + used: number; + billingPeriodEnd: string; + }; + weekly?: { + creditUsagePercent: number; + billingPeriodEnd: string; + }; + }; +} + +export interface AccountsSnapshot { + accounts: AccountSnapshot[]; +} + export function isGrokCliProvider(provider: string | undefined): boolean { return provider === GROK_CLI_PROVIDER || /^grok-cli-(?:[2-9]|[1-9]\d+)$/.test(provider ?? ''); } @@ -67,6 +97,13 @@ function labelError(config: GrokCliConfig, provider: string, label: string) { return undefined; } +function normalizeLabel(config: GrokCliConfig, provider: string, value: string) { + const label = value.trim() || defaultLabel(provider); + const error = labelError(config, provider, label); + if (error) throw new Error(error); + return label; +} + async function promptLabel( ctx: ExtensionCommandContext, config: GrokCliConfig, @@ -84,7 +121,13 @@ async function promptLabel( } function hasStoredAuth(ctx: ExtensionContext, provider: string) { - return ctx.modelRegistry.authStorage.has(provider); + return ctx.modelRegistry.getProviderAuthStatus(provider).configured; +} + +function accountRuntime(ctx: ExtensionContext) { + const runtime = (ctx.modelRegistry as unknown as { runtime?: ModelRuntime }).runtime; + if (!runtime) throw new Error('Pi account authentication runtime is unavailable.'); + return runtime; } function hasAccountAuth(ctx: ExtensionContext, provider: string) { @@ -130,6 +173,7 @@ function showAccountSelector( ctx: ExtensionCommandContext, config: GrokCliConfig, mode: 'main' | 'manage', + manager: AccountManager, ) { return ctx.ui.custom((tui, theme, _keybindings, done) => { const accountItems = new Map( @@ -214,39 +258,18 @@ function showAccountSelector( status = `Refreshing quotas… 0/${accounts.length}`; updateDescriptions(); tui.requestRender(); - let completed = 0; - let updated = 0; - let failed = 0; - - await refreshAccountBatches( - accounts, - async (account) => { - try { - const token = await resolveAccountToken(ctx, account.provider); - if (!token) throw new Error('authentication unavailable'); - const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]); - await saveQuotaUsage(account.provider, await fetchBillingUsage(token, signal)); - updated += 1; - } catch { - if (!controller.signal.aborted) { - failedProviders.add(account.provider); - failed += 1; - } - } finally { - refreshingProviders.delete(account.provider); - completed += 1; - cache = loadQuotaCache(); - status = `Refreshing quotas… ${completed}/${accounts.length}`; - updateDescriptions(); - if (!disposed) tui.requestRender(); - } - }, - controller.signal, - ); + const result = await manager.refresh(ctx, controller.signal, (progress) => { + refreshingProviders.delete(progress.provider); + if (!progress.updated) failedProviders.add(progress.provider); + cache = loadQuotaCache(); + status = `Refreshing quotas… ${progress.completed}/${progress.total}`; + updateDescriptions(); + if (!disposed) tui.requestRender(); + }); refreshing = false; if (disposed) return; - status = `Updated ${updated} accounts; ${failed} failed`; + status = `Updated ${result.updated} accounts; ${result.failed.length} failed`; tui.requestRender(); }; @@ -295,65 +318,16 @@ function prefillLogin(ctx: ExtensionContext, provider: string) { ctx.ui.setEditorText(`/login ${provider}`); } -async function clearQuotaUsage(ctx: ExtensionContext, provider: string) { - try { - await removeQuotaUsage(provider); - } catch (error) { - ctx.ui.notify( - `Grok CLI quota cache cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - 'warning', - ); - } -} - -async function switchAccount( - pi: ExtensionAPI, - ctx: ExtensionContext, - config: GrokCliConfig, - account: GrokCliAccount, -) { - const modelId = isGrokCliProvider(ctx.model?.provider) ? ctx.model?.id : DEFAULT_GROK_MODEL; - const model = - ctx.modelRegistry.find(account.provider, modelId ?? DEFAULT_GROK_MODEL) ?? - ctx.modelRegistry.find(account.provider, DEFAULT_GROK_MODEL); - if (!model) { - ctx.ui.notify(`Grok CLI model unavailable for “${account.label}”.`, 'error'); - return false; - } - if (!(await pi.setModel(model))) { - ctx.ui.notify( - `Could not switch to “${account.label}”; authentication is unavailable.`, - 'error', - ); - return false; - } - config.accounts.selectedProvider = account.provider; - saveConfig(config); - return true; -} - async function addAccount( ctx: ExtensionCommandContext, config: GrokCliConfig, - registerAccount: RegisterAccount, - reservedProviders: Set, + manager: AccountManager, ) { - const accountNumber = findAvailableAccountNumber( - config.accounts.items.map((account) => account.provider), - reservedProviders, - ); - const provider = `${GROK_CLI_PROVIDER}-${accountNumber}`; + const provider = manager.nextProvider(); const label = await promptLabel(ctx, config, provider, 'Label this Grok CLI account:'); if (!label) return; - const account = { provider, label }; - config.accounts.items.push(account); - config.accounts.nextAccountNumber = findAvailableAccountNumber( - config.accounts.items.map((candidate) => candidate.provider), - ); try { - saveConfig(config); - registerAccount(account); - prefillLogin(ctx, provider); + prefillLogin(ctx, (await manager.add(ctx, label)).provider); } catch (error) { ctx.ui.notify( `Could not add Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, @@ -366,14 +340,12 @@ async function renameAccount( ctx: ExtensionCommandContext, config: GrokCliConfig, account: GrokCliAccount, - registerAccount: RegisterAccount, + manager: AccountManager, ) { const label = await promptLabel(ctx, config, account.provider, `Rename “${account.label}”:`); if (!label) return; - account.label = label; try { - saveConfig(config); - registerAccount(account); + await manager.rename(ctx, account.provider, label); } catch (error) { ctx.ui.notify( `Could not rename Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, @@ -384,9 +356,8 @@ async function renameAccount( async function removeBaseAccount( ctx: ExtensionCommandContext, - config: GrokCliConfig, account: GrokCliAccount, - registerAccount: RegisterAccount, + manager: AccountManager, ) { if ( !(await ctx.ui.confirm( @@ -397,11 +368,8 @@ async function removeBaseAccount( return; } try { - ctx.modelRegistry.authStorage.logout(account.provider); - account.label = 'Account 1'; - saveConfig(config); - registerAccount(account); - await clearQuotaUsage(ctx, account.provider); + const result = await manager.logout(ctx, account.provider); + if (result.warning) ctx.ui.notify(result.warning, 'warning'); } catch (error) { ctx.ui.notify( `Could not log out of Grok CLI: ${error instanceof Error ? error.message : String(error)}`, @@ -412,7 +380,7 @@ async function removeBaseAccount( async function fallbackBeforeRemoval( pi: ExtensionAPI, - ctx: ExtensionCommandContext, + ctx: ExtensionContext, config: GrokCliConfig, removed: GrokCliAccount, ) { @@ -434,11 +402,9 @@ async function fallbackBeforeRemoval( } async function removeAlias( - pi: ExtensionAPI, ctx: ExtensionCommandContext, - config: GrokCliConfig, account: GrokCliAccount, - deferredRemovals: Set, + manager: AccountManager, ) { if ( !(await ctx.ui.confirm( @@ -448,28 +414,9 @@ async function removeAlias( ) { return; } - const fallback = await fallbackBeforeRemoval(pi, ctx, config, account); - if (fallback === null) { - ctx.ui.notify('Could not switch to another authenticated Grok CLI account.', 'error'); - return; - } try { - ctx.modelRegistry.authStorage.logout(account.provider); - config.accounts.items = config.accounts.items.filter( - (candidate) => candidate.provider !== account.provider, - ); - config.accounts.selectedProvider = fallback ?? GROK_CLI_PROVIDER; - saveConfig(config); - await clearQuotaUsage(ctx, account.provider); - if (ctx.model?.provider === account.provider && fallback === undefined) { - deferredRemovals.add(account.provider); - ctx.ui.notify( - 'Grok CLI account removed. The current model now requires login and will disappear after you switch models.', - 'warning', - ); - return; - } - pi.unregisterProvider(account.provider); + const result = await manager.remove(ctx, account.provider); + if (result.warning) ctx.ui.notify(result.warning, 'warning'); } catch (error) { ctx.ui.notify( `Could not remove Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, @@ -479,12 +426,10 @@ async function removeAlias( } async function manageAccount( - pi: ExtensionAPI, ctx: ExtensionCommandContext, config: GrokCliConfig, account: GrokCliAccount, - registerAccount: RegisterAccount, - deferredRemovals: Set, + manager: AccountManager, ) { const environment = account.provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN); @@ -495,7 +440,7 @@ async function manageAccount( : ['Rename', loginLabel, removeLabel, 'Back']; const action = await ctx.ui.select(`Manage “${account.label}”:`, options); if (action === 'Rename') { - await renameAccount(ctx, config, account, registerAccount); + await renameAccount(ctx, config, account, manager); return; } if (action === loginLabel) { @@ -510,11 +455,11 @@ async function manageAccount( return; } if (action === 'Log out') { - await removeBaseAccount(ctx, config, account, registerAccount); + await removeBaseAccount(ctx, account, manager); return; } if (action === 'Log out and remove') { - await removeAlias(pi, ctx, config, account, deferredRemovals); + await removeAlias(ctx, account, manager); } } @@ -543,24 +488,292 @@ export async function resolveGrokToken( } } +function createAccountManager( + pi: ExtensionAPI, + registerAccount: RegisterAccount, + deferredRemovals: Set, +) { + let mutations = Promise.resolve(); + const mutate = (operation: () => Promise | T) => { + const result = mutations.then(operation, operation); + mutations = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + const accountFrom = (config: GrokCliConfig, provider: string) => { + const account = config.accounts.items.find((candidate) => candidate.provider === provider); + if (!account) throw new Error(`Unknown Grok CLI account: ${provider}`); + return account; + }; + const clearQuota = async (provider: string) => { + try { + await removeQuotaUsage(provider); + return undefined; + } catch (error) { + return `Grok CLI quota cache cleanup failed: ${error instanceof Error ? error.message : String(error)}`; + } + }; + const refreshAccounts = async ( + ctx: ExtensionContext, + accounts: GrokCliAccount[], + signal: AbortSignal, + onProgress?: (progress: { + provider: string; + completed: number; + total: number; + updated: boolean; + }) => void, + ) => { + let completed = 0; + let updated = 0; + const failed: string[] = []; + await refreshAccountBatches( + accounts, + async (account) => { + let succeeded = false; + try { + const token = await resolveAccountToken(ctx, account.provider); + if (!token) throw new Error('authentication unavailable'); + await saveQuotaUsage( + account.provider, + await fetchBillingUsage(token, AbortSignal.any([signal, AbortSignal.timeout(30_000)])), + ); + updated += 1; + succeeded = true; + } catch { + if (!signal.aborted) failed.push(account.provider); + } finally { + completed += 1; + onProgress?.({ + provider: account.provider, + completed, + total: accounts.length, + updated: succeeded, + }); + } + }, + signal, + ); + return { updated, failed }; + }; + + return { + nextProvider() { + return `${GROK_CLI_PROVIDER}-${findAvailableAccountNumber( + copyConfig().accounts.items.map((account) => account.provider), + deferredRemovals, + )}`; + }, + add(_ctx: ExtensionContext, value: string) { + return mutate(() => { + const config = copyConfig(); + const provider = `${GROK_CLI_PROVIDER}-${findAvailableAccountNumber( + config.accounts.items.map((account) => account.provider), + deferredRemovals, + )}`; + const account = { provider, label: normalizeLabel(config, provider, value) }; + config.accounts.items.push(account); + config.accounts.nextAccountNumber = findAvailableAccountNumber( + config.accounts.items.map((candidate) => candidate.provider), + ); + saveConfig(config); + registerAccount(account); + return account; + }); + }, + rename(_ctx: ExtensionContext, provider: string, value: string) { + return mutate(() => { + const config = copyConfig(); + const account = accountFrom(config, provider); + account.label = normalizeLabel(config, provider, value); + saveConfig(config); + registerAccount(account); + return account; + }); + }, + activate(ctx: ExtensionContext, provider: string) { + return mutate(async () => { + const config = copyConfig(); + const account = accountFrom(config, provider); + if (!hasAccountAuth(ctx, provider)) { + throw new Error(`Log in to “${account.label}” before making it active.`); + } + const modelId = isGrokCliProvider(ctx.model?.provider) ? ctx.model?.id : DEFAULT_GROK_MODEL; + const model = + ctx.modelRegistry.find(provider, modelId ?? DEFAULT_GROK_MODEL) ?? + ctx.modelRegistry.find(provider, DEFAULT_GROK_MODEL); + if (!model) throw new Error(`Grok CLI model unavailable for “${account.label}”.`); + if (!(await pi.setModel(model))) { + throw new Error(`Could not switch to “${account.label}”; authentication is unavailable.`); + } + config.accounts.selectedProvider = provider; + saveConfig(config); + return account; + }); + }, + login(ctx: ExtensionContext, provider: string, interaction: AuthInteraction) { + const config = copyConfig(); + const account = accountFrom(config, provider); + if (provider === GROK_CLI_PROVIDER && process.env.GROK_CLI_OAUTH_TOKEN) { + throw new Error('Unset GROK_CLI_OAUTH_TOKEN before logging in from the dashboard.'); + } + return accountRuntime(ctx).login(account.provider, 'oauth', interaction); + }, + logout(ctx: ExtensionContext, provider: string) { + return mutate(async () => { + if (provider !== GROK_CLI_PROVIDER) { + throw new Error('Only the permanent base account can be logged out without removal.'); + } + if (process.env.GROK_CLI_OAUTH_TOKEN) { + throw new Error( + 'Unset GROK_CLI_OAUTH_TOKEN and restart Pi to remove the environment token.', + ); + } + const config = copyConfig(); + const account = accountFrom(config, provider); + await accountRuntime(ctx).logout(provider); + account.label = 'Account 1'; + saveConfig(config); + registerAccount(account); + return { warning: await clearQuota(provider) }; + }); + }, + remove(ctx: ExtensionContext, provider: string) { + return mutate(async () => { + if (provider === GROK_CLI_PROVIDER) { + throw new Error('The permanent base account cannot be removed.'); + } + const config = copyConfig(); + const account = accountFrom(config, provider); + const fallback = await fallbackBeforeRemoval(pi, ctx, config, account); + if (fallback === null) { + throw new Error('Could not switch to another authenticated Grok CLI account.'); + } + await accountRuntime(ctx).logout(provider); + config.accounts.items = config.accounts.items.filter( + (candidate) => candidate.provider !== provider, + ); + config.accounts.selectedProvider = fallback ?? GROK_CLI_PROVIDER; + saveConfig(config); + const cacheWarning = await clearQuota(provider); + if (ctx.model?.provider === provider && fallback === undefined) { + deferredRemovals.add(provider); + return { + warning: + cacheWarning ?? + 'Grok CLI account removed. The current model now requires login and will disappear after you switch models.', + }; + } + pi.unregisterProvider(provider); + return { warning: cacheWarning }; + }); + }, + refresh( + ctx: ExtensionContext, + signal: AbortSignal, + onProgress?: (progress: { + provider: string; + completed: number; + total: number; + updated: boolean; + }) => void, + ) { + return refreshAccounts( + ctx, + copyConfig().accounts.items.filter((account) => hasAccountAuth(ctx, account.provider)), + signal, + onProgress, + ); + }, + refreshOne(ctx: ExtensionContext, provider: string, signal: AbortSignal) { + const config = copyConfig(); + const account = accountFrom(config, provider); + if (!hasAccountAuth(ctx, provider)) { + throw new Error(`Log in to “${account.label}” before refreshing its quota.`); + } + return refreshAccounts(ctx, [account], signal); + }, + snapshot(ctx: ExtensionContext): AccountsSnapshot { + const config = copyConfig(); + const cache = loadQuotaCache(); + return { + accounts: config.accounts.items.map((account) => { + const environment = + account.provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN); + const authenticated = hasAccountAuth(ctx, account.provider); + const quota = cache.accounts[account.provider]; + return { + provider: account.provider, + label: account.label, + status: accountStatus(ctx, config, account.provider), + authenticated, + active: config.accounts.selectedProvider === account.provider && authenticated, + environment, + ...(quota + ? { + quota: { + updatedAt: quota.updatedAt, + fresh: isCachedQuotaFresh(quota), + monthly: { ...quota.monthly }, + ...(quota.weekly ? { weekly: { ...quota.weekly } } : {}), + }, + } + : {}), + }; + }), + }; + }, + handleModelSelect(event: { + model: { provider: string }; + previousModel?: { provider: string }; + }) { + if (event.previousModel && deferredRemovals.delete(event.previousModel.provider)) { + pi.unregisterProvider(event.previousModel.provider); + } + if (!isGrokCliProvider(event.model.provider)) return; + const config = copyConfig(); + if (!config.accounts.items.some((account) => account.provider === event.model.provider)) + return; + if (config.accounts.selectedProvider === event.model.provider) return; + config.accounts.selectedProvider = event.model.provider; + saveConfig(config); + }, + }; +} + +export type AccountManager = ReturnType; + export function registerAccountManagement(pi: ExtensionAPI, registerAccount: RegisterAccount) { const deferredRemovals = new Set(); + const manager = createAccountManager(pi, registerAccount, deferredRemovals); + const dashboard = createAccountDashboard(manager); pi.registerCommand('grok-cli-accounts', { description: 'Add, switch, rename, relogin, or remove Grok CLI accounts', - handler: async (_args, ctx) => { + handler: async (args, ctx) => { + const command = args.trim(); + if (command === 'gui') { + await dashboard.open(ctx); + return; + } + if (command) { + ctx.ui.notify('Usage: /grok-cli-accounts [gui]', 'warning'); + return; + } const config = copyConfig(); - const choice = await showAccountSelector(ctx, config, 'main'); + const choice = await showAccountSelector(ctx, config, 'main', manager); if (choice === 'action:add') { - await addAccount(ctx, config, registerAccount, deferredRemovals); + await addAccount(ctx, config, manager); return; } if (choice === 'action:manage') { const latest = copyConfig(); - const selected = await showAccountSelector(ctx, latest, 'manage'); + const selected = await showAccountSelector(ctx, latest, 'manage', manager); const account = latest.accounts.items.find(({ provider }) => provider === selected); if (account) { - await manageAccount(pi, ctx, latest, account, registerAccount, deferredRemovals); + await manageAccount(ctx, latest, account, manager); } return; } @@ -570,25 +783,17 @@ export function registerAccountManagement(pi: ExtensionAPI, registerAccount: Reg prefillLogin(ctx, account.provider); return; } - await switchAccount(pi, ctx, config, account); + try { + await manager.activate(ctx, account.provider); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error'); + } }, }); return { - handleModelSelect(event: { - model: { provider: string }; - previousModel?: { provider: string }; - }) { - if (event.previousModel && deferredRemovals.delete(event.previousModel.provider)) { - pi.unregisterProvider(event.previousModel.provider); - } - if (!isGrokCliProvider(event.model.provider)) return; - const config = copyConfig(); - if (!config.accounts.items.some((account) => account.provider === event.model.provider)) - return; - if (config.accounts.selectedProvider === event.model.provider) return; - config.accounts.selectedProvider = event.model.provider; - saveConfig(config); - }, + manager, + closeDashboard: dashboard.close, + handleModelSelect: manager.handleModelSelect, }; } diff --git a/src/provider/dashboard/app.css b/src/provider/dashboard/app.css index 87a9911..17ca114 100644 --- a/src/provider/dashboard/app.css +++ b/src/provider/dashboard/app.css @@ -1,29 +1,30 @@ :root { color-scheme: dark; - --bg: #0b0f11; - --panel: #111619; - --panel-raised: #161c20; - --border: #232b30; - --border-strong: #2f383e; - --text: #e9edee; - --muted: #9aa7ab; - --dim: #849298; - --accent: #4fd1e8; - --accent-ink: #052730; - --ok: #4ade80; - --ok-soft: rgb(74 222 128 / 12%); - --ok-ring: rgb(74 222 128 / 38%); - --amber: #f5b453; - --amber-soft: rgb(245 180 83 / 12%); - --red: #f26d64; - --red-soft: rgb(242 109 100 / 10%); - --meter-empty: #1b2327; - --appbar-bg: rgb(11 15 17 / 85%); - --input-bg: #0a0e10; - --accent-hover: #86e5f3; - --danger-ink: #200a08; - --danger-hover: #ff867e; - --ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --bg: #08090a; + --panel: #0f1113; + --panel-raised: #17191c; + --border: rgb(255 255 255 / 8%); + --border-strong: rgb(255 255 255 / 14%); + --text: #f7f8f8; + --muted: #8a8f98; + --dim: #757a83; + --accent: #5e6ad2; + --accent-ring: rgb(94 106 210 / 45%); + --primary-hover: #dcdde2; + --ok: #4cb782; + --ok-soft: rgb(76 183 130 / 14%); + --amber: #f2c94c; + --amber-soft: rgb(242 201 76 / 13%); + --red: #eb5757; + --red-soft: rgb(235 87 87 / 10%); + --meter-empty: #1d1f23; + --appbar-bg: rgb(8 9 10 / 80%); + --input-bg: #0c0e10; + --danger-ink: #210b0b; + --danger-hover: #f07373; + --ui: + Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, + sans-serif; --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; background: var(--bg); color: var(--text); @@ -166,7 +167,7 @@ p { } .link-pill.error { - border-color: rgb(242 109 100 / 35%); + border-color: rgb(235 87 87 / 35%); color: var(--red); } @@ -178,7 +179,7 @@ input { min-width: 0; height: 34px; border: 1px solid var(--border); - border-radius: 7px; + border-radius: 6px; padding: 0 11px; background: var(--input-bg); color: var(--text); @@ -203,7 +204,7 @@ input:hover { justify-content: center; height: 34px; border: 1px solid transparent; - border-radius: 7px; + border-radius: 6px; padding: 0 13px; cursor: pointer; font-size: 12.5px; @@ -217,12 +218,12 @@ input:hover { } .button.primary { - background: var(--accent); - color: var(--accent-ink); + background: var(--text); + color: var(--bg); } .button.primary:hover:not(:disabled) { - background: var(--accent-hover); + background: var(--primary-hover); } .button.primary.danger { @@ -340,12 +341,13 @@ main { border-radius: 10px; padding: 16px; background: var(--panel); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%); animation: card-enter 220ms ease both; } .account-card.active { - border-color: var(--ok-ring); - background: linear-gradient(160deg, rgb(74 222 128 / 4%), transparent 45%), var(--panel); + border-color: var(--accent-ring); + background: linear-gradient(160deg, rgb(94 106 210 / 5%), transparent 45%), var(--panel); } .card-head { @@ -377,6 +379,10 @@ main { .card-provider { flex: none; + border: 1px solid var(--border); + border-radius: 5px; + padding: 2px 6px; + background: rgb(255 255 255 / 3%); color: var(--dim); font-family: var(--mono); font-size: 11px; @@ -424,7 +430,7 @@ main { } .status-pill.pending { - border-color: rgb(245 180 83 / 30%); + border-color: rgb(242 201 76 / 30%); color: var(--amber); } @@ -434,7 +440,7 @@ main { } .status-pill.error { - border-color: rgb(242 109 100 / 30%); + border-color: rgb(235 87 87 / 30%); color: var(--red); } @@ -542,8 +548,8 @@ main { .card-error { margin: 0; - border: 1px solid rgb(242 109 100 / 28%); - border-radius: 7px; + border: 1px solid rgb(235 87 87 / 28%); + border-radius: 6px; padding: 9px 11px; background: var(--red-soft); color: var(--red); @@ -572,10 +578,10 @@ main { .login-panel { display: grid; gap: 10px; - border: 1px solid rgb(245 180 83 / 25%); + border: 1px solid rgb(242 201 76 / 25%); border-radius: 8px; padding: 12px; - background: rgb(245 180 83 / 6%); + background: rgb(242 201 76 / 6%); } .login-panel p { @@ -604,11 +610,13 @@ dialog { padding: 0; background: var(--panel-raised); color: var(--text); - box-shadow: 0 24px 64px rgb(0 0 0 / 55%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 5%), + 0 24px 64px rgb(0 0 0 / 55%); } dialog::backdrop { - background: rgb(4 6 8 / 70%); + background: rgb(2 3 5 / 70%); backdrop-filter: blur(4px); } @@ -660,7 +668,9 @@ dialog::backdrop { padding: 13px 15px; background: var(--panel-raised); color: var(--text); - box-shadow: 0 16px 40px rgb(0 0 0 / 45%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 5%), + 0 16px 40px rgb(0 0 0 / 45%); font-size: 12.5px; line-height: 1.5; opacity: 0; @@ -677,7 +687,7 @@ dialog::backdrop { } .toast.error { - border-color: rgb(242 109 100 / 40%); + border-color: rgb(235 87 87 / 40%); } /* ---------- Animations ---------- */ diff --git a/src/provider/dashboard/index.html b/src/provider/dashboard/index.html index bdd25dc..2537214 100644 --- a/src/provider/dashboard/index.html +++ b/src/provider/dashboard/index.html @@ -7,7 +7,7 @@ Grok accounts @@ -16,8 +16,8 @@

Grok accounts

Connecting… diff --git a/src/provider/register.ts b/src/provider/register.ts index 707a562..61cff13 100644 --- a/src/provider/register.ts +++ b/src/provider/register.ts @@ -1,11 +1,5 @@ -import type { - Api, - Model, - OAuthCredentials, - OAuthLoginCallbacks, - OAuthProviderInterface, -} from '@earendil-works/pi-ai'; -import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from '@earendil-works/pi-ai'; +import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import * as oauth from '../auth/oauth.js'; import { getBaseUrl, type XaiOAuthCredentials } from '../auth/oauth.js'; import { type GrokCliAccount, loadConfig, migrateLegacyConfig } from '../config.js'; @@ -57,7 +51,7 @@ export default function registerGrokCli(pi: ExtensionAPI) { model.provider === account.provider ? { ...model, baseUrl: effectiveBaseUrl } : model, ); }, - } satisfies Omit; + } satisfies NonNullable; pi.registerProvider(account.provider, { name: `Grok CLI — ${account.label}`, @@ -123,7 +117,8 @@ export default function registerGrokCli(pi: ExtensionAPI) { syncTools(ctx.model); }); - pi.on('session_shutdown', (event) => { + pi.on('session_shutdown', async (event) => { + await accountManagement.closeDashboard(); syncTools(undefined); if (event.reason === 'new' || event.reason === 'resume' || event.reason === 'fork') { handoffGrokTools(pi, event.targetSessionFile); diff --git a/src/provider/rotation.ts b/src/provider/rotation.ts index 71a9197..6bdc6d4 100644 --- a/src/provider/rotation.ts +++ b/src/provider/rotation.ts @@ -28,7 +28,7 @@ function isExhaustionMessage(message: unknown): message is AssistantMessage { function hasAccountAuth(ctx: ExtensionContext, provider: string) { return ( - ctx.modelRegistry.authStorage.has(provider) || + ctx.modelRegistry.getProviderAuthStatus(provider).configured || (provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN)) ); } diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts index 3cdc12e..9273a23 100644 --- a/tests/provider/accounts.test.ts +++ b/tests/provider/accounts.test.ts @@ -1,8 +1,4 @@ -import { - AuthStorage, - type ExtensionAPI, - type ExtensionContext, -} from '@earendil-works/pi-coding-agent'; +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; import type { Component } from '@earendil-works/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; @@ -66,6 +62,10 @@ async function runAccountsCommand(extension: ReturnType) { await extension.commands.get('grok-cli-accounts')?.handler('', extension.context); } +async function runAccountsCommandWith(extension: ReturnType, args: string) { + await extension.commands.get('grok-cli-accounts')?.handler(args, extension.context); +} + function setup( options: { auth?: Record>; @@ -93,7 +93,13 @@ function setup( } as unknown as ExtensionAPI; const accountManagement = registerAccountManagement(pi, registerAccount); - const authStorage = AuthStorage.inMemory(options.auth); + const credentials = new Map(Object.entries(options.auth ?? {})); + const authStorage = { + get: (provider: string) => credentials.get(provider), + has: (provider: string) => credentials.has(provider), + set: (provider: string, credential: ReturnType) => + credentials.set(provider, credential), + }; const selections = [...(options.selections ?? [])]; const inputs = [...(options.inputs ?? [])]; const confirms = [...(options.confirms ?? [])]; @@ -110,8 +116,14 @@ function setup( const context = { model: options.model, modelRegistry: { - authStorage, + runtime: { + login: vi.fn(), + logout: async (provider: string) => { + credentials.delete(provider); + }, + }, find: (provider: string, id: string) => models.get(`${provider}/${id}`), + getProviderAuthStatus: (provider: string) => ({ configured: credentials.has(provider) }), getApiKeyForProvider: async (provider: string) => { const credential = authStorage.get(provider); return credential?.type === 'oauth' ? credential.access : undefined; @@ -221,6 +233,97 @@ describe('Grok CLI account helpers', () => { }); describe('/grok-cli-accounts', () => { + it('rejects unknown arguments without opening the TUI', async () => { + const extension = setup(); + + await runAccountsCommandWith(extension, 'wat'); + + expect(extension.notify).toHaveBeenCalledWith('Usage: /grok-cli-accounts [gui]', 'warning'); + expect(extension.context.ui.custom).not.toHaveBeenCalled(); + }); + + it('produces a credential-free account snapshot for alternate interfaces', async () => { + configureAccounts(); + await saveQuotaUsage('grok-cli-2', { + monthly: { + monthlyLimit: 2000, + used: 700, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: 35, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + + const snapshot = extension.accountManagement.manager.snapshot( + extension.context as unknown as ExtensionContext, + ); + + expect(snapshot.accounts[1]).toMatchObject({ + provider: 'grok-cli-2', + label: 'Work', + authenticated: true, + active: false, + environment: false, + quota: { + monthly: { monthlyLimit: 2000, used: 700 }, + weekly: { creditUsagePercent: 35 }, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain('work-token'); + }); + + it('reads authentication through the Pi 0.80.9 model-registry facade', () => { + configureAccounts(); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + const modelRegistry = extension.context.modelRegistry as unknown as { + authStorage?: unknown; + getProviderAuthStatus?: (provider: string) => { configured: boolean }; + }; + delete modelRegistry.authStorage; + modelRegistry.getProviderAuthStatus = (provider) => ({ + configured: provider === 'grok-cli' || provider === 'grok-cli-2', + }); + + expect( + extension.accountManagement.manager.snapshot(extension.context as unknown as ExtensionContext) + .accounts, + ).toEqual([ + expect.objectContaining({ provider: 'grok-cli', authenticated: true }), + expect.objectContaining({ provider: 'grok-cli-2', authenticated: true }), + ]); + }); + + it('shares add and rename mutations with alternate interfaces', async () => { + const extension = setup(); + + const account = await extension.accountManagement.manager.add( + extension.context as unknown as ExtensionContext, + ' Work ', + ); + await extension.accountManagement.manager.rename( + extension.context as unknown as ExtensionContext, + account.provider, + 'Client', + ); + + expect(account.provider).toBe('grok-cli-2'); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Account 1' }, + { provider: 'grok-cli-2', label: 'Client' }, + ]); + expect(extension.registerAccount).toHaveBeenNthCalledWith(1, { + provider: 'grok-cli-2', + label: 'Work', + }); + expect(extension.registerAccount).toHaveBeenNthCalledWith(2, { + provider: 'grok-cli-2', + label: 'Client', + }); + }); + it('shows cached quota usage in both account selectors', async () => { configureAccounts(); await saveQuotaUsage( diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts index c23e0ef..269fcdc 100644 --- a/tests/provider/dashboard.test.ts +++ b/tests/provider/dashboard.test.ts @@ -358,9 +358,9 @@ describe('account dashboard loopback server', () => { // Failures are announced assertively; routine status stays polite. expect(page).toContain('role="alert"'); expect(page).toContain('role="status"'); - // The favicon matches the in-app brand mark (Signal Cyan, not mint). - expect(page).toContain('%234fd1e8'); - expect(page).not.toContain('%233fe0c5'); + // The favicon matches the in-app brand mark (ink glyph on a raised tile). + expect(page).toContain('%23f7f8f8'); + expect(page).not.toContain('%234fd1e8'); // Re-renders preserve keyboard focus and typed login codes. expect(script).toContain('data-action'); expect(script).toContain('[data-provider]'); diff --git a/tests/provider/package.test.ts b/tests/provider/package.test.ts index a8009c3..fad3415 100644 --- a/tests/provider/package.test.ts +++ b/tests/provider/package.test.ts @@ -27,10 +27,10 @@ describe('npm package manifest', () => { expect(existsSync(new URL('../../vitest.config.ts', import.meta.url))).toBe(true); }); - it('declares the Pi runtime version required by web search delegation', () => { - expect(packageJson.peerDependencies?.['@earendil-works/pi-ai']).toBe('>=0.80.0'); - expect(packageJson.peerDependencies?.['@earendil-works/pi-coding-agent']).toBe('>=0.80.0'); - expect(packageJson.peerDependencies?.['@earendil-works/pi-tui']).toBe('>=0.80.0'); + it('declares the Pi runtime version required by dashboard auth and web search', () => { + expect(packageJson.peerDependencies?.['@earendil-works/pi-ai']).toBe('>=0.80.9'); + expect(packageJson.peerDependencies?.['@earendil-works/pi-coding-agent']).toBe('>=0.80.9'); + expect(packageJson.peerDependencies?.['@earendil-works/pi-tui']).toBe('>=0.80.9'); expect(packageJson.peerDependencies?.['pi-web-access']).toBe('>=0.13.0'); expect(packageJson.dependencies?.jiti).toBeUndefined(); expect(packageJson.dependencies?.typebox).toBeUndefined(); @@ -63,6 +63,7 @@ describe('repository layout', () => { 'src/payload/sanitize.ts', 'src/provider/accounts.ts', 'src/provider/billing.ts', + 'src/provider/dashboard/server.ts', 'src/provider/quotaCache.ts', 'src/provider/register.ts', 'src/provider/rotation.ts', diff --git a/tests/provider/register.test.ts b/tests/provider/register.test.ts index 7e55487..2629904 100644 --- a/tests/provider/register.test.ts +++ b/tests/provider/register.test.ts @@ -1,13 +1,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { - Api, - Model, - OAuthCredentials, - OAuthLoginCallbacks, - OAuthProviderInterface, -} from '@earendil-works/pi-ai'; +import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from '@earendil-works/pi-ai'; import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, saveConfig } from '../../src/config.js'; @@ -606,7 +600,9 @@ describe('Grok CLI provider registration', () => { const context = { model: { provider: 'grok-cli', id: 'grok-build' }, modelRegistry: { - authStorage: { has: (provider: string) => provider.startsWith('grok-cli') }, + getProviderAuthStatus: (provider: string) => ({ + configured: provider.startsWith('grok-cli'), + }), find: (provider: string, id: string) => ({ provider, id }), getAll: () => [], }, @@ -665,7 +661,7 @@ describe('Grok CLI provider registration', () => { expect(provider?.apiKey).toBe('$GROK_CLI_OAUTH_TOKEN'); expect(provider?.streamSimple).toBeUndefined(); expect(provider?.models?.map((model) => model.id)).toContain('grok-build'); - expect((provider?.oauth as Omit)?.usesCallbackServer).toBe(true); + expect(provider?.oauth?.usesCallbackServer).toBe(true); expect(provider?.oauth?.getApiKey({ access: 'access-token', refresh: '', expires: 0 })).toBe( 'access-token', ); diff --git a/tests/provider/rotation.test.ts b/tests/provider/rotation.test.ts index 9e24a25..ce116cf 100644 --- a/tests/provider/rotation.test.ts +++ b/tests/provider/rotation.test.ts @@ -1,4 +1,4 @@ -import { AuthStorage, type ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; import type { BillingUsage } from '../../src/provider/billing.js'; @@ -8,7 +8,7 @@ import { ROTATION_CONTINUATION, registerExhaustionRotation, } from '../../src/provider/rotation.js'; -import { oauthCredential, useTempHome } from '../vision/helpers.js'; +import { useTempHome } from '../vision/helpers.js'; const setupHome = useTempHome(); const THREE_ACCOUNTS = [ @@ -66,19 +66,12 @@ function setup( }, }); const handlers = new Map unknown)[]>(); - const authStorage = AuthStorage.inMemory( - Object.fromEntries( - (options.auth ?? accounts.map((account) => account.provider)).map((provider) => [ - provider, - oauthCredential(`${provider}-token`), - ]), - ), - ); + const authenticated = new Set(options.auth ?? accounts.map((account) => account.provider)); const model = options.current ?? { provider: 'grok-cli', id: 'grok-build' }; const context = { model: { ...model }, modelRegistry: { - authStorage, + getProviderAuthStatus: (provider: string) => ({ configured: authenticated.has(provider) }), find: (provider: string, id: string) => options.missingModels?.includes(`${provider}/${id}`) ? undefined : { provider, id }, }, diff --git a/tests/provider/toolScope.integration.test.ts b/tests/provider/toolScope.integration.test.ts index 97d6535..4c7086a 100644 --- a/tests/provider/toolScope.integration.test.ts +++ b/tests/provider/toolScope.integration.test.ts @@ -1,9 +1,8 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai'; +import { fauxAssistantMessage, fauxProvider, InMemoryCredentialStore } from '@earendil-works/pi-ai'; import { AgentSessionRuntime, - AuthStorage, type CreateAgentSessionRuntimeFactory, createAgentSession, createAgentSessionFromServices, @@ -11,6 +10,7 @@ import { DefaultResourceLoader, type ExtensionFactory, ModelRegistry, + ModelRuntime, SessionManager, SettingsManager, } from '@earendil-works/pi-coding-agent'; @@ -165,10 +165,14 @@ async function modelFacingTools(modelId: string) { provider: 'grok-cli', models: [{ id: modelId }], }); - const authStorage = AuthStorage.inMemory(); - const modelRegistry = ModelRegistry.inMemory(authStorage); + const modelRuntime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + allowModelNetwork: false, + }); + const modelRegistry = new ModelRegistry(modelRuntime); const model = faux.getModel(); - modelRegistry.registerProvider('grok-cli', { + modelRuntime.registerProvider('grok-cli', { api: faux.api, apiKey: 'local-test-key', baseUrl: model.baseUrl, @@ -196,9 +200,8 @@ async function modelFacingTools(modelId: string) { const { session } = await createAgentSession({ cwd, agentDir, - authStorage, + modelRuntime, model: modelRegistry.find('grok-cli', modelId), - modelRegistry, resourceLoader: resources.resourceLoader, sessionManager: SessionManager.inMemory(cwd), settingsManager: resources.settingsManager, @@ -209,7 +212,7 @@ async function modelFacingTools(modelId: string) { return names; } finally { session.dispose(); - modelRegistry.unregisterProvider('grok-cli'); + modelRuntime.unregisterProvider('grok-cli'); } } From 4516d41a667abbc943c53b4c7f4663b408c0a5e0 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 14:30:46 +0900 Subject: [PATCH 09/28] fix(accounts): harden dashboard offline handling and card states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: - The dashboard went blank on server disconnect, hid login errors behind a "no quota" hint, and the [hidden] utility lost to component display rules. What: - Keep the last good state on screen when the server is unreachable, retry polling every 5s, re-render on recovery, and toast the offline transition only once. - Show a "Logging in…" pill for pending logins, prefer the error text over the no-quota hint, and add empty and loading grid placeholders. - Double the [hidden] selector so it beats component display rules, close the dialog on backdrop click, and label it for assistive tech. Validation: - bun run check (typecheck, knip, biome lint, 484 tests passed) --- src/provider/dashboard/app.css | 9 +++-- src/provider/dashboard/app.js | 56 ++++++++++++++++++++++--------- src/provider/dashboard/index.html | 10 +++--- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/provider/dashboard/app.css b/src/provider/dashboard/app.css index 17ca114..e833374 100644 --- a/src/provider/dashboard/app.css +++ b/src/provider/dashboard/app.css @@ -7,7 +7,7 @@ --border-strong: rgb(255 255 255 / 14%); --text: #f7f8f8; --muted: #8a8f98; - --dim: #757a83; + --dim: #7a7f89; --accent: #5e6ad2; --accent-ring: rgb(94 106 210 / 45%); --primary-hover: #dcdde2; @@ -74,7 +74,8 @@ p { margin: 0; } -[hidden] { +/* Doubled selector so the utility beats component display rules (.dialog-panel label, .button). */ +[hidden][hidden] { display: none; } @@ -484,6 +485,10 @@ main { font-size: 11.5px; } +.quota-meta > span:last-child { + margin-left: auto; +} + .quota-meta .mono { font-family: var(--mono); font-size: 11px; diff --git a/src/provider/dashboard/app.js b/src/provider/dashboard/app.js index b5fc6ba..f71222c 100644 --- a/src/provider/dashboard/app.js +++ b/src/provider/dashboard/app.js @@ -16,6 +16,7 @@ const toastStatus = document.querySelector('#toast'); const toastAlert = document.querySelector('#toast-alert'); let lastState = ''; +let wasOffline = false; let entranceDone = false; let timer; @@ -92,6 +93,9 @@ const modal = ({ title, message, value, confirm = 'Confirm', danger = false, can }); dialogCancel.addEventListener('click', () => dialog.close('cancel')); +dialog.addEventListener('click', (event) => { + if (event.target === dialog) dialog.close('cancel'); +}); const percent = (used, limit) => !Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0 @@ -145,7 +149,10 @@ const statusPill = (account) => { const pill = element('p', `status-pill${variant ? ` ${variant}` : ''}`); const dot = element('span', 'status-dot'); dot.setAttribute('aria-hidden', 'true'); - pill.append(dot, element('span', '', account.status)); + pill.append( + dot, + element('span', '', account.login.state === 'pending' ? 'Logging in…' : account.status), + ); return pill; }; @@ -314,6 +321,7 @@ const accountCard = (account, index) => { card.append(head, body); return card; } + const errorText = account.login.error || account.login.quotaError; if (account.quota) { body.append( quotaRow( @@ -337,7 +345,7 @@ const accountCard = (account, index) => { if (!account.quota.fresh) freshness.append(element('span', 'tag', 'Stale')); freshness.append(element('span', '', `Updated ${dateLabel(account.quota.updatedAt)}`)); body.append(freshness); - } else { + } else if (!errorText) { body.append( element( 'p', @@ -348,8 +356,8 @@ const accountCard = (account, index) => { ), ); } - if (account.login.error || account.login.quotaError) { - body.append(element('p', 'card-error', account.login.error || account.login.quotaError)); + if (errorText) { + body.append(element('p', 'card-error', errorText)); } card.append(head, body, cardActions(account)); @@ -378,7 +386,10 @@ const render = (state) => { .filter(([, value]) => value), ); if (entranceDone) accountsRoot.classList.add('settled'); - accountsRoot.replaceChildren(...state.accounts.map(accountCard)); + const children = state.accounts.length + ? state.accounts.map(accountCard) + : [element('p', 'grid-message', 'No accounts configured. Use Add account to connect one.')]; + accountsRoot.replaceChildren(...children); entranceDone = true; for (const [provider, value] of codes) { const input = accountsRoot.querySelector(`[data-provider="${provider}"] input[name="code"]`); @@ -403,24 +414,37 @@ async function refreshState(force = false) { try { const state = await api('/api/state'); const serialized = JSON.stringify(state); - if (force || serialized !== lastState) { + if (force || wasOffline || serialized !== lastState) { lastState = serialized; render(state); } + wasOffline = false; schedule(state); } catch (error) { clearTimeout(timer); linkState.className = 'link-pill error'; linkText.textContent = 'Offline'; accountsRoot.setAttribute('aria-busy', 'false'); - accountsRoot.replaceChildren( - element( - 'p', - 'grid-message', - 'Dashboard connection lost. Run /grok-cli-accounts gui to reopen it.', - ), - ); - showToast(error.message, true); + // Keep the last good state on screen once loaded; a stale console beats a blank one. + if (!lastState) { + accountsRoot.replaceChildren( + element( + 'p', + 'grid-message', + 'Dashboard connection lost. Run /grok-cli-accounts gui to reopen it.', + ), + ); + } + if (!wasOffline) { + showToast( + error instanceof TypeError + ? 'Connection to the dashboard server failed. Retrying…' + : error.message, + true, + ); + } + wasOffline = true; + if (!document.hidden) timer = setTimeout(() => refreshState(), 5000); } } @@ -448,7 +472,9 @@ refreshButton.addEventListener('click', async () => { try { const result = await mutation('/api/quotas/refresh', 'POST'); showToast( - `Updated ${result.updated} account${result.updated === 1 ? '' : 's'}; ${result.failed.length} failed.`, + result.failed.length + ? `Updated ${result.updated} account${result.updated === 1 ? '' : 's'}; ${result.failed.length} failed.` + : `Updated ${result.updated} account${result.updated === 1 ? '' : 's'}.`, ); await refreshState(true); } catch (error) { diff --git a/src/provider/dashboard/index.html b/src/provider/dashboard/index.html index 2537214..0b307cd 100644 --- a/src/provider/dashboard/index.html +++ b/src/provider/dashboard/index.html @@ -35,20 +35,22 @@

Grok accounts

> - +
- +
- +

- +
diff --git a/src/provider/dashboard/server.ts b/src/provider/dashboard/server.ts index 5151d5b..70d1566 100644 --- a/src/provider/dashboard/server.ts +++ b/src/provider/dashboard/server.ts @@ -217,7 +217,7 @@ export async function startAccountDashboard( send( res, status, - `Grok accounts

Grok accounts

${escapeHtml(publicError(error))}

`, + `Pi Grok CLI

Pi Grok CLI

${escapeHtml(publicError(error))}

`, 'text/html', ); return; diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts index 269fcdc..be8ee60 100644 --- a/tests/provider/dashboard.test.ts +++ b/tests/provider/dashboard.test.ts @@ -163,7 +163,7 @@ describe('account dashboard loopback server', () => { expect(page.status).toBe(200); expect(page.headers.get('content-security-policy')).toContain("default-src 'self'"); expect(page.headers.get('cache-control')).toBe('no-store'); - expect(await page.text()).toContain('Grok accounts'); + expect(await page.text()).toContain('Pi Grok CLI'); expect(await state.json()).toMatchObject({ accounts: [ { From a944b1508e33ebdd4c7ed5d5f762fa3d98e5483d Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 17:22:31 +0900 Subject: [PATCH 12/28] feat(accounts): add cursor spotlight border to dashboard cards Why: - Proximity hover expressed as scale did not read on the dense, uniform card grid; cards have no room to grow. What: - Replace scale/brightness proximity with a 1px spotlight ring masked to the card border band, positioned and faded via --mx/--my/--glow custom properties. - The light follows the cursor across cards (220px falloff) without touching layout; far-card dimming removed. - Gated on fine pointers and prefers-reduced-motion; resets when the pointer leaves the grid. Validation: - bun run check - Browser-verified against a stubbed /api/state: falloff gradient, pointer-leave reset, reduced-motion no-op --- src/provider/dashboard/app.css | 28 +++++++++++++++++++ src/provider/dashboard/app.js | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/provider/dashboard/app.css b/src/provider/dashboard/app.css index e833374..098bbfd 100644 --- a/src/provider/dashboard/app.css +++ b/src/provider/dashboard/app.css @@ -335,6 +335,7 @@ main { /* ---------- Account card ---------- */ .account-card { + position: relative; display: flex; flex-direction: column; gap: 14px; @@ -346,6 +347,32 @@ main { animation: card-enter 220ms ease both; } +/* Cursor spotlight: a 1px ring masked to the border band, lit near the pointer. */ +.account-card::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: radial-gradient( + 200px circle at var(--mx, 50%) var(--my, 50%), + rgb(255 255 255 / 40%), + rgb(255 255 255 / 12%) 55%, + transparent 75% + ); + opacity: var(--glow, 0); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + pointer-events: none; + transition: opacity 180ms ease; +} + .account-card.active { border-color: var(--accent-ring); background: linear-gradient(160deg, rgb(94 106 210 / 5%), transparent 45%), var(--panel); @@ -773,6 +800,7 @@ dialog::backdrop { .button, .icon-button, + .account-card::before, .toast { transition: none; } diff --git a/src/provider/dashboard/app.js b/src/provider/dashboard/app.js index 9c1a5ac..dc49172 100644 --- a/src/provider/dashboard/app.js +++ b/src/provider/dashboard/app.js @@ -497,4 +497,53 @@ window.addEventListener('focus', () => { lastFocusRefresh = Date.now(); void refreshState(); }); + +// Cursor spotlight: proximity lights each card's border through --mx/--my/--glow +// custom properties, so the effect travels across cards without touching layout. +// Resets when the pointer leaves the grid or motion/pointer preferences change. +const setupProximity = () => { + const media = { + motion: matchMedia('(prefers-reduced-motion: no-preference)'), + pointer: matchMedia('(hover: hover) and (pointer: fine)'), + }; + let pointer; + let frame = 0; + const apply = () => { + frame = 0; + const cards = accountsRoot.querySelectorAll('.account-card'); + if (!pointer) { + for (const card of cards) card.style.removeProperty('--glow'); + return; + } + for (const card of cards) { + const rect = card.getBoundingClientRect(); + const dx = Math.max(rect.left - pointer.x, 0, pointer.x - rect.right); + const dy = Math.max(rect.top - pointer.y, 0, pointer.y - rect.bottom); + const t = Math.max(0, 1 - Math.hypot(dx, dy) / 220); + card.style.setProperty('--mx', `${(pointer.x - rect.left).toFixed(1)}px`); + card.style.setProperty('--my', `${(pointer.y - rect.top).toFixed(1)}px`); + card.style.setProperty('--glow', t.toFixed(3)); + } + }; + const scheduleApply = () => { + if (frame) return; + frame = requestAnimationFrame(apply); + }; + accountsRoot.addEventListener('pointermove', (event) => { + if (!media.motion.matches || !media.pointer.matches) return; + pointer = { x: event.clientX, y: event.clientY }; + scheduleApply(); + }); + const reset = () => { + pointer = undefined; + scheduleApply(); + }; + accountsRoot.addEventListener('pointerleave', reset); + window.addEventListener('scroll', scheduleApply, { passive: true }); + window.addEventListener('resize', scheduleApply); + media.motion.addEventListener('change', reset); + media.pointer.addEventListener('change', reset); +}; +setupProximity(); + void refreshState(true); From 985415489991d2164b15e426ba181f11667903db Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 17:33:45 +0900 Subject: [PATCH 13/28] chore: update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8c4224a..27362e5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ docs/ .impeccable DESIGN.md PRODUCT.md -.playwright +.playwright-cli From 2a633ab092856054b0bdf0c4c0ee344f11bdabc9 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 17 Jul 2026 18:34:51 +0900 Subject: [PATCH 14/28] feat(accounts): rebuild dashboard as a state-driven WebGL experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: - Turn the accounts surface into an impressive, informational single-page console where the backdrop itself reports fleet state. What: - WebGL2 domain-warped FBM shader field driven by live state: aggregate quota burn sets energy and palette (indigo → teal → amber), errored or offline states bleed ember through the warp, sync activity shimmers, and the pointer stirs the flow; eased uniforms, half-res rendering, pause on hidden tab, still frame under reduced motion, CSS aurora fallback. - Quota meters replaced by conic-gradient ring gauges on a registered @property, swept via WAAPI on mount and on poll-driven value changes. - Modern CSS throughout: linear() spring easings, @starting-style and allow-discrete dialog transitions, corner-shape squircles, OKLCH relative colors, container queries, scroll-driven appbar progress hairline, and :has() state signals (offline desaturation, dialog field dim, pending login ring). - Structural mutations (add/remove/switch/login) morph via the View Transitions API with per-card view-transition-name; polling, typing, and reduced-motion renders stay plain. - All existing behavior preserved: polling, login code flow, toasts, modal, offline retry, cursor spotlight. Validation: - bun run check (biome, typecheck, knip, jscpd, vitest coverage) - Playwright: desktop/mobile/dialog/reduced-motion/offline/recovery, zero console errors --- src/provider/dashboard/app.css | 405 +++++++++++++++++++++------ src/provider/dashboard/app.js | 438 ++++++++++++++++++++++++++++-- src/provider/dashboard/index.html | 2 + 3 files changed, 728 insertions(+), 117 deletions(-) diff --git a/src/provider/dashboard/app.css b/src/provider/dashboard/app.css index 098bbfd..a2d9fe1 100644 --- a/src/provider/dashboard/app.css +++ b/src/provider/dashboard/app.css @@ -1,37 +1,65 @@ :root { color-scheme: dark; - --bg: #08090a; - --panel: #0f1113; - --panel-raised: #17191c; + --bg: oklch(0.14 0.005 260); + --panel: oklch(0.185 0.007 260); + --panel-raised: oklch(0.225 0.008 260); + --panel-veil: oklch(0.185 0.007 260 / 84%); + --gauge-disc: oklch(0.19 0.007 260 / 94%); --border: rgb(255 255 255 / 8%); --border-strong: rgb(255 255 255 / 14%); - --text: #f7f8f8; - --muted: #8a8f98; - --dim: #7a7f89; - --accent: #5e6ad2; - --accent-ring: rgb(94 106 210 / 45%); - --primary-hover: #dcdde2; - --ok: #4cb782; - --ok-soft: rgb(76 183 130 / 14%); - --amber: #f2c94c; - --amber-soft: rgb(242 201 76 / 13%); - --red: #eb5757; - --red-soft: rgb(235 87 87 / 10%); - --meter-empty: #1d1f23; - --appbar-bg: rgb(8 9 10 / 80%); - --input-bg: #0c0e10; - --danger-ink: #210b0b; - --danger-hover: #f07373; + --text: oklch(0.96 0.004 250); + --muted: oklch(0.68 0.015 260); + --dim: oklch(0.62 0.015 260); + --accent: oklch(0.55 0.16 275); + --accent-ring: oklch(0.55 0.16 275 / 45%); + --ok: oklch(0.72 0.14 160); + --ok-soft: oklch(0.72 0.14 160 / 14%); + --amber: oklch(0.84 0.14 90); + --amber-soft: oklch(0.84 0.14 90 / 13%); + --red: oklch(0.65 0.19 25); + --red-soft: oklch(0.65 0.19 25 / 10%); + --track: oklch(0.26 0.008 260); + --appbar-bg: oklch(0.14 0.005 260 / 72%); + --input-bg: oklch(0.165 0.006 260); + --danger-ink: oklch(0.2 0.04 25); + /* Overshoot spring, expressed as a linear() timing function. */ + --spring: linear( + 0, + 0.01, + 0.04 2.2%, + 0.16 4.8%, + 0.36 8%, + 0.6 12%, + 0.81 16.2%, + 0.96 20.8%, + 1.05 25.7%, + 1.09 31.1%, + 1.1 37%, + 1.09 43.5%, + 1.07 50.7%, + 1.05 58.7%, + 1.04 68%, + 1.03 79.3%, + 1.02 + ); --ui: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + interpolate-size: allow-keywords; background: var(--bg); color: var(--text); font-family: var(--ui); font-size: 14px; } +/* Gauge sweep angle, registered so it interpolates in CSS and WAAPI. */ +@property --gauge { + syntax: ""; + inherits: false; + initial-value: 0; +} + * { box-sizing: border-box; } @@ -44,7 +72,10 @@ html { body { min-height: 100vh; margin: 0; - background: var(--bg); +} + +::selection { + background: oklch(0.55 0.16 275 / 38%); } button, @@ -79,6 +110,37 @@ p { display: none; } +/* ---------- State field backdrop ---------- */ + +#field { + position: fixed; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + transition: filter 240ms ease; +} + +/* Painted stand-in when WebGL is unavailable: layered aurora, static. */ +.no-field #field { + display: none; +} + +.no-field body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background: + radial-gradient(65% 55% at 18% 8%, oklch(0.55 0.16 275 / 11%), transparent 70%), + radial-gradient(50% 45% at 85% 85%, oklch(0.72 0.14 160 / 7%), transparent 70%), var(--bg); +} + +/* Dim the field while a modal owns attention. */ +body:has(dialog[open]) #field { + filter: brightness(0.55); +} + /* ---------- App bar ---------- */ .appbar { @@ -96,6 +158,23 @@ p { backdrop-filter: blur(14px); } +/* Scroll progress hairline, driven by the root scroll timeline. */ +@supports (animation-timeline: scroll()) { + .appbar::after { + content: ""; + position: absolute; + bottom: -1px; + left: 0; + width: 100%; + height: 1px; + background: var(--accent); + transform: scaleX(0); + transform-origin: left; + animation: scroll-progress linear both; + animation-timeline: scroll(root); + } +} + .appbar-brand { display: flex; min-width: 0; @@ -145,7 +224,7 @@ p { padding: 0 11px; border: 1px solid var(--border); border-radius: 999px; - background: var(--panel); + background: var(--panel-veil); color: var(--muted); font-size: 12px; white-space: nowrap; @@ -168,7 +247,7 @@ p { } .link-pill.error { - border-color: rgb(235 87 87 / 35%); + border-color: oklch(0.65 0.19 25 / 35%); color: var(--red); } @@ -224,7 +303,7 @@ input:hover { } .button.primary:hover:not(:disabled) { - background: var(--primary-hover); + background: oklch(from var(--text) calc(l - 0.12) c h); } .button.primary.danger { @@ -233,7 +312,7 @@ input:hover { } .button.primary.danger:hover:not(:disabled) { - background: var(--danger-hover); + background: oklch(from var(--red) calc(l + 0.07) c h); color: var(--danger-ink); } @@ -313,6 +392,12 @@ main { width: min(1200px, 100%); margin: 0 auto; padding: 20px 20px 72px; + transition: filter 300ms ease; +} + +/* Offline: the whole board desaturates until the link recovers. */ +body:has(.link-pill.error) main { + filter: saturate(0.55) brightness(0.82); } .account-grid { @@ -326,6 +411,7 @@ main { padding: 56px 24px; border: 1px dashed var(--border-strong); border-radius: 10px; + background: var(--panel-veil); color: var(--muted); font-size: 13px; line-height: 1.6; @@ -335,16 +421,30 @@ main { /* ---------- Account card ---------- */ .account-card { + container-type: inline-size; position: relative; display: flex; flex-direction: column; gap: 14px; border: 1px solid var(--border); - border-radius: 10px; + border-radius: 12px; padding: 16px; - background: var(--panel); + background: var(--panel-veil); + backdrop-filter: blur(12px) saturate(1.1); box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%); - animation: card-enter 220ms ease both; +} + +@supports (corner-shape: squircle) { + .account-card { + border-radius: 22px; + corner-shape: squircle; + } +} + +/* Entrance runs on first mount only; poll-driven re-renders stay still. */ +.account-grid:not(.settled) .account-card { + animation: card-enter 620ms var(--spring) both; + animation-delay: var(--enter-delay, 0ms); } /* Cursor spotlight: a 1px ring masked to the border band, lit near the pointer. */ @@ -375,7 +475,12 @@ main { .account-card.active { border-color: var(--accent-ring); - background: linear-gradient(160deg, rgb(94 106 210 / 5%), transparent 45%), var(--panel); + background: + linear-gradient(160deg, oklch(0.55 0.16 275 / 7%), transparent 45%), var(--panel-veil); +} + +.account-card:has(.login-panel) { + border-color: oklch(0.84 0.14 90 / 32%); } .card-head { @@ -458,7 +563,7 @@ main { } .status-pill.pending { - border-color: rgb(242 201 76 / 30%); + border-color: oklch(0.84 0.14 90 / 30%); color: var(--amber); } @@ -468,7 +573,7 @@ main { } .status-pill.error { - border-color: rgb(235 87 87 / 30%); + border-color: oklch(0.65 0.19 25 / 30%); color: var(--red); } @@ -476,76 +581,102 @@ main { background: var(--red); } -/* ---------- Card body: quotas ---------- */ +/* ---------- Card body: quota gauges ---------- */ .card-body { display: grid; gap: 13px; } -.quota-head, -.quota-meta { +.quota { display: flex; - gap: 10px; - align-items: baseline; - justify-content: space-between; + gap: 12px; + align-items: center; } -.quota-head { - margin-bottom: 6px; - font-size: 12px; +.quota-gauge { + --gauge: 0; + --gauge-color: var(--ok); + position: relative; + flex: none; + width: 54px; + aspect-ratio: 1; + display: grid; + place-items: center; + border-radius: 50%; + background: conic-gradient( + from -90deg, + var(--gauge-color) calc(var(--gauge) * 1%), + var(--track) 0 + ); + transition: filter 200ms ease; } -.quota-head span:first-child { - color: var(--muted); +.quota-gauge::after { + content: ""; + position: absolute; + inset: 5px; + border-radius: 50%; + background: var(--gauge-disc); } -.quota-pct { - color: var(--text); - font-family: var(--mono); - font-size: 11.5px; +.quota-gauge.warning { + --gauge-color: var(--amber); } -.quota-meta { - margin-top: 6px; - color: var(--dim); - font-size: 11.5px; +.quota-gauge.danger { + --gauge-color: var(--red); + filter: drop-shadow(0 0 7px oklch(0.65 0.19 25 / 35%)); } -.quota-meta > span:last-child { - margin-left: auto; +.gauge-value { + position: relative; + z-index: 1; + color: var(--text); + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: -0.02em; } -.quota-meta .mono { - font-family: var(--mono); - font-size: 11px; +.quota-side { + display: grid; + min-width: 0; + flex: 1; + gap: 4px; } -.meter { - height: 6px; - overflow: hidden; - border-radius: 3px; - background: var(--meter-empty); +.quota-head, +.quota-meta { + display: flex; + gap: 10px; + align-items: baseline; + justify-content: space-between; } -.meter-fill { - height: 100%; - width: var(--meter-value); - border-radius: 3px; - background: var(--ok); - transform-origin: left; - animation: meter-rise 500ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +.quota-head { + font-size: 12px; } -.meter.warning .meter-fill { - background: var(--amber); +.quota-head > span:first-child { + color: var(--muted); } -.meter.danger .meter-fill { - background: var(--red); +.quota-head .mono { + overflow: hidden; + color: var(--dim); + font-family: var(--mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quota-meta { + color: var(--dim); + font-size: 11.5px; } .quota-unavailable { + flex: 1; color: var(--dim); font-size: 11.5px; } @@ -580,7 +711,7 @@ main { .card-error { margin: 0; - border: 1px solid rgb(235 87 87 / 28%); + border: 1px solid oklch(0.65 0.19 25 / 28%); border-radius: 6px; padding: 9px 11px; background: var(--red-soft); @@ -605,15 +736,31 @@ main { margin-left: auto; } +@container (max-width: 340px) { + .quota-gauge { + width: 46px; + } + + .quota-head { + flex-direction: column; + gap: 1px; + align-items: flex-start; + } + + .card-actions .push-right { + margin-left: 0; + } +} + /* ---------- Login panel ---------- */ .login-panel { display: grid; gap: 10px; - border: 1px solid rgb(242 201 76 / 25%); + border: 1px solid oklch(0.84 0.14 90 / 25%); border-radius: 8px; padding: 12px; - background: rgb(242 201 76 / 6%); + background: oklch(0.84 0.14 90 / 6%); } .login-panel p { @@ -645,11 +792,52 @@ dialog { box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 24px 64px rgb(0 0 0 / 55%); + opacity: 1; + transform: none; + transition: + opacity 180ms ease, + transform 300ms var(--spring), + overlay 180ms ease allow-discrete, + display 180ms ease allow-discrete; +} + +@supports (corner-shape: squircle) { + dialog { + border-radius: 20px; + corner-shape: squircle; + } +} + +dialog:not([open]) { + opacity: 0; + transform: translateY(12px) scale(0.96); +} + +@starting-style { + dialog[open] { + opacity: 0; + transform: translateY(12px) scale(0.96); + } } dialog::backdrop { - background: rgb(2 3 5 / 70%); - backdrop-filter: blur(4px); + background: oklch(0.08 0.005 260 / 72%); + backdrop-filter: blur(6px); + opacity: 0; + transition: + opacity 180ms ease, + overlay 180ms ease allow-discrete, + display 180ms ease allow-discrete; +} + +dialog[open]::backdrop { + opacity: 1; +} + +@starting-style { + dialog[open]::backdrop { + opacity: 0; + } } .dialog-panel { @@ -660,6 +848,7 @@ dialog::backdrop { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; + text-wrap: balance; } .dialog-message { @@ -667,6 +856,7 @@ dialog::backdrop { color: var(--muted); font-size: 13px; line-height: 1.6; + text-wrap: pretty; } .dialog-panel label { @@ -706,40 +896,69 @@ dialog::backdrop { font-size: 12.5px; line-height: 1.5; opacity: 0; + translate: 0 10px; pointer-events: none; - transform: translateY(8px); transition: opacity 160ms ease, - transform 160ms ease; + translate 340ms var(--spring); +} + +@supports (corner-shape: squircle) { + .toast { + border-radius: 16px; + corner-shape: squircle; + } } .toast.visible { opacity: 1; - transform: translateY(0); + translate: 0 0; } .toast.error { - border-color: rgb(235 87 87 / 40%); + border-color: oklch(0.65 0.19 25 / 40%); } -/* ---------- Animations ---------- */ +/* ---------- View transitions ---------- */ -/* Entrance motion runs on first mount only; poll-driven re-renders stay still. */ -.account-grid.settled .account-card, -.account-grid.settled .meter-fill { - animation: none; +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 240ms; + animation-timing-function: ease; } +::view-transition-old(root) { + animation-name: vt-fade-out; +} + +::view-transition-new(root) { + animation-name: vt-fade-in; +} + +/* ---------- Animations ---------- */ + @keyframes card-enter { from { opacity: 0; - transform: translateY(8px); + translate: 0 12px; + } +} + +@keyframes scroll-progress { + to { + transform: scaleX(1); + } +} + +@keyframes vt-fade-out { + to { + opacity: 0; } } -@keyframes meter-rise { +@keyframes vt-fade-in { from { - transform: scaleX(0); + opacity: 0; } } @@ -789,9 +1008,10 @@ dialog::backdrop { } } +/* ---------- Reduced motion ---------- */ + @media (prefers-reduced-motion: reduce) { - .account-card, - .meter-fill, + .account-grid:not(.settled) .account-card, .link-pill.pending .link-dot, .status-pill.pending .status-dot, .is-refreshing .refresh-glyph { @@ -801,6 +1021,11 @@ dialog::backdrop { .button, .icon-button, .account-card::before, + .quota-gauge, + #field, + main, + dialog, + dialog::backdrop, .toast { transition: none; } diff --git a/src/provider/dashboard/app.js b/src/provider/dashboard/app.js index dc49172..037a865 100644 --- a/src/provider/dashboard/app.js +++ b/src/provider/dashboard/app.js @@ -14,11 +14,15 @@ const dialogConfirm = document.querySelector('#dialog-confirm'); const dialogCancel = document.querySelector('#dialog-cancel'); const toastStatus = document.querySelector('#toast'); const toastAlert = document.querySelector('#toast-alert'); +const fieldCanvas = document.querySelector('#field'); let lastState = ''; let wasOffline = false; let entranceDone = false; let timer; +let pendingProviders = new Set(); + +const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)'); const element = (tag, className, text) => { const node = document.createElement(tag); @@ -110,23 +114,353 @@ const dateLabel = (value) => minute: '2-digit', }).format(new Date(value)); -const quotaRow = (label, usedLabel, reset, value) => { +/* ---------- State field backdrop ---------- + * A WebGL2 domain-warped noise field driven by live account state: aggregate + * quota burn raises its energy and shifts the palette indigo → teal → amber, + * errored accounts bleed ember into the warp, sync activity shimmers, and the + * pointer stirs the flow. Reduced motion gets a single composed still frame; + * no WebGL gets the painted CSS fallback (.no-field). */ + +const FIELD_VERTEX = `#version 300 es +layout(location = 0) in vec2 aPos; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +}`; + +const FIELD_FRAGMENT = `#version 300 es +precision highp float; + +uniform vec2 uRes; +uniform float uTime; +uniform float uEnergy; +uniform float uAlert; +uniform float uPending; +uniform vec2 uPointer; +uniform float uPointerForce; + +out vec4 outColor; + +float hash(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm(vec2 p) { + float value = 0.0; + float amp = 0.5; + mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); + for (int i = 0; i < 5; i++) { + value += amp * noise(p); + p = rot * p * 2.02; + amp *= 0.5; + } + return value; +} + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y; + vec2 toPtr = uv - (uPointer - 0.5 * uRes) / uRes.y; + float stir = uPointerForce * exp(-dot(toPtr, toPtr) * 5.0); + uv += stir * 0.22 * vec2(-toPtr.y, toPtr.x); + + float t = uTime * (0.045 + uEnergy * 0.035 + uPending * 0.02); + float warp = 2.1 + uEnergy * 0.9 + uAlert * 0.5; + + vec2 q = vec2( + fbm(uv * 1.35 + vec2(0.0, t)), + fbm(uv * 1.35 + vec2(5.2, t * 1.3)) + ); + vec2 r = vec2( + fbm(uv * 1.35 + warp * q + vec2(1.7, 9.2) + t * 0.6), + fbm(uv * 1.35 + warp * q + vec2(8.3, 2.8) - t * 0.4) + ); + float f = fbm(uv * 1.35 + (warp + 0.4) * r); + // fbm clusters near 0.5; stretch it so the color bands actually saturate. + f = clamp((f - 0.5) * 2.4 + 0.5, 0.0, 1.0); + + float body = smoothstep(0.3, 0.48, f); + float mid = smoothstep(0.48, 0.66, f); + float core = smoothstep(0.66, 0.92, f); + float glow = 0.55 + 0.45 * uEnergy + 0.2 * uPending; + + vec3 deep = vec3(0.028, 0.032, 0.052); + vec3 indigo = vec3(0.32, 0.38, 0.9); + vec3 teal = vec3(0.2, 0.75, 0.55); + vec3 amber = vec3(0.95, 0.75, 0.25); + vec3 ember = vec3(0.92, 0.3, 0.28); + + float hueArg = clamp((r.y * 0.6 + q.x * 0.4 - 0.5) * 2.6 + 0.5, 0.0, 1.0); + vec3 zone = mix(indigo, teal, smoothstep(0.32, 0.68, hueArg)); + zone = mix(zone, amber, smoothstep(0.6, 0.95, uEnergy) * core); + zone = mix(zone, ember, uAlert * smoothstep(0.32, 0.75, f) * 0.85); + + vec3 col = deep; + col += indigo * 0.03 * (0.5 + 0.5 * q.y); + col = mix(col, zone * 0.45, body); + col = mix(col, zone, mid * 0.9); + col += zone * core * 0.6 * glow; + col += zone * (0.14 * q.x * uPending + stir * 0.3); + + vec2 sp = uv * 70.0; + vec2 cell = floor(sp); + float h = hash(cell); + if (h > 0.99) { + vec2 pos = vec2(hash(cell + 1.3), hash(cell + 2.7)); + float d = length(fract(sp) - pos); + float tw = 0.5 + 0.5 * sin(uTime * (1.0 + h * 3.0) + h * 40.0); + col += vec3(0.75, 0.82, 1.0) * (1.0 - smoothstep(0.0, 0.16, d)) * tw * 0.6; + } + + vec2 vuv = uv * vec2(0.75, 1.0); + col *= clamp(1.0 - 0.35 * dot(vuv, vuv), 0.0, 1.0); + col *= 1.0 + uPending * 0.06 * sin(uTime * 2.4) + uAlert * 0.08 * sin(uTime * 1.3); + col = col / (1.0 + col * 0.6); + col += (hash(gl_FragCoord.xy + vec2(fract(uTime))) - 0.5) * (1.5 / 255.0); + + outColor = vec4(col, 1.0); +}`; + +const createField = (canvas) => { + const gl = canvas.getContext('webgl2', { + alpha: false, + antialias: false, + depth: false, + powerPreference: 'low-power', + stencil: false, + }); + if (!gl) return undefined; + const compile = (type, source) => { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(gl.getShaderInfoLog(shader) || 'Field shader failed to compile.'); + } + return shader; + }; + try { + const program = gl.createProgram(); + gl.attachShader(program, compile(gl.VERTEX_SHADER, FIELD_VERTEX)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FIELD_FRAGMENT)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(gl.getProgramInfoLog(program) || 'Field shader failed to link.'); + } + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + const uniforms = {}; + for (const name of [ + 'uRes', + 'uTime', + 'uEnergy', + 'uAlert', + 'uPending', + 'uPointer', + 'uPointerForce', + ]) { + uniforms[name] = gl.getUniformLocation(program, name); + } + + const current = { energy: 0.12, alert: 0, pending: 0 }; + const target = { energy: 0.12, alert: 0, pending: 0 }; + const pointer = { x: 0, y: 0, fx: 0, fy: 0, force: 0, forceTarget: 0 }; + let raf = 0; + let last = 0; + let time = 30; + + const pixelScale = () => Math.min(window.devicePixelRatio || 1, 1.5) * 0.5; + + const resize = () => { + const scale = pixelScale(); + const width = Math.max(1, Math.round(canvas.clientWidth * scale)); + const height = Math.max(1, Math.round(canvas.clientHeight * scale)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + gl.viewport(0, 0, width, height); + } + }; + + const draw = () => { + gl.uniform2f(uniforms.uRes, canvas.width, canvas.height); + gl.uniform1f(uniforms.uTime, time); + gl.uniform1f(uniforms.uEnergy, current.energy); + gl.uniform1f(uniforms.uAlert, current.alert); + gl.uniform1f(uniforms.uPending, current.pending); + gl.uniform2f(uniforms.uPointer, pointer.fx, pointer.fy); + gl.uniform1f(uniforms.uPointerForce, pointer.force); + gl.drawArrays(gl.TRIANGLES, 0, 3); + }; + + const frame = (now) => { + raf = 0; + const dt = Math.min(0.1, Math.max(0.001, (now - last) / 1000)); + last = now; + time += dt; + const ease = 1 - Math.exp(-dt * 2.2); + current.energy += (target.energy - current.energy) * ease; + current.alert += (target.alert - current.alert) * ease; + current.pending += (target.pending - current.pending) * ease; + const snap = 1 - Math.exp(-dt * 9); + pointer.fx += (pointer.x - pointer.fx) * snap; + pointer.fy += (pointer.y - pointer.fy) * snap; + pointer.forceTarget *= Math.exp(-dt * 1.4); + pointer.force += (pointer.forceTarget - pointer.force) * (1 - Math.exp(-dt * 4)); + resize(); + draw(); + if (!reduceMotion.matches && !document.hidden) raf = requestAnimationFrame(frame); + }; + + const start = () => { + if (raf || reduceMotion.matches || document.hidden) return; + last = performance.now(); + raf = requestAnimationFrame(frame); + }; + + const stop = () => { + if (!raf) return; + cancelAnimationFrame(raf); + raf = 0; + }; + + const still = () => { + resize(); + draw(); + }; + + window.addEventListener( + 'pointermove', + (event) => { + const scale = pixelScale(); + pointer.x = event.clientX * scale; + pointer.y = (canvas.clientHeight - event.clientY) * scale; + pointer.forceTarget = 1; + }, + { passive: true }, + ); + window.addEventListener('resize', () => { + if (!raf) still(); + }); + document.addEventListener('visibilitychange', () => { + if (document.hidden) stop(); + else start(); + }); + reduceMotion.addEventListener('change', () => { + if (reduceMotion.matches) { + stop(); + still(); + return; + } + start(); + }); + canvas.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + stop(); + document.documentElement.classList.add('no-field'); + }); + + if (reduceMotion.matches) still(); + else start(); + + return { + setTargets(next) { + Object.assign(target, next); + if (!reduceMotion.matches) return; + Object.assign(current, target); + still(); + }, + }; + } catch { + return undefined; + } +}; + +const field = createField(fieldCanvas); +if (!field) document.documentElement.classList.add('no-field'); + +const updateFieldTargets = (state, offline = false) => { + if (!field) return; + const usages = state.accounts + .filter((account) => account.quota) + .map((account) => percent(account.quota.monthly.used, account.quota.monthly.monthlyLimit)); + const energy = usages.length + ? usages.reduce((sum, value) => sum + value, 0) / usages.length / 100 + : 0.12; + const errors = state.accounts.filter( + (account) => account.login.error || account.login.quotaError, + ).length; + field.setTargets({ + alert: offline ? 0.65 : state.accounts.length ? errors / state.accounts.length : 0, + energy: Math.max(0.12, energy), + pending: + state.refreshing || state.accounts.some((account) => account.login.state === 'pending') + ? 1 + : 0, + }); +}; + +/* ---------- Quota gauges ---------- + * Ring gauges drawn with a conic-gradient over the registered --gauge + * property. Sweeps are animated with WAAPI (registered custom property + * interpolation); where that is unsupported the gauge renders statically. */ + +const gaugeMemory = new Map(); + +const animateGauge = (gauge, from, to) => { + if (reduceMotion.matches) return; + try { + gauge.animate([{ '--gauge': String(from) }, { '--gauge': String(to) }], { + duration: 780, + easing: 'cubic-bezier(0.22, 0.9, 0.24, 1)', + }); + } catch { + // Custom-property WAAPI unsupported: the inline --gauge value already shows the truth. + } +}; + +const quotaRow = (provider, label, usedLabel, reset, value) => { const row = element('div', 'quota'); + const gauge = element( + 'div', + `quota-gauge${value >= 95 ? ' danger' : value >= 75 ? ' warning' : ''}`, + ); + gauge.style.setProperty('--gauge', value.toFixed(1)); + gauge.setAttribute('role', 'progressbar'); + gauge.setAttribute('aria-label', `${label}: ${Math.round(value)} percent used`); + gauge.setAttribute('aria-valuemin', '0'); + gauge.setAttribute('aria-valuemax', '100'); + gauge.setAttribute('aria-valuenow', String(Math.round(value))); + gauge.append(element('span', 'gauge-value', `${Math.round(value)}%`)); + const key = `${provider}:${label}`; + const previous = gaugeMemory.get(key); + gaugeMemory.set(key, value); + if (!entranceDone) animateGauge(gauge, 0, value); + else if (previous !== undefined && Math.abs(previous - value) > 0.5) { + animateGauge(gauge, previous, value); + } + const side = element('div', 'quota-side'); const header = element('div', 'quota-head'); - header.append(element('span', '', label), element('span', 'quota-pct', `${Math.round(value)}%`)); - const meter = element('div', `meter${value >= 95 ? ' danger' : value >= 75 ? ' warning' : ''}`); - meter.setAttribute('role', 'progressbar'); - meter.setAttribute('aria-label', `${label}: ${Math.round(value)} percent used`); - meter.setAttribute('aria-valuemin', '0'); - meter.setAttribute('aria-valuemax', '100'); - meter.setAttribute('aria-valuenow', String(Math.round(value))); - const fill = element('div', 'meter-fill'); - fill.style.setProperty('--meter-value', `${value}%`); - meter.append(fill); + header.append(element('span', '', label)); + if (usedLabel) header.append(element('span', 'mono', usedLabel)); const meta = element('div', 'quota-meta'); - if (usedLabel) meta.append(element('span', 'mono', usedLabel)); meta.append(element('span', '', `Resets ${dateLabel(reset)}`)); - row.append(header, meter, meta); + side.append(header, meta); + row.append(gauge, side); return row; }; @@ -173,13 +507,13 @@ const startLogin = async (provider) => { showToast('Pop-up blocked. Allow pop-ups, then use Log in on the account card.', true); return; } - await refreshState(true); + await refreshState(true, true); } catch (error) { showToast(error.message, true); } }; -const loginPanel = (account) => { +const loginPanel = (account, isNew) => { const panel = element('form', 'login-panel'); panel.append(element('p', '', account.login.progress || 'Waiting for browser authorization…')); const row = element('div', 'login-row'); @@ -198,7 +532,7 @@ const loginPanel = (account) => { cancel.addEventListener('click', async () => { try { await mutation(`/api/accounts/${account.provider}/login-cancel`, 'POST'); - await refreshState(true); + await refreshState(true, true); } catch (error) { showToast(error.message, true); } @@ -217,6 +551,15 @@ const loginPanel = (account) => { showToast(error.message, true); } }); + if (isNew) { + panel.animate( + [ + { opacity: 0, translate: '0 8px' }, + { opacity: 1, translate: '0 0' }, + ], + { duration: 340, easing: 'cubic-bezier(0.22, 0.9, 0.24, 1)' }, + ); + } return panel; }; @@ -225,7 +568,7 @@ const cardActions = (account) => { const activate = async () => { try { await mutation(`/api/accounts/${account.provider}/activate`, 'POST'); - await refreshState(true); + await refreshState(true, true); } catch (error) { showToast(error.message, true); } @@ -273,7 +616,7 @@ const cardActions = (account) => { : `/api/accounts/${account.provider}`, account.provider === 'grok-cli' ? 'POST' : 'DELETE', ); - await refreshState(true); + await refreshState(true, true); } catch (error) { showToast(error.message, true); } @@ -302,10 +645,13 @@ const cardActions = (account) => { return actions; }; -const accountCard = (account, index) => { +const accountCard = (account, index, isNewPending) => { const card = element('article', `account-card${account.active ? ' active' : ''}`); card.dataset.provider = account.provider; - card.style.animationDelay = `${Math.min(index * 40, 200)}ms`; + card.style.viewTransitionName = `card-${account.provider}`; + if (!entranceDone) { + card.style.setProperty('--enter-delay', `${Math.min(index * 45, 220)}ms`); + } const head = element('header', 'card-head'); const titleRow = element('div', 'card-title-row'); @@ -317,7 +663,7 @@ const accountCard = (account, index) => { const body = element('div', 'card-body'); if (account.login.state === 'pending') { - body.append(loginPanel(account)); + body.append(loginPanel(account, isNewPending)); card.append(head, body); return card; } @@ -325,8 +671,9 @@ const accountCard = (account, index) => { if (account.quota) { body.append( quotaRow( + account.provider, 'Monthly credits', - `${account.quota.monthly.used.toLocaleString()} / ${account.quota.monthly.monthlyLimit.toLocaleString()} used`, + `${account.quota.monthly.used.toLocaleString()} / ${account.quota.monthly.monthlyLimit.toLocaleString()}`, account.quota.monthly.billingPeriodEnd, percent(account.quota.monthly.used, account.quota.monthly.monthlyLimit), ), @@ -334,6 +681,7 @@ const accountCard = (account, index) => { body.append( account.quota.weekly ? quotaRow( + account.provider, 'Weekly credits', '', account.quota.weekly.billingPeriodEnd, @@ -366,12 +714,19 @@ const accountCard = (account, index) => { const render = (state) => { const online = state.accounts.filter((account) => account.authenticated).length; - statsSummary.textContent = `${state.accounts.length} account${state.accounts.length === 1 ? '' : 's'} · ${online} logged in`; + const usages = state.accounts + .filter((account) => account.quota) + .map((account) => percent(account.quota.monthly.used, account.quota.monthly.monthlyLimit)); + const averageUsage = usages.length + ? Math.round(usages.reduce((sum, value) => sum + value, 0) / usages.length) + : undefined; + statsSummary.textContent = `${state.accounts.length} account${state.accounts.length === 1 ? '' : 's'} · ${online} logged in${averageUsage === undefined ? '' : ` · ${averageUsage}% avg usage`}`; linkState.className = 'link-pill ok'; linkText.textContent = 'Synced'; refreshButton.disabled = state.refreshing; refreshButton.classList.toggle('is-refreshing', state.refreshing); accountsRoot.setAttribute('aria-busy', String(state.refreshing)); + updateFieldTargets(state); const active = document.activeElement; const refocus = active instanceof HTMLElement && accountsRoot.contains(active) && active.dataset.action @@ -386,10 +741,22 @@ const render = (state) => { .filter(([, value]) => value), ); if (entranceDone) accountsRoot.classList.add('settled'); + const nextPending = new Set( + state.accounts + .filter((account) => account.login.state === 'pending') + .map((account) => account.provider), + ); const children = state.accounts.length - ? state.accounts.map(accountCard) + ? state.accounts.map((account, index) => + accountCard( + account, + index, + nextPending.has(account.provider) && !pendingProviders.has(account.provider), + ), + ) : [element('p', 'grid-message', 'No accounts configured. Use Add account to connect one.')]; accountsRoot.replaceChildren(...children); + pendingProviders = nextPending; entranceDone = true; for (const [provider, value] of codes) { const input = accountsRoot.querySelector(`[data-provider="${provider}"] input[name="code"]`); @@ -402,6 +769,20 @@ const render = (state) => { } }; +// Structural, user-initiated changes (add / remove / switch / login) morph via +// the View Transitions API; everything else re-renders plainly. +const renderTransition = (state) => { + if ( + reduceMotion.matches || + typeof document.startViewTransition !== 'function' || + accountsRoot.querySelector('input[name="code"]:focus') + ) { + render(state); + return; + } + document.startViewTransition(() => render(state)); +}; + const schedule = (state) => { clearTimeout(timer); if (document.hidden) return; @@ -410,13 +791,14 @@ const schedule = (state) => { timer = setTimeout(() => refreshState(), pending ? 2000 : 15000); }; -async function refreshState(force = false) { +async function refreshState(force = false, animate = false) { try { const state = await api('/api/state'); const serialized = JSON.stringify(state); if (force || wasOffline || serialized !== lastState) { lastState = serialized; - render(state); + if (animate) renderTransition(state); + else render(state); } wasOffline = false; schedule(state); @@ -425,6 +807,8 @@ async function refreshState(force = false) { linkState.className = 'link-pill error'; linkText.textContent = 'Offline'; accountsRoot.setAttribute('aria-busy', 'false'); + if (lastState) updateFieldTargets(JSON.parse(lastState), true); + else field?.setTargets({ alert: 0.65, pending: 0 }); // Keep the last good state on screen once loaded; a stale console beats a blank one. if (!lastState) { accountsRoot.replaceChildren( @@ -459,7 +843,7 @@ addAccount.addEventListener('click', async () => { if (label === undefined) return; try { const account = await mutation('/api/accounts', 'POST', { label }); - await refreshState(true); + await refreshState(true, true); await startLogin(account.provider); } catch (error) { showToast(error.message, true); diff --git a/src/provider/dashboard/index.html b/src/provider/dashboard/index.html index 3e05c52..39158f6 100644 --- a/src/provider/dashboard/index.html +++ b/src/provider/dashboard/index.html @@ -13,6 +13,8 @@ + +

Pi Grok CLI

Syncing - +
@@ -52,7 +44,7 @@

Pi Grok CLI

- +
@@ -54,6 +55,7 @@

+
diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts index be8ee60..3a2063f 100644 --- a/tests/provider/dashboard.test.ts +++ b/tests/provider/dashboard.test.ts @@ -109,6 +109,11 @@ const mutationHeaders = (dashboard: AccountDashboardHandle, cookie: string) => ( 'X-Grok-CSRF': dashboard.csrfToken, }); +const servedFile = (session: Awaited>, path = '') => + fetch(`${session.dashboard.origin}${path}`, { headers: { Cookie: session.cookie } }).then( + (response) => response.text(), + ); + async function openDashboard(options?: Parameters[2]) { const extension = await setup(); const dashboard = await startAccountDashboard( @@ -318,11 +323,7 @@ describe('account dashboard loopback server', () => { it('serves DOM-safe client code and rejects malformed or oversized mutations', async () => { const session = await openDashboard(); - const script = await ( - await fetch(`${session.dashboard.origin}/app.js`, { - headers: { Cookie: session.cookie }, - }) - ).text(); + const script = await servedFile(session, '/app.js'); const malformed = await fetch(`${session.dashboard.origin}/api/accounts`, { method: 'POST', headers: session.headers, @@ -342,22 +343,21 @@ describe('account dashboard loopback server', () => { it('serves an accessible page shell with brand-consistent client behavior', async () => { const session = await openDashboard(); - const page = await ( - await fetch(session.dashboard.origin, { - headers: { Cookie: session.cookie }, - }) - ).text(); - const script = await ( - await fetch(`${session.dashboard.origin}/app.js`, { - headers: { Cookie: session.cookie }, - }) - ).text(); + const page = await servedFile(session); + const script = await servedFile(session, '/app.js'); // The confirm button stays the dialog's default action (Enter submits, never cancels). expect(page).toContain('id="dialog-cancel" type="button"'); // Failures are announced assertively; routine status stays polite. expect(page).toContain('role="alert"'); expect(page).toContain('role="status"'); + // A persistent polite region carries login progress across poll-driven re-renders. + expect(page).toContain('id="sr-status"'); + // The appbar separator is decorative and stays out of the accessibility tree. + expect(page).toContain('class="brand-sep" aria-hidden="true"'); + // Card titles are h2s: the heading outline never skips a level under the h1 brand. + expect(script).toContain("element('h2'"); + expect(script).not.toContain("element('h3'"); // The favicon matches the in-app brand mark (ink glyph on a raised tile). expect(page).toContain('%23f7f8f8'); expect(page).not.toContain('%234fd1e8'); @@ -370,6 +370,42 @@ describe('account dashboard loopback server', () => { expect(script).toContain('pointerenter'); }); + it('keeps long labels contained and offline text readable', async () => { + const session = await openDashboard(); + const styles = await servedFile(session, '/app.css'); + + // Long account labels truncate inside the card instead of overflowing the grid. + expect(styles).toContain('.card-title-row h2'); + expect(styles).not.toContain('.card-title-row h3'); + expect(styles).toMatch(/\.card-title-row\s*\{[^}]*min-width:\s*0/); + // Offline desaturation never dims text below AA contrast. + expect(styles).toContain('filter: saturate(0.55)'); + expect(styles).not.toContain('brightness(0.82)'); + // Component rules derive status hues from tokens instead of repeating raw values. + expect(styles).not.toMatch(/border-color: oklch\(0/); + expect(styles).toContain('oklch(from var(--red)'); + expect(styles).toContain('oklch(from var(--amber)'); + expect(styles).toContain('oklch(from var(--accent)'); + expect(styles).not.toContain('.brand-meta::before'); + }); + + it('confirms account operations and throttles the state field', async () => { + const session = await openDashboard(); + const script = await servedFile(session, '/app.js'); + + // Successful account operations confirm audibly, not only visually. + expect(script).toContain(`Switched to \${account.label}.`); + expect(script).toContain(`Renamed to \${updated.label}.`); + expect(script).toContain(`Removed \${account.label}.`); + expect(script).toContain(`Logged out \${account.label}.`); + expect(script).toContain(`Logged in \${account.label}.`); + // Hidden toasts clear their text so stale messages leave the accessibility tree. + expect(script).toContain("node.textContent = ''"); + // The state field renders on its documented 30fps cadence and resizes via observer. + expect(script).toContain('1000 / 30'); + expect(script).toContain('ResizeObserver'); + }); + it('reuses one server, reports browser-launch failures, and closes cleanly', async () => { const extension = await setup(); const launchBrowser = vi.fn(async () => false); From 5c3ff286e825c089380805fec162fd0660cd231c Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Sat, 18 Jul 2026 16:48:43 +0900 Subject: [PATCH 23/28] fix: harden payload and account state handling Why: - Review found malformed reasoning payloads, cross-session dashboard cookie collisions, stale quota resurrection, invalid two-digit account routes, and inaccurate security wording. What: - Normalize reasoning content while preserving valid replay fields. - Isolate dashboard cookies and accept every valid numbered account alias. - Serialize quota refreshes with account mutations so removed aliases cannot regain cached usage. - Document manual authorization-code handling accurately and add focused regressions. Validation: - bun run test -- tests/payload/sanitize.test.ts tests/provider/dashboard.test.ts tests/provider/accounts.test.ts (65 tests) - bun run check (491 tests, typecheck, knip, jscpd, coverage) - npm pack --dry-run --json --ignore-scripts - autoreview --mode local (clean) --- SECURITY.md | 2 +- src/payload/sanitize.ts | 27 +++++++++++++++------ src/provider/accounts.ts | 26 ++++++++++++--------- src/provider/dashboard/server.ts | 15 +++++++----- tests/payload/sanitize.test.ts | 40 +++++++++++++++++++++++++++++++- tests/provider/accounts.test.ts | 39 +++++++++++++++++++++++++++++++ tests/provider/dashboard.test.ts | 38 ++++++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 26 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index c68ef6e..57bd139 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,7 +20,7 @@ The maintainer aims to acknowledge a complete report within 7 calendar days and - pi-grok-cli and its tools run with the user's operating-system permissions. File and shell tools can read or modify the workspace and execute commands when the model invokes them. - OAuth credentials returned by this extension are stored and refreshed by pi. pi-grok-cli does not read or modify Grok Build credentials. -- `/grok-cli-accounts gui` starts a temporary account-management server bound only to an OS-assigned `127.0.0.1` port. A random capability URL bootstraps a session cookie; subsequent mutations require same-origin and CSRF validation. The page receives account labels, status, and quota data, but never OAuth credentials, authorization codes, callback URLs, or environment-token values. Treat the private dashboard URL as sensitive and do not share it while the server is running. +- `/grok-cli-accounts gui` starts a temporary account-management server bound only to an OS-assigned `127.0.0.1` port. A random capability URL bootstraps a session cookie; subsequent mutations require same-origin and CSRF validation. The page receives account labels, status, and quota data. It never receives stored OAuth credentials, callback URLs, or environment-token values; a manually entered one-time authorization code is handled transiently during login and is never returned by `/api/state`. Treat the private dashboard URL as sensitive and do not share it while the server is running. - Prompts, conversation context, tool definitions, and tool results are sent to the configured Grok CLI proxy. - Images handled by vision routing are sent to the configured describer model. The local vision cache stores descriptions and hashes, not raw images. - Optional web search is delegated to `pi-web-access` and the providers configured there. diff --git a/src/payload/sanitize.ts b/src/payload/sanitize.ts index 8e8c695..91c0d7d 100644 --- a/src/payload/sanitize.ts +++ b/src/payload/sanitize.ts @@ -226,6 +226,23 @@ function rewriteFunctionCallOutput(input: Record[]): Record { + if (typeof part === 'string') { + return part ? [{ type: 'reasoning_text', text: part }] : []; + } + if (!part || typeof part !== 'object' || Array.isArray(part)) return []; + if (typeof (part as Record).type === 'string') return [part]; + if (typeof (part as Record).text !== 'string') return []; + return [{ ...(part as Record), type: 'reasoning_text' }]; + }); + return normalized.length ? normalized : undefined; +} + /** * Sanitize a provider request payload for xAI's Responses API via * cli-chat-proxy.grok.com. @@ -249,13 +266,9 @@ export function sanitizePayload( if (obj.type === 'reasoning') { delete obj.status; - if (Array.isArray(obj.content)) { - obj.content = obj.content.map((part) => { - if (!part || typeof part !== 'object' || Array.isArray(part)) return part; - if (typeof (part as Record).type === 'string') return part; - return { ...(part as Record), type: 'reasoning_text' }; - }); - } + const content = normalizeReasoningContent(obj.content); + if (content) obj.content = content; + else delete obj.content; } // Drop empty string content diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts index 6fc3867..04b817a 100644 --- a/src/provider/accounts.ts +++ b/src/provider/accounts.ts @@ -688,20 +688,24 @@ function createAccountManager( updated: boolean; }) => void, ) { - return refreshAccounts( - ctx, - copyConfig().accounts.items.filter((account) => hasAccountAuth(ctx, account.provider)), - signal, - onProgress, + return mutate(() => + refreshAccounts( + ctx, + copyConfig().accounts.items.filter((account) => hasAccountAuth(ctx, account.provider)), + signal, + onProgress, + ), ); }, refreshOne(ctx: ExtensionContext, provider: string, signal: AbortSignal) { - const config = copyConfig(); - const account = accountFrom(config, provider); - if (!hasAccountAuth(ctx, provider)) { - throw new Error(`Log in to “${account.label}” before refreshing its quota.`); - } - return refreshAccounts(ctx, [account], signal); + return mutate(() => { + const config = copyConfig(); + const account = accountFrom(config, provider); + if (!hasAccountAuth(ctx, provider)) { + throw new Error(`Log in to “${account.label}” before refreshing its quota.`); + } + return refreshAccounts(ctx, [account], signal); + }); }, snapshot(ctx: ExtensionContext): AccountsSnapshot { const config = copyConfig(); diff --git a/src/provider/dashboard/server.ts b/src/provider/dashboard/server.ts index 70d1566..cf30635 100644 --- a/src/provider/dashboard/server.ts +++ b/src/provider/dashboard/server.ts @@ -7,7 +7,7 @@ import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; import type { AccountManager } from '../accounts.js'; const HOST = '127.0.0.1'; -const COOKIE = 'grok_cli_dashboard'; +const COOKIE_PREFIX = 'grok_cli_dashboard_'; const MAX_BODY_BYTES = 8 * 1024; const DEFAULT_IDLE_MS = 15 * 60_000; const SECURITY_HEADERS = { @@ -62,11 +62,11 @@ function safeEqual(left: string, right: string) { return a.length === b.length && timingSafeEqual(a, b); } -function cookieValue(req: IncomingMessage) { +function cookieValue(req: IncomingMessage, cookieName: string) { return req.headers.cookie ?.split(';') .map((part) => part.trim().split('=')) - .find(([name]) => name === COOKIE)?.[1]; + .find(([name]) => name === cookieName)?.[1]; } function send(res: ServerResponse, status: number, body = '', contentType = 'text/plain') { @@ -117,7 +117,9 @@ function escapeHtml(value: string) { } function accountProvider(pathname: string, suffix = '') { - const match = new RegExp(`^/api/accounts/(grok-cli(?:-[2-9][0-9]*)?)${suffix}$`).exec(pathname); + const match = new RegExp(`^/api/accounts/(grok-cli(?:-(?:[2-9]|[1-9][0-9]+))?)${suffix}$`).exec( + pathname, + ); return match?.[1]; } @@ -191,6 +193,7 @@ export async function startAccountDashboard( options: DashboardOptions = {}, ): Promise { const capability = randomBytes(32).toString('base64url'); + const cookieName = `${COOKIE_PREFIX}${randomBytes(8).toString('hex')}`; const csrfToken = randomBytes(32).toString('base64url'); const html = readFileSync(new URL('./index.html', import.meta.url), 'utf8').replace( '__GROK_CSRF_TOKEN__', @@ -372,12 +375,12 @@ export async function startAccountDashboard( res.writeHead(302, { ...SECURITY_HEADERS, Location: '/', - 'Set-Cookie': `${COOKIE}=${capability}; HttpOnly; SameSite=Strict; Path=/`, + 'Set-Cookie': `${cookieName}=${capability}; HttpOnly; SameSite=Strict; Path=/`, }); res.end(); return; } - if (!safeEqual(cookieValue(req) ?? '', capability)) { + if (!safeEqual(cookieValue(req, cookieName) ?? '', capability)) { throw new HttpError( 401, 'Dashboard session expired — reopen it with /grok-cli-accounts gui.', diff --git a/tests/payload/sanitize.test.ts b/tests/payload/sanitize.test.ts index 924fdaf..0a17d8b 100644 --- a/tests/payload/sanitize.test.ts +++ b/tests/payload/sanitize.test.ts @@ -49,7 +49,10 @@ describe('payload sanitization', () => { 'existing instruction\n\nsystem instruction\n\ndeveloper instruction\noutput text instruction\n\nlater system instruction', ); expect(payload.input).toEqual([ - { type: 'reasoning', content: 'cached reasoning' }, + { + type: 'reasoning', + content: [{ type: 'reasoning_text', text: 'cached reasoning' }], + }, { role: 'user', content: 'hello' }, ]); expect(payload.include).toEqual(['reasoning.encrypted_content', 'message.output_text']); @@ -100,6 +103,41 @@ describe('payload sanitization', () => { expect(payload.include).toEqual(['reasoning.encrypted_content']); }); + it('drops malformed reasoning content while normalizing text parts', () => { + const payload = sanitizePayload( + { + input: [ + { + type: 'reasoning', + content: [ + 'plain text', + null, + 42, + ['nested'], + { ignored: true }, + { text: 'missing discriminator' }, + { type: 'future_reasoning_type', text: 'keep discriminator' }, + ], + }, + ], + }, + 'grok-build', + 'session-123', + process.cwd(), + ); + + expect(payload.input).toEqual([ + { + type: 'reasoning', + content: [ + { type: 'reasoning_text', text: 'plain text' }, + { type: 'reasoning_text', text: 'missing discriminator' }, + { type: 'future_reasoning_type', text: 'keep discriminator' }, + ], + }, + ]); + }); + it('maintains serialized input prefixes across three cumulative turns', () => { const sanitizeInput = (input: unknown[]) => sanitizePayload( diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts index 452dd84..4d2d13e 100644 --- a/tests/provider/accounts.test.ts +++ b/tests/provider/accounts.test.ts @@ -468,6 +468,45 @@ describe('/grok-cli-accounts', () => { expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(700); }); + it('does not restore quota cache after an account is removed during refresh', async () => { + configureAccounts(); + let releaseMonthly = () => {}; + globalThis.fetch = vi.fn(async (input) => { + if (String(input).includes('format=credits')) { + return new Response('no weekly', { status: 500 }); + } + await new Promise((resolve) => { + releaseMonthly = resolve; + }); + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 900 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + const refresh = extension.accountManagement.manager.refreshOne( + extension.context as unknown as ExtensionContext, + 'grok-cli-2', + new AbortController().signal, + ); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1)); + const removal = extension.accountManagement.manager.remove( + extension.context as unknown as ExtensionContext, + 'grok-cli-2', + ); + + releaseMonthly(); + await Promise.all([refresh, removal]); + + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + ]); + expect(loadQuotaCache().accounts['grok-cli-2']).toBeUndefined(); + }); + it('handles uppercase R with no authenticated accounts without fetching', async () => { configureAccounts(); const fetchMock = vi.fn(); diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts index 3a2063f..1cf8c59 100644 --- a/tests/provider/dashboard.test.ts +++ b/tests/provider/dashboard.test.ts @@ -148,6 +148,21 @@ async function waitForAccount( } describe('account dashboard loopback server', () => { + it('keeps simultaneous dashboard sessions isolated by cookie name', async () => { + const first = await openDashboard(); + const second = await openDashboard(); + const cookies = `${first.cookie}; ${second.cookie}`; + + expect(first.cookie.split('=')[0]).not.toBe(second.cookie.split('=')[0]); + expect( + (await fetch(`${first.dashboard.origin}/api/state`, { headers: { Cookie: cookies } })).status, + ).toBe(200); + expect( + (await fetch(`${second.dashboard.origin}/api/state`, { headers: { Cookie: cookies } })) + .status, + ).toBe(200); + }); + it('requires its capability cookie and serves credential-free state with strict headers', async () => { const session = await openDashboard({ refreshAfterLogin: false }); @@ -226,6 +241,29 @@ describe('account dashboard loopback server', () => { }); }); + it('accepts account routes for two-digit provider aliases', async () => { + const session = await openDashboard(); + for (let account = 2; account <= 10; account += 1) { + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: `Account ${account}` }), + }); + } + + const renamed = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-10`, { + method: 'PATCH', + headers: session.headers, + body: JSON.stringify({ label: 'Account ten' }), + }); + + expect(renamed.status).toBe(200); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-10', + label: 'Account ten', + }); + }); + it('renames, activates, logs out, and removes accounts through the shared manager', async () => { const session = await openDashboard(); await fetch(`${session.dashboard.origin}/api/accounts`, { From c7b2b595e22d98cf7530acac405ddfc4c552cb80 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Sat, 18 Jul 2026 18:25:51 +0900 Subject: [PATCH 24/28] fix(docs): align security and configuration guidance Why: - A documentation audit found inaccurate OAuth recovery guidance, incomplete security boundaries, and advanced configuration missing from the published package. What: - Distinguish callback bind failures from manual callback recovery. - Document persisted base URLs, OS-permitted tool reach, and OAuth, image, Imagine, and quota data flows. - Publish CONFIGURATION.md and lock the corrected package inventory. Validation: - bun run check (491 tests, typecheck, knip, jscpd, coverage) - npm pack --dry-run --json --ignore-scripts - git diff --check --- CONFIGURATION.md | 2 ++ README.md | 2 +- SECURITY.md | 9 ++++++--- package.json | 1 + tests/provider/package.test.ts | 8 +++++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8ac52ec..4fdc7b9 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -13,3 +13,5 @@ Most users do not need these overrides. Only point base URL overrides at endpoin | `PI_GROK_CLI_TOKEN_TIMEOUT_MS` | `30000` | Timeout for OAuth token requests. | | `PI_GROK_CLI_IMAGINE_BASE_URL` | `https://api.x.ai/v1` | Override the Imagine API base URL. | | `PI_GROK_CLI_IMAGINE_MODEL` | `grok-imagine-image-quality` | Override the Imagine image model. | + +OAuth logins store the effective main API base URL in each account's credentials. Changing or removing `PI_GROK_CLI_BASE_URL` or `GROK_CLI_BASE_URL` does not immediately redirect an already authenticated account; its stored URL remains active until token refresh or re-login. Log in again to apply the change immediately. diff --git a/README.md b/README.md index 8ae5eaf..c6fa5ad 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ See [Advanced configuration](./CONFIGURATION.md) for OAuth, callback, endpoint, | Problem | What to do | | --- | --- | | grok-cli is missing from `/model` | Confirm the package appears in `pi list`, run `/login`, choose **Grok CLI**, then restart pi or run `/reload`. | -| Browser login cannot bind or complete | Paste the complete callback URL into pi when prompted. If xAI displays a one-time code instead, paste it into the same prompt. Otherwise, use device-code login or adjust `PI_GROK_CLI_CALLBACK_HOST` and `PI_GROK_CLI_CALLBACK_PORT`. Never post the callback URL or authorization code publicly. | +| Browser login cannot bind or complete | If the callback listener started but the browser redirect does not complete, paste the complete callback URL into pi when prompted. If xAI displays a one-time code instead, paste it into the same prompt. If pi reports that the listener could not bind, use device-code login or adjust `PI_GROK_CLI_CALLBACK_HOST` and `PI_GROK_CLI_CALLBACK_PORT`. Never post the callback URL or authorization code publicly. | | Authentication returns HTTP 401 or 403 | Run `/login` again and confirm the account can access the selected model. Replace an expired `GROK_CLI_OAUTH_TOKEN` if using the bypass. | | A listed model is unavailable | Availability can differ by account or region, and the catalog is bundled rather than discovered live. Try another model or update the extension. | | Images are not being described | Run `/grok-cli-vision:status`, confirm routing is on, and verify Grok authentication. Native image-capable models bypass routing by design. | diff --git a/SECURITY.md b/SECURITY.md index 57bd139..5d9a7c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,12 +18,15 @@ The maintainer aims to acknowledge a complete report within 7 calendar days and ## Security boundaries -- pi-grok-cli and its tools run with the user's operating-system permissions. File and shell tools can read or modify the workspace and execute commands when the model invokes them. +- pi-grok-cli and its tools run with the user's operating-system permissions. File and shell tools can access any path those permissions allow, including paths outside the workspace, and can execute commands when the model invokes them. - OAuth credentials returned by this extension are stored and refreshed by pi. pi-grok-cli does not read or modify Grok Build credentials. +- Browser OAuth starts a temporary callback server on `127.0.0.1:56122` by default and falls back to an ephemeral port. `PI_GROK_CLI_CALLBACK_HOST` can bind it to another interface. The server validates the callback path and OAuth state and closes after login. Treat complete callback URLs and authorization codes as sensitive. - `/grok-cli-accounts gui` starts a temporary account-management server bound only to an OS-assigned `127.0.0.1` port. A random capability URL bootstraps a session cookie; subsequent mutations require same-origin and CSRF validation. The page receives account labels, status, and quota data. It never receives stored OAuth credentials, callback URLs, or environment-token values; a manually entered one-time authorization code is handled transiently during login and is never returned by `/api/state`. Treat the private dashboard URL as sensitive and do not share it while the server is running. -- Prompts, conversation context, tool definitions, and tool results are sent to the configured Grok CLI proxy. +- Prompts, conversation context, tool definitions, tool results, and native image inputs are sent to the configured Grok CLI proxy. - Images handled by vision routing are sent to the configured describer model. The local vision cache stores descriptions and hashes, not raw images. +- Grok Imagine sends the selected account's bearer token, generation prompt, and options to `https://api.x.ai/v1` or `PI_GROK_CLI_IMAGINE_BASE_URL`. Generated JPEGs and PNG previews are saved under session storage, a requested output path, or temporary storage. +- Monthly and weekly quota totals and reset timestamps are cached per account in `~/.pi/grok-cli/quota-cache.json` with file mode `0600`. The cache does not contain OAuth tokens. - Optional web search is delegated to `pi-web-access` and the providers configured there. -- A configured base URL override is trusted with bearer tokens, prompts, conversation data, and tool results. +- A configured main API base URL override is trusted with bearer tokens, prompts, conversation data, tool results, images, and billing queries. An Imagine base URL override is trusted with the bearer token and generation request described above. Expected provider behavior, account-specific model availability, subscription quotas, model-generated content, a model choosing an enabled local tool, and changes made after a user deliberately configures an untrusted endpoint are generally out of scope unless pi-grok-cli violates a documented boundary or exposes data beyond that configuration. diff --git a/package.json b/package.json index 534328f..958ce17 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "type": "module", "main": "./src/index.ts", "files": [ + "CONFIGURATION.md", "README.md", "SECURITY.md", "src", diff --git a/tests/provider/package.test.ts b/tests/provider/package.test.ts index fad3415..67cad75 100644 --- a/tests/provider/package.test.ts +++ b/tests/provider/package.test.ts @@ -11,7 +11,13 @@ describe('npm package manifest', () => { expect(packageJson.keywords).toContain('pi-package'); expect(packageJson.pi?.extensions).toEqual(['./src/index.ts']); expect(packageJson.main).toBe('./src/index.ts'); - expect(packageJson.files).toEqual(['README.md', 'SECURITY.md', 'src', 'tsconfig.json']); + expect(packageJson.files).toEqual([ + 'CONFIGURATION.md', + 'README.md', + 'SECURITY.md', + 'src', + 'tsconfig.json', + ]); }); it('runs publish checks before packing', () => { From 54cc8476f2733120f02c5ce85c765cf8daf8751b Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Sat, 18 Jul 2026 18:38:16 +0900 Subject: [PATCH 25/28] docs: refresh README presentation Why: - The README introduction needed stronger visual identity and a clearer summary of the current account experience. What: - Add the branded Pi Grok CLI header image. - Reorder feature highlights and describe browser account management and exhaustion rotation. - Keep the image repository-only; the npm package inventory remains unchanged. Validation: - bun run check (491 tests, typecheck, knip, jscpd, coverage) - git diff --cached --check --- .github/assets/pi-grok-cli.png | Bin 0 -> 287633 bytes README.md | 14 ++++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 .github/assets/pi-grok-cli.png diff --git a/.github/assets/pi-grok-cli.png b/.github/assets/pi-grok-cli.png new file mode 100644 index 0000000000000000000000000000000000000000..17d900a37e5366f50acff320c9be9b426add8466 GIT binary patch literal 287633 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ͦMNFUHCEvcWG4=CkQ>%Xv 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 z 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 _}5~bXa3n*Z2I6Qdc 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

MjZ}s&!rvAGZ{nx1Dk?H@Vbp$S7L))zj9jq;olm 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

|{icCIWXz6}J1Xoj87gh}rTao# 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 Q=l}o!07*qoM6N<$f{G<9&;S4c literal 0 HcmV?d00001 diff --git a/README.md b/README.md index c6fa5ad..05fd13a 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,20 @@ [![npm version](https://img.shields.io/npm/v/pi-grok-cli?label=npm&color=blue)](https://www.npmjs.com/package/pi-grok-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-red.svg)](./LICENSE) +
+ +[![Pi Grok CLI](./.github/assets/pi-grok-cli.png)](./.github/assets/pi-grok-cli.png) + +
+ Use your X Premium or SuperGrok subscription in [pi](https://pi.dev/) with a clean, focused toolset. -- **Vision for text-only models** — automatically describe images with a vision-capable Grok model and cache descriptions locally. -- **Grok Imagine** — generate JPEGs from the TUI or let Grok call the `image_gen` tool, with inline previews. - **Subscription OAuth** — sign in through a browser or device code; tokens refresh automatically. -- **Multiple accounts** — keep independent Pi logins, switch from a TUI, and continue automatically on an exact exhausted-balance error. -- **Model-scoped compatibility** — Cursor-style tool names only for Grok Build and Composer 2.5. All other models keep Pi's native tools. +- **Multiple accounts** — keep independent Pi logins, switch from a TUI or browser UI, and rotate automatically on exhausted-balance error. - **Usage tracking** — check account limits, remaining credits, and reset times from pi. +- **Grok Imagine** — generate JPEGs from the TUI or let Grok call the `image_gen` tool, with inline previews. +- **Vision for text-only models** — automatically describe images with a vision-capable Grok model and cache descriptions locally. +- **Model-scoped compatibility** — Cursor-style tool names only for Grok Build and Composer 2.5. All other models keep Pi's native tools. > Requires pi 0.80.9 or newer and an xAI/Grok account with access to the selected model. Model availability varies by account, plan, region, and xAI rollout. The Grok Build executable is not required. > From dd1c1b2538cd0d947797f6f9e33d39fdf646edca Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Sat, 18 Jul 2026 19:35:50 +0900 Subject: [PATCH 26/28] fix: resolve multiple-account review findings Why: - Overlapping account, model, quota, replay, and dashboard lifecycle work could restore stale state, emit invalid payloads, or block shutdown. What: - Serialize model persistence without re-entrant waits and guard quota writes by account generation. - Keep only valid reasoning content discriminators. - Bound incomplete dashboard bodies and close active connections during shutdown. Validation: - bun run check (495 tests, typecheck, knip, jscpd, coverage) - autoreview --mode local (clean) --- src/payload/sanitize.ts | 9 ++-- src/provider/accounts.ts | 73 +++++++++++++++++++----------- src/provider/dashboard/server.ts | 76 +++++++++++++++++++++++++------- tests/payload/sanitize.test.ts | 8 +--- tests/provider/accounts.test.ts | 44 +++++++++++++++--- tests/provider/dashboard.test.ts | 54 +++++++++++++++++++++++ tests/provider/register.test.ts | 17 ++++++- 7 files changed, 222 insertions(+), 59 deletions(-) diff --git a/src/payload/sanitize.ts b/src/payload/sanitize.ts index 91c0d7d..258897b 100644 --- a/src/payload/sanitize.ts +++ b/src/payload/sanitize.ts @@ -236,9 +236,12 @@ function normalizeReasoningContent(content: unknown) { return part ? [{ type: 'reasoning_text', text: part }] : []; } if (!part || typeof part !== 'object' || Array.isArray(part)) return []; - if (typeof (part as Record).type === 'string') return [part]; - if (typeof (part as Record).text !== 'string') return []; - return [{ ...(part as Record), type: 'reasoning_text' }]; + const reasoningPart = part as Record; + if (reasoningPart.type === 'reasoning_text' && typeof reasoningPart.text === 'string') { + return [part]; + } + if (reasoningPart.type !== undefined || typeof reasoningPart.text !== 'string') return []; + return [{ ...reasoningPart, type: 'reasoning_text' }]; }); return normalized.length ? normalized : undefined; } diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts index 04b817a..6a6028d 100644 --- a/src/provider/accounts.ts +++ b/src/provider/accounts.ts @@ -168,9 +168,9 @@ async function resolveAccountToken(ctx: ExtensionContext, provider: string) { } } -async function refreshAccountBatches( - accounts: GrokCliAccount[], - refresh: (account: GrokCliAccount) => Promise, +async function refreshAccountBatches( + accounts: T[], + refresh: (account: T) => Promise, signal: AbortSignal, ): Promise { if (signal.aborted) return; @@ -502,6 +502,7 @@ function createAccountManager( deferredRemovals: Set, ) { let mutations = Promise.resolve(); + const accountGenerations = new Map(); const mutate = (operation: () => Promise | T) => { const result = mutations.then(operation, operation); mutations = result.then( @@ -510,6 +511,9 @@ function createAccountManager( ); return result; }; + const accountGeneration = (provider: string) => accountGenerations.get(provider) ?? 0; + const invalidateAccount = (provider: string) => + accountGenerations.set(provider, accountGeneration(provider) + 1); const accountFrom = (config: GrokCliConfig, provider: string) => { const account = config.accounts.items.find((candidate) => candidate.provider === provider); if (!account) throw new Error(`Unknown Grok CLI account: ${provider}`); @@ -525,7 +529,7 @@ function createAccountManager( }; const refreshAccounts = async ( ctx: ExtensionContext, - accounts: GrokCliAccount[], + accounts: { account: GrokCliAccount; generation: number }[], signal: AbortSignal, onProgress?: (progress: { provider: string; @@ -539,17 +543,28 @@ function createAccountManager( const failed: string[] = []; await refreshAccountBatches( accounts, - async (account) => { + async ({ account, generation }) => { let succeeded = false; try { const token = await resolveAccountToken(ctx, account.provider); if (!token) throw new Error('authentication unavailable'); - await saveQuotaUsage( - account.provider, - await fetchBillingUsage(token, AbortSignal.any([signal, AbortSignal.timeout(30_000)])), + const usage = await fetchBillingUsage( + token, + AbortSignal.any([signal, AbortSignal.timeout(30_000)]), ); - updated += 1; - succeeded = true; + succeeded = await mutate(async () => { + if ( + accountGeneration(account.provider) !== generation || + !copyConfig().accounts.items.some( + (candidate) => candidate.provider === account.provider, + ) + ) { + return false; + } + await saveQuotaUsage(account.provider, usage); + return true; + }); + if (succeeded) updated += 1; } catch { if (!signal.aborted) failed.push(account.provider); } finally { @@ -587,6 +602,7 @@ function createAccountManager( config.accounts.items.map((candidate) => candidate.provider), ); saveConfig(config); + invalidateAccount(provider); registerAccount(account); return account; }); @@ -621,13 +637,15 @@ function createAccountManager( return account; }); }, - login(ctx: ExtensionContext, provider: string, interaction: AuthInteraction) { + async login(ctx: ExtensionContext, provider: string, interaction: AuthInteraction) { const config = copyConfig(); const account = accountFrom(config, provider); if (provider === GROK_CLI_PROVIDER && process.env.GROK_CLI_OAUTH_TOKEN) { throw new Error('Unset GROK_CLI_OAUTH_TOKEN before logging in from the dashboard.'); } - return accountRuntime(ctx).login(account.provider, 'oauth', interaction); + const result = await accountRuntime(ctx).login(account.provider, 'oauth', interaction); + invalidateAccount(provider); + return result; }, logout(ctx: ExtensionContext, provider: string) { return mutate(async () => { @@ -642,6 +660,7 @@ function createAccountManager( const config = copyConfig(); const account = accountFrom(config, provider); await accountRuntime(ctx).logout(provider); + invalidateAccount(provider); account.label = 'Account 1'; saveConfig(config); registerAccount(account); @@ -660,6 +679,7 @@ function createAccountManager( throw new Error('Could not switch to another authenticated Grok CLI account.'); } await accountRuntime(ctx).logout(provider); + invalidateAccount(provider); config.accounts.items = config.accounts.items.filter( (candidate) => candidate.provider !== provider, ); @@ -689,13 +709,12 @@ function createAccountManager( }) => void, ) { return mutate(() => - refreshAccounts( - ctx, - copyConfig().accounts.items.filter((account) => hasAccountAuth(ctx, account.provider)), - signal, - onProgress, + copyConfig().accounts.items.flatMap((account) => + hasAccountAuth(ctx, account.provider) + ? [{ account, generation: accountGeneration(account.provider) }] + : [], ), - ); + ).then((accounts) => refreshAccounts(ctx, accounts, signal, onProgress)); }, refreshOne(ctx: ExtensionContext, provider: string, signal: AbortSignal) { return mutate(() => { @@ -704,8 +723,8 @@ function createAccountManager( if (!hasAccountAuth(ctx, provider)) { throw new Error(`Log in to “${account.label}” before refreshing its quota.`); } - return refreshAccounts(ctx, [account], signal); - }); + return { account, generation: accountGeneration(provider) }; + }).then((account) => refreshAccounts(ctx, [account], signal)); }, snapshot(ctx: ExtensionContext): AccountsSnapshot { const config = copyConfig(); @@ -746,12 +765,14 @@ function createAccountManager( pi.unregisterProvider(event.previousModel.provider); } if (!isGrokCliProvider(event.model.provider)) return; - const config = copyConfig(); - if (!config.accounts.items.some((account) => account.provider === event.model.provider)) - return; - if (config.accounts.selectedProvider === event.model.provider) return; - config.accounts.selectedProvider = event.model.provider; - saveConfig(config); + return mutate(() => { + const config = copyConfig(); + if (!config.accounts.items.some((account) => account.provider === event.model.provider)) + return; + if (config.accounts.selectedProvider === event.model.provider) return; + config.accounts.selectedProvider = event.model.provider; + saveConfig(config); + }); }, }; } diff --git a/src/provider/dashboard/server.ts b/src/provider/dashboard/server.ts index cf30635..e47970b 100644 --- a/src/provider/dashboard/server.ts +++ b/src/provider/dashboard/server.ts @@ -9,6 +9,7 @@ import type { AccountManager } from '../accounts.js'; const HOST = '127.0.0.1'; const COOKIE_PREFIX = 'grok_cli_dashboard_'; const MAX_BODY_BYTES = 8 * 1024; +const DEFAULT_BODY_TIMEOUT_MS = 5_000; const DEFAULT_IDLE_MS = 15 * 60_000; const SECURITY_HEADERS = { 'Cache-Control': 'no-store', @@ -37,6 +38,7 @@ export interface AccountDashboardHandle { } interface DashboardOptions { + bodyTimeoutMs?: number; idleMs?: number; refreshAfterLogin?: boolean; } @@ -81,23 +83,55 @@ function json(res: ServerResponse, status: number, value: unknown) { send(res, status, JSON.stringify(value), 'application/json'); } -async function readJson(req: IncomingMessage) { +function readJson(req: IncomingMessage, timeoutMs: number) { if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) { throw new HttpError(415, 'Expected application/json.'); } - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of req) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - size += buffer.length; - if (size > MAX_BODY_BYTES) throw new HttpError(413, 'Request body is too large.'); - chunks.push(buffer); - } - try { - return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; - } catch { - throw new HttpError(400, 'Request body must be valid JSON.'); - } + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + let timer: ReturnType | undefined; + const cleanup = (keepErrorListener = false) => { + if (timer) clearTimeout(timer); + req.removeListener('data', onData); + req.removeListener('end', onEnd); + req.removeListener('aborted', onAborted); + if (!keepErrorListener) req.removeListener('error', onError); + }; + const fail = (error: unknown) => { + cleanup(true); + req.once('close', () => req.removeListener('error', onError)); + reject(error); + }; + const onData = (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) { + fail(new HttpError(413, 'Request body is too large.')); + return; + } + chunks.push(buffer); + }; + const onEnd = () => { + cleanup(); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown); + } catch { + reject(new HttpError(400, 'Request body must be valid JSON.')); + } + }; + const onAborted = () => fail(new HttpError(400, 'Request body was interrupted.')); + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + req.on('data', onData); + req.once('end', onEnd); + req.once('aborted', onAborted); + req.once('error', onError); + timer = setTimeout(() => fail(new HttpError(408, 'Request body timed out.')), timeoutMs); + timer.unref(); + }); } function objectBody(value: unknown) { @@ -216,6 +250,7 @@ export async function startAccountDashboard( return; } const status = error instanceof HttpError ? error.status : 500; + if (status === 408 || status === 413) res.shouldKeepAlive = false; if (req.headers.accept?.includes('text/html')) { send( res, @@ -243,6 +278,7 @@ export async function startAccountDashboard( return; } server.close(() => resolve()); + server.closeAllConnections(); }); return closing; }; @@ -404,7 +440,9 @@ export async function startAccountDashboard( } if (req.method === 'POST' && url.pathname === '/api/accounts') { requireMutation(req); - const body = objectBody(await readJson(req)); + const body = objectBody( + await readJson(req, options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS), + ); if (body.label !== undefined && typeof body.label !== 'string') { throw new HttpError(400, 'Account label must be text.'); } @@ -414,7 +452,9 @@ export async function startAccountDashboard( const renameProvider = accountProvider(url.pathname); if (req.method === 'PATCH' && renameProvider) { requireMutation(req); - const body = objectBody(await readJson(req)); + const body = objectBody( + await readJson(req, options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS), + ); if (typeof body.label !== 'string') { throw new HttpError(400, 'Account label must be text.'); } @@ -480,7 +520,9 @@ export async function startAccountDashboard( const codeProvider = accountProvider(url.pathname, '/login-code'); if (req.method === 'POST' && codeProvider) { requireMutation(req); - const body = objectBody(await readJson(req)); + const body = objectBody( + await readJson(req, options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS), + ); if (typeof body.code !== 'string' || !body.code.trim()) { throw new HttpError(400, 'Authorization code is required.'); } diff --git a/tests/payload/sanitize.test.ts b/tests/payload/sanitize.test.ts index 0a17d8b..a0bceff 100644 --- a/tests/payload/sanitize.test.ts +++ b/tests/payload/sanitize.test.ts @@ -63,7 +63,7 @@ describe('payload sanitization', () => { expect(payload.prompt_cache_key).toBe('session-123'); }); - it('preserves encrypted reasoning and repairs only missing reasoning-text types', () => { + it('preserves encrypted reasoning and drops invalid reasoning-content types', () => { const payload = sanitizePayload( { input: [ @@ -92,10 +92,7 @@ describe('payload sanitization', () => { type: 'reasoning', id: 'reasoning-1', summary: [{ type: 'summary_text', text: 'summary' }], - content: [ - { type: 'reasoning_text', text: 'missing discriminator' }, - { type: 'future_reasoning_type', text: 'keep discriminator' }, - ], + content: [{ type: 'reasoning_text', text: 'missing discriminator' }], encrypted_content: 'encrypted-reasoning', future_field: { keep: true }, }, @@ -132,7 +129,6 @@ describe('payload sanitization', () => { content: [ { type: 'reasoning_text', text: 'plain text' }, { type: 'reasoning_text', text: 'missing discriminator' }, - { type: 'future_reasoning_type', text: 'keep discriminator' }, ], }, ]); diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts index 4d2d13e..eb1d526 100644 --- a/tests/provider/accounts.test.ts +++ b/tests/provider/accounts.test.ts @@ -468,7 +468,7 @@ describe('/grok-cli-accounts', () => { expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(700); }); - it('does not restore quota cache after an account is removed during refresh', async () => { + it('allows removal during refresh without restoring the removed quota cache', async () => { configureAccounts(); let releaseMonthly = () => {}; globalThis.fetch = vi.fn(async (input) => { @@ -498,12 +498,18 @@ describe('/grok-cli-accounts', () => { 'grok-cli-2', ); - releaseMonthly(); - await Promise.all([refresh, removal]); - + await removal; + await extension.accountManagement.manager.add( + extension.context as unknown as ExtensionContext, + 'Replacement', + ); expect(loadConfig().config.accounts.items).toEqual([ { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Replacement' }, ]); + releaseMonthly(); + await refresh; + expect(loadQuotaCache().accounts['grok-cli-2']).toBeUndefined(); }); @@ -1031,15 +1037,41 @@ describe('/grok-cli-accounts', () => { } }); - it('persists a Grok alias selected through Pi model controls', () => { + it('persists a Grok alias selected through Pi model controls', async () => { configureAccounts(); const extension = setup({ preserveHome: true }); - extension.accountManagement.handleModelSelect({ + await extension.accountManagement.handleModelSelect({ model: { provider: 'grok-cli-2' }, previousModel: { provider: 'grok-cli' }, }); expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); }); + + it('applies model selections after an overlapping account mutation', async () => { + configureAccounts(); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + let finishModelSwitch = () => {}; + extension.setModel.mockImplementationOnce( + () => + new Promise((resolve) => { + finishModelSwitch = () => resolve(true); + }), + ); + const activation = extension.accountManagement.manager.activate( + extension.context as unknown as ExtensionContext, + 'grok-cli-2', + ); + await vi.waitFor(() => expect(extension.setModel).toHaveBeenCalledTimes(1)); + + const selection = extension.accountManagement.handleModelSelect({ + model: { provider: 'grok-cli' }, + previousModel: { provider: 'grok-cli-2' }, + }); + finishModelSwitch(); + await Promise.all([activation, selection]); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + }); }); diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts index 1cf8c59..0da230a 100644 --- a/tests/provider/dashboard.test.ts +++ b/tests/provider/dashboard.test.ts @@ -1,3 +1,5 @@ +import { once } from 'node:events'; +import { createConnection } from 'node:net'; import { InMemoryCredentialStore, type OAuthCredentials, @@ -131,6 +133,26 @@ async function openDashboard(options?: Parameters[ }; } +async function openIncompleteMutation(session: Awaited>) { + const url = new URL(session.dashboard.origin); + const socket = createConnection({ host: url.hostname, port: Number(url.port) }); + await once(socket, 'connect'); + socket.write( + [ + 'POST /api/accounts HTTP/1.1', + `Host: ${url.host}`, + `Cookie: ${session.cookie}`, + `Origin: ${session.dashboard.origin}`, + 'Content-Type: application/json', + `X-Grok-CSRF: ${session.dashboard.csrfToken}`, + 'Content-Length: 100', + '', + '{', + ].join('\r\n'), + ); + return socket; +} + async function waitForAccount( dashboard: AccountDashboardHandle, cookie: string, @@ -464,6 +486,38 @@ describe('account dashboard loopback server', () => { await expect(fetch(first.origin)).rejects.toThrow(); }); + it('times out incomplete mutation request bodies', async () => { + const session = await openDashboard({ bodyTimeoutMs: 20 }); + const socket = await openIncompleteMutation(session); + const response = await Promise.race([ + once(socket, 'data').then(([data]) => data.toString()), + new Promise((resolve) => setTimeout(() => resolve(''), 100)), + ]); + const connectionClosed = await Promise.race([ + once(socket, 'close').then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + socket.destroy(); + + expect(response).toContain('408 Request Timeout'); + expect(connectionClosed).toBe(true); + }); + + it('closes promptly with an incomplete mutation request body', async () => { + const session = await openDashboard(); + const socket = await openIncompleteMutation(session); + await new Promise((resolve) => setTimeout(resolve, 10)); + const closing = session.dashboard.close(); + const closedPromptly = await Promise.race([ + closing.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + socket.destroy(); + await closing; + + expect(closedPromptly).toBe(true); + }); + it('expires an abandoned server after its idle timeout', async () => { const extension = await setup(); const dashboard = await startAccountDashboard( diff --git a/tests/provider/register.test.ts b/tests/provider/register.test.ts index 2629904..3981e0c 100644 --- a/tests/provider/register.test.ts +++ b/tests/provider/register.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from '@earendil-works/pi-ai'; import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_CONFIG, saveConfig } from '../../src/config.js'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; import { loadQuotaCache, saveQuotaUsage } from '../../src/provider/quotaCache.js'; import { getQuotaCachePath } from '../../src/storage.js'; import { GROK_SHIM_TOOL_NAMES } from '../../src/tools/register.js'; @@ -896,6 +896,21 @@ describe('Grok CLI tool scoping', () => { expect(next).not.toEqual(expect.arrayContaining(['Write', 'Delete', 'Shell'])); }); + it('queues model selection persistence without blocking the event handler', async () => { + saveTestAccounts('grok-cli'); + const extension = await setupExtension(); + + const result = extension.handlers.get('model_select')?.( + { model: { provider: 'grok-cli-2', id: 'grok-build' } }, + contextForModel('grok-cli-2', 'grok-build'), + ); + + expect(result).toBeUndefined(); + await vi.waitFor(() => { + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + }); + }); + it('translates compatibility names back to native tools for non-Grok models', async () => { const extension = await setupExtension(['read', 'Grep', 'custom_tool', 'Shell']); From f75ed4b7d531f7f361bf07977d8da5b6e395bfcd Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Sat, 18 Jul 2026 20:41:06 +0900 Subject: [PATCH 27/28] fix: resolve follow-up review findings Why: - Migration, refresh, and rotation races could discard legacy or concurrent state, while dashboard tests relied on source-string checks. What: - Defer legacy cleanup and revalidate asynchronous account and quota commits. - Preserve concurrent rotation changes and align timeout, style, sanitization, and registration behavior. - Execute dashboard client behavior in a browser-capable DOM and cover the affected concurrency paths. Validation: - bun run check (38 test files, 499 tests) --- bun.lock | 13 + package.json | 1 + src/config.ts | 8 +- src/payload/sanitize.ts | 16 +- src/provider/accounts.ts | 27 +- src/provider/dashboard/app.css | 6 +- src/provider/quotaCache.ts | 5 +- src/provider/register.ts | 33 ++- src/provider/rotation.ts | 20 +- src/provider/usage.ts | 2 +- src/storage.ts | 6 +- tests/provider/accounts.test.ts | 34 +++ tests/provider/dashboard.test.ts | 414 +++++++++++++++++++++++++----- tests/provider/quotaCache.test.ts | 16 ++ tests/provider/register.test.ts | 2 + tests/provider/rotation.test.ts | 30 +++ tests/storage.test.ts | 23 +- 17 files changed, 523 insertions(+), 133 deletions(-) diff --git a/bun.lock b/bun.lock index 305308c..97d1550 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@earendil-works/pi-coding-agent": "^0.80.9", "@earendil-works/pi-tui": "^0.80.9", "@vitest/coverage-v8": "^4.1.8", + "happy-dom": "^20.10.6", "husky": "^9.1.7", "jscpd": "^4.2.4", "knip": "^6.15.0", @@ -357,6 +358,10 @@ "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], @@ -411,6 +416,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -457,6 +464,8 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -529,6 +538,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -843,6 +854,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], diff --git a/package.json b/package.json index 958ce17..4be59c4 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@earendil-works/pi-coding-agent": "^0.80.9", "@earendil-works/pi-tui": "^0.80.9", "@vitest/coverage-v8": "^4.1.8", + "happy-dom": "^20.10.6", "husky": "^9.1.7", "jscpd": "^4.2.4", "knip": "^6.15.0", diff --git a/src/config.ts b/src/config.ts index 0380c61..dfcc01c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -467,8 +467,9 @@ function removeLegacyConfigs(paths: string[]) { } export function migrateLegacyConfig(): { warning?: string } { + const migratedLegacyConfig = existsSync(getLegacyConfigPath()) && !existsSync(getConfigPath()); const storageWarning = combineWarnings([ - migrateStoredFile(getLegacyConfigPath(), getConfigPath()), + migrateStoredFile(getLegacyConfigPath(), getConfigPath(), true), migrateStoredFile(getLegacyVisionCachePath(), getVisionCachePath()), ]); if (!existsSync(getConfigPath()) && existsSync(getLegacyConfigPath())) { @@ -506,7 +507,10 @@ export function migrateLegacyConfig(): { warning?: string } { }; } } - const cleanupWarning = removeLegacyConfigs(legacy.recognizedPaths); + const cleanupWarning = removeLegacyConfigs([ + ...legacy.recognizedPaths, + ...(migratedLegacyConfig ? [getLegacyConfigPath()] : []), + ]); const warning = combineWarnings([ storageWarning, loaded.warning, diff --git a/src/payload/sanitize.ts b/src/payload/sanitize.ts index 258897b..e085c95 100644 --- a/src/payload/sanitize.ts +++ b/src/payload/sanitize.ts @@ -271,7 +271,7 @@ export function sanitizePayload( delete obj.status; const content = normalizeReasoningContent(obj.content); if (content) obj.content = content; - else delete obj.content; + if (!content) delete obj.content; } // Drop empty string content @@ -323,18 +323,16 @@ export function sanitizePayload( const reasoningSupported = supportsReasoning(modelId); delete next.reasoningEffort; - if (!reasoningSupported || !reasoning) { - delete next.reasoning; - } else { - if (supportsReasoningEffort(modelId)) { + if (!reasoningSupported || !reasoning) delete next.reasoning; + if (reasoningSupported && reasoning) { + const effortSupported = supportsReasoningEffort(modelId); + if (effortSupported) { if (reasoning.effort === 'minimal') reasoning.effort = 'low'; if (reasoning.effort === undefined) delete reasoning.effort; - } else { - delete reasoning.effort; } - + if (!effortSupported) delete reasoning.effort; if (Object.keys(reasoning).length === 0) delete next.reasoning; - else next.reasoning = reasoning; + if (Object.keys(reasoning).length > 0) next.reasoning = reasoning; } // ── Strip unsupported fields ───────────────────────────────────────── diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts index 6a6028d..f5e984f 100644 --- a/src/provider/accounts.ts +++ b/src/provider/accounts.ts @@ -557,7 +557,8 @@ function createAccountManager( accountGeneration(account.provider) !== generation || !copyConfig().accounts.items.some( (candidate) => candidate.provider === account.provider, - ) + ) || + !hasAccountAuth(ctx, account.provider) ) { return false; } @@ -698,7 +699,7 @@ function createAccountManager( return { warning: cacheWarning }; }); }, - refresh( + async refresh( ctx: ExtensionContext, signal: AbortSignal, onProgress?: (progress: { @@ -708,23 +709,23 @@ function createAccountManager( updated: boolean; }) => void, ) { - return mutate(() => + return refreshAccounts( + ctx, copyConfig().accounts.items.flatMap((account) => hasAccountAuth(ctx, account.provider) ? [{ account, generation: accountGeneration(account.provider) }] : [], ), - ).then((accounts) => refreshAccounts(ctx, accounts, signal, onProgress)); + signal, + onProgress, + ); }, - refreshOne(ctx: ExtensionContext, provider: string, signal: AbortSignal) { - return mutate(() => { - const config = copyConfig(); - const account = accountFrom(config, provider); - if (!hasAccountAuth(ctx, provider)) { - throw new Error(`Log in to “${account.label}” before refreshing its quota.`); - } - return { account, generation: accountGeneration(provider) }; - }).then((account) => refreshAccounts(ctx, [account], signal)); + async refreshOne(ctx: ExtensionContext, provider: string, signal: AbortSignal) { + const account = accountFrom(copyConfig(), provider); + if (!hasAccountAuth(ctx, provider)) { + throw new Error(`Log in to “${account.label}” before refreshing its quota.`); + } + return refreshAccounts(ctx, [{ account, generation: accountGeneration(provider) }], signal); }, snapshot(ctx: ExtensionContext): AccountsSnapshot { const config = copyConfig(); diff --git a/src/provider/dashboard/app.css b/src/provider/dashboard/app.css index 4d03f46..abd94ed 100644 --- a/src/provider/dashboard/app.css +++ b/src/provider/dashboard/app.css @@ -30,9 +30,10 @@ /* Entrance and overlay motion: exponential ease-out, no overshoot. */ --ease: cubic-bezier(0.16, 1, 0.3, 1); --ui: - Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, + "Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", sans-serif; - --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Menlo", "Consolas", monospace; + background: var(--bg); color: var(--text); font-family: var(--ui); @@ -101,7 +102,6 @@ p { width: 1px; height: 1px; overflow: hidden; - clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } diff --git a/src/provider/quotaCache.ts b/src/provider/quotaCache.ts index 3600e73..9b67af5 100644 --- a/src/provider/quotaCache.ts +++ b/src/provider/quotaCache.ts @@ -87,11 +87,12 @@ async function updateQuotaCache( path = getQuotaCachePath(), ) { const previous = cacheUpdates.get(path) ?? Promise.resolve(); - const next = previous.then(() => { + const operation = () => { const cache = loadQuotaCache(path); if (!update(cache)) return; writeFileAtomic(path, `${JSON.stringify(cache, null, 2)}\n`, 0o600); - }); + }; + const next = previous.then(operation, operation); cacheUpdates.set(path, next); try { await next; diff --git a/src/provider/register.ts b/src/provider/register.ts index 61cff13..e85fe13 100644 --- a/src/provider/register.ts +++ b/src/provider/register.ts @@ -1,10 +1,9 @@ -import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from '@earendil-works/pi-ai'; import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import * as oauth from '../auth/oauth.js'; import { getBaseUrl, type XaiOAuthCredentials } from '../auth/oauth.js'; import { type GrokCliAccount, loadConfig, migrateLegacyConfig } from '../config.js'; import { registerImagineFeature } from '../imagine/register.js'; -import { type GrokCliModelConfig, resolveModels } from '../models/catalog.js'; +import { resolveModels } from '../models/catalog.js'; import { sanitizePayload } from '../payload/sanitize.js'; import { registerGrokTools } from '../tools/register.js'; import { bindLivePiWebAccess, ensureWebSearchDelegate } from '../tools/webSearchDelegate.js'; @@ -27,22 +26,22 @@ export default function registerGrokCli(pi: ExtensionAPI) { name: `Grok CLI — ${account.label}`, usesCallbackServer: true, - async login(callbacks: OAuthLoginCallbacks): Promise { + async login(callbacks) { const credentials = await oauth.login(callbacks); await removeQuotaUsage(account.provider).catch(() => undefined); exhaustionRotation.clearRecentExhaustion(account.provider); return credentials; }, - async refreshToken(credentials: OAuthCredentials): Promise { + async refreshToken(credentials) { return oauth.refresh(credentials); }, - getApiKey(credentials: OAuthCredentials): string { + getApiKey(credentials) { return credentials.access; }, - modifyModels(registeredModels: Model[], credentials: OAuthCredentials) { + modifyModels(registeredModels, credentials) { const effectiveBaseUrl = String( (credentials as XaiOAuthCredentials).baseUrl ?? getBaseUrl(), ).replace(/\/+$/, ''); @@ -58,25 +57,25 @@ export default function registerGrokCli(pi: ExtensionAPI) { baseUrl, ...(account.provider === 'grok-cli' ? { apiKey: '$GROK_CLI_OAUTH_TOKEN' } : {}), api: 'openai-responses', - models: models.map((m: GrokCliModelConfig) => ({ - id: m.id, - name: m.name, - reasoning: m.reasoning, - thinkingLevelMap: m.thinkingLevelMap, - input: m.input, - cost: m.cost, - contextWindow: m.contextWindow, - maxTokens: m.maxTokens, + models: models.map((model) => ({ + id: model.id, + name: model.name, + reasoning: model.reasoning, + thinkingLevelMap: model.thinkingLevelMap, + input: model.input, + cost: model.cost, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, // Carried as model.headers so the version-gate headers reach the server on // every request even when the API-provider registry reverts to pi-ai's // built-in openai-responses handler (see grokCliModelHeaders). - headers: grokCliModelHeaders(m.id), + headers: grokCliModelHeaders(model.id), })), oauth: oauthProvider, }); }; - for (const account of loadConfig().config.accounts.items) registerAccount(account); + loadConfig().config.accounts.items.forEach(registerAccount); const accountManagement = registerAccountManagement(pi, registerAccount); const { webSearchRegistered } = registerGrokTools(pi); diff --git a/src/provider/rotation.ts b/src/provider/rotation.ts index 6bdc6d4..ea2880a 100644 --- a/src/provider/rotation.ts +++ b/src/provider/rotation.ts @@ -162,16 +162,17 @@ export function registerExhaustionRotation(pi: ExtensionAPI) { continue; } - const failedLabel = config.accounts.items.find( + const refreshed = loadConfig().config; + const failedLabel = refreshed.accounts.items.find( (account) => account.provider === failed.provider, )?.label; - const selected = config.accounts.items.find((account) => account.provider === provider); + const selected = refreshed.accounts.items.find((account) => account.provider === provider); if (!failedLabel || !selected) { unavailable.add(provider); continue; } - config.accounts.selectedProvider = provider; - saveConfig(config); + refreshed.accounts.selectedProvider = provider; + saveConfig(refreshed); ctx.ui.notify( `Grok CLI: “${failedLabel}” exhausted; switched to “${selected.label}” and continuing.`, 'info', @@ -187,12 +188,13 @@ export function registerExhaustionRotation(pi: ExtensionAPI) { ) ) { ctx.ui.notify('Grok CLI: all logged-in accounts are exhausted.', 'warning'); - } else { - ctx.ui.notify( - 'Grok CLI: no other logged-in account is available for automatic rotation.', - 'warning', - ); + clearChain(); + return; } + ctx.ui.notify( + 'Grok CLI: no other logged-in account is available for automatic rotation.', + 'warning', + ); clearChain(); }); diff --git a/src/provider/usage.ts b/src/provider/usage.ts index 1c1c9a2..f38d9c6 100644 --- a/src/provider/usage.ts +++ b/src/provider/usage.ts @@ -35,7 +35,7 @@ export function registerUsageCommand(pi: Pick) try { ctx.ui.notify('Fetching grok cli usage…', 'info'); - const usage = await fetchBillingUsage(apiKey); + const usage = await fetchBillingUsage(apiKey, AbortSignal.timeout(30_000)); try { await saveQuotaUsage(provider, usage); } catch (error) { diff --git a/src/storage.ts b/src/storage.ts index 1d9f224..ac1e492 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -39,7 +39,7 @@ export function writeFileAtomic(path: string, contents: string, mode?: number) { } } -export function migrateStoredFile(source: string, destination: string) { +export function migrateStoredFile(source: string, destination: string, preserveSource = false) { if (!existsSync(source)) return undefined; if (existsSync(destination)) { return `Could not migrate ${source}: ${destination} already exists. The legacy file was preserved.`; @@ -52,10 +52,10 @@ export function migrateStoredFile(source: string, destination: string) { throw new Error(`could not verify ${destination}`); } verified = true; - unlinkSync(source); + if (!preserveSource) unlinkSync(source); return undefined; } catch (error) { - if (!verified) rmSync(destination, { force: true }); + if (!verified && existsSync(destination)) rmSync(destination, { force: true }); return `Could not migrate ${source} to ${destination}: ${error instanceof Error ? error.message : String(error)}. The legacy file was preserved.`; } } diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts index eb1d526..bd4ca75 100644 --- a/tests/provider/accounts.test.ts +++ b/tests/provider/accounts.test.ts @@ -513,6 +513,40 @@ describe('/grok-cli-accounts', () => { expect(loadQuotaCache().accounts['grok-cli-2']).toBeUndefined(); }); + it('allows logout during refresh without restoring the logged-out quota cache', async () => { + configureAccounts(); + let releaseMonthly = () => {}; + globalThis.fetch = vi.fn(async (input) => { + if (String(input).includes('format=credits')) return new Response(null, { status: 500 }); + await new Promise((resolve) => { + releaseMonthly = resolve; + }); + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 900 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + const refresh = extension.accountManagement.manager.refreshOne( + extension.context as unknown as ExtensionContext, + 'grok-cli', + new AbortController().signal, + ); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1)); + + await extension.accountManagement.manager.logout( + extension.context as unknown as ExtensionContext, + 'grok-cli', + ); + releaseMonthly(); + await refresh; + + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); + }); + it('handles uppercase R with no authenticated accounts without fetching', async () => { configureAccounts(); const fetchMock = vi.fn(); diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts index 0da230a..c6995c4 100644 --- a/tests/provider/dashboard.test.ts +++ b/tests/provider/dashboard.test.ts @@ -11,6 +11,12 @@ import { ModelRegistry, ModelRuntime, } from '@earendil-works/pi-coding-agent'; +import { + type HTMLDialogElement as BrowserDialog, + type HTMLElement as BrowserElement, + type HTMLInputElement as BrowserInput, + Window, +} from 'happy-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; import { registerAccountManagement } from '../../src/provider/accounts.js'; @@ -116,6 +122,181 @@ const servedFile = (session: Awaited>, path = ' (response) => response.text(), ); +const accountState = ( + overrides: Record = {}, + login: Record = { state: 'idle' }, +) => ({ + provider: 'grok-cli', + label: 'Account 1', + status: 'Logged in', + authenticated: true, + active: true, + environment: false, + login, + ...overrides, +}); + +async function browserDashboard( + session: Awaited>, + states: { refreshing: boolean; accounts: ReturnType[] }[], + mutate: (path: string, method: string, body: Record) => unknown = () => ({}), + webgl = false, +) { + const [page, styles, script] = await Promise.all([ + servedFile(session), + servedFile(session, '/app.css'), + servedFile(session, '/app.js'), + ]); + const window = new Window({ url: session.dashboard.origin }); + const timers = new Map(); + const frames = new Map(); + const resizeCallbacks: ResizeObserverCallback[] = []; + let timerId = 0; + let frameId = 0; + let stateIndex = 0; + let canvasWidth = 800; + let canvasHeight = 600; + const drawArrays = vi.fn(); + const viewport = vi.fn(); + const animations = vi.fn(() => ({ cancel() {} })); + + Object.assign(window, { + fetch: vi.fn(async (input: RequestInfo | URL, options: RequestInit = {}) => { + const url = new URL(String(input), session.dashboard.origin); + if (url.pathname === '/api/state') { + const state = states[Math.min(stateIndex, states.length - 1)]; + stateIndex += 1; + return Response.json(state); + } + return Response.json( + mutate( + url.pathname, + options.method ?? 'GET', + options.body ? JSON.parse(String(options.body)) : {}, + ), + ); + }), + matchMedia: () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }), + open: () => window, + requestAnimationFrame: (callback: FrameRequestCallback) => { + frameId += 1; + frames.set(frameId, callback); + return frameId; + }, + cancelAnimationFrame: (id: number) => frames.delete(id), + ResizeObserver: class { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(callback); + } + observe() {} + unobserve() {} + disconnect() {} + }, + setTimeout: (callback: TimerHandler, delay = 0) => { + timerId += 1; + timers.set(timerId, { callback, delay }); + return timerId; + }, + clearTimeout: (id: number) => timers.delete(id), + }); + Object.defineProperties(window.HTMLCanvasElement.prototype, { + clientWidth: { configurable: true, get: () => canvasWidth }, + clientHeight: { configurable: true, get: () => canvasHeight }, + }); + Object.defineProperty(window.performance, 'now', { configurable: true, value: () => 0 }); + window.HTMLCanvasElement.prototype.getContext = vi.fn(() => + webgl + ? { + ARRAY_BUFFER: 1, + COMPILE_STATUS: 1, + FLOAT: 1, + FRAGMENT_SHADER: 1, + LINK_STATUS: 1, + STATIC_DRAW: 1, + TRIANGLES: 1, + VERTEX_SHADER: 1, + attachShader() {}, + bindBuffer() {}, + bufferData() {}, + compileShader() {}, + createBuffer: () => ({}), + createProgram: () => ({}), + createShader: () => ({}), + drawArrays, + enableVertexAttribArray() {}, + getProgramParameter: () => true, + getShaderParameter: () => true, + getUniformLocation: () => ({}), + linkProgram() {}, + shaderSource() {}, + uniform1f() {}, + uniform2f() {}, + useProgram() {}, + vertexAttribPointer() {}, + viewport, + } + : null, + ) as typeof window.HTMLCanvasElement.prototype.getContext; + Object.defineProperty(window.Element.prototype, 'animate', { + configurable: true, + value: animations, + }); + window.HTMLDialogElement.prototype.showModal = function () { + this.open = true; + }; + window.HTMLDialogElement.prototype.close = function (returnValue = '') { + this.returnValue = returnValue; + this.open = false; + this.dispatchEvent(new window.Event('close')); + }; + window.document.write( + page + .replace('', ``) + .replace('', ''), + ); + window.eval(script); + await vi.waitFor(() => expect(window.document.querySelector('.account-card')).not.toBeNull()); + + const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + return { + animations, + drawArrays, + resizeCallbacks, + viewport, + window, + async close() { + await window.happyDOM.abort(); + }, + async runFrame(now: number) { + const next = frames.entries().next().value as [number, FrameRequestCallback] | undefined; + if (!next) return; + frames.delete(next[0]); + next[1](now); + await flush(); + }, + async runTimers(delay: number) { + const pending = [...timers].filter(([, timer]) => timer.delay === delay); + for (const [id, timer] of pending) { + timers.delete(id); + if (typeof timer.callback === 'function') timer.callback(); + } + await flush(); + }, + resize(width: number, height: number) { + canvasWidth = width; + canvasHeight = height; + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }, + }; +} + async function openDashboard(options?: Parameters[2]) { const extension = await setup(); const dashboard = await startAccountDashboard( @@ -381,9 +562,8 @@ describe('account dashboard loopback server', () => { expect(state).not.toContain('browser-state'); }); - it('serves DOM-safe client code and rejects malformed or oversized mutations', async () => { + it('rejects malformed or oversized mutations', async () => { const session = await openDashboard(); - const script = await servedFile(session, '/app.js'); const malformed = await fetch(`${session.dashboard.origin}/api/accounts`, { method: 'POST', headers: session.headers, @@ -395,75 +575,189 @@ describe('account dashboard loopback server', () => { body: JSON.stringify({ label: 'x'.repeat(9000) }), }); - expect(script).toContain('textContent'); - expect(script).not.toContain('innerHTML'); expect(malformed.status).toBe(400); expect(oversized.status).toBe(413); }); - it('serves an accessible page shell with brand-consistent client behavior', async () => { + it('renders safely, preserves focused login input, and announces login progress', async () => { const session = await openDashboard(); - const page = await servedFile(session); - const script = await servedFile(session, '/app.js'); - - // The confirm button stays the dialog's default action (Enter submits, never cancels). - expect(page).toContain('id="dialog-cancel" type="button"'); - // Failures are announced assertively; routine status stays polite. - expect(page).toContain('role="alert"'); - expect(page).toContain('role="status"'); - // A persistent polite region carries login progress across poll-driven re-renders. - expect(page).toContain('id="sr-status"'); - // The appbar separator is decorative and stays out of the accessibility tree. - expect(page).toContain('class="brand-sep" aria-hidden="true"'); - // Card titles are h2s: the heading outline never skips a level under the h1 brand. - expect(script).toContain("element('h2'"); - expect(script).not.toContain("element('h3'"); - // The favicon matches the in-app brand mark (ink glyph on a raised tile). - expect(page).toContain('%23f7f8f8'); - expect(page).not.toContain('%234fd1e8'); - // Re-renders preserve keyboard focus and typed login codes. - expect(script).toContain('data-action'); - expect(script).toContain('[data-provider]'); - // Entrance motion runs once instead of replaying on every poll. - expect(script).toContain('settled'); - // Toasts pause their dismiss timer on hover. - expect(script).toContain('pointerenter'); + const malicious = ''; + const browser = await browserDashboard(session, [ + { + refreshing: false, + accounts: [ + accountState( + { label: malicious }, + { state: 'pending', progress: 'Waiting for browser authorization…' }, + ), + ], + }, + { + refreshing: false, + accounts: [ + accountState({ label: malicious }, { state: 'pending', progress: 'Enter code' }), + ], + }, + { + refreshing: false, + accounts: [accountState({ label: malicious }, { state: 'success' })], + }, + ]); + const document = browser.window.document; + const code = document.querySelector('input[name="code"]') as unknown as BrowserInput; + + expect(document.querySelector('.card-title-row h2')?.textContent).toBe(malicious); + expect(document.querySelector('.card-title-row img')).toBeNull(); + expect(document.querySelector('#dialog-cancel')?.getAttribute('type')).toBe('button'); + expect(document.querySelector('[role="alert"]')).not.toBeNull(); + expect(document.querySelectorAll('[role="status"]')).toHaveLength(2); + expect(document.querySelector('.brand-sep')?.getAttribute('aria-hidden')).toBe('true'); + code.value = 'keep-me'; + code.focus(); + + await browser.runTimers(2000); + await vi.waitFor(() => + expect(document.querySelector('#sr-status')?.textContent).toBe('Enter code'), + ); + + const rerenderedCode = document.querySelector('input[name="code"]') as unknown as BrowserInput; + expect(rerenderedCode.value).toBe('keep-me'); + expect(document.activeElement).toBe(rerenderedCode); + expect(document.querySelector('#accounts')?.classList.contains('settled')).toBe(true); + expect(browser.animations).toHaveBeenCalledOnce(); + + await browser.runTimers(2000); + + const toast = document.querySelector('#toast') as unknown as BrowserElement; + await vi.waitFor(() => expect(toast.textContent).toBe(`Logged in ${malicious}.`)); + expect(toast.classList.contains('visible')).toBe(true); + toast.dispatchEvent(new browser.window.Event('pointerenter')); + await browser.runTimers(4800); + expect(toast.classList.contains('visible')).toBe(true); + toast.dispatchEvent(new browser.window.Event('pointerleave')); + await browser.runTimers(2500); + expect(toast.classList.contains('visible')).toBe(false); + await browser.runTimers(400); + expect(toast.textContent).toBe(''); + await browser.close(); }); - it('keeps long labels contained and offline text readable', async () => { + it('applies label containment and preserves text contrast while offline', async () => { const session = await openDashboard(); - const styles = await servedFile(session, '/app.css'); - - // Long account labels truncate inside the card instead of overflowing the grid. - expect(styles).toContain('.card-title-row h2'); - expect(styles).not.toContain('.card-title-row h3'); - expect(styles).toMatch(/\.card-title-row\s*\{[^}]*min-width:\s*0/); - // Offline desaturation never dims text below AA contrast. - expect(styles).toContain('filter: saturate(0.55)'); - expect(styles).not.toContain('brightness(0.82)'); - // Component rules derive status hues from tokens instead of repeating raw values. - expect(styles).not.toMatch(/border-color: oklch\(0/); - expect(styles).toContain('oklch(from var(--red)'); - expect(styles).toContain('oklch(from var(--amber)'); - expect(styles).toContain('oklch(from var(--accent)'); - expect(styles).not.toContain('.brand-meta::before'); + const browser = await browserDashboard(session, [ + { + refreshing: false, + accounts: [accountState({ label: 'A'.repeat(200) })], + }, + ]); + const document = browser.window.document; + const titleRow = document.querySelector('.card-title-row'); + const title = document.querySelector('.card-title-row h2'); + const main = document.querySelector('main'); + if (!titleRow || !title || !main) throw new Error('Dashboard content did not render.'); + + expect(browser.window.getComputedStyle(titleRow).minWidth).toBe('0'); + expect(browser.window.getComputedStyle(title).overflow).toBe('hidden'); + expect(browser.window.getComputedStyle(title).textOverflow).toBe('ellipsis'); + expect(browser.window.getComputedStyle(title).whiteSpace).toBe('nowrap'); + document.querySelector('#link-state')?.classList.add('error'); + const offlineRule = [...document.styleSheets[0].cssRules].find( + (rule) => 'selectorText' in rule && rule.selectorText === 'body:has(.link-pill.error) main', + ) as unknown as { selectorText: string; style: { filter: string } }; + expect(document.querySelector(offlineRule.selectorText)).toBe(main); + expect(offlineRule.style.filter).toBe('saturate(0.55)'); + expect(offlineRule.style.filter).not.toContain('brightness'); + await browser.close(); }); - it('confirms account operations and throttles the state field', async () => { + it('confirms switch, rename, removal, and logout operations', async () => { const session = await openDashboard(); - const script = await servedFile(session, '/app.js'); - - // Successful account operations confirm audibly, not only visually. - expect(script).toContain(`Switched to \${account.label}.`); - expect(script).toContain(`Renamed to \${updated.label}.`); - expect(script).toContain(`Removed \${account.label}.`); - expect(script).toContain(`Logged out \${account.label}.`); - expect(script).toContain(`Logged in \${account.label}.`); - // Hidden toasts clear their text so stale messages leave the accessibility tree. - expect(script).toContain("node.textContent = ''"); - // The state field renders on its documented 30fps cadence and resizes via observer. - expect(script).toContain('1000 / 30'); - expect(script).toContain('ResizeObserver'); + const cases = [ + { + button: 'Switch', + initial: accountState({ active: false, label: 'Work', provider: 'grok-cli-2' }), + next: accountState({ label: 'Work', provider: 'grok-cli-2' }), + toast: 'Switched to Work.', + }, + { + button: 'Rename', + initial: accountState({ label: 'Work', provider: 'grok-cli-2' }), + next: accountState({ label: 'Renamed', provider: 'grok-cli-2' }), + response: { label: 'Renamed' }, + value: 'Renamed', + toast: 'Renamed to Renamed.', + }, + { + button: 'Remove', + initial: accountState({ active: false, label: 'Work', provider: 'grok-cli-2' }), + next: undefined, + toast: 'Removed Work.', + }, + { + button: 'Log out', + initial: accountState({ label: 'Personal' }), + next: accountState({ + authenticated: false, + active: false, + label: 'Personal', + status: 'Login required', + }), + toast: 'Logged out Personal.', + }, + ]; + + for (const operation of cases) { + const browser = await browserDashboard( + session, + [ + { refreshing: false, accounts: [operation.initial] }, + { refreshing: false, accounts: operation.next ? [operation.next] : [] }, + ], + () => operation.response ?? {}, + ); + const button = [...browser.window.document.querySelectorAll('button')].find( + (candidate) => candidate.textContent === operation.button, + ); + button?.click(); + if (operation.value) { + const input = browser.window.document.querySelector( + '#dialog-input', + ) as unknown as BrowserInput; + input.value = operation.value; + (browser.window.document.querySelector('#action-dialog') as unknown as BrowserDialog).close( + 'confirm', + ); + } else if (operation.button === 'Remove' || operation.button === 'Log out') { + (browser.window.document.querySelector('#action-dialog') as unknown as BrowserDialog).close( + 'confirm', + ); + } + await vi.waitFor(() => + expect(browser.window.document.querySelector('#toast')?.textContent).toBe(operation.toast), + ); + await browser.close(); + } + }); + + it('throttles state-field drawing and resizes through ResizeObserver', async () => { + const session = await openDashboard(); + const browser = await browserDashboard( + session, + [{ refreshing: false, accounts: [accountState()] }], + undefined, + true, + ); + + expect(browser.resizeCallbacks).toHaveLength(1); + expect(browser.viewport).toHaveBeenLastCalledWith(0, 0, 400, 300); + await browser.runFrame(10); + await browser.runFrame(20); + expect(browser.drawArrays).not.toHaveBeenCalled(); + await browser.runFrame(40); + expect(browser.drawArrays).toHaveBeenCalledOnce(); + browser.resize(1000, 500); + expect(browser.viewport).toHaveBeenLastCalledWith(0, 0, 500, 250); + await browser.close(); }); it('reuses one server, reports browser-launch failures, and closes cleanly', async () => { diff --git a/tests/provider/quotaCache.test.ts b/tests/provider/quotaCache.test.ts index 0e983b3..c0be262 100644 --- a/tests/provider/quotaCache.test.ts +++ b/tests/provider/quotaCache.test.ts @@ -87,6 +87,22 @@ describe('Grok CLI quota cache', () => { expect(Object.keys(loadQuotaCache().accounts)).toEqual(['grok-cli']); }); + it('runs a queued update after the previous update fails', async () => { + setupHome(); + const invalid = Object.defineProperty({}, 'monthly', { + get() { + throw new Error('invalid usage'); + }, + }) as BillingUsage; + + const failed = saveQuotaUsage('grok-cli', invalid); + const recovered = saveQuotaUsage('grok-cli-2', usage(900)); + + await expect(failed).rejects.toThrow('invalid usage'); + await expect(recovered).resolves.toBeUndefined(); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(900); + }); + it('formats fresh, stale, and weekly-unavailable usage explicitly as consumed quota', () => { expect( formatCachedQuota( diff --git a/tests/provider/register.test.ts b/tests/provider/register.test.ts index 3981e0c..df61071 100644 --- a/tests/provider/register.test.ts +++ b/tests/provider/register.test.ts @@ -429,6 +429,7 @@ describe('Grok CLI status command', () => { it('persists successful billing usage in the selected provider cache', async () => { process.env.GROK_CLI_OAUTH_TOKEN = 'env-token'; setupHome(); + const timeout = vi.spyOn(AbortSignal, 'timeout'); globalThis.fetch = vi.fn(async () => billingResponse(4000, 1421, '2026-07-01T00:00:00+00:00'), ); @@ -438,6 +439,7 @@ describe('Grok CLI status command', () => { expect(loadQuotaCache().accounts['grok-cli']).toMatchObject({ monthly: { monthlyLimit: 4000, used: 1421 }, }); + expect(timeout).toHaveBeenCalledWith(30_000); expect(existsSync(getQuotaCachePath())).toBe(true); }); diff --git a/tests/provider/rotation.test.ts b/tests/provider/rotation.test.ts index ce116cf..3a3304e 100644 --- a/tests/provider/rotation.test.ts +++ b/tests/provider/rotation.test.ts @@ -180,6 +180,36 @@ describe('Grok CLI exhaustion rotation', () => { expect(extension.sendUserMessage).toHaveBeenCalledWith(ROTATION_CONTINUATION); }); + it('preserves configuration changes made while switching models', async () => { + const extension = setup(); + let releaseSwitch = () => {}; + extension.setModel.mockImplementation(async (model) => { + await new Promise((resolve) => { + releaseSwitch = resolve; + }); + extension.context.model = model; + return true; + }); + await extension.emit('message_end', assistant('grok-cli')); + const settling = extension.emit('agent_settled'); + await vi.waitFor(() => expect(extension.setModel).toHaveBeenCalledOnce()); + const concurrent = loadConfig().config; + concurrent.accounts.items.push({ provider: 'grok-cli-3', label: 'Added concurrently' }); + concurrent.vision.enabled = false; + saveConfig(concurrent); + + releaseSwitch(); + await settling; + + expect(loadConfig().config).toMatchObject({ + accounts: { + selectedProvider: 'grok-cli-2', + items: expect.arrayContaining([{ provider: 'grok-cli-3', label: 'Added concurrently' }]), + }, + vision: { enabled: false }, + }); + }); + it.each([ ['near match', 'OpenAI API error (402): 402 "Grok Build usage balance exhausted".'], ['other 402', 'OpenAI API error (402): payment required'], diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 3f17b1b..ba6e670 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, migrateLegacyConfig } from '../src/config.js'; @@ -72,22 +72,17 @@ describe('Grok CLI storage', () => { it('falls back to legacy files when the destination directory cannot be created', () => { const home = setupHome(); - const piDirectory = join(home, '.pi'); - mkdirSync(piDirectory, { recursive: true }); + mkdirSync(join(home, '.pi'), { recursive: true }); writeJson(getLegacyConfigPath(), { ...DEFAULT_CONFIG, imagine: { enabled: false } }); writeJson(getLegacyVisionCachePath(), { version: 1, entries: {} }); - chmodSync(piDirectory, 0o500); + writeFileSync(getGrokCliDirectory(), 'not a directory'); - try { - const migration = migrateLegacyConfig(); + const migration = migrateLegacyConfig(); - expect(migration.warning).toMatch(/Could not migrate/); - expect(loadConfig().config.imagine.enabled).toBe(false); - expect(getCachePath()).toBe(getLegacyVisionCachePath()); - expect(existsSync(getLegacyConfigPath())).toBe(true); - expect(existsSync(getLegacyVisionCachePath())).toBe(true); - } finally { - chmodSync(piDirectory, 0o700); - } + expect(migration.warning).toMatch(/Could not migrate/); + expect(loadConfig().config.imagine.enabled).toBe(false); + expect(getCachePath()).toBe(getLegacyVisionCachePath()); + expect(existsSync(getLegacyConfigPath())).toBe(true); + expect(existsSync(getLegacyVisionCachePath())).toBe(true); }); }); From e8426ab58fd0fe664de911efd8000cd5484df0ef Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Sat, 18 Jul 2026 20:45:08 +0900 Subject: [PATCH 28/28] fix(dashboard): show free quota as unavailable Why: - Free-plan accounts can include weekly billing data, which made the dashboard show 100% remaining with a reset date. What: - Make the free-plan state override weekly payload values for both quota metadata and percentage. - Add a browser-behavior regression covering monthly and weekly free-plan gauges. Validation: - bun run check (38 test files, 500 tests) --- src/provider/dashboard/app.js | 4 ++-- tests/provider/dashboard.test.ts | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/provider/dashboard/app.js b/src/provider/dashboard/app.js index 0adb992..4a9db50 100644 --- a/src/provider/dashboard/app.js +++ b/src/provider/dashboard/app.js @@ -716,10 +716,10 @@ const accountCard = (account, index, isNewPending, refreshing) => { account.provider, 'Weekly credits', '', - account.quota.weekly + !isFree && account.quota.weekly ? `Resets ${dateLabel(account.quota.weekly.billingPeriodEnd)}` : 'Not available', - account.quota.weekly + !isFree && account.quota.weekly ? Math.max(0, Math.min(100, 100 - account.quota.weekly.creditUsagePercent)) : 0, ) diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts index c6995c4..0a2f387 100644 --- a/tests/provider/dashboard.test.ts +++ b/tests/provider/dashboard.test.ts @@ -670,6 +670,44 @@ describe('account dashboard loopback server', () => { await browser.close(); }); + it('renders free-plan monthly and weekly quotas as unavailable at zero percent', async () => { + const session = await openDashboard(); + const browser = await browserDashboard(session, [ + { + refreshing: false, + accounts: [ + accountState({ + plan: 'free', + quota: { + updatedAt: '2026-07-18T11:05:00.000Z', + fresh: true, + monthly: { + monthlyLimit: 0, + used: 0, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: 0, + billingPeriodEnd: '2026-07-22T00:00:00.000Z', + }, + }, + }), + ], + }, + ]); + const meters = [...browser.window.document.querySelectorAll('[role="meter"]')]; + + expect(meters.map((meter) => meter.getAttribute('aria-valuenow'))).toEqual(['0', '0']); + expect(meters.map((meter) => meter.querySelector('.gauge-value')?.textContent)).toEqual([ + '0%', + '0%', + ]); + expect( + [...browser.window.document.querySelectorAll('.quota-meta')].map((meta) => meta.textContent), + ).toEqual(['Not available', 'Not available']); + await browser.close(); + }); + it('confirms switch, rename, removal, and logout operations', async () => { const session = await openDashboard(); const cases = [