diff --git a/CHANGELOG.md b/CHANGELOG.md index e08df45..f2589a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Fix the generate transport reporting a per-attempt `timeoutMs` that fires while the response body is stalled as `Request aborted`. The stall is now reported as a timeout error and, when no visible content has been emitted and retries remain, retried like a timeout before the response headers. Previously the host treated the stall as a user cancellation, so neither this extension nor pi retried it. +- Read ambient credentials through one reader on both transports. The generate fallback consulted `~/.omp/agent/auth.json` before `~/.pi/agent/auth.json` and did not accept the `"command-code": "user_..."` string form, so a user with different keys in both files could stream with a different account than the Provider API path and the quota commands. The documented README order now applies everywhere, and the pi auth file is read from `PI_CODING_AGENT_DIR` when pi runs with that variable so an isolated profile never falls back to another profile's credentials. +- Validate pasted `/login` API keys against the configured `COMMANDCODE_API_BASE` instead of always calling production, and bound the validation request by a 15-second deadline that also covers fetch implementations ignoring cancellation, so a stalled `whoami` no longer hangs `/login`. +- Require the login state token before the local OAuth callback honors an error payload. Previously any local client, or any page on the formerly trusted `http://localhost:3000` origin, could cancel a pending browser login with an arbitrary error message. Error payloads without the matching state are rejected with `403` and the server keeps waiting for the legitimate callback; `http://localhost:3000` is no longer an allowed browser origin. +- Send `x-taste-learning: false` on the generate transport unless `COMMANDCODE_TASTE_LEARNING=1` is set, and always `false` under `CMD_ZDR=1`. The header mirrors the official CLI's `tasteLearning` setting and was sent as `true` unconditionally, opting every Go account into server-side taste learning although this extension never reads the learned profile back. The README now documents the working directory, project slug, and session id the generate request carries. +- `/commandcode-refresh` now also forgets the probed transport, so an account upgraded from Go is re-checked against the Provider API on the next request instead of staying on the generate fallback until the key changes or the host restarts. +- Keep context-tier pricing when applying DeepSeek V4 peak rates on the Provider API path. The peak table dropped `tiers`, which would have silently disabled long-context pricing for any time-priced model that gains a tier; no current model is affected. +- Scope the `/commandcode-status` stream warning to the most recent Responses request. A single malformed reasoning event no longer keeps the warning visible until the host restarts. +- Apply the shared credential redactor to `/commandcode-status` diagnostics, which kept a narrower pattern set of their own; the URL query stripping is unchanged. Defensive hardening, no confirmed leak. + ## 0.7.8 - 2026-10-15 - Redact sensitive text in direct stream errors and normalized context-overflow messages before returning them to the host. This closes two error-reporting paths that did not use the shared redactor; it is defensive hardening, not a claim of a confirmed credential leak ([#155](https://github.com/patlux/pi-commandcode-provider/pull/155)). diff --git a/README.md b/README.md index fb383e4..2230534 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,14 @@ export COMMAND_CODE_API_KEY="user_..." ### Auth file -The provider also reads existing credentials from: +The provider also reads existing credentials from, in this order: - `~/.commandcode/auth.json` -- `~/.pi/agent/auth.json` +- `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json` when pi runs with that variable) - `~/.omp/agent/auth.json` +Both transports and the quota commands use the same lookup. + Supported examples: ```json @@ -132,8 +134,8 @@ When a valid cache exists, the provider registers the cached catalog immediately While pi is running, use these provider commands without restarting: -- `/commandcode-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active. -- `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning. If a Responses reasoning event has an unsupported shape, a separate stream warning remains visible until the extension is reloaded or the host restarts. The event is preserved unchanged; no reasoning text is included in the warning. +- `/commandcode-refresh` fetches and re-registers the current model catalog, and forgets the probed transport so the next request tries the Provider API again (for example after upgrading a Go account). Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active. +- `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning. If a Responses reasoning event has an unsupported shape, a separate stream warning reflects the most recent Responses request and clears once a later request streams cleanly. The event is preserved unchanged; no reasoning text is included in the warning. - `/commandcode-quota` shows your Command Code account usage and quota in a dashboard-style layout: credits remaining and used with a percentage, monthly/purchased/free sources, the current plan, available usage totals, the API key name, and the 5-hour and weekly usage windows. The `commandcode-quota` command reads from the Command Code alpha usage endpoints (the same ones the `cmd` CLI `/usage` command uses): `whoami`, `billing/credits`, `billing/subscriptions`, and `usage/summary`. It authenticates with the same API key the provider already uses. If the command cannot reach those endpoints or an endpoint schema changes, unavailable sections are reported explicitly instead of being displayed as zero usage. Output is plain text (via `ui.notify`) so it works across pi and compatible hosts such as OMP. @@ -142,9 +144,19 @@ On Oh My Pi the provider also registers an OMP usage provider through the `usage Set `CMD_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. The legacy `COMMANDCODE_ZDR=1` alias remains supported. +### Request metadata on the generate transport + +The legacy `/alpha/generate` fallback used by Go accounts mirrors the official CLI's request shape, which includes metadata the Provider API path does not send: + +- the absolute working directory (`config.workingDir`) and a slug derived from it (`x-project-slug`); +- the host session id (`x-session-id`) and, when it is a UUID, the same value as `threadId`; +- `x-taste-learning`, the CLI's opt-in for Command Code's server-side taste learning. The CLI sends its `tasteLearning` setting (default on); this extension never reads the learned profile back, so it sends `false` unless `COMMANDCODE_TASTE_LEARNING=1` is set, and always `false` under `CMD_ZDR=1`. + +No other local files, git state, memory, or skills are sent; those request fields stay empty. + The following environment variables are intended for tests, local mocks, and compatible API endpoints: -- `COMMANDCODE_API_BASE` +- `COMMANDCODE_API_BASE` (Provider API base; the alpha endpoints used by `/login` key validation and `/commandcode-quota` are derived from it without the `/provider/v1` suffix) - `COMMANDCODE_MODELS_URL` - `COMMANDCODE_MODELS_CACHE` - `COMMANDCODE_MODELS_TIMEOUT_MS` (defaults to 10 seconds; invalid or non-positive values use the default) diff --git a/index.ts b/index.ts index da485c6..6371c45 100644 --- a/index.ts +++ b/index.ts @@ -221,6 +221,8 @@ export default async function (pi: ExtensionAPI) { const modelApis = new Map() const resolveModelApi = (modelId: string): CommandCodeApi => modelApis.get(modelId) ?? apiForModelId(modelId) + // Diagnostic for /commandcode-status: reflects the most recent Responses + // request, so a one-off malformed stream does not stick until restart. let streamWarning: string | undefined const transport = createCommandCodeTransportRouter({ createStream: () => new AssistantMessageEventStream(), @@ -232,13 +234,15 @@ export default async function (pi: ExtensionAPI) { wire === "openai-responses" ? { ...resolvedOptions, - fetch: async (...args: Parameters) => - normalizeCommandCodeResponsesResponse( + fetch: async (...args: Parameters) => { + streamWarning = undefined + return normalizeCommandCodeResponsesResponse( await (resolvedOptions?.fetch ?? fetch)(...args), (warning) => { streamWarning = warning }, - ), + ) + }, } : resolvedOptions return streamNativeProvider( @@ -298,6 +302,7 @@ export default async function (pi: ExtensionAPI) { return createProviderConfig(models, apiBase, transport.stream) }, getTransport: transport.getTransport, + resetTransport: transport.reset, getStreamWarning: () => streamWarning, }) diff --git a/src/api-key.ts b/src/api-key.ts index f09e155..2d36696 100644 --- a/src/api-key.ts +++ b/src/api-key.ts @@ -10,10 +10,17 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } -function defaultAuthPaths(home: string): string[] { +/** + * Credential files consulted after the environment, in precedence order (also + * documented in README.md). pi honors `PI_CODING_AGENT_DIR` for its agent + * directory, so a profile started with that variable must not fall back to the + * default `~/.pi/agent/auth.json` of another profile. + */ +export function defaultAuthPaths(home: string, env: NodeJS.ProcessEnv = process.env): string[] { + const piAgentDir = env.PI_CODING_AGENT_DIR || join(home, ".pi", "agent") return [ join(home, ".commandcode", "auth.json"), - join(home, ".pi", "agent", "auth.json"), + join(piAgentDir, "auth.json"), join(home, ".omp", "agent", "auth.json"), ] } @@ -26,19 +33,25 @@ function apiKeyFromCredential(value: unknown): string | undefined { return stringValue(value.access) ?? stringValue(value.key) } -export function getConfiguredApiKey( - options: { - env?: NodeJS.ProcessEnv - authPaths?: readonly string[] - homeDir?: () => string - } = {}, -): string | undefined { +export interface ApiKeyLookupOptions { + env?: NodeJS.ProcessEnv + authPaths?: readonly string[] + homeDir?: () => string +} + +/** + * The single credential reader shared by the Provider API path, the generate + * fallback, and the quota/usage commands: environment first, then the auth + * files above. Hosts resolve their own stored credentials before this runs; + * this is the ambient fallback for keys they do not know about. + */ +export function getConfiguredApiKey(options: ApiKeyLookupOptions = {}): string | undefined { const env = options.env ?? process.env if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY const home = options.homeDir?.() ?? homedir() - const authPaths = options.authPaths ?? defaultAuthPaths(home) + const authPaths = options.authPaths ?? defaultAuthPaths(home, env) for (const authPath of authPaths) { try { @@ -46,9 +59,12 @@ export function getConfiguredApiKey( const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")) if (!isRecord(parsed)) continue + // Legacy: direct apiKey or commandcode field. const apiKey = stringValue(parsed.apiKey) if (apiKey) return apiKey + // pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"..."}}. + // The official Command Code CLI stores API credentials under "command-code". const commandcode = stringValue(parsed.commandcode) if (commandcode) return commandcode diff --git a/src/auth-server.ts b/src/auth-server.ts index 9e76149..284cf92 100644 --- a/src/auth-server.ts +++ b/src/auth-server.ts @@ -11,6 +11,10 @@ import type { AddressInfo } from "node:net" const DEFAULT_PORT = 5959 const DEFAULT_PORT_RANGE = 10 +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + export interface AuthCallback { apiKey: string state: string @@ -92,13 +96,10 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise< }) const server = createServer((req, res) => { - // CORS: allow requests from Command Code domains and localhost for dev. + // CORS: allow requests from the Command Code Studio origins only. A local + // development origin would let any page on that port reach the callback. const origin = req.headers.origin || "" - const allowedOrigins = [ - "http://localhost:3000", - "https://staging.commandcode.ai", - "https://commandcode.ai", - ] + const allowedOrigins = ["https://commandcode.ai", "https://staging.commandcode.ai"] // CORS response headers do not prevent cross-origin POST side effects. // Reject untrusted browser origins before handling the callback. if (origin && !allowedOrigins.includes(origin)) { @@ -155,29 +156,43 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise< req.on("end", () => { try { - const parsed = JSON.parse(body) as Record + const payload: unknown = JSON.parse(body) + if (!isRecord(payload)) { + res.writeHead(400) + res.end(JSON.stringify({ success: false, error: "Invalid JSON" })) + return + } + const state = typeof payload.state === "string" ? payload.state : "" - if (parsed.error) { + // Only the page that received the state token may settle this login, + // including by reporting an error. Anything else keeps the server + // waiting for the legitimate callback. + if (options.expectedState !== undefined && state !== options.expectedState) { + res.writeHead(403) + res.end(JSON.stringify({ success: false, error: "Invalid state token" })) + return + } + + if (payload.error) { res.writeHead(200) res.end(JSON.stringify({ success: true })) const description = - typeof parsed.error_description === "string" - ? parsed.error_description - : String(parsed.error) - if (parsed.error === "access_denied") { + typeof payload.error_description === "string" + ? payload.error_description + : String(payload.error) + if (payload.error === "access_denied") { rejectCallback(new Error(description || "Authorization was denied by the user")) } else { - rejectCallback(new Error(description || String(parsed.error))) + rejectCallback(new Error(description || String(payload.error))) } closeServer(server) return } - const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : "" - const state = typeof parsed.state === "string" ? parsed.state : "" - const userId = typeof parsed.userId === "string" ? parsed.userId : "" - const userName = typeof parsed.userName === "string" ? parsed.userName : "" - const keyName = typeof parsed.keyName === "string" ? parsed.keyName : "" + const apiKey = typeof payload.apiKey === "string" ? payload.apiKey : "" + const userId = typeof payload.userId === "string" ? payload.userId : "" + const userName = typeof payload.userName === "string" ? payload.userName : "" + const keyName = typeof payload.keyName === "string" ? payload.keyName : "" if (!apiKey || !state || !userId || !userName || !keyName) { res.writeHead(400) @@ -190,12 +205,6 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise< return } - if (options.expectedState !== undefined && state !== options.expectedState) { - res.writeHead(403) - res.end(JSON.stringify({ success: false, error: "Invalid state token" })) - return - } - res.writeHead(200) res.end(JSON.stringify({ success: true })) diff --git a/src/converters.ts b/src/converters.ts index f4d33d5..d9186ec 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto" -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" import type { MessageLike, StopReason, ToolLike } from "./types.ts" import { geminiSafeJsonSchema, toJsonSchema } from "./json-schema.ts" export { toJsonSchema } from "./json-schema.ts" +// The generate transport reads ambient credentials through the same reader +// as the Provider API path and the quota commands (README "Auth file" order). +export { getConfiguredApiKey as getApiKey } from "./api-key.ts" export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -38,24 +38,6 @@ export function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined } -function defaultAuthPaths(home: string): string[] { - return [ - join(home, ".commandcode", "auth.json"), - join(home, ".omp", "agent", "auth.json"), - join(home, ".pi", "agent", "auth.json"), - ] -} - -function apiKeyFromCredentialRecord(value: unknown): string | undefined { - if (!isRecord(value)) return undefined - - const type = stringValue(value.type) - if (type === "api") return stringValue(value.key) - if (type === "oauth") return stringValue(value.access) - - return stringValue(value.key) ?? stringValue(value.access) -} - function imageParts(value: unknown): readonly Record[] { if (isRecord(value)) return value.type === "image" ? [value] : [] return recordArray(value).filter((part) => part.type === "image") @@ -99,46 +81,6 @@ function userContentToCommandCode(content: unknown, allowImages: boolean): unkno }) } -export function getApiKey( - options: { - env?: NodeJS.ProcessEnv - authPaths?: readonly string[] - homeDir?: () => string - } = {}, -): string | undefined { - const env = options.env ?? process.env - if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY - if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY - - const home = options.homeDir?.() ?? homedir() - const authPaths = options.authPaths ?? defaultAuthPaths(home) - - for (const authPath of authPaths) { - try { - if (!existsSync(authPath)) continue - const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")) - if (!isRecord(parsed)) continue - - // Legacy: direct apiKey or commandcode field. - const apiKey = stringValue(parsed.apiKey) - if (apiKey) return apiKey - const commandcode = stringValue(parsed.commandcode) - if (commandcode) return commandcode - - // pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"..."}}. - // The official Command Code CLI stores API credentials under "command-code". - const providerKey = - apiKeyFromCredentialRecord(parsed.commandcode) ?? - apiKeyFromCredentialRecord(parsed["command-code"]) - if (providerKey) return providerKey - } catch { - // Ignore malformed or unreadable auth files. - } - } - - return undefined -} - // Hosts such as OMP may pass a literal env-var name as the "resolved" registry // key instead of the actual credential. Treat those as unresolved. export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([ diff --git a/src/core.ts b/src/core.ts index f58f856..f626f48 100644 --- a/src/core.ts +++ b/src/core.ts @@ -149,6 +149,18 @@ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): strin return typeof mapped === "string" && mapped !== "off" ? mapped : undefined } +/** + * Whether the generate request opts the account into Command Code's + * server-side taste learning. The official CLI sends its `tasteLearning` + * setting on every request (default on). This extension never reads the + * learned profile back, so learning is off unless the user enables it, and + * always off under the zero-data-retention flag. + */ +export function tasteLearningEnabled(env: NodeJS.ProcessEnv): boolean { + if (env.CMD_ZDR === "1" || env.COMMANDCODE_ZDR === "1") return false + return env.COMMANDCODE_TASTE_LEARNING === "1" +} + function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } @@ -165,6 +177,7 @@ export function projectSlugFromPath(pathName: string): string { export function createStreamCommandCode(deps: CoreDependencies) { const apiBase = deps.apiBase ?? DEFAULT_API_BASE const fetchImpl = deps.fetchImpl ?? fetch + const env = () => deps.env ?? process.env const cwd = deps.cwd ?? (() => process.cwd()) const now = deps.now ?? (() => Date.now()) const uuid = deps.uuid ?? (() => randomUUID()) @@ -594,7 +607,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { "x-command-code-version": COMMAND_CODE_CLI_VERSION, "x-cli-environment": "production", "x-project-slug": projectSlugFromPath(workingDir), - "x-taste-learning": "true", + "x-taste-learning": String(tasteLearningEnabled(env())), ...(options?.sessionId ? { "x-session-id": options.sessionId } : {}), "User-Agent": "cli", ...options?.headers, @@ -745,9 +758,15 @@ export function createStreamCommandCode(deps: CoreDependencies) { } catch {} reader = undefined + // The per-attempt timer aborts `attemptController`, so a stalled + // body surfaces as an AbortError here. Classify it as a timeout + // before the user-abort check, or it would be reported as a + // cancellation and never retried. + if (controller.signal.aborted) throw streamError if ( - controller.signal.aborted || - (streamError instanceof Error && streamError.name === "AbortError") + !attemptTimedOut && + streamError instanceof Error && + streamError.name === "AbortError" ) { throw streamError } diff --git a/src/cost.ts b/src/cost.ts index 1bfafad..00f003b 100644 --- a/src/cost.ts +++ b/src/cost.ts @@ -7,7 +7,7 @@ * `tests/test-cost.ts` locks this implementation to the pi-ai original. */ -import type { ModelCostRates, ModelLike, Usage } from "./types.ts" +import type { ModelCostRates, ModelCostTier, ModelLike, Usage } from "./types.ts" const DEEPSEEK_V4_TIME_PRICED_MODELS = new Set([ "deepseek/deepseek-v4-pro", @@ -30,18 +30,9 @@ export function isDeepSeekV4Peak(atMs: number): boolean { return (hour >= 1 && hour < 4) || (hour >= 6 && hour < 10) } -/** - * Select request-time rates for both the native provider transport and the - * local generate fallback. DeepSeek V4 peak rates are exactly 2x off-peak. - */ -export function commandCodeCostRatesAt( - modelId: string, - rates: ModelCostRates, - atMs: number = Date.now(), -): ModelCostRates { - if (!DEEPSEEK_V4_TIME_PRICED_MODELS.has(modelId) || !isDeepSeekV4Peak(atMs)) return rates - +function doubledRates(rates: T): T { return { + ...rates, input: rates.input * 2, output: rates.output * 2, cacheRead: rates.cacheRead * 2, @@ -49,6 +40,22 @@ export function commandCodeCostRatesAt( } } +/** + * Select request-time rates for both the native provider transport and the + * local generate fallback. DeepSeek V4 peak rates are exactly 2x off-peak. + * Context tiers on a full model cost table are kept and doubled alongside + * the base rates so the host's tier selection still applies during peak. + */ +export function commandCodeCostRatesAt< + T extends ModelCostRates & { tiers?: readonly ModelCostTier[] }, +>(modelId: string, rates: T, atMs: number = Date.now()): T { + if (!DEEPSEEK_V4_TIME_PRICED_MODELS.has(modelId) || !isDeepSeekV4Peak(atMs)) return rates + + const doubled = doubledRates(rates) + if (!rates.tiers) return doubled + return { ...doubled, tiers: rates.tiers.map((tier) => doubledRates(tier)) } +} + export function calculateCommandCodeCost( model: ModelLike, usage: Usage, diff --git a/src/oauth.ts b/src/oauth.ts index 470ebd3..8c16469 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -20,6 +20,8 @@ const STUDIO_BASE_URL = "https://commandcode.ai" const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire const DEFAULT_AUTH_TIMEOUT_MS = 120_000 const DEFAULT_API_BASE = "https://api.commandcode.ai" +const DEFAULT_PROVIDER_API_BASE = `${DEFAULT_API_BASE}/provider/v1` +export const VALIDATE_API_KEY_TIMEOUT_MS = 15_000 export interface OAuthLoginCallbacks { onAuth(params: { url: string }): void @@ -96,22 +98,54 @@ export function sanitizeApiKey(input: string): string { .trim() } +/** + * Base of the alpha endpoints used for key validation. Mirrors index.ts: the + * configured `COMMANDCODE_API_BASE` is the Provider API base, and the alpha + * endpoints live next to it without the `/provider/v1` suffix. + */ +export function validationApiBase(env: NodeJS.ProcessEnv = process.env): string { + const providerApiBase = env.COMMANDCODE_API_BASE || DEFAULT_PROVIDER_API_BASE + return providerApiBase.replace(/\/provider\/v1\/?$/, "") +} + export async function validateApiKey( apiKey: string, - options: { fetchImpl?: typeof fetch; apiBase?: string } = {}, + options: { fetchImpl?: typeof fetch; apiBase?: string; timeoutMs?: number } = {}, ): Promise { + const timeoutMs = options.timeoutMs ?? VALIDATE_API_KEY_TIMEOUT_MS + // AbortSignal is cooperative: a custom fetch may ignore it, so the deadline + // also races the request. /login must never hang on a stalled whoami. + const controller = new AbortController() + let rejectDeadline: ((error: Error) => void) | undefined + const deadline = new Promise((_resolve, reject) => { + rejectDeadline = reject + }) + const timer = setTimeout(() => { + const error = new Error( + `Could not validate the Command Code API key: request timed out after ${timeoutMs}ms`, + ) + controller.abort(error) + rejectDeadline?.(error) + }, timeoutMs) + let response: Response try { - response = await (options.fetchImpl ?? fetch)( - `${options.apiBase ?? DEFAULT_API_BASE}/alpha/whoami`, - { + response = await Promise.race([ + (options.fetchImpl ?? fetch)(`${options.apiBase ?? validationApiBase()}/alpha/whoami`, { headers: { Authorization: `Bearer ${apiKey}` }, - }, - ) + signal: controller.signal, + }), + deadline, + ]) } catch (error) { + if (controller.signal.aborted && controller.signal.reason instanceof Error) { + throw controller.signal.reason + } throw new Error( `Could not validate the Command Code API key: ${error instanceof Error ? error.message : String(error)}`, ) + } finally { + clearTimeout(timer) } if (response.status === 401) throw new Error("Invalid Command Code API key") diff --git a/src/runtime.ts b/src/runtime.ts index afb0ba2..4a32674 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,4 +1,5 @@ import type { CommandCodeModel, LoadCommandCodeModelsResult } from "./models.ts" +import { redactCommandCodeErrorText } from "./overflow.ts" export interface CommandCodeUi { notify(message: string, type?: "info" | "warning" | "error"): void @@ -31,6 +32,13 @@ export interface CommandCodeRuntimeOptions { loadCachedModels: () => Promise createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig getTransport?: () => "unknown" | "provider" | "generate" + /** + * Forget the probed transport so the next request re-tries the Provider API. + * Called by `/commandcode-refresh`, the user's explicit "re-check my + * account" action, since an account upgraded from Go stays on the generate + * fallback until the key changes or the host restarts. + */ + resetTransport?: () => void getStreamWarning?: () => string | undefined now?: () => number logWarning?: (message: string) => void @@ -71,15 +79,13 @@ function redactUrl(value: string): string { } } +/** + * Diagnostics drop URL query strings and fragments entirely, then apply the + * same credential redaction as error messages so both surfaces agree. + */ export function redactDiagnosticText(value: string): string { const redactedUrls = value.replace(/https?:\/\/[^\s)]+/gi, (match) => redactUrl(match)) - return redactedUrls - .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`) - .replace(/\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi, REDACTED) - .replace(/\b(?:api[-_ ]?key|token|secret|password)\s*[=:]\s*[^\s,;)]+/gi, (match) => { - const separator = match.match(/\s*[=:]\s*/)?.[0] ?? "=" - return `${match.slice(0, match.indexOf(separator))}${separator}${REDACTED}` - }) + return redactCommandCodeErrorText(redactedUrls) } export function redactEndpoint(value: string): string { @@ -291,6 +297,7 @@ export class CommandCodeRuntime { await ctx.waitForIdle?.() + this.options.resetTransport?.() const result = await this.refresh() if (result.refreshed) { ctx.ui.notify( diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts index f3029cb..8102513 100644 --- a/tests/test-api-key.ts +++ b/tests/test-api-key.ts @@ -1,10 +1,11 @@ import assert from "node:assert/strict" -import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { describe, it } from "node:test" -import { getConfiguredApiKey } from "../src/api-key.ts" +import { defaultAuthPaths, getConfiguredApiKey } from "../src/api-key.ts" +import { getApiKey as getGenerateApiKey } from "../src/core.ts" async function withAuthFile( value: unknown, @@ -43,6 +44,8 @@ describe("getConfiguredApiKey()", () => { }, { credential: { commandcode: { type: "api", key: "api-key" } }, expected: "api-key" }, { credential: { "command-code": { type: "api", key: "cli-key" } }, expected: "cli-key" }, + { credential: { "command-code": "cli-string-key" }, expected: "cli-string-key" }, + { credential: { commandcode: "legacy-string-key" }, expected: "legacy-string-key" }, { credential: { apiKey: "legacy-key" }, expected: "legacy-key" }, ] @@ -53,6 +56,52 @@ describe("getConfiguredApiKey()", () => { } }) + it("is the reader the generate transport uses", () => { + // Both transports must agree on auth-file precedence and credential shapes. + assert.equal(getGenerateApiKey, getConfiguredApiKey) + }) + + it("consults auth files in the documented order", () => { + assert.deepEqual(defaultAuthPaths("/home/user", {}), [ + join("/home/user", ".commandcode", "auth.json"), + join("/home/user", ".pi", "agent", "auth.json"), + join("/home/user", ".omp", "agent", "auth.json"), + ]) + }) + + it("reads the pi auth file from PI_CODING_AGENT_DIR instead of the default profile", async () => { + const home = await mkdtemp(join(tmpdir(), "pi-commandcode-home-")) + const profileDir = await mkdtemp(join(tmpdir(), "pi-commandcode-profile-")) + try { + await mkdir(join(home, ".pi", "agent"), { recursive: true }) + await writeFile( + join(home, ".pi", "agent", "auth.json"), + JSON.stringify({ commandcode: { type: "api", key: "default-profile-key" } }), + "utf-8", + ) + await writeFile( + join(profileDir, "auth.json"), + JSON.stringify({ commandcode: { type: "api", key: "profile-key" } }), + "utf-8", + ) + + assert.equal(getConfiguredApiKey({ env: {}, homeDir: () => home }), "default-profile-key") + assert.equal( + getConfiguredApiKey({ env: { PI_CODING_AGENT_DIR: profileDir }, homeDir: () => home }), + "profile-key", + ) + // An isolated profile without credentials must not fall back to another profile. + await rm(join(profileDir, "auth.json")) + assert.equal( + getConfiguredApiKey({ env: { PI_CODING_AGENT_DIR: profileDir }, homeDir: () => home }), + undefined, + ) + } finally { + await rm(home, { recursive: true, force: true }) + await rm(profileDir, { recursive: true, force: true }) + } + }) + it("ignores malformed files", async () => { const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-auth-")) const authPath = join(directory, "auth.json") diff --git a/tests/test-cost.ts b/tests/test-cost.ts index b8e47fc..97c26d2 100644 --- a/tests/test-cost.ts +++ b/tests/test-cost.ts @@ -159,6 +159,35 @@ describe("calculateCommandCodeCost()", () => { assert.equal(commandCodeCostRatesAt("deepseek/deepseek-v4-flash", base, OFF_PEAK_WEEKDAY), base) }) + it("keeps and doubles context tiers of a full cost table during peak windows", () => { + // No time-priced model ships tiers today; this guards the provider path, + // which hands the host the whole table including `tiers`. + const table = { + input: 0.15, + output: 0.6, + cacheRead: 0.003, + cacheWrite: 0, + tiers: [ + { inputTokensAbove: 128_000, input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, + ], + } + assert.deepEqual(commandCodeCostRatesAt("deepseek/deepseek-v4-flash", table, PEAK_WEEKDAY), { + input: 0.3, + output: 1.2, + cacheRead: 0.006, + cacheWrite: 0, + tiers: [ + { inputTokensAbove: 128_000, input: 0.6, output: 2.4, cacheRead: 0.012, cacheWrite: 0 }, + ], + }) + assert.equal( + commandCodeCostRatesAt("deepseek/deepseek-v4-flash", table, OFF_PEAK_WEEKDAY), + table, + ) + // Models without time-based pricing return the table untouched. + assert.equal(commandCodeCostRatesAt("claude-sonnet-4-6", table, PEAK_WEEKDAY), table) + }) + it("doubles DeepSeek V4 rates during peak windows", () => { const model = commandCodeModel("deepseek/deepseek-v4-pro", { input: 0.66, diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 806b808..2b5be8d 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -9,7 +9,14 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { startAuthServer, type AuthCallback } from "../src/auth-server.ts" -import { getApiKey, login, refreshToken, sanitizeApiKey, validateApiKey } from "../src/oauth.ts" +import { + getApiKey, + login, + refreshToken, + sanitizeApiKey, + validateApiKey, + validationApiBase, +} from "../src/oauth.ts" /** * Helper: wait for an HTTP server to close, or resolve immediately if already closed. @@ -142,6 +149,84 @@ describe("startAuthServer()", () => { await waitForClose(server) }) + it("ignores callback errors that do not carry the state token", async () => { + const { server, port, waitForCallback } = await startAuthServer({ + startPort: 0, + expectedState: "trusted-state", + }) + + // A trusted origin (or an origin-less local client) must still prove it + // received the state token before it can cancel the pending login. + const missingState = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ error: "access_denied", error_description: "forged cancellation" }), + }) + assert.equal(missingState.status, 403) + assert.equal(server.listening, true) + + const wrongState = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, + body: JSON.stringify({ error: "server_error", state: "wrong-state" }), + }) + assert.equal(wrongState.status, 403) + assert.equal(server.listening, true) + + const accepted = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, + body: JSON.stringify({ + apiKey: "user_legitimate", + state: "trusted-state", + userId: "user_123", + userName: "Test User", + keyName: "test-key", + }), + }) + assert.equal(accepted.status, 200) + assert.equal((await waitForCallback).apiKey, "user_legitimate") + await waitForClose(server) + }) + + it("honors callback errors that carry the state token", async () => { + const { server, port, waitForCallback } = await startAuthServer({ + startPort: 0, + expectedState: "trusted-state", + }) + const errorPromise: Promise = waitForCallback.then( + () => { + throw new Error("Expected callback to reject") + }, + (e: Error) => e.message, + ) + + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, + body: JSON.stringify({ + error: "access_denied", + error_description: "User cancelled", + state: "trusted-state", + }), + }) + assert.equal(response.status, 200) + assert.match(await errorPromise, /User cancelled/) + await waitForClose(server) + }) + + it("rejects the local development origin", async () => { + const { server, port } = await startAuthServer({ startPort: 0, expectedState: "s" }) + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "text/plain", Origin: "http://localhost:3000" }, + body: JSON.stringify({ error: "access_denied", state: "s" }), + }) + assert.equal(response.status, 403) + assert.equal(server.listening, true) + server.close() + }) + it("rejects untrusted browser preflights", async () => { const { server, port } = await startAuthServer({ startPort: 0 }) const response = await fetch(`http://127.0.0.1:${port}/callback`, { @@ -286,6 +371,64 @@ describe("OAuth functions", () => { /Invalid Command Code API key/, ) }) + + it("validates against the configured API base like the chat transports", async () => { + assert.equal(validationApiBase({}), "https://api.commandcode.ai") + assert.equal( + validationApiBase({ COMMANDCODE_API_BASE: "http://127.0.0.1:4321/provider/v1" }), + "http://127.0.0.1:4321", + ) + assert.equal( + validationApiBase({ COMMANDCODE_API_BASE: "http://127.0.0.1:4321/provider/v1/" }), + "http://127.0.0.1:4321", + ) + + const originalBase = process.env.COMMANDCODE_API_BASE + process.env.COMMANDCODE_API_BASE = "http://127.0.0.1:4321/provider/v1" + try { + let requestedUrl = "" + await validateApiKey("valid-key", { + fetchImpl: (input) => { + requestedUrl = String(input) + return Promise.resolve(new Response(JSON.stringify({ user: {} }), { status: 200 })) + }, + }) + assert.equal(requestedUrl, "http://127.0.0.1:4321/alpha/whoami") + } finally { + if (originalBase === undefined) delete process.env.COMMANDCODE_API_BASE + else process.env.COMMANDCODE_API_BASE = originalBase + } + }) + + it("times out a stalled whoami request even when fetch ignores the signal", async () => { + const started = Date.now() + await assert.rejects( + validateApiKey("stalled-key", { + timeoutMs: 20, + fetchImpl: () => new Promise(() => {}), + }), + /Could not validate the Command Code API key: request timed out after 20ms/, + ) + assert.ok(Date.now() - started < 1_000) + }) + + it("reports the timeout when fetch honors the abort signal", async () => { + let aborted = false + await assert.rejects( + validateApiKey("stalled-key", { + timeoutMs: 20, + fetchImpl: (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + aborted = true + reject(init.signal?.reason) + }) + }), + }), + /request timed out after 20ms/, + ) + assert.equal(aborted, true) + }) }) describe("login()", () => { diff --git a/tests/test-retry.ts b/tests/test-retry.ts index ba27ea0..0e8bf92 100644 --- a/tests/test-retry.ts +++ b/tests/test-retry.ts @@ -295,6 +295,69 @@ describe("streamCommandCode — timeout", () => { assert.equal(server.requestCount(), 1) assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "error"]) + const error = events.at(-1) + if (error?.type !== "error") throw new Error("expected error") + // A stalled body is a timeout, not a user cancellation. + assert.equal(error.reason, "error") + assert.equal(error.error.stopReason, "error") + assert.match(error.error.errorMessage ?? "", /timed out after 50ms/) + }) + + it("retries when the body stalls after the headers and a content-free event", async () => { + // The first event flushes the response headers, so the timeout fires while + // reading the body rather than while waiting for fetch() to resolve. + server.mockResponseQueue([ + { + type: "success", + events: [JSON.stringify({ type: "reasoning-start" })], + hangAfterLast: true, + }, + { + type: "success", + events: [ + JSON.stringify({ type: "text-delta", text: "ok" }), + JSON.stringify({ type: "finish", finishReason: "stop" }), + ], + }, + ]) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: TEST_API_KEY, + timeoutMs: 50, + maxRetries: 2, + }), + 5_000, + ) + + assert.equal(server.requestCount(), 2) + assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"]) + }) + + it("reports a timeout when the body stalls after the headers and retries are exhausted", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "reasoning-start" })], + hangAfterLast: true, + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: TEST_API_KEY, + timeoutMs: 50, + maxRetries: 0, + }), + 5_000, + ) + + assert.equal(server.requestCount(), 1) + assert.deepEqual(eventTypes(events), ["start", "error"]) + const error = events.at(-1) + if (error?.type !== "error") throw new Error("expected error") + assert.equal(error.reason, "error") + assert.match(error.error.errorMessage ?? "", /timed out after 50ms/) }) it("emits error when all retry attempts time out", async () => { diff --git a/tests/test-runtime.ts b/tests/test-runtime.ts index 8d2d441..e8c677a 100644 --- a/tests/test-runtime.ts +++ b/tests/test-runtime.ts @@ -3,6 +3,7 @@ import { describe, it } from "node:test" import { createCommandCodeRuntime, + redactDiagnosticText, type CommandCodeCommandContext, type CommandCodeRuntimeApi, } from "../src/runtime.ts" @@ -86,6 +87,20 @@ function deferred(): { return { promise, resolve: resolvePromise, reject: rejectPromise } } +describe("redactDiagnosticText()", () => { + it("strips URL queries and applies the shared credential redaction", () => { + const redacted = redactDiagnosticText( + "GET https://api.commandcode.ai/provider/v1/models?api_key=user_secret_value#frag failed: " + + "Bearer user_secret_value access_token=user_secret_value sk-abcdefghijklmnopqrstuvwxyz", + ) + assert.doesNotMatch(redacted, /user_secret_value/) + assert.doesNotMatch(redacted, /sk-abcdefghijklmnopqrstuvwxyz/) + assert.match(redacted, /https:\/\/api\.commandcode\.ai\/provider\/v1\/models failed/) + assert.match(redacted, /Bearer \[redacted\]/) + assert.match(redacted, /access_token=\[redacted\]/) + }) +}) + describe("Command Code runtime", () => { it("keeps default warning diagnostics off the console and available in status", async (t) => { const warn = t.mock.method(console, "warn", () => {}) @@ -266,6 +281,37 @@ describe("Command Code runtime", () => { assert.deepEqual(pi.providers.at(-1)?.models, [FIRST_MODEL, SECOND_MODEL]) }) + it("resets the transport probe when the user refreshes the catalog", async () => { + const pi = new ExtensionAPITestDouble() + const context = new CommandContext() + let transport: "unknown" | "provider" | "generate" = "generate" + let resetCalls = 0 + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: async () => loaded([FIRST_MODEL]), + loadCachedModels: async () => [], + createProviderConfig: (models) => ({ models }), + getTransport: () => transport, + resetTransport: () => { + resetCalls += 1 + transport = "unknown" + }, + logWarning: () => {}, + }) + + await runtime.initialize() + assert.equal(resetCalls, 0, "startup and background refreshes keep the probe") + assert.equal(runtime.getStatus().transport, "generate") + + const refreshCommand = pi.commands.get("commandcode-refresh") + assert.ok(refreshCommand) + await refreshCommand("", context) + assert.equal(resetCalls, 1) + assert.equal(runtime.getStatus().transport, "unknown") + assert.equal(context.notifications.at(-1)?.type, "info") + }) + it("registers the cached catalog immediately and refreshes it in the background", async () => { const pi = new ExtensionAPITestDouble() const liveLoad = deferred() diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 402e384..92a9a7f 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -775,12 +775,36 @@ describe("streamCommandCode — request serialization", () => { assert.equal(headers.authorization, "Bearer mock-key") assert.equal(headers["x-command-code-version"], COMMAND_CODE_CLI_VERSION) assert.equal(headers["x-project-slug"], "repo") - assert.equal(headers["x-taste-learning"], "true") + assert.equal(headers["x-taste-learning"], "false") assert.equal(headers["user-agent"], "cli") assert.equal(headers["x-co-flag"], undefined) assert.equal(headers["x-session-id"], undefined) }) + it("opts into taste learning only when enabled and never under ZDR", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + + const cases: readonly { env: NodeJS.ProcessEnv; expected: string }[] = [ + { env: {}, expected: "false" }, + { env: { COMMANDCODE_TASTE_LEARNING: "1" }, expected: "true" }, + { env: { COMMANDCODE_TASTE_LEARNING: "0" }, expected: "false" }, + { env: { COMMANDCODE_TASTE_LEARNING: "1", CMD_ZDR: "1" }, expected: "false" }, + { env: { COMMANDCODE_TASTE_LEARNING: "1", COMMANDCODE_ZDR: "1" }, expected: "false" }, + ] + for (const testCase of cases) { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl(), env: testCase.env }) + await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })) + assert.equal( + server.lastRequestHeaders()["x-taste-learning"], + testCase.expected, + JSON.stringify(testCase.env), + ) + } + }) + it("replays previous assistant reasoning in the generate request history", async () => { server.mockResponse({ type: "success", diff --git a/tests/test-transport.ts b/tests/test-transport.ts index 7da21e0..edf7051 100644 --- a/tests/test-transport.ts +++ b/tests/test-transport.ts @@ -122,6 +122,48 @@ describe("Command Code transport router", () => { assert.equal(generateCalls, 2) }) + it("re-probes the Provider API after an explicit reset with the same key", async () => { + // An account upgraded from Go keeps the same key; /commandcode-refresh + // resets the sticky generate choice so the next request tries again. + let providerCalls = 0 + let generateCalls = 0 + let upgraded = false + const upgradeBody = JSON.stringify({ error: { code: "upgrade_required" } }) + const currentResponse = () => + upgraded ? new Response("ok", { status: 200 }) : new Response(upgradeBody, { status: 403 }) + const router = createCommandCodeTransportRouter({ + createStream: createTestEventStream, + streamProvider: (_model, _context, options) => { + providerCalls += 1 + return providerStream(currentResponse(), "provider", options) + }, + streamGenerate: () => { + generateCalls += 1 + return completedStream("generate") + }, + }) + const options: StreamOptions = { + apiKey: "same-key", + fetch: () => Promise.resolve(currentResponse()), + } + + await collectEvents(router.stream(makeModel(), makeContext(), options)) + assert.equal(router.getTransport(), "generate") + + upgraded = true + await collectEvents(router.stream(makeModel(), makeContext(), options)) + assert.equal(router.getTransport(), "generate", "generate stays sticky without a reset") + assert.equal(providerCalls, 1) + + router.reset() + assert.equal(router.getTransport(), "unknown") + const events = await collectEvents(router.stream(makeModel(), makeContext(), options)) + assert.equal(events.at(-1)?.type, "done") + assert.equal(router.getTransport(), "provider") + assert.equal(providerCalls, 2) + assert.equal(generateCalls, 2) + }) + it("re-detects the transport after the API key changes", async () => { let providerCalls = 0 let generateCalls = 0