Skip to content
Open
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ export default async function (pi: ExtensionAPI) {
const modelApis = new Map<string, CommandCodeApi>()
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(),
Expand All @@ -232,13 +234,15 @@ export default async function (pi: ExtensionAPI) {
wire === "openai-responses"
? {
...resolvedOptions,
fetch: async (...args: Parameters<typeof fetch>) =>
normalizeCommandCodeResponsesResponse(
fetch: async (...args: Parameters<typeof fetch>) => {
streamWarning = undefined
return normalizeCommandCodeResponsesResponse(
await (resolvedOptions?.fetch ?? fetch)(...args),
(warning) => {
streamWarning = warning
},
),
)
},
}
: resolvedOptions
return streamNativeProvider(
Expand Down Expand Up @@ -298,6 +302,7 @@ export default async function (pi: ExtensionAPI) {
return createProviderConfig(models, apiBase, transport.stream)
},
getTransport: transport.getTransport,
resetTransport: transport.reset,
getStreamWarning: () => streamWarning,
})

Expand Down
36 changes: 26 additions & 10 deletions src/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
}
Expand All @@ -26,29 +33,38 @@ 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 {
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

// 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

Expand Down
57 changes: 33 additions & 24 deletions src/auth-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

export interface AuthCallback {
apiKey: string
state: string
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -155,29 +156,43 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise<

req.on("end", () => {
try {
const parsed = JSON.parse(body) as Record<string, unknown>
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)
Expand All @@ -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 }))

Expand Down
64 changes: 3 additions & 61 deletions src/converters.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
Expand Down Expand Up @@ -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<string, unknown>[] {
if (isRecord(value)) return value.type === "image" ? [value] : []
return recordArray(value).filter((part) => part.type === "image")
Expand Down Expand Up @@ -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([
Expand Down
Loading
Loading