diff --git a/.gitattributes b/.gitattributes index e1f894bd3..f21efa3b5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,8 @@ *.mjs -linguist-detectable *.sh -linguist-detectable *.html -linguist-detectable + +# The generated schema is byte-compared against a fresh render in +# test/config-schema-generated.test.ts; pin LF so Windows autocrlf +# checkouts do not produce spurious drift failures. +config/schema/config.schema.json text eol=lf diff --git a/config/schema/config.schema.json b/config/schema/config.schema.json index b10a2fb72..6cd1bb159 100644 --- a/config/schema/config.schema.json +++ b/config/schema/config.schema.json @@ -2,11 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://codex-multi-auth.local/schema/config.schema.json", "title": "codex-multi-auth config template", + "description": "GENERATED FILE — do not edit by hand. Regenerate with `npm run generate:schema` (source: lib/config-schema.ts + PluginConfigSchema in lib/schemas.ts).", "type": "object", "properties": { "plugin": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "provider": { "type": "object", @@ -14,8 +17,252 @@ }, "model": { "type": "string" + }, + "pluginConfig": { + "$ref": "#/$defs/pluginConfig" } }, - "required": ["plugin", "provider"], - "additionalProperties": true -} \ No newline at end of file + "required": [ + "plugin", + "provider" + ], + "additionalProperties": true, + "$defs": { + "pluginConfig": { + "type": "object", + "properties": { + "codexMode": { + "type": "boolean" + }, + "codexRuntimeRotationProxy": { + "type": "boolean" + }, + "codexTuiV2": { + "type": "boolean" + }, + "codexTuiColorProfile": { + "type": "string", + "enum": [ + "truecolor", + "ansi16", + "ansi256" + ] + }, + "codexTuiGlyphMode": { + "type": "string", + "enum": [ + "ascii", + "unicode", + "auto" + ] + }, + "fastSession": { + "type": "boolean" + }, + "fastSessionStrategy": { + "type": "string", + "enum": [ + "hybrid", + "always" + ] + }, + "fastSessionMaxInputItems": { + "type": "number", + "minimum": 8, + "maximum": 200 + }, + "retryAllAccountsRateLimited": { + "type": "boolean" + }, + "retryAllAccountsMaxWaitMs": { + "type": "number", + "minimum": 0 + }, + "retryAllAccountsMaxRetries": { + "type": "number", + "minimum": 0 + }, + "unsupportedCodexPolicy": { + "type": "string", + "enum": [ + "strict", + "fallback" + ] + }, + "fallbackOnUnsupportedCodexModel": { + "type": "boolean" + }, + "fallbackToGpt52OnUnsupportedGpt53": { + "type": "boolean" + }, + "unsupportedCodexFallbackChain": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "tokenRefreshSkewMs": { + "type": "number", + "minimum": 0 + }, + "rateLimitToastDebounceMs": { + "type": "number", + "minimum": 0 + }, + "toastDurationMs": { + "type": "number", + "minimum": 1000 + }, + "perProjectAccounts": { + "type": "boolean" + }, + "sessionRecovery": { + "type": "boolean" + }, + "autoResume": { + "type": "boolean" + }, + "parallelProbing": { + "type": "boolean" + }, + "parallelProbingMaxConcurrency": { + "type": "number", + "minimum": 1, + "maximum": 5 + }, + "emptyResponseMaxRetries": { + "type": "number", + "minimum": 0 + }, + "emptyResponseRetryDelayMs": { + "type": "number", + "minimum": 0 + }, + "rateLimitDedupWindowMs": { + "type": "number", + "minimum": 0 + }, + "rateLimitStateResetMs": { + "type": "number", + "minimum": 1000 + }, + "rateLimitMaxBackoffMs": { + "type": "number", + "minimum": 1000 + }, + "rateLimitShortRetryThresholdMs": { + "type": "number", + "minimum": 0 + }, + "pidOffsetEnabled": { + "type": "boolean" + }, + "fetchTimeoutMs": { + "type": "number", + "minimum": 1000 + }, + "streamStallTimeoutMs": { + "type": "number", + "minimum": 1000 + }, + "liveAccountSync": { + "type": "boolean" + }, + "liveAccountSyncDebounceMs": { + "type": "number", + "minimum": 50 + }, + "liveAccountSyncPollMs": { + "type": "number", + "minimum": 500 + }, + "sessionAffinity": { + "type": "boolean" + }, + "sessionAffinityTtlMs": { + "type": "number", + "minimum": 1000 + }, + "sessionAffinityMaxEntries": { + "type": "number", + "minimum": 8 + }, + "responseContinuation": { + "type": "boolean" + }, + "backgroundResponses": { + "type": "boolean" + }, + "proactiveRefreshGuardian": { + "type": "boolean" + }, + "proactiveRefreshIntervalMs": { + "type": "number", + "minimum": 5000 + }, + "proactiveRefreshBufferMs": { + "type": "number", + "minimum": 30000 + }, + "networkErrorCooldownMs": { + "type": "number", + "minimum": 0 + }, + "serverErrorCooldownMs": { + "type": "number", + "minimum": 0 + }, + "tokenInvalidationCooldownMs": { + "type": "number", + "minimum": 0 + }, + "minRotationIntervalMs": { + "type": "number", + "minimum": 0 + }, + "storageBackupEnabled": { + "type": "boolean" + }, + "preemptiveQuotaEnabled": { + "type": "boolean" + }, + "preemptiveQuotaRemainingPercent5h": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "preemptiveQuotaRemainingPercent7d": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "preemptiveQuotaMaxDeferralMs": { + "type": "number", + "minimum": 1000 + }, + "routingMutex": { + "type": "string", + "enum": [ + "enabled", + "legacy" + ] + }, + "schedulingStrategy": { + "type": "string", + "enum": [ + "hybrid", + "sequential" + ] + } + }, + "description": "Runtime plugin configuration (the `pluginConfig` section of unified settings.json, also accepted flat in CODEX_MULTI_AUTH_CONFIG_PATH overrides). Generated from PluginConfigSchema in lib/schemas.ts — do not edit by hand; run `npm run generate:schema`." + } + } +} diff --git a/lib/config-schema.ts b/lib/config-schema.ts new file mode 100644 index 000000000..ac1229de7 --- /dev/null +++ b/lib/config-schema.ts @@ -0,0 +1,105 @@ +/** + * Single source of truth for `config/schema/config.schema.json`. + * + * The JSON schema shipped under `config/schema/` is GENERATED from the zod + * `PluginConfigSchema` in `lib/schemas.ts` so it cannot drift from the real + * runtime config surface (audit roadmap §4.5.2). + * + * - Regenerate the committed file with `npm run generate:schema` + * (see `scripts/generate-config-schema.mjs`). + * - Drift is a test failure: `test/config-schema-generated.test.ts` + * regenerates the schema in-memory and compares it to the committed file. + * + * @internal Generator/tooling module: deliberately NOT re-exported from + * lib/index.ts. Its only consumers are scripts/generate-config-schema.mjs + * (via the compiled dist output) and the drift-guard test. + */ +import { z } from "zod"; +import { PluginConfigSchema } from "./schemas.js"; + +/** Repo-relative path of the committed, generated schema file. */ +export const CONFIG_SCHEMA_RELATIVE_PATH = "config/schema/config.schema.json"; + +type JsonObject = Record; + +/** + * Build the full JSON schema document for `config/schema/config.schema.json`. + * + * Structure: + * - Root keeps the original template metadata (`$schema` draft 2020-12, `$id`, + * `title`) and the template root keys (`plugin`, `provider`, `model`) that + * the shipped `config/*.json` templates reference via `$schema`. + * - The complete runtime plugin configuration surface is generated from + * `PluginConfigSchema` via zod v4's native `z.toJSONSchema()` and embedded + * as `$defs.pluginConfig`, referenced by the optional root `pluginConfig` + * property (the shape persisted in unified `settings.json`). + * + * `io: "input"` matches runtime semantics: `loadPluginConfig` tolerates and + * strips unknown keys instead of rejecting them, so the generated definition + * must not emit `additionalProperties: false`. + * + * Key order is the zod shape definition order, which is stable across runs, + * so output is deterministic. + */ +export function buildConfigJsonSchema(): JsonObject { + const pluginConfigRaw: unknown = z.toJSONSchema(PluginConfigSchema, { + target: "draft-2020-12", + io: "input", + }); + // Guard before mutating: a zod behavior change returning a non-object + // here should fail loudly, not via a confusing delete/??= throw below. + if ( + !pluginConfigRaw || + typeof pluginConfigRaw !== "object" || + Array.isArray(pluginConfigRaw) + ) { + throw new Error("z.toJSONSchema(PluginConfigSchema) returned a non-object"); + } + const pluginConfig = pluginConfigRaw as JsonObject; + // The embedded definition inherits the root document's dialect; a nested + // `$schema` keyword would be redundant noise. + delete pluginConfig.$schema; + // ??= so a future .describe() on PluginConfigSchema wins over this default. + pluginConfig.description ??= + "Runtime plugin configuration (the `pluginConfig` section of unified settings.json, also accepted flat in CODEX_MULTI_AUTH_CONFIG_PATH overrides). Generated from PluginConfigSchema in lib/schemas.ts — do not edit by hand; run `npm run generate:schema`."; + + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://codex-multi-auth.local/schema/config.schema.json", + title: "codex-multi-auth config template", + description: + "GENERATED FILE — do not edit by hand. Regenerate with `npm run generate:schema` (source: lib/config-schema.ts + PluginConfigSchema in lib/schemas.ts).", + type: "object", + properties: { + plugin: { + type: "array", + items: { type: "string" }, + }, + provider: { + type: "object", + additionalProperties: true, + }, + model: { + type: "string", + }, + pluginConfig: { + $ref: "#/$defs/pluginConfig", + }, + }, + required: ["plugin", "provider"], + additionalProperties: true, + $defs: { + pluginConfig, + }, + }; +} + +/** + * Serialize the generated schema exactly as it is committed on disk: + * 2-space indent (matching the previous handwritten file) plus a trailing + * newline. `JSON.stringify` preserves insertion order, so the output is + * byte-for-byte deterministic. + */ +export function renderConfigJsonSchema(): string { + return `${JSON.stringify(buildConfigJsonSchema(), null, 2)}\n`; +} diff --git a/package.json b/package.json index d046a083f..84570d1c8 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ }, "scripts": { "build": "tsc && node scripts/copy-oauth-success.js", + "generate:schema": "npm run build && node scripts/generate-config-schema.mjs", "typecheck": "tsc --noEmit", "typecheck:scripts": "tsc -p tsconfig.scripts.json", "lint": "npm run lint:ts && npm run lint:scripts", diff --git a/scripts/generate-config-schema.mjs b/scripts/generate-config-schema.mjs new file mode 100644 index 000000000..7a79a88b0 --- /dev/null +++ b/scripts/generate-config-schema.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Regenerate config/schema/config.schema.json from the zod PluginConfigSchema + * (audit roadmap §4.5.2). The schema content lives in lib/config-schema.ts; + * this script just writes its deterministic serialization to disk. + * + * Run via `npm run generate:schema` (which builds dist/ first — this script + * imports the compiled output because lib/ is TypeScript). + * + * Drift guard: test/config-schema-generated.test.ts fails whenever the + * committed file no longer matches the generated output. + */ +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +let renderConfigJsonSchema; +try { + ({ renderConfigJsonSchema } = await import("../dist/lib/config-schema.js")); +} catch (error) { + console.error( + "Failed to import dist/lib/config-schema.js — run `npm run build` first (or use `npm run generate:schema`, which builds automatically).", + ); + throw error; +} + +const targetUrl = new URL( + "../config/schema/config.schema.json", + import.meta.url, +); +await writeFile(targetUrl, renderConfigJsonSchema(), "utf8"); +console.log(`Wrote ${fileURLToPath(targetUrl)}`); diff --git a/test/config-schema-generated.test.ts b/test/config-schema-generated.test.ts new file mode 100644 index 000000000..52ed4834f --- /dev/null +++ b/test/config-schema-generated.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + buildConfigJsonSchema, + CONFIG_SCHEMA_RELATIVE_PATH, + renderConfigJsonSchema, +} from "../lib/config-schema.js"; +import { PluginConfigSchema } from "../lib/schemas.js"; + +const committedPath = path.join(process.cwd(), CONFIG_SCHEMA_RELATIVE_PATH); + +/** + * Drift guard for audit roadmap §4.5.2: config/schema/config.schema.json is + * generated from the zod PluginConfigSchema. If PluginConfigSchema (or the + * generator) changes without regenerating the committed file, these tests + * fail. Fix: run `npm run generate:schema` and commit the result. + */ +describe("config.schema.json is generated from PluginConfigSchema", () => { + let committedRaw = ""; + + beforeAll(() => { + // Read inside beforeAll so a missing/locked file surfaces as a named + // test failure with remediation, not an ENOENT at collection time. + try { + // Normalize CRLF as belt-and-braces for checkouts that predate the + // .gitattributes eol=lf pin; the renderer always emits LF. + committedRaw = readFileSync(committedPath, "utf8").replace( + /\r\n/g, + "\n", + ); + } catch (error) { + throw new Error( + `could not read ${CONFIG_SCHEMA_RELATIVE_PATH} — run \`npm run generate:schema\` and commit the result (${String(error)})`, + ); + } + }); + + it("committed schema deep-equals the in-memory regeneration (if this fails, run `npm run generate:schema`)", () => { + expect( + JSON.parse(committedRaw), + "config/schema/config.schema.json is out of date — run `npm run generate:schema` and commit the result", + ).toEqual(buildConfigJsonSchema()); + }); + + // This doubles as the cross-process determinism check: the committed file + // was rendered by a separate generator invocation, so byte-equality here + // proves a fresh render reproduces it exactly. + it("committed schema matches the serialized output byte-for-byte (if this fails, run `npm run generate:schema`)", () => { + expect( + committedRaw, + "config/schema/config.schema.json serialization drifted — run `npm run generate:schema` and commit the result", + ).toBe(renderConfigJsonSchema()); + }); + + it("covers every PluginConfigSchema field and preserves root metadata", () => { + const schema = buildConfigJsonSchema() as { + $schema?: string; + $id?: string; + title?: string; + $defs?: { + pluginConfig?: { properties?: Record }; + }; + }; + + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.$id).toBe( + "https://codex-multi-auth.local/schema/config.schema.json", + ); + expect(schema.title).toBe("codex-multi-auth config template"); + + // Structural invariants of the document (cheap stand-in for a full + // metaschema validation, which would require a new validator dep). + const doc = schema as unknown as { + type?: unknown; + properties?: unknown; + required?: unknown; + }; + expect(doc.type).toBe("object"); + expect(doc.properties).toBeTypeOf("object"); + expect(doc.required).toEqual(["plugin", "provider"]); + expect(schema.$defs?.pluginConfig?.properties).toBeTypeOf("object"); + + const generatedKeys = Object.keys( + schema.$defs?.pluginConfig?.properties ?? {}, + ).sort(); + const zodKeys = Object.keys(PluginConfigSchema.shape).sort(); + expect(generatedKeys).toEqual(zodKeys); + expect(zodKeys.length).toBeGreaterThan(0); + }); +});