From 55bb132d4dcf73cf28f8d56a07a6fe203d467cce Mon Sep 17 00:00:00 2001 From: mohemohe Date: Wed, 14 Oct 2026 10:33:53 +0900 Subject: [PATCH 1/5] feat(zdr): add x-cmd-zdr opt-in via CMD_ZDR and config Add a zero-data-retention opt-in that sends `x-cmd-zdr: 1` on every Command Code request. Enable it with env `CMD_ZDR=1` (1/true/yes/on) or `zdr: true` in the plugin config file. V1 merges the header into provider options.headers, V2 registers it on the commandcode provider, and createCommandCode honors CMD_ZDR. Explicit user headers always win, and custom npm transports are left untouched. --- README.md | 31 +++ docs/specs/2026-10-14-zdr-request-header.md | 83 ++++++++ index.ts | 17 +- plugin.ts | 10 + src/schemas.ts | 1 + src/zdr.ts | 45 ++++ tests/integration/zdr.integration.test.ts | 220 ++++++++++++++++++++ tests/unit/index.test.ts | 56 +++++ tests/unit/plugin-v2.test.ts | 79 +++++++ tests/unit/plugin.test.ts | 77 +++++++ tests/unit/schemas.test.ts | 4 + tests/unit/zdr.test.ts | 54 +++++ 12 files changed, 673 insertions(+), 4 deletions(-) create mode 100644 docs/specs/2026-10-14-zdr-request-header.md create mode 100644 src/zdr.ts create mode 100644 tests/integration/zdr.integration.test.ts create mode 100644 tests/unit/zdr.test.ts diff --git a/README.md b/README.md index dd5b8f9..9867b5d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - The provider's `supported_endpoints` metadata chooses each model's API route. Messages is preferred when advertised; otherwise Chat Completions is preferred, and Responses is used only when it is the sole advertised route. This avoids the malformed Responses function-call start event observed on DeepSeek V4.1 Flash `#max` in OpenCode V2.0.18: `response.output_item.added` contained a `function_call` item without its required `arguments` string. That exact tool-call flow now passes through Chat Completions on V1 and V2. Responses mappings remain available for response-only models, but the current catalog has none. Claude Sonnet 4.6 returned `MODEL_NOT_IN_PLAN` with the message “available in Pro and above plans or extra on-demand usage”; entitled Claude access has not been live-verified. If a model fails on an account entitled to use it, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). - Free models show `(Free)` after their display name on V1 and V2 when all catalog prices are zero. Model IDs and routing stay unchanged; this is a name suffix, not a native desktop badge. Names that already end in `Free` or `(Free)` keep their existing label. +- Zero data retention on request: `CMD_ZDR=1` or `zdr: true` in the plugin config adds `x-cmd-zdr: 1` to every Command Code request on both OpenCode generations. ## How it works @@ -88,6 +89,36 @@ Optional, `~/.config/opencode/opencode-commandcode.json` (legacy fallback name ` | `commandCodePackagePath` | `""` | Maintainer override: extract the catalog from a local `command-code` checkout instead of the bundle. Same as env `COMMANDCODE_PACKAGE_PATH`. | | `debugStartupLogs` | `false` | Also mirror the startup summary to stderr. Default is quiet (`startup.json` only). | | `disableModelSync` | `false` | Accepted for forward compatibility; currently has no effect. | +| `zdr` | `false` | Send `x-cmd-zdr: 1` on every request (zero data retention). Same as env `CMD_ZDR=1`. | + +## Zero data retention (ZDR) + +Command Code enforces [zero data retention](https://commandcode.ai/docs/provider#zero-data-retention-zdr) with the +`x-cmd-zdr: 1` request header; the CLI exposes the same opt-in as `CMD_ZDR=1`. This plugin never invoked the CLI, so +set either: + +- env `CMD_ZDR=1` (accepts `1`, `true`, `yes`, `on`; case-insensitive), or +- `"zdr": true` in `~/.config/opencode/opencode-commandcode.json`. + +The plugin then adds the header to every Command Code request: V2 registers it on the `commandcode` provider, V1 on the +provider `options.headers`. A handler you configure yourself (`providers.commandcode.headers` on V2, +`provider.commandcode.options.headers` on V1) wins over the plugin's value, and custom npm transports keep owning their +options. + +Behavior to expect from the API: when set, requests route only through ZDR-capable upstreams, and a model without one +**fails with `422 cmd_zdr_no_providers`** instead of falling back to a non-ZDR provider. ZDR can change which upstream +serves a request, so it may cost more. Anthropic models on `/v1/messages` are already zero-data-retention at the account +level; the header is accepted there for parity and does not change routing. + +The programmatic SDK factory (`createCommandCode`) applies the same `CMD_ZDR` opt-in, and an explicit `headers` value +always wins over it. + +Live verification against the Provider API lives in `tests/integration/zdr.integration.test.ts` and is skipped unless +`COMMANDCODE_API_KEY` is set: + +```bash +COMMANDCODE_API_KEY=... bun run test:integration +``` ## Optional local CLI override diff --git a/docs/specs/2026-10-14-zdr-request-header.md b/docs/specs/2026-10-14-zdr-request-header.md new file mode 100644 index 0000000..f36f949 --- /dev/null +++ b/docs/specs/2026-10-14-zdr-request-header.md @@ -0,0 +1,83 @@ +# Zero data retention request header + +Status: implemented + +## Goal + +Command Code accounts opt into zero data retention (ZDR) with the `x-cmd-zdr: 1` request header. The CLI exposes that +opt-in as `CMD_ZDR=1`; this plugin talks to the Provider API directly, so before this change nothing in an OpenCode +session could request ZDR. Users on both OpenCode generations must be able to turn ZDR on with the same env var the CLI +documents, without editing provider internals. + +Reference: [Command Code Provider API — Zero data retention](https://commandcode.ai/docs/provider#zero-data-retention-zdr). + +## Decisions + +### Opt-in surface + +ZDR is on when either input is true: + +- env `CMD_ZDR` parses as `1`, `true`, `yes`, or `on` (trimmed, case-insensitive); every other value — including + `"0"`, `"false"`, `""`, and an unset variable — leaves ZDR off; or +- `~/.config/opencode/opencode-commandcode.json` sets `"zdr": true` (validated by `PluginFileConfigSchema`). + +The config flag adds a durable per-machine option; the env var keeps parity with the CLI. No CLI installation is +required for either. + +### Header injection + +The header value is always `1` and the key is always `x-cmd-zdr`. + +- **V1** (`plugin.ts` `server()`): the header is written into `provider.commandcode.options.headers` next to the + existing default `baseURL`. OpenCode V1 merges provider `options` into every model of that provider, whichever + `provider.npm` a model overrides, so one map reaches the OpenAI-compatible (Chat Completions), Anthropic (Messages), + and OpenAI (Responses) routes. Custom npm transports keep owning their options, so the plugin does not add headers + when the user replaced `npm`. +- **V2** (`plugin.ts` `setup()`): the header is written into the registered provider's `headers` on the add path and + merged into the existing provider on the update path. Provider headers apply to every model of the provider, + including models that select the Anthropic or Responses runtime package. +- **SDK factory** (`index.ts createCommandCode`): `CMD_ZDR` fills in the ZDR header when the caller's `headers` do not + define it. + +### User override precedence + +An existing value always wins: a user who sets `headers["x-cmd-zdr"] = "0"` keeps it, and other headers in the same map +survive untouched. When ZDR is off the plugin never creates a `headers` key, so configurations without the opt-in are +byte-identical to the previous behavior. + +## Out of scope + +- Per-model ZDR selection. The API refuses ZDR-incapable models with `422 cmd_zdr_no_providers`; users who want that + granularity can set `providers.commandcode.models..headers` themselves. +- Any behavior change for `typesafe/jev`: the decision model is not in the bundled catalog and has no ZDR-capable + upstream. + +## How ZDR capability can be known without a request + +Checked on 2026-10-14 against `command-code@1.82.1` and the live Provider API: + +- `GET /provider/v1/models` carries no ZDR field. The only per-model keys are `context_length`, `created`, `id`, `name`, + `object`, `owned_by`, and `supported_endpoints`. +- The CLI bundle does know. `knownModelSupportsZdr()` and `modelSupportsZdr2()` consult two hardcoded denylists of + models with no ZDR-capable upstream (23 IDs in 1.82.1; 22 are in the current bundled catalog). The CLI uses them to + swap feature defaults so it never sends a request that would be refused. +- Live agreement: probing denylisted models with `x-cmd-zdr: 1` returned `422 cmd_zdr_no_providers` (4 of 5 sampled). + The fifth returned `403 MODEL_NOT_IN_PLAN`, because the entitlement check runs before ZDR routing — so a probe cannot + classify models the account cannot call. +- A refusal never reaches an upstream, so probing is free; an accepted request costs one generation. + +Consequence: capability is knowable offline by extracting the denylist at sync time (the catalog job already downloads +that bundle), but the list is only as fresh as the CLI release, and account entitlements can mask it. The live suite +below therefore treats the API as the authority and discovers both outcomes dynamically. + +## Verification + +- `tests/unit/zdr.test.ts` covers env parsing, config/env precedence, and header merging. +- `tests/unit/plugin.test.ts` covers V1 injection, user-header precedence, the off state, and the custom-npm skip. +- `tests/unit/plugin-v2.test.ts` covers the V2 add and update paths. +- `tests/unit/index.test.ts` covers `CMD_ZDR` in the SDK factory and the explicit-header win. +- `tests/integration/zdr.integration.test.ts` live-checks the Provider API with no hardcoded model ID: chat-capable IDs + come from `GET /provider/v1/models`, are probed with the plugin's own ZDR header map until both a `200` and a + `422 cmd_zdr_no_providers` are observed, and a no-header control on a refused model proves the header causes the + refusal. A `400`/`401` among the probes fails the suite. It is skipped unless `COMMANDCODE_API_KEY` is set and stays + out of `bun run check`. diff --git a/index.ts b/index.ts index a4974e9..bcbfa5c 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ import { resolveApiKey } from "./src/auth.js"; import { CommandCodeLanguageModel } from "./src/model.js"; +import { withZdrHeaders, zdrFromEnvValue } from "./src/zdr.js"; // Re-export the plugin definition so a directory plugin entry (/index.ts) // loads the dual V1+V2 plugin. No cycle: plugin.ts only imports from src/. @@ -13,6 +14,15 @@ export interface CommandCodeProviderOptions { authPaths?: string[]; } +/** Caller headers win; `CMD_ZDR=1` only fills the ZDR header when it is absent. */ +function resolveHeaders(options: CommandCodeProviderOptions): Record | undefined { + const provided = + typeof options.headers === "object" && options.headers !== null + ? (options.headers as Record) + : undefined; + return withZdrHeaders(provided, zdrFromEnvValue(process.env.CMD_ZDR)); +} + export function createCommandCode(options: CommandCodeProviderOptions = {}) { const apiKey = resolveApiKey({ apiKey: options.apiKey, authPaths: options.authPaths }); if (!apiKey) { @@ -21,15 +31,14 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) { ); } + const headers = resolveHeaders(options); + return { languageModel(modelId: string): CommandCodeLanguageModel { return new CommandCodeLanguageModel(modelId, { apiKey, baseURL: typeof options.baseURL === "string" ? options.baseURL : undefined, - headers: - typeof options.headers === "object" && options.headers !== null - ? (options.headers as Record) - : undefined, + headers, }); }, }; diff --git a/plugin.ts b/plugin.ts index afa2a39..1e5e440 100644 --- a/plugin.ts +++ b/plugin.ts @@ -13,6 +13,7 @@ import type { CatalogManifest } from "./src/manifest.js"; import { generateV2Models } from "./src/v2models.js"; import { catalogPaths } from "./src/paths.js"; import { ManifestSchema, ModelEntrySchema, PluginFileConfigSchema } from "./src/schemas.js"; +import { withZdrHeaders, zdrEnabled } from "./src/zdr.js"; const { models: MODELS_PATH, manifest: MANIFEST_PATH, version: VERSION_PATH } = catalogPaths(); @@ -238,6 +239,7 @@ export async function server() { const pluginCfg = loadPluginConfig(); const debug = pluginCfg.debugStartupLogs === true; + const zdr = zdrEnabled(pluginCfg); if (!cc.npm) cc.npm = V1_PROVIDER_SDK_NPM; if (!cc.name) cc.name = "Command Code"; @@ -245,11 +247,14 @@ export async function server() { // V1 resolves `npm` through BunProc.install and calls the first `create*` export, so // the SDK package must be the OpenAI-compatible one; give it the Provider API URL // unless the user already configured another baseURL (custom npm stays untouched). + // V1 merges `options` into every model of the provider, whichever `provider.npm` the + // model overrides, so one headers map reaches Chat, Messages, and Responses routes. if (cc.npm === V1_PROVIDER_SDK_NPM) { if (cc.options === undefined) cc.options = {}; if (typeof cc.options === "object" && cc.options !== null) { const options = cc.options as Record; if (!options.baseURL) options.baseURL = PROVIDER_API_BASE_URL; + if (zdr) options.headers = withZdrHeaders(options.headers, true); } } @@ -293,6 +298,7 @@ export async function server() { async function setup(ctx: any): Promise { const pluginCfg = loadPluginConfig(); const debug = pluginCfg.debugStartupLogs === true; + const zdr = zdrEnabled(pluginCfg); const load = loadCatalogEntries(); persistCatalogSideEffects(load, debug); const v2models = generateV2Models(load.models); @@ -308,6 +314,9 @@ async function setup(ctx: any): Promise { integrationID: "commandcode", env: ["COMMANDCODE_API_KEY"], package: V2_PROVIDER_PACKAGE, + // Provider headers reach every model of the provider, including the + // Messages/Responses package a model may select instead of the default. + ...(zdr ? { headers: withZdrHeaders(undefined, true) } : {}), settings: { baseURL: PROVIDER_API_BASE_URL, }, @@ -321,6 +330,7 @@ async function setup(ctx: any): Promise { provider.activation = provider.activation ?? "auto"; provider.integrationID = provider.integrationID ?? "commandcode"; provider.env = provider.env ?? ["COMMANDCODE_API_KEY"]; + if (zdr) provider.headers = withZdrHeaders(provider.headers, true); provider.settings = provider.settings ?? {}; if (typeof provider.settings === "object") { if (!provider.settings.baseURL) provider.settings.baseURL = PROVIDER_API_BASE_URL; diff --git a/src/schemas.ts b/src/schemas.ts index f754834..96ed2cf 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -132,4 +132,5 @@ export const PluginFileConfigSchema = z.object({ disableModelSync: z.boolean().optional().default(false), commandCodePackagePath: z.string().optional().default(""), debugStartupLogs: z.boolean().optional().default(false), + zdr: z.boolean().optional().default(false), }); diff --git a/src/zdr.ts b/src/zdr.ts new file mode 100644 index 0000000..837819d --- /dev/null +++ b/src/zdr.ts @@ -0,0 +1,45 @@ +import { liveEnv, type EnvDeps } from "./env.js"; + +/** + * Command Code zero-data-retention opt-in. The Provider API enforces ZDR with + * the `x-cmd-zdr: 1` request header (the CLI exposes the same opt-in as + * `CMD_ZDR=1`); ZDR-incapable models fail with `422 cmd_zdr_no_providers` + * instead of falling back to a non-ZDR upstream. + */ +export const ZDR_HEADER = "x-cmd-zdr"; +export const ZDR_HEADER_VALUE = "1"; + +/** Values that turn `CMD_ZDR` on; everything else (including "0") leaves ZDR off. */ +const ZDR_TRUTHY = new Set(["1", "true", "yes", "on"]); + +/** Parse the `CMD_ZDR` environment value. */ +export function zdrFromEnvValue(value: string | undefined): boolean { + return typeof value === "string" && ZDR_TRUTHY.has(value.trim().toLowerCase()); +} + +/** ZDR is on when the plugin config opts in or `CMD_ZDR` is truthy. */ +export function zdrEnabled(config: { zdr?: boolean }, deps: EnvDeps = liveEnv): boolean { + return config.zdr === true || zdrFromEnvValue(deps.getEnv("CMD_ZDR")); +} + +/** ZDR header map, or undefined when off so callers never create an empty `headers` key. */ +export function zdrHeaders(enabled: boolean): Record | undefined { + return enabled ? { [ZDR_HEADER]: ZDR_HEADER_VALUE } : undefined; +} + +/** + * Add the ZDR header without discarding headers already configured. An explicit + * user value (`headers["x-cmd-zdr"] = "0"`) wins over the plugin's opt-in. + */ +export function withZdrHeaders( + existing: unknown, + enabled: boolean, +): Record | undefined { + const current = + typeof existing === "object" && existing !== null + ? (existing as Record) + : undefined; + const added = zdrHeaders(enabled); + if (!added) return current; + return { ...added, ...current }; +} diff --git a/tests/integration/zdr.integration.test.ts b/tests/integration/zdr.integration.test.ts new file mode 100644 index 0000000..3e87f13 --- /dev/null +++ b/tests/integration/zdr.integration.test.ts @@ -0,0 +1,220 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { createCommandCode } from "@/index.js"; +import { withZdrHeaders } from "@/src/zdr.js"; + +/** + * Live zero-data-retention checks against api.commandcode.ai. + * + * The unit suites prove the plugin emits `x-cmd-zdr: 1`; this suite proves the + * Provider API honors that exact header: some chat model answers, and some + * model without a ZDR-capable upstream is refused with `cmd_zdr_no_providers`. + * + * No model ID is hardcoded. Chat-capable IDs come from `GET /provider/v1/models` + * and are probed with `max_tokens: 1` until both outcomes appear (a 422 response + * never reaches an upstream, so refusals are free). 403 (account entitlement), + * 429, and 5xx are tolerated along the way; a 400/401 would be a real defect. + * + * Skipped unless COMMANDCODE_API_KEY is set, and outside `bun run check` because + * ZDR coverage depends on live upstream availability and account entitlements. + */ + +const API_KEY = process.env.COMMANDCODE_API_KEY?.trim(); +const hasApiKey = !!API_KEY; + +const skipIfNoKey = hasApiKey ? describe : describe.skip; + +const PROVIDER_BASE_URL = "https://api.commandcode.ai/provider/v1"; +const ZDR_REFUSAL = "cmd_zdr_no_providers"; + +/** Stop after this many probes even if a branch is still missing. */ +const MAX_PROBES = 40; +const CONCURRENCY = 4; +const NETWORK_TIMEOUT_MS = 180_000; +/** OpenAI-routed models reject `max_tokens` below 16, so keep the probe above it. */ +const PROBE_MAX_TOKENS = 64; + +type Probe = { id: string; status: number; code?: string }; +type Discovery = { probes: Probe[]; capable: Probe[]; denied: Probe[] }; + +async function listChatModelIds(): Promise { + const response = await fetch(`${PROVIDER_BASE_URL}/models`, { + headers: { Authorization: `Bearer ${API_KEY}` }, + }); + if (response.status !== 200) { + throw new Error(`GET /models: ${response.status} ${(await response.text()).slice(0, 300)}`); + } + const payload = (await response.json()) as { + data?: Array<{ id?: string; supported_endpoints?: string[] }>; + }; + return (payload.data ?? []) + .filter((entry) => + (entry.supported_endpoints ?? []).some((endpoint) => endpoint.includes("chat/completions")), + ) + .map((entry) => entry.id) + .filter((id): id is string => typeof id === "string" && id.length > 0); +} + +/** One minimal chat request; `extraHeaders` carries the plugin's ZDR map. */ +async function postChat(model: string, extraHeaders: Record) { + const response = await fetch(`${PROVIDER_BASE_URL}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: "hi" }], + max_tokens: PROBE_MAX_TOKENS, + }), + }); + const body = await response.text(); + let code: string | undefined; + if (response.status !== 200) { + try { + code = (JSON.parse(body) as { error?: { code?: string } }).error?.code; + } catch { + code = undefined; + } + } + return { status: response.status, body, code }; +} + +/** Probe chat models until one ZDR-capable and one ZDR-refused sample exist. */ +async function discoverZdrOutcomes(ids: string[]): Promise { + const probes: Probe[] = []; + const capable: Probe[] = []; + const denied: Probe[] = []; + let started = 0; + + const header = withZdrHeaders(undefined, true) as Record; + + const worker = async () => { + while (capable.length === 0 || denied.length === 0) { + const index = started++; + if (index >= ids.length || started > MAX_PROBES) return; + const id = ids[index]!; + const result = await postChat(id, header); + const probe: Probe = { + id, + status: result.status, + ...(result.code ? { code: result.code } : {}), + }; + probes.push(probe); + if (probe.status === 200) capable.push(probe); + else if (probe.status === 422 && probe.code === ZDR_REFUSAL) denied.push(probe); + } + }; + + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + return { probes, capable, denied }; +} + +skipIfNoKey("integration: zero data retention", () => { + let chatIds: string[] = []; + let discovery: Discovery | undefined; + + beforeAll(async () => { + chatIds = await listChatModelIds(); + discovery = await discoverZdrOutcomes(chatIds); + console.log( + `ZDR probe: ${discovery.probes.length}/${chatIds.length} chat models probed, ` + + `${discovery.capable.length} served, ${discovery.denied.length} refused`, + ); + }, NETWORK_TIMEOUT_MS); + + test("at least one chat model serves a ZDR request", () => { + const capable = discovery?.capable ?? []; + console.log( + "ZDR-capable sample:", + capable + .slice(0, 3) + .map((probe) => probe.id) + .join(", "), + ); + if (capable.length === 0) { + throw new Error( + `no ZDR-capable chat model among ${discovery?.probes.length ?? 0} probes: ` + + `${JSON.stringify(discovery?.probes.slice(0, 10))}`, + ); + } + for (const probe of capable) expect(probe.status).toBe(200); + }); + + test("a model without a ZDR-capable upstream is refused with cmd_zdr_no_providers", () => { + const denied = discovery?.denied ?? []; + console.log( + "ZDR-refused sample:", + denied + .slice(0, 3) + .map((probe) => probe.id) + .join(", "), + ); + if (denied.length === 0) { + throw new Error( + `no ${ZDR_REFUSAL} response among ${discovery?.probes.length ?? 0} probes; ` + + `upstream ZDR coverage may have widened, or MAX_PROBES is too low`, + ); + } + for (const probe of denied) expect(probe.code).toBe(ZDR_REFUSAL); + }); + + test("the header never produces a malformed request or an auth error", () => { + const rejected = (discovery?.probes ?? []).filter( + (probe) => probe.status === 400 || probe.status === 401, + ); + expect(rejected).toEqual([]); + }); + + test( + "the same model answers without the header (control)", + async () => { + const sample = discovery?.denied[0]; + if (!sample) throw new Error("no ZDR-refused sample to use as a control"); + const result = await postChat(sample.id, {}); + if (result.status !== 200) { + throw new Error( + `control request for ${sample.id} returned ${result.status}: ${result.body.slice(0, 300)}`, + ); + } + expect(result.status).toBe(200); + }, + NETWORK_TIMEOUT_MS, + ); + + test( + "CMD_ZDR=1 reaches a live SDK request", + async () => { + const sample = discovery?.capable[0]; + if (!sample) throw new Error("no ZDR-capable sample to drive the SDK path"); + + const previous = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const provider = createCommandCode({ apiKey: API_KEY! }); + const model = provider.languageModel(sample.id); + // The SDK path targets the CLI-compatible endpoint, so this asserts the + // opt-in survives a real request rather than re-checking upstream routing. + // Reasoning models may answer with reasoning-only content at a small budget. + const result = await model.doGenerate({ + prompt: [{ role: "user", content: "Reply with exactly the word: hello" }], + maxOutputTokens: 64, + }); + + expect(result.content.length).toBeGreaterThan(0); + expect(result.finishReason.unified).toMatch(/stop|length/); + } finally { + if (previous === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = previous; + } + }, + NETWORK_TIMEOUT_MS, + ); +}); + +test("integration tests are skipped when COMMANDCODE_API_KEY is not set", () => { + if (!hasApiKey) { + console.log("Skipping ZDR integration tests: COMMANDCODE_API_KEY not set"); + } +}); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 899869c..1e3305d 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,5 +1,6 @@ import { expect, test, beforeAll, afterAll } from "bun:test"; import { createCommandCode } from "@/index.js"; +import { makeCallOptions, mockFetchTrack } from "@/tests/helpers/mocks.js"; let originalEnv: Record = {}; beforeAll(() => { @@ -67,3 +68,58 @@ test("each languageModel call returns a new instance", () => { expect(m1.modelId).toBe("a"); expect(m2.modelId).toBe("b"); }); + +test("CMD_ZDR=1 adds the ZDR header to SDK requests", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + const { calls, restore, respondWith } = mockFetchTrack(); + respondWith({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }); + try { + const provider = createCommandCode({ apiKey: "sk-test" }); + await provider.languageModel("m").doStream(makeCallOptions()); + const headers = calls[0].options.headers as Record; + expect(headers["x-cmd-zdr"]).toBe("1"); + } finally { + restore(); + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("an explicit header wins over the CMD_ZDR opt-in", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + const { calls, restore, respondWith } = mockFetchTrack(); + respondWith({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }); + try { + const provider = createCommandCode({ + apiKey: "sk-test", + headers: { "x-cmd-zdr": "0" }, + }); + await provider.languageModel("m").doStream(makeCallOptions()); + const headers = calls[0].options.headers as Record; + expect(headers["x-cmd-zdr"]).toBe("0"); + } finally { + restore(); + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts index dfc7b04..47b28ba 100644 --- a/tests/unit/plugin-v2.test.ts +++ b/tests/unit/plugin-v2.test.ts @@ -136,3 +136,82 @@ test("V2 setup preserves existing package and sets models", async () => { expect(setModels.id).toBe("commandcode"); expect(setModels.models.length).toBeGreaterThan(20); }); + +type Captured = { added: any; updated: any; setModels: any }; + +/** Minimal V2 context; `existing` seeds the update path, absence exercises the add path. */ +function makeCtx(existing?: any): { ctx: any; captured: Captured } { + const captured: Captured = { added: null, updated: null, setModels: null }; + const ctx = { + provider: { + transform: async (fn: (editor: any) => void) => { + const editor = { + get: () => existing, + add: (input: any) => { + captured.added = input; + }, + update: (id: string, fn2: (p: any) => void) => { + captured.updated = existing ?? { id, settings: {} }; + fn2(captured.updated); + }, + models: { + set: (id: string, models: any) => { + captured.setModels = { id, models }; + }, + }, + }; + fn(editor); + }, + }, + integration: { + transform: async (fn: (editor: any) => void) => { + fn({ method: { update: () => {} } }); + }, + }, + }; + return { ctx, captured }; +} + +async function withEnvZdr(value: string | undefined, fn: () => Promise): Promise { + const prev = process.env.CMD_ZDR; + if (value === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = value; + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +} + +test("V2 setup leaves headers unset without a ZDR opt-in", async () => { + await withEnvZdr(undefined, async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toBeUndefined(); + }); +}); + +test("CMD_ZDR=1 adds the ZDR header to a new V2 provider", async () => { + await withEnvZdr("1", async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toEqual({ "x-cmd-zdr": "1" }); + expect(captured.added.info.settings.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + }); +}); + +test("V2 setup merges the ZDR header into existing provider headers", async () => { + await withEnvZdr("1", async () => { + const existing = { + id: "commandcode", + headers: { "x-other": "yes", "x-cmd-zdr": "0" }, + settings: {}, + }; + const { ctx, captured } = makeCtx(existing); + await dual.setup(ctx); + // An explicit user value survives the plugin opt-in. + expect(captured.updated.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + expect(captured.setModels.id).toBe("commandcode"); + }); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 5c0b009..018ebf4 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -223,6 +223,83 @@ test("config hook creates provider block if missing", async () => { ); }); +test("CMD_ZDR=1 adds the ZDR header to the V1 provider options", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + expect(options.headers).toEqual({ "x-cmd-zdr": "1" }); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("ZDR opt-in keeps user headers, and an explicit user ZDR value wins", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { + provider: { + commandcode: { + npm: "@ai-sdk/openai-compatible", + options: { headers: { "x-other": "yes", "x-cmd-zdr": "0" } }, + }, + }, + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + expect(options.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("no ZDR header without CMD_ZDR or the config flag", async () => { + const prev = process.env.CMD_ZDR; + delete process.env.CMD_ZDR; + try { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect((cc.options as Record).headers).toBeUndefined(); + } finally { + if (prev !== undefined) process.env.CMD_ZDR = prev; + } +}); + +test("CMD_ZDR=1 leaves custom npm transports untouched", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { + provider: { commandcode: { npm: "custom-package" } }, + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect(cc.npm).toBe("custom-package"); + expect(cc.options).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + test("startup summary uses bundled manifest version and status", async () => { const plugin = await pluginFn(); const config: Record = { provider: { commandcode: {} } }; diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts index 385bfae..c7acd0f 100644 --- a/tests/unit/schemas.test.ts +++ b/tests/unit/schemas.test.ts @@ -243,6 +243,7 @@ describe("PluginFileConfigSchema", () => { disableModelSync: false, commandCodePackagePath: "", debugStartupLogs: false, + zdr: false, }); }); @@ -251,12 +252,14 @@ describe("PluginFileConfigSchema", () => { PluginFileConfigSchema.parse({ debugStartupLogs: true, commandCodePackagePath: "/opt/command-code", + zdr: true, extraKey: "ignored", }), ).toEqual({ disableModelSync: false, commandCodePackagePath: "/opt/command-code", debugStartupLogs: true, + zdr: true, }); }); @@ -268,6 +271,7 @@ describe("PluginFileConfigSchema", () => { disableModelSync: false, commandCodePackagePath: "", debugStartupLogs: false, + zdr: false, }); }); }); diff --git a/tests/unit/zdr.test.ts b/tests/unit/zdr.test.ts new file mode 100644 index 0000000..d4560fe --- /dev/null +++ b/tests/unit/zdr.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { + ZDR_HEADER, + ZDR_HEADER_VALUE, + withZdrHeaders, + zdrEnabled, + zdrFromEnvValue, + zdrHeaders, +} from "@/src/zdr.ts"; + +test("CMD_ZDR truthy spellings turn ZDR on", () => { + for (const value of ["1", "true", "TRUE", "Yes", "on", " 1 "]) { + expect(zdrFromEnvValue(value)).toBe(true); + } +}); + +test("CMD_ZDR falsey or absent values leave ZDR off", () => { + for (const value of [undefined, "", "0", "false", "no", "off", "banana"]) { + expect(zdrFromEnvValue(value)).toBe(false); + } +}); + +test("zdrEnabled reads the config flag or CMD_ZDR", () => { + const env = (value: string | undefined) => ({ + homedir: () => "/tmp", + getEnv: (key: string) => (key === "CMD_ZDR" ? value : undefined), + }); + + expect(zdrEnabled({}, env(undefined))).toBe(false); + expect(zdrEnabled({}, env("1"))).toBe(true); + expect(zdrEnabled({ zdr: true }, env(undefined))).toBe(true); + expect(zdrEnabled({ zdr: false }, env("1"))).toBe(true); + // A false config value never overrides a truthy env opt-in, and other env keys are ignored. + expect(zdrEnabled({ zdr: false }, env("0"))).toBe(false); +}); + +test("zdrHeaders returns the header map only when enabled", () => { + expect(zdrHeaders(true)).toEqual({ [ZDR_HEADER]: ZDR_HEADER_VALUE }); + expect(zdrHeaders(false)).toBeUndefined(); +}); + +test("withZdrHeaders keeps existing headers and never creates an empty map", () => { + expect(withZdrHeaders(undefined, false)).toBeUndefined(); + expect(withZdrHeaders(undefined, true)).toEqual({ [ZDR_HEADER]: ZDR_HEADER_VALUE }); + expect(withZdrHeaders({ "x-other": "1" }, true)).toEqual({ + [ZDR_HEADER]: ZDR_HEADER_VALUE, + "x-other": "1", + }); + expect(withZdrHeaders({ "x-other": "1" }, false)).toEqual({ "x-other": "1" }); +}); + +test("withZdrHeaders lets an explicit user value win over the opt-in", () => { + expect(withZdrHeaders({ [ZDR_HEADER]: "0" }, true)).toEqual({ [ZDR_HEADER]: "0" }); +}); From 85e9414f2a37ba59cfd066de149cdd61f99ffd8d Mon Sep 17 00:00:00 2001 From: mohemohe Date: Mon, 19 Oct 2026 17:18:02 +0900 Subject: [PATCH 2/5] fix(zdr): harden header casing, config, and SDK Match x-cmd-zdr case-insensitively and collapse every spelling into the lowercase key, so a user X-Cmd-Zdr: 0 is kept instead of being sent as "1, 0". Fall back per field in PluginFileConfigSchema so an invalid sibling no longer discards a valid zdr opt-in; a present but invalid zdr value fails safe to on. createCommandCode calls /alpha/generate, which the ZDR docs do not cover, so languageModel() now throws when ZDR is requested instead of sending the header; an explicit x-cmd-zdr: 0 opts out of CMD_ZDR. --- index.ts | 29 ++++++---- src/schemas.ts | 12 +++-- src/zdr.ts | 36 ++++++++++--- tests/helpers/plugin-config.ts | 30 +++++++++++ tests/unit/index.test.ts | 99 +++++++++++++++++++++------------- tests/unit/plugin-v2.test.ts | 30 +++++++++++ tests/unit/plugin.test.ts | 44 +++++++++++++++ tests/unit/schemas.test.ts | 31 +++++++++-- tests/unit/zdr.test.ts | 32 +++++++++++ 9 files changed, 280 insertions(+), 63 deletions(-) create mode 100644 tests/helpers/plugin-config.ts diff --git a/index.ts b/index.ts index bcbfa5c..aa37e75 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,6 @@ import { resolveApiKey } from "./src/auth.js"; import { CommandCodeLanguageModel } from "./src/model.js"; -import { withZdrHeaders, zdrFromEnvValue } from "./src/zdr.js"; +import { findZdrHeader, zdrFromEnvValue } from "./src/zdr.js"; // Re-export the plugin definition so a directory plugin entry (/index.ts) // loads the dual V1+V2 plugin. No cycle: plugin.ts only imports from src/. @@ -14,13 +14,13 @@ export interface CommandCodeProviderOptions { authPaths?: string[]; } -/** Caller headers win; `CMD_ZDR=1` only fills the ZDR header when it is absent. */ -function resolveHeaders(options: CommandCodeProviderOptions): Record | undefined { - const provided = - typeof options.headers === "object" && options.headers !== null - ? (options.headers as Record) - : undefined; - return withZdrHeaders(provided, zdrFromEnvValue(process.env.CMD_ZDR)); +/** + * ZDR is documented for the Provider API (`/provider/v1/*`) only, and this SDK + * calls `/alpha/generate`. A caller ZDR header (any casing) decides, so an + * explicit `x-cmd-zdr: 0` opts out of `CMD_ZDR`; otherwise `CMD_ZDR` decides. + */ +function zdrRequested(headers: Record | undefined): boolean { + return zdrFromEnvValue(findZdrHeader(headers) ?? process.env.CMD_ZDR); } export function createCommandCode(options: CommandCodeProviderOptions = {}) { @@ -31,10 +31,21 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) { ); } - const headers = resolveHeaders(options); + const headers = + typeof options.headers === "object" && options.headers !== null + ? (options.headers as Record) + : undefined; return { languageModel(modelId: string): CommandCodeLanguageModel { + // Fail closed before any request: sending without enforced ZDR would leak the + // prompt the caller asked to keep out of retention. Thrown here rather than in + // the factory, which dist/plugin.js also exports to OpenCode's plugin loader. + if (zdrRequested(headers)) { + throw new Error( + "Zero data retention is only supported on the Command Code Provider API (/provider/v1/*); createCommandCode uses /alpha/generate. Unset CMD_ZDR, or use the OpenCode provider plugin.", + ); + } return new CommandCodeLanguageModel(modelId, { apiKey, baseURL: typeof options.baseURL === "string" ? options.baseURL : undefined, diff --git a/src/schemas.ts b/src/schemas.ts index 96ed2cf..78b7eac 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -126,11 +126,13 @@ export const AvailabilityPayloadSchema = z /** * Plugin file config (`~/.config/opencode/opencode-commandcode.json`). * Deliberately lenient: user config files may carry extra keys, so unknown - * keys are stripped and every field falls back to its default. + * keys are stripped, and an invalid field falls back on its own without + * discarding the valid ones. `zdr` fails safe instead: a present but invalid + * value (e.g. the string "true") keeps ZDR on rather than silently disabling it. */ export const PluginFileConfigSchema = z.object({ - disableModelSync: z.boolean().optional().default(false), - commandCodePackagePath: z.string().optional().default(""), - debugStartupLogs: z.boolean().optional().default(false), - zdr: z.boolean().optional().default(false), + disableModelSync: z.boolean().optional().default(false).catch(false), + commandCodePackagePath: z.string().optional().default("").catch(""), + debugStartupLogs: z.boolean().optional().default(false).catch(false), + zdr: z.boolean().optional().default(false).catch(true), }); diff --git a/src/zdr.ts b/src/zdr.ts index 837819d..0670eec 100644 --- a/src/zdr.ts +++ b/src/zdr.ts @@ -27,19 +27,39 @@ export function zdrHeaders(enabled: boolean): Record | undefined return enabled ? { [ZDR_HEADER]: ZDR_HEADER_VALUE } : undefined; } +function asHeaderMap(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +/** + * The caller's ZDR header value, matched case-insensitively (HTTP header names + * are). The lowercase spelling wins when several spellings are present. + */ +export function findZdrHeader(headers: unknown): string | undefined { + const current = asHeaderMap(headers); + if (!current) return undefined; + if (ZDR_HEADER in current) return String(current[ZDR_HEADER]); + const key = Object.keys(current).find((name) => name.toLowerCase() === ZDR_HEADER); + return key === undefined ? undefined : String(current[key]); +} + /** * Add the ZDR header without discarding headers already configured. An explicit - * user value (`headers["x-cmd-zdr"] = "0"`) wins over the plugin's opt-in. + * user value in any casing (`X-Cmd-Zdr: 0`) wins over the plugin's opt-in, and + * every spelling collapses into the single lowercase key so a request never + * carries two ZDR headers (`1, 0`). */ export function withZdrHeaders( existing: unknown, enabled: boolean, ): Record | undefined { - const current = - typeof existing === "object" && existing !== null - ? (existing as Record) - : undefined; - const added = zdrHeaders(enabled); - if (!added) return current; - return { ...added, ...current }; + const current = asHeaderMap(existing); + if (!enabled) return current; + const value = findZdrHeader(current) ?? ZDR_HEADER_VALUE; + const rest = Object.fromEntries( + Object.entries(current ?? {}).filter(([name]) => name.toLowerCase() !== ZDR_HEADER), + ); + return { [ZDR_HEADER]: value, ...rest }; } diff --git a/tests/helpers/plugin-config.ts b/tests/helpers/plugin-config.ts new file mode 100644 index 0000000..f9836ea --- /dev/null +++ b/tests/helpers/plugin-config.ts @@ -0,0 +1,30 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { liveEnv } from "@/src/env.ts"; + +/** + * Run `fn` with `~/.config/opencode/opencode-commandcode.json` holding `config` + * and `CMD_ZDR` unset. The plugin hooks read the file through `liveEnv`, so its + * `homedir` is pointed at a temp home for the duration (Bun's `os.homedir()` + * ignores a runtime `HOME` change). + */ +export async function withPluginConfigFile(config: unknown, fn: () => Promise): Promise { + const home = mkdtempSync(join(tmpdir(), "cc-plugin-home-")); + const dir = join(home, ".config", "opencode"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode-commandcode.json"), JSON.stringify(config)); + + const prevHomedir = liveEnv.homedir; + const prevZdr = process.env.CMD_ZDR; + liveEnv.homedir = () => home; + delete process.env.CMD_ZDR; + try { + return await fn(); + } finally { + liveEnv.homedir = prevHomedir; + if (prevZdr === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prevZdr; + rmSync(home, { recursive: true, force: true }); + } +} diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 1e3305d..6432970 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -6,10 +6,14 @@ let originalEnv: Record = {}; beforeAll(() => { originalEnv.COMMANDCODE_API_KEY = process.env.COMMANDCODE_API_KEY; delete process.env.COMMANDCODE_API_KEY; + // A developer's CMD_ZDR=1 would make every languageModel() call fail closed. + originalEnv.CMD_ZDR = process.env.CMD_ZDR; + delete process.env.CMD_ZDR; }); afterAll(() => { if (originalEnv.COMMANDCODE_API_KEY) process.env.COMMANDCODE_API_KEY = originalEnv.COMMANDCODE_API_KEY; + if (originalEnv.CMD_ZDR !== undefined) process.env.CMD_ZDR = originalEnv.CMD_ZDR; }); const noAuthPaths = { authPaths: [] as string[] }; @@ -69,36 +73,19 @@ test("each languageModel call returns a new instance", () => { expect(m2.modelId).toBe("b"); }); -test("CMD_ZDR=1 adds the ZDR header to SDK requests", async () => { +async function withCmdZdr(value: string | undefined, fn: () => Promise | T): Promise { const prev = process.env.CMD_ZDR; - process.env.CMD_ZDR = "1"; - const { calls, restore, respondWith } = mockFetchTrack(); - respondWith({ - ok: true, - status: 200, - headers: new Headers({ "content-type": "text/event-stream" }), - body: new ReadableStream({ - start(controller) { - controller.close(); - }, - }), - }); + if (value === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = value; try { - const provider = createCommandCode({ apiKey: "sk-test" }); - await provider.languageModel("m").doStream(makeCallOptions()); - const headers = calls[0].options.headers as Record; - expect(headers["x-cmd-zdr"]).toBe("1"); + return await fn(); } finally { - restore(); if (prev === undefined) delete process.env.CMD_ZDR; else process.env.CMD_ZDR = prev; } -}); +} -test("an explicit header wins over the CMD_ZDR opt-in", async () => { - const prev = process.env.CMD_ZDR; - process.env.CMD_ZDR = "1"; - const { calls, restore, respondWith } = mockFetchTrack(); +function emptyStream(respondWith: (response: Partial) => void): void { respondWith({ ok: true, status: 200, @@ -109,17 +96,57 @@ test("an explicit header wins over the CMD_ZDR opt-in", async () => { }, }), }); - try { - const provider = createCommandCode({ - apiKey: "sk-test", - headers: { "x-cmd-zdr": "0" }, - }); - await provider.languageModel("m").doStream(makeCallOptions()); - const headers = calls[0].options.headers as Record; - expect(headers["x-cmd-zdr"]).toBe("0"); - } finally { - restore(); - if (prev === undefined) delete process.env.CMD_ZDR; - else process.env.CMD_ZDR = prev; - } +} + +test("CMD_ZDR=1 fails closed in the SDK without sending a request", async () => { + await withCmdZdr("1", () => { + const { calls, restore } = mockFetchTrack(); + try { + // The factory itself still succeeds: dist/plugin.js exports it to OpenCode's loader. + const provider = createCommandCode({ apiKey: "sk-test" }); + expect(() => provider.languageModel("m")).toThrow("/provider/v1/*"); + expect(calls).toHaveLength(0); + } finally { + restore(); + } + }); +}); + +test("an explicit ZDR header in any casing fails closed in the SDK", async () => { + await withCmdZdr(undefined, () => { + for (const headers of [{ "x-cmd-zdr": "1" }, { "X-Cmd-Zdr": "1" }]) { + const provider = createCommandCode({ apiKey: "sk-test", headers }); + expect(() => provider.languageModel("m")).toThrow("/alpha/generate"); + } + }); +}); + +test("an explicit x-cmd-zdr: 0 opts the SDK out of CMD_ZDR", async () => { + await withCmdZdr("1", async () => { + const { calls, restore, respondWith } = mockFetchTrack(); + emptyStream(respondWith); + try { + const provider = createCommandCode({ apiKey: "sk-test", headers: { "X-Cmd-Zdr": "0" } }); + await provider.languageModel("m").doStream(makeCallOptions()); + const headers = calls[0].options.headers as Record; + expect(headers["X-Cmd-Zdr"]).toBe("0"); + } finally { + restore(); + } + }); +}); + +test("the SDK sends no ZDR header when ZDR is off", async () => { + await withCmdZdr(undefined, async () => { + const { calls, restore, respondWith } = mockFetchTrack(); + emptyStream(respondWith); + try { + const provider = createCommandCode({ apiKey: "sk-test" }); + await provider.languageModel("m").doStream(makeCallOptions()); + const headers = calls[0].options.headers as Record; + expect(Object.keys(headers).some((name) => name.toLowerCase() === "x-cmd-zdr")).toBe(false); + } finally { + restore(); + } + }); }); diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts index 47b28ba..8956202 100644 --- a/tests/unit/plugin-v2.test.ts +++ b/tests/unit/plugin-v2.test.ts @@ -2,6 +2,7 @@ import { expect, test, beforeAll, afterAll } from "bun:test"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { withPluginConfigFile } from "@/tests/helpers/plugin-config.ts"; let dual: { id: string; setup: (ctx: unknown) => Promise; server: () => Promise }; let prevStateDir: string | undefined; @@ -215,3 +216,32 @@ test("V2 setup merges the ZDR header into existing provider headers", async () = expect(captured.setModels.id).toBe("commandcode"); }); }); + +test("a mixed-case user ZDR header wins and collapses to lowercase on V2", async () => { + await withEnvZdr("1", async () => { + const existing = { + id: "commandcode", + headers: { "X-Cmd-Zdr": "0", "x-other": "yes" }, + settings: {}, + }; + const { ctx, captured } = makeCtx(existing); + await dual.setup(ctx); + expect(captured.updated.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + }); +}); + +test("config zdr survives an invalid sibling field on V2", async () => { + await withPluginConfigFile({ zdr: true, debugStartupLogs: "false" }, async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); + +test("an invalid config zdr value fails safe to on for V2", async () => { + await withPluginConfigFile({ zdr: "true" }, async () => { + const { ctx, captured } = makeCtx(); + await dual.setup(ctx); + expect(captured.added.info.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 018ebf4..4fab326 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { writeCatalogCache, type ModelEntry } from "@/src/startup.ts"; +import { withPluginConfigFile } from "@/tests/helpers/plugin-config.ts"; type PluginResult = { config: (config: Record) => Promise; @@ -266,6 +267,49 @@ test("ZDR opt-in keeps user headers, and an explicit user ZDR value wins", async } }); +test("a mixed-case user ZDR header wins and collapses to lowercase on V1", async () => { + const prev = process.env.CMD_ZDR; + process.env.CMD_ZDR = "1"; + try { + const plugin = await pluginFn(); + const config: Record = { + provider: { commandcode: { options: { headers: { "X-Cmd-Zdr": "0", "x-other": "yes" } } } }, + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.headers).toEqual({ "x-cmd-zdr": "0", "x-other": "yes" }); + } finally { + if (prev === undefined) delete process.env.CMD_ZDR; + else process.env.CMD_ZDR = prev; + } +}); + +test("config zdr survives an invalid sibling field on V1", async () => { + await withPluginConfigFile({ zdr: true, debugStartupLogs: "false" }, async () => { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); + +test("an invalid config zdr value fails safe to on for V1", async () => { + await withPluginConfigFile({ zdr: "true" }, async () => { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + const options = cc.options as Record; + expect(options.headers).toEqual({ "x-cmd-zdr": "1" }); + }); +}); + test("no ZDR header without CMD_ZDR or the config flag", async () => { const prev = process.env.CMD_ZDR; delete process.env.CMD_ZDR; diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts index c7acd0f..5a1ea00 100644 --- a/tests/unit/schemas.test.ts +++ b/tests/unit/schemas.test.ts @@ -263,15 +263,36 @@ describe("PluginFileConfigSchema", () => { }); }); - test("wrong-typed values fail validation so callers fall back to defaults", () => { - const raw = { debugStartupLogs: "yes" }; - const parsed = PluginFileConfigSchema.safeParse(raw); - const config = parsed.success ? parsed.data : PluginFileConfigSchema.parse({}); - expect(config).toEqual({ + test("a wrong-typed field falls back to its default on its own", () => { + expect(PluginFileConfigSchema.parse({ debugStartupLogs: "yes" })).toEqual({ disableModelSync: false, commandCodePackagePath: "", debugStartupLogs: false, zdr: false, }); }); + + test("an invalid field never discards a valid zdr opt-in", () => { + expect(PluginFileConfigSchema.parse({ zdr: true, debugStartupLogs: "false" })).toEqual({ + disableModelSync: false, + commandCodePackagePath: "", + debugStartupLogs: false, + zdr: true, + }); + expect( + PluginFileConfigSchema.parse({ commandCodePackagePath: "/opt/cc", disableModelSync: 1 }), + ).toEqual({ + disableModelSync: false, + commandCodePackagePath: "/opt/cc", + debugStartupLogs: false, + zdr: false, + }); + }); + + test("a present but invalid zdr value fails safe to on", () => { + for (const zdr of ["true", "false", 1, null]) { + expect(PluginFileConfigSchema.parse({ zdr }).zdr).toBe(true); + } + expect(PluginFileConfigSchema.parse({ zdr: false }).zdr).toBe(false); + }); }); diff --git a/tests/unit/zdr.test.ts b/tests/unit/zdr.test.ts index d4560fe..b4abe07 100644 --- a/tests/unit/zdr.test.ts +++ b/tests/unit/zdr.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { ZDR_HEADER, ZDR_HEADER_VALUE, + findZdrHeader, withZdrHeaders, zdrEnabled, zdrFromEnvValue, @@ -52,3 +53,34 @@ test("withZdrHeaders keeps existing headers and never creates an empty map", () test("withZdrHeaders lets an explicit user value win over the opt-in", () => { expect(withZdrHeaders({ [ZDR_HEADER]: "0" }, true)).toEqual({ [ZDR_HEADER]: "0" }); }); + +test("findZdrHeader matches the header name case-insensitively", () => { + expect(findZdrHeader(undefined)).toBeUndefined(); + expect(findZdrHeader({ "x-other": "1" })).toBeUndefined(); + expect(findZdrHeader({ "X-Cmd-Zdr": "0" })).toBe("0"); + expect(findZdrHeader({ "X-CMD-ZDR": "1" })).toBe("1"); + // The lowercase spelling wins when several are present. + expect(findZdrHeader({ "X-Cmd-Zdr": "1", [ZDR_HEADER]: "0" })).toBe("0"); +}); + +test("withZdrHeaders keeps a mixed-case user value and collapses it to lowercase", () => { + expect(withZdrHeaders({ "X-Cmd-Zdr": "0" }, true)).toEqual({ [ZDR_HEADER]: "0" }); + expect(withZdrHeaders({ "X-CMD-ZDR": "0", "x-other": "yes" }, true)).toEqual({ + [ZDR_HEADER]: "0", + "x-other": "yes", + }); + expect(withZdrHeaders({ "X-Cmd-Zdr": "1", [ZDR_HEADER]: "0" }, true)).toEqual({ + [ZDR_HEADER]: "0", + }); +}); + +test("withZdrHeaders leaves headers untouched when ZDR is off", () => { + const headers = { "X-Cmd-Zdr": "0" }; + expect(withZdrHeaders(headers, false)).toBe(headers); +}); + +test("withZdrHeaders does not mutate the caller's map", () => { + const headers = { "X-Cmd-Zdr": "0", "x-other": "yes" }; + withZdrHeaders(headers, true); + expect(headers).toEqual({ "X-Cmd-Zdr": "0", "x-other": "yes" }); +}); From 5a64a5777eccc769e8c5adf432a587c6ee45ce02 Mon Sep 17 00:00:00 2001 From: mohemohe Date: Mon, 19 Oct 2026 17:18:02 +0900 Subject: [PATCH 3/5] test(zdr): cancel live probes at the deadline Tie every live request to an AbortSignal that fires before the test timeout, so a timed-out run cancels in-flight paid probes and schedules no new ones. Drop the live SDK test (the SDK now fails closed) and the log-only skip test. --- tests/integration/zdr.integration.test.ts | 71 +++++++++-------------- 1 file changed, 26 insertions(+), 45 deletions(-) diff --git a/tests/integration/zdr.integration.test.ts b/tests/integration/zdr.integration.test.ts index 3e87f13..f23e514 100644 --- a/tests/integration/zdr.integration.test.ts +++ b/tests/integration/zdr.integration.test.ts @@ -1,5 +1,4 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { createCommandCode } from "@/index.js"; import { withZdrHeaders } from "@/src/zdr.js"; /** @@ -14,6 +13,9 @@ import { withZdrHeaders } from "@/src/zdr.js"; * never reaches an upstream, so refusals are free). 403 (account entitlement), * 429, and 5xx are tolerated along the way; a 400/401 would be a real defect. * + * Every request carries an AbortSignal tied to a deadline inside the test + * timeout, so a timed-out run cancels in-flight probes and schedules no more. + * * Skipped unless COMMANDCODE_API_KEY is set, and outside `bun run check` because * ZDR coverage depends on live upstream availability and account entitlements. */ @@ -30,15 +32,18 @@ const ZDR_REFUSAL = "cmd_zdr_no_providers"; const MAX_PROBES = 40; const CONCURRENCY = 4; const NETWORK_TIMEOUT_MS = 180_000; +/** Abort requests before the test timeout fires, so none outlive their test. */ +const REQUEST_BUDGET_MS = NETWORK_TIMEOUT_MS - 15_000; /** OpenAI-routed models reject `max_tokens` below 16, so keep the probe above it. */ const PROBE_MAX_TOKENS = 64; type Probe = { id: string; status: number; code?: string }; type Discovery = { probes: Probe[]; capable: Probe[]; denied: Probe[] }; -async function listChatModelIds(): Promise { +async function listChatModelIds(signal: AbortSignal): Promise { const response = await fetch(`${PROVIDER_BASE_URL}/models`, { headers: { Authorization: `Bearer ${API_KEY}` }, + signal, }); if (response.status !== 200) { throw new Error(`GET /models: ${response.status} ${(await response.text()).slice(0, 300)}`); @@ -55,7 +60,7 @@ async function listChatModelIds(): Promise { } /** One minimal chat request; `extraHeaders` carries the plugin's ZDR map. */ -async function postChat(model: string, extraHeaders: Record) { +async function postChat(model: string, extraHeaders: Record, signal: AbortSignal) { const response = await fetch(`${PROVIDER_BASE_URL}/chat/completions`, { method: "POST", headers: { @@ -68,6 +73,7 @@ async function postChat(model: string, extraHeaders: Record) { messages: [{ role: "user", content: "hi" }], max_tokens: PROBE_MAX_TOKENS, }), + signal, }); const body = await response.text(); let code: string | undefined; @@ -81,8 +87,11 @@ async function postChat(model: string, extraHeaders: Record) { return { status: response.status, body, code }; } -/** Probe chat models until one ZDR-capable and one ZDR-refused sample exist. */ -async function discoverZdrOutcomes(ids: string[]): Promise { +/** + * Probe chat models until one ZDR-capable and one ZDR-refused sample exist, or + * until `signal` aborts: an aborted probe is dropped and no new one starts. + */ +async function discoverZdrOutcomes(ids: string[], signal: AbortSignal): Promise { const probes: Probe[] = []; const capable: Probe[] = []; const denied: Probe[] = []; @@ -91,11 +100,17 @@ async function discoverZdrOutcomes(ids: string[]): Promise { const header = withZdrHeaders(undefined, true) as Record; const worker = async () => { - while (capable.length === 0 || denied.length === 0) { + while (!signal.aborted && (capable.length === 0 || denied.length === 0)) { const index = started++; if (index >= ids.length || started > MAX_PROBES) return; const id = ids[index]!; - const result = await postChat(id, header); + let result: Awaited>; + try { + result = await postChat(id, header, signal); + } catch (error) { + if (signal.aborted) return; + throw error; + } const probe: Probe = { id, status: result.status, @@ -116,8 +131,9 @@ skipIfNoKey("integration: zero data retention", () => { let discovery: Discovery | undefined; beforeAll(async () => { - chatIds = await listChatModelIds(); - discovery = await discoverZdrOutcomes(chatIds); + const signal = AbortSignal.timeout(REQUEST_BUDGET_MS); + chatIds = await listChatModelIds(signal); + discovery = await discoverZdrOutcomes(chatIds, signal); console.log( `ZDR probe: ${discovery.probes.length}/${chatIds.length} chat models probed, ` + `${discovery.capable.length} served, ${discovery.denied.length} refused`, @@ -172,7 +188,7 @@ skipIfNoKey("integration: zero data retention", () => { async () => { const sample = discovery?.denied[0]; if (!sample) throw new Error("no ZDR-refused sample to use as a control"); - const result = await postChat(sample.id, {}); + const result = await postChat(sample.id, {}, AbortSignal.timeout(REQUEST_BUDGET_MS)); if (result.status !== 200) { throw new Error( `control request for ${sample.id} returned ${result.status}: ${result.body.slice(0, 300)}`, @@ -182,39 +198,4 @@ skipIfNoKey("integration: zero data retention", () => { }, NETWORK_TIMEOUT_MS, ); - - test( - "CMD_ZDR=1 reaches a live SDK request", - async () => { - const sample = discovery?.capable[0]; - if (!sample) throw new Error("no ZDR-capable sample to drive the SDK path"); - - const previous = process.env.CMD_ZDR; - process.env.CMD_ZDR = "1"; - try { - const provider = createCommandCode({ apiKey: API_KEY! }); - const model = provider.languageModel(sample.id); - // The SDK path targets the CLI-compatible endpoint, so this asserts the - // opt-in survives a real request rather than re-checking upstream routing. - // Reasoning models may answer with reasoning-only content at a small budget. - const result = await model.doGenerate({ - prompt: [{ role: "user", content: "Reply with exactly the word: hello" }], - maxOutputTokens: 64, - }); - - expect(result.content.length).toBeGreaterThan(0); - expect(result.finishReason.unified).toMatch(/stop|length/); - } finally { - if (previous === undefined) delete process.env.CMD_ZDR; - else process.env.CMD_ZDR = previous; - } - }, - NETWORK_TIMEOUT_MS, - ); -}); - -test("integration tests are skipped when COMMANDCODE_API_KEY is not set", () => { - if (!hasApiKey) { - console.log("Skipping ZDR integration tests: COMMANDCODE_API_KEY not set"); - } }); From 08585b1fe626ce905ea880cf58d875f2c1e250e5 Mon Sep 17 00:00:00 2001 From: mohemohe Date: Mon, 19 Oct 2026 17:18:02 +0900 Subject: [PATCH 4/5] docs(zdr): document fail-closed SDK and header rules Scope ZDR to /provider/v1/*, describe case-insensitive header handling, per-field config fallback, and the SDK fail-closed behavior, and limit the Messages/Responses claim to what has been verified live. --- README.md | 24 +++++++------ docs/specs/2026-10-14-zdr-request-header.md | 40 +++++++++++++++------ 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9867b5d..1a39f58 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - The provider's `supported_endpoints` metadata chooses each model's API route. Messages is preferred when advertised; otherwise Chat Completions is preferred, and Responses is used only when it is the sole advertised route. This avoids the malformed Responses function-call start event observed on DeepSeek V4.1 Flash `#max` in OpenCode V2.0.18: `response.output_item.added` contained a `function_call` item without its required `arguments` string. That exact tool-call flow now passes through Chat Completions on V1 and V2. Responses mappings remain available for response-only models, but the current catalog has none. Claude Sonnet 4.6 returned `MODEL_NOT_IN_PLAN` with the message “available in Pro and above plans or extra on-demand usage”; entitled Claude access has not been live-verified. If a model fails on an account entitled to use it, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). - Free models show `(Free)` after their display name on V1 and V2 when all catalog prices are zero. Model IDs and routing stay unchanged; this is a name suffix, not a native desktop badge. Names that already end in `Free` or `(Free)` keep their existing label. -- Zero data retention on request: `CMD_ZDR=1` or `zdr: true` in the plugin config adds `x-cmd-zdr: 1` to every Command Code request on both OpenCode generations. +- Zero data retention on request: `CMD_ZDR=1` or `zdr: true` in the plugin config adds `x-cmd-zdr: 1` to every Command Code Provider API (`/provider/v1/*`) request on both OpenCode generations. ## How it works @@ -82,14 +82,14 @@ Catalog patches ship in new npm releases. OpenCode loads the version in its pack ## Plugin config file -Optional, `~/.config/opencode/opencode-commandcode.json` (legacy fallback name `commandcode-go-opencode-provider.json` still loads). Unknown keys are ignored; every field has a default. +Optional, `~/.config/opencode/opencode-commandcode.json` (legacy fallback name `commandcode-go-opencode-provider.json` still loads). Unknown keys are ignored; every field has a default, and an invalid field falls back on its own without discarding the valid ones (an invalid `zdr` value keeps ZDR on). | Key | Default | Effect | |---|---|---| | `commandCodePackagePath` | `""` | Maintainer override: extract the catalog from a local `command-code` checkout instead of the bundle. Same as env `COMMANDCODE_PACKAGE_PATH`. | | `debugStartupLogs` | `false` | Also mirror the startup summary to stderr. Default is quiet (`startup.json` only). | | `disableModelSync` | `false` | Accepted for forward compatibility; currently has no effect. | -| `zdr` | `false` | Send `x-cmd-zdr: 1` on every request (zero data retention). Same as env `CMD_ZDR=1`. | +| `zdr` | `false` | Send `x-cmd-zdr: 1` on every Provider API request (zero data retention). Same as env `CMD_ZDR=1`. A present but invalid value (e.g. the string `"true"`) counts as on. | ## Zero data retention (ZDR) @@ -100,18 +100,20 @@ set either: - env `CMD_ZDR=1` (accepts `1`, `true`, `yes`, `on`; case-insensitive), or - `"zdr": true` in `~/.config/opencode/opencode-commandcode.json`. -The plugin then adds the header to every Command Code request: V2 registers it on the `commandcode` provider, V1 on the -provider `options.headers`. A handler you configure yourself (`providers.commandcode.headers` on V2, -`provider.commandcode.options.headers` on V1) wins over the plugin's value, and custom npm transports keep owning their -options. +The plugin then adds the header to every Command Code Provider API (`/provider/v1/*`) request: V2 registers it on the +`commandcode` provider, V1 on the provider `options.headers`. A ZDR header you configure yourself +(`providers.commandcode.headers` on V2, `provider.commandcode.options.headers` on V1) wins over the plugin's value. +Header names are matched case-insensitively, and the plugin collapses any spelling (`X-Cmd-Zdr`) into a single lowercase +`x-cmd-zdr` key, so a request never carries two ZDR headers. Custom npm transports keep owning their options. Behavior to expect from the API: when set, requests route only through ZDR-capable upstreams, and a model without one **fails with `422 cmd_zdr_no_providers`** instead of falling back to a non-ZDR provider. ZDR can change which upstream -serves a request, so it may cost more. Anthropic models on `/v1/messages` are already zero-data-retention at the account -level; the header is accepted there for parity and does not change routing. +serves a request, so it may cost more. The header is sent on the Chat Completions, Messages, and Responses routes alike; +live verification so far covers Chat Completions only. -The programmatic SDK factory (`createCommandCode`) applies the same `CMD_ZDR` opt-in, and an explicit `headers` value -always wins over it. +The programmatic SDK factory (`createCommandCode`) calls `/alpha/generate`, which the ZDR documentation does not cover, +so it fails closed: with ZDR requested (`CMD_ZDR` on, or an `x-cmd-zdr: 1` header in any casing), +`languageModel()` throws before any request is sent. An explicit `x-cmd-zdr: 0` header opts the SDK out of `CMD_ZDR`. Live verification against the Provider API lives in `tests/integration/zdr.integration.test.ts` and is skipped unless `COMMANDCODE_API_KEY` is set: diff --git a/docs/specs/2026-10-14-zdr-request-header.md b/docs/specs/2026-10-14-zdr-request-header.md index f36f949..7c9841f 100644 --- a/docs/specs/2026-10-14-zdr-request-header.md +++ b/docs/specs/2026-10-14-zdr-request-header.md @@ -21,12 +21,23 @@ ZDR is on when either input is true: `"0"`, `"false"`, `""`, and an unset variable — leaves ZDR off; or - `~/.config/opencode/opencode-commandcode.json` sets `"zdr": true` (validated by `PluginFileConfigSchema`). +`PluginFileConfigSchema` falls back per field: an invalid sibling (`"debugStartupLogs": "false"`) resets only itself and +never discards a valid `zdr: true`. `zdr` itself fails safe: a missing key is off, but a present invalid value (the +string `"true"`, `1`, `null`) is on. A mistaken opt-in at worst surfaces as `422 cmd_zdr_no_providers`; a silently +dropped one would send prompts to non-ZDR upstreams. + The config flag adds a durable per-machine option; the env var keeps parity with the CLI. No CLI installation is required for either. +### Scope: Provider API only + +The ZDR documentation covers the Provider API (`/provider/v1/*`) only. Both plugin generations route every model there: +the default OpenAI-compatible package, and the Anthropic (Messages) and OpenAI (Responses) packages a model may select, +all inherit the provider's `/provider/v1` base URL. The SDK factory is the one surface on another endpoint. + ### Header injection -The header value is always `1` and the key is always `x-cmd-zdr`. +The header value is always `1` and the key is always the lowercase `x-cmd-zdr`. - **V1** (`plugin.ts` `server()`): the header is written into `provider.commandcode.options.headers` next to the existing default `baseURL`. OpenCode V1 merges provider `options` into every model of that provider, whichever @@ -36,13 +47,18 @@ The header value is always `1` and the key is always `x-cmd-zdr`. - **V2** (`plugin.ts` `setup()`): the header is written into the registered provider's `headers` on the add path and merged into the existing provider on the update path. Provider headers apply to every model of the provider, including models that select the Anthropic or Responses runtime package. -- **SDK factory** (`index.ts createCommandCode`): `CMD_ZDR` fills in the ZDR header when the caller's `headers` do not - define it. +- **SDK factory** (`index.ts createCommandCode`): not injected. The factory calls `/alpha/generate`, where ZDR + enforcement is unestablished, so it fails closed instead: when ZDR is requested, `languageModel()` throws before any + request is sent. A caller ZDR header (any casing) decides, so `x-cmd-zdr: 1` throws and `x-cmd-zdr: 0` opts out of + `CMD_ZDR`; without one, `CMD_ZDR` decides. The throw lives in `languageModel()`, not the factory, because + `dist/plugin.js` also exports the factory to OpenCode's plugin loader. ### User override precedence An existing value always wins: a user who sets `headers["x-cmd-zdr"] = "0"` keeps it, and other headers in the same map -survive untouched. When ZDR is off the plugin never creates a `headers` key, so configurations without the opt-in are +survive untouched. Header names match case-insensitively: every spelling (`X-Cmd-Zdr`, `X-CMD-ZDR`) collapses into the +single lowercase key carrying the user's value (the lowercase spelling wins if several exist), because two spellings +would reach the wire as `x-cmd-zdr: 1, 0`. When ZDR is off the plugin never creates a `headers` key, so configurations without the opt-in are byte-identical to the previous behavior. ## Out of scope @@ -72,12 +88,16 @@ below therefore treats the API as the authority and discovers both outcomes dyna ## Verification -- `tests/unit/zdr.test.ts` covers env parsing, config/env precedence, and header merging. -- `tests/unit/plugin.test.ts` covers V1 injection, user-header precedence, the off state, and the custom-npm skip. -- `tests/unit/plugin-v2.test.ts` covers the V2 add and update paths. -- `tests/unit/index.test.ts` covers `CMD_ZDR` in the SDK factory and the explicit-header win. +- `tests/unit/zdr.test.ts` covers env parsing, config/env precedence, and case-insensitive header merging. +- `tests/unit/schemas.test.ts` covers per-field config fallback and the fail-safe invalid `zdr` value. +- `tests/unit/plugin.test.ts` covers V1 injection, user-header precedence (mixed case included), the config file with an + invalid sibling field or an invalid `zdr` value, the off state, and the custom-npm skip. +- `tests/unit/plugin-v2.test.ts` covers the same cases on the V2 add and update paths. +- `tests/unit/index.test.ts` covers the SDK fail-closed throw (`CMD_ZDR`, explicit header in any casing) with no request + sent, the explicit `x-cmd-zdr: 0` opt-out, and the off state. - `tests/integration/zdr.integration.test.ts` live-checks the Provider API with no hardcoded model ID: chat-capable IDs come from `GET /provider/v1/models`, are probed with the plugin's own ZDR header map until both a `200` and a `422 cmd_zdr_no_providers` are observed, and a no-header control on a refused model proves the header causes the - refusal. A `400`/`401` among the probes fails the suite. It is skipped unless `COMMANDCODE_API_KEY` is set and stays - out of `bun run check`. + refusal. A `400`/`401` among the probes fails the suite. Every request carries an `AbortSignal` with a deadline inside + the test timeout, so a timed-out run cancels in-flight probes and starts no new ones. It is skipped unless + `COMMANDCODE_API_KEY` is set and stays out of `bun run check`. From 399f58074e6829ca88624f5ac1dce71961fc4829 Mon Sep 17 00:00:00 2001 From: mohemohe Date: Mon, 19 Oct 2026 17:20:52 +0900 Subject: [PATCH 5/5] docs(zdr): correct the SDK guard rationale OpenCode's plugin loader calls only default.server for this package, so it never invokes createCommandCode. The guard lives in languageModel() because every caller, a direct import or the create* lookup for a provider npm set to this package, resolves a model there before it can send a request. --- docs/specs/2026-10-14-zdr-request-header.md | 5 +++-- index.ts | 5 +++-- tests/unit/index.test.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/specs/2026-10-14-zdr-request-header.md b/docs/specs/2026-10-14-zdr-request-header.md index 7c9841f..4b2ab81 100644 --- a/docs/specs/2026-10-14-zdr-request-header.md +++ b/docs/specs/2026-10-14-zdr-request-header.md @@ -50,8 +50,9 @@ The header value is always `1` and the key is always the lowercase `x-cmd-zdr`. - **SDK factory** (`index.ts createCommandCode`): not injected. The factory calls `/alpha/generate`, where ZDR enforcement is unestablished, so it fails closed instead: when ZDR is requested, `languageModel()` throws before any request is sent. A caller ZDR header (any casing) decides, so `x-cmd-zdr: 1` throws and `x-cmd-zdr: 0` opts out of - `CMD_ZDR`; without one, `CMD_ZDR` decides. The throw lives in `languageModel()`, not the factory, because - `dist/plugin.js` also exports the factory to OpenCode's plugin loader. + `CMD_ZDR`; without one, `CMD_ZDR` decides. Every caller resolves a model through `languageModel()` before it can + send a request — a direct import, or OpenCode's `create*` lookup when a provider `npm` points at this package — so + the guard there covers both. ### User override precedence diff --git a/index.ts b/index.ts index aa37e75..1e533f9 100644 --- a/index.ts +++ b/index.ts @@ -39,8 +39,9 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) { return { languageModel(modelId: string): CommandCodeLanguageModel { // Fail closed before any request: sending without enforced ZDR would leak the - // prompt the caller asked to keep out of retention. Thrown here rather than in - // the factory, which dist/plugin.js also exports to OpenCode's plugin loader. + // prompt the caller asked to keep out of retention. Every caller (direct import, + // or OpenCode's `create*` lookup on a provider `npm` set to this package) + // resolves a model here before it can send one. if (zdrRequested(headers)) { throw new Error( "Zero data retention is only supported on the Command Code Provider API (/provider/v1/*); createCommandCode uses /alpha/generate. Unset CMD_ZDR, or use the OpenCode provider plugin.", diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 6432970..6acfcba 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -102,7 +102,7 @@ test("CMD_ZDR=1 fails closed in the SDK without sending a request", async () => await withCmdZdr("1", () => { const { calls, restore } = mockFetchTrack(); try { - // The factory itself still succeeds: dist/plugin.js exports it to OpenCode's loader. + // The guard sits on model resolution, so the factory itself still succeeds. const provider = createCommandCode({ apiKey: "sk-test" }); expect(() => provider.languageModel("m")).toThrow("/provider/v1/*"); expect(calls).toHaveLength(0);