From cef7402f9f382e9b30252d29178efaf49392cc75 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:50:48 +0200 Subject: [PATCH 1/9] fix(core): report stalled-body timeouts and retry them before visible content The per-attempt timer aborts the attempt controller, so a body that stalls after the headers surfaced as an AbortError and was classified as a user cancellation: the host saw "Request aborted" and never retried. Classify it as a timeout before the user-abort check so it is retried when no content was emitted and reported as a timeout otherwise. --- CHANGELOG.md | 2 ++ src/core.ts | 10 +++++-- tests/test-retry.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08df45..8c41c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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. + ## 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/src/core.ts b/src/core.ts index f58f856..3ddbd64 100644 --- a/src/core.ts +++ b/src/core.ts @@ -745,9 +745,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/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 () => { From 35a774b9cbae729e6a9dd7b0d19e5b5a94681388 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:52:10 +0200 Subject: [PATCH 2/9] fix(auth): read ambient credentials through one reader on both transports converters.ts carried a second copy of the auth-file reader with a different precedence (~/.omp before ~/.pi) and without the string form of "command-code", so the generate fallback could stream with a different key than the Provider API path and the quota commands. Re-export the shared reader and honor PI_CODING_AGENT_DIR for the pi auth file so an isolated profile does not fall back to another profile's credentials. --- CHANGELOG.md | 1 + README.md | 6 ++-- src/api-key.ts | 36 +++++++++++++++++------- src/converters.ts | 64 ++----------------------------------------- tests/test-api-key.ts | 53 +++++++++++++++++++++++++++++++++-- 5 files changed, 85 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c41c31..087b0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 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. ## 0.7.8 - 2026-10-15 diff --git a/README.md b/README.md index fb383e4..db8ae87 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 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/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/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") From 576b3063b189fbb170ff8f1eb194337a1570a204 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:53:11 +0200 Subject: [PATCH 3/9] fix(oauth): validate pasted keys against the configured API base with a deadline validateApiKey always called the production whoami endpoint and had no timeout, so /login validated against the wrong host under COMMANDCODE_API_BASE and hung on a stalled response. Derive the alpha base the same way index.ts does and race the request against a 15s deadline that also covers fetch implementations ignoring the abort signal. --- CHANGELOG.md | 1 + README.md | 2 +- src/oauth.ts | 46 +++++++++++++++++++++++++++---- tests/test-oauth.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 087b0a9..ce89e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 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`. ## 0.7.8 - 2026-10-15 diff --git a/README.md b/README.md index db8ae87..1cf3865 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Set `CMD_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-reten 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/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/tests/test-oauth.ts b/tests/test-oauth.ts index 806b808..354f1c6 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. @@ -286,6 +293,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()", () => { From a9181388db895b09d5c6312d770b989f5d18b217 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:54:12 +0200 Subject: [PATCH 4/9] fix(oauth): require the state token before honoring callback errors The callback server settled the pending login on any error payload before checking the state token, so a local client or a page on the trusted http://localhost:3000 development origin could cancel a browser login with an arbitrary message. Validate the state first and drop the development origin from the allow list. --- CHANGELOG.md | 1 + src/auth-server.ts | 57 +++++++++++++++++++-------------- tests/test-oauth.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce89e9b..3517f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - 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. ## 0.7.8 - 2026-10-15 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/tests/test-oauth.ts b/tests/test-oauth.ts index 354f1c6..2b5be8d 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -149,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`, { From 29a31deceacf1c505f0a5d8d4cc89f0401fe89b6 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:55:00 +0200 Subject: [PATCH 5/9] fix(core): make taste learning opt-in on the generate transport The generate request sent x-taste-learning: true unconditionally, even with CMD_ZDR=1, mirroring the CLI's default although this extension never reads the learned profile back. Send false unless COMMANDCODE_TASTE_LEARNING=1 is set and always false under ZDR, and document the working directory, project slug, and session id the request carries. --- CHANGELOG.md | 1 + README.md | 10 ++++++++++ src/core.ts | 15 ++++++++++++++- tests/test-stream.ts | 26 +++++++++++++++++++++++++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3517f59..08d2136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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. ## 0.7.8 - 2026-10-15 diff --git a/README.md b/README.md index 1cf3865..4dd04d4 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,16 @@ 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` (Provider API base; the alpha endpoints used by `/login` key validation and `/commandcode-quota` are derived from it without the `/provider/v1` suffix) diff --git a/src/core.ts b/src/core.ts index 3ddbd64..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, 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", From 00253691c09eb7c04a41e47c4efab23e1fe4f525 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:56:10 +0200 Subject: [PATCH 6/9] fix(core): re-probe the transport on /commandcode-refresh The router's reset() existed but was never wired, so an account upgraded from Go stayed on the generate fallback until the key changed or the host restarted. Reset the probe from the user's explicit refresh command. --- CHANGELOG.md | 1 + README.md | 2 +- index.ts | 1 + src/runtime.ts | 8 ++++++++ tests/test-runtime.ts | 31 ++++++++++++++++++++++++++++++ tests/test-transport.ts | 42 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d2136..649ce95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - 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. ## 0.7.8 - 2026-10-15 diff --git a/README.md b/README.md index 4dd04d4..03b4cd1 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ 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-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 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-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. diff --git a/index.ts b/index.ts index da485c6..53adf29 100644 --- a/index.ts +++ b/index.ts @@ -298,6 +298,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/runtime.ts b/src/runtime.ts index afb0ba2..01ce61d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -31,6 +31,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 @@ -291,6 +298,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-runtime.ts b/tests/test-runtime.ts index 8d2d441..5296662 100644 --- a/tests/test-runtime.ts +++ b/tests/test-runtime.ts @@ -266,6 +266,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-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 From 188a344e5a835089b8ff7941f33a66e917c08821 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:57:01 +0200 Subject: [PATCH 7/9] fix(cost): keep context tiers when applying peak rates commandCodeCostRatesAt rebuilt the table from the four base rates during DeepSeek V4 peak windows, dropping tiers on the Provider API path. Keep the full table and double tiers alongside the base rates. --- CHANGELOG.md | 1 + src/cost.ts | 31 +++++++++++++++++++------------ tests/test-cost.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 649ce95..f2eda37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - 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. ## 0.7.8 - 2026-10-15 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/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, From c6c88422238bafdc4f82fb2b58b0daef66ee8c3f Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:57:32 +0200 Subject: [PATCH 8/9] fix(core): scope the stream warning to the latest Responses request The /commandcode-status stream warning was set once and never cleared, so a single malformed reasoning event stayed visible until restart. --- CHANGELOG.md | 1 + README.md | 2 +- index.ts | 10 +++++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2eda37..ed32913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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. ## 0.7.8 - 2026-10-15 diff --git a/README.md b/README.md index 03b4cd1..2230534 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ 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, 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 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-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. diff --git a/index.ts b/index.ts index 53adf29..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( From 8a2dc7310d275665baa96c0a4c2fb14547ed2d33 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 23:58:23 +0200 Subject: [PATCH 9/9] refactor(runtime): reuse the shared credential redactor for diagnostics runtime.ts kept its own, narrower redaction patterns next to redactCommandCodeErrorText. Strip URL queries as before, then delegate to the shared redactor so both surfaces agree. --- CHANGELOG.md | 1 + src/runtime.ts | 13 ++++++------- tests/test-runtime.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed32913..f2589a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - `/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 diff --git a/src/runtime.ts b/src/runtime.ts index 01ce61d..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 @@ -78,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 { diff --git a/tests/test-runtime.ts b/tests/test-runtime.ts index 5296662..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", () => {})