From 2a93532f167720d0b54271b9742eaaac9f59ee52 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:39:39 +0200 Subject: [PATCH 01/94] feat(lab): CL-10 public evidence trust core Rebased onto current dev with the reviewed public evidence trust core, consumer durability recovery, sparse-array JCS hardening, and required Windows publisher-key ACL hardening. --- src/lab/conformance/jcs.ts | 40 +- src/lab/paths.ts | 23 ++ src/lab/public/bundle.ts | 207 ++++++++++ src/lab/public/community-authority.ts | 133 ++++++ src/lab/public/file-safety.ts | 155 +++++++ src/lab/public/ids.ts | 26 ++ src/lab/public/privacy.ts | 139 +++++++ src/lab/public/private-file.ts | 214 ++++++++++ src/lab/public/project.ts | 122 ++++++ src/lab/public/registry.ts | 43 ++ src/lab/public/signature.ts | 205 +++++++++ src/lab/public/storage.ts | 105 +++++ src/lab/public/strict-json.ts | 198 +++++++++ src/lab/public/time.ts | 19 + src/lab/public/types.ts | 171 ++++++++ src/lab/public/validate.ts | 390 ++++++++++++++++++ ...lab-private-file-consumer-recovery.test.ts | 70 ++++ tests/lab-private-file-durability.test.ts | 70 ++++ tests/lab-public-core-contract.test.ts | 162 ++++++++ tests/lab-public-file-safety.test.ts | 36 ++ tests/lab-public-security-regressions.test.ts | 52 +++ 21 files changed, 2578 insertions(+), 2 deletions(-) create mode 100644 src/lab/public/bundle.ts create mode 100644 src/lab/public/community-authority.ts create mode 100644 src/lab/public/file-safety.ts create mode 100644 src/lab/public/ids.ts create mode 100644 src/lab/public/privacy.ts create mode 100644 src/lab/public/private-file.ts create mode 100644 src/lab/public/project.ts create mode 100644 src/lab/public/registry.ts create mode 100644 src/lab/public/signature.ts create mode 100644 src/lab/public/storage.ts create mode 100644 src/lab/public/strict-json.ts create mode 100644 src/lab/public/time.ts create mode 100644 src/lab/public/types.ts create mode 100644 src/lab/public/validate.ts create mode 100644 tests/lab-private-file-consumer-recovery.test.ts create mode 100644 tests/lab-private-file-durability.test.ts create mode 100644 tests/lab-public-core-contract.test.ts create mode 100644 tests/lab-public-file-safety.test.ts create mode 100644 tests/lab-public-security-regressions.test.ts diff --git a/src/lab/conformance/jcs.ts b/src/lab/conformance/jcs.ts index 6bbcb923c..6ef064ee0 100644 --- a/src/lab/conformance/jcs.ts +++ b/src/lab/conformance/jcs.ts @@ -1,5 +1,40 @@ /** RFC 8785 JSON Canonicalization Scheme (JCS) for deterministic equality. */ +function assertValidUnicodeScalarString(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new TypeError("jcsStringify: lone UTF-16 surrogate is not valid Unicode"); + } + index += 1; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) { + throw new TypeError("jcsStringify: lone UTF-16 surrogate is not valid Unicode"); + } + } +} + +function stringifyJcsString(value: string): string { + assertValidUnicodeScalarString(value); + return JSON.stringify(value); +} + +function assertDenseJsonArray(value: readonly unknown[]): void { + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + throw new TypeError("jcsStringify: sparse arrays / array holes are not representable in JCS"); + } + } + for (const key of Object.keys(value)) { + if (!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= value.length) { + throw new TypeError("jcsStringify: arrays with extra enumerable properties are not representable in JCS"); + } + } +} + export function jcsStringify(value: unknown): string { if (value === undefined) throw new TypeError("jcsStringify: undefined is not representable in JCS"); if (value === null || typeof value === "boolean") return JSON.stringify(value); @@ -7,14 +42,15 @@ export function jcsStringify(value: unknown): string { if (!Number.isFinite(value)) throw new TypeError("jcsStringify: non-finite numbers are not representable in JCS"); return JSON.stringify(value); } - if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "string") return stringifyJcsString(value); if (Array.isArray(value)) { + assertDenseJsonArray(value); return `[${value.map(jcsStringify).join(",")}]`; } if (typeof value === "object") { const obj = value as Record; const keys = Object.keys(obj).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${jcsStringify(obj[k])}`).join(",")}}`; + return `{${keys.map((key) => `${stringifyJcsString(key)}:${jcsStringify(obj[key])}`).join(",")}}`; } throw new TypeError(`jcsStringify: unsupported value type ${typeof value}`); } diff --git a/src/lab/paths.ts b/src/lab/paths.ts index f202f4121..fa39148a9 100644 --- a/src/lab/paths.ts +++ b/src/lab/paths.ts @@ -81,10 +81,25 @@ export function labScratchDir(configDir = getConfigDir()): string { return join(labRoot(configDir), "scratch"); } +/** Shared Lab export directory. Public evidence bundles intentionally live here too. */ export function labExportDir(configDir = getConfigDir()): string { return join(labRoot(configDir), "export"); } +export function labCommunityDir(configDir = getConfigDir()): string { + return join(labRoot(configDir), "community"); +} + +export function labPublicOriginDir(configDir = getConfigDir()): string { + return join(labRoot(configDir), "public-origin-v1"); +} + +export const LAB_PUBLIC_PUBLISHER_KEY_FILE = "publisher-ed25519.pem"; + +export function labPublicPublisherKeyPath(configDir = getConfigDir()): string { + return join(labRoot(configDir), LAB_PUBLIC_PUBLISHER_KEY_FILE); +} + /** Opaque per-installation salt for local fingerprinting (never exported as evidence). */ export function labInstallationSaltPath(configDir = getConfigDir()): string { return join(labRoot(configDir), "installation-salt.bin"); @@ -110,15 +125,21 @@ export function ensureLabDirs(configDir = getConfigDir()): { artifactsDir: string; scratchDir: string; exportDir: string; + communityDir: string; + publicOriginDir: string; } { const root = labRoot(configDir); const artifactsDir = labArtifactsDir(configDir); const scratchDir = labScratchDir(configDir); const exportDir = labExportDir(configDir); + const communityDir = labCommunityDir(configDir); + const publicOriginDir = labPublicOriginDir(configDir); ensureRestrictedDir(root, root); ensureRestrictedDir(artifactsDir, root); ensureRestrictedDir(scratchDir, root); ensureRestrictedDir(exportDir, root); + ensureRestrictedDir(communityDir, root); + ensureRestrictedDir(publicOriginDir, root); return { root, ledgerPath: labLedgerPath(configDir), @@ -126,5 +147,7 @@ export function ensureLabDirs(configDir = getConfigDir()): { artifactsDir, scratchDir, exportDir, + communityDir, + publicOriginDir, }; } diff --git a/src/lab/public/bundle.ts b/src/lab/public/bundle.ts new file mode 100644 index 000000000..712a3c310 --- /dev/null +++ b/src/lab/public/bundle.ts @@ -0,0 +1,207 @@ +import { jcsStringify } from "../digest"; +import { publicEvidenceId } from "./ids"; +import { + PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + PUBLIC_EXPORT_POLICY_VERSION, + type PublicArtifactV1, + type PublicEvidenceBundleUnsignedV1, + type PublicEvidenceRecordV1, + type PublicPublisherV1, +} from "./types"; +import { PublicEvidenceValidationError, validatePublicEvidenceRecord } from "./validate"; + +export const MAX_PUBLIC_BUNDLE_BYTES = 2 * 1024 * 1024; +export const MAX_PUBLIC_BUNDLE_RECORDS = 256; +export const MAX_PUBLIC_BUNDLE_ARTIFACTS = 16; +export const MAX_PUBLIC_ARTIFACT_BYTES = 256 * 1024; +export const MAX_PUBLIC_ARTIFACT_BYTES_TOTAL = 1024 * 1024; + +export interface PublicEvidenceContentInput { + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; + createdDayUtc: string; +} + +export interface BuildPublicEvidenceBundleInput extends PublicEvidenceContentInput { + publisher: PublicPublisherV1; +} + +function utcDay(value: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be YYYY-MM-DD"); + } + const parsed = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) { + throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be a real UTC day"); + } + return value; +} + +function validatePublisher(publisher: PublicPublisherV1): PublicPublisherV1 { + const raw = publisher as unknown as Record; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher must be an object"); + } + const keys = Object.keys(raw); + if (keys.some((key) => !["algorithm", "keyId", "publicKey"].includes(key))) { + throw new PublicEvidenceValidationError("unknown_field", "publisher contains unknown fields"); + } + if (publisher.algorithm !== "ed25519") { + throw new PublicEvidenceValidationError("unsupported_algorithm", "publisher must use ed25519"); + } + if (!/^[0-9a-f]{64}$/.test(publisher.keyId)) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher.keyId must be sha256 hex"); + } + if (typeof publisher.publicKey !== "string" || publisher.publicKey.length === 0 || publisher.publicKey.length > 1024) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey is invalid"); + } + const publicKeyBytes = Buffer.from(publisher.publicKey, "base64"); + if (publicKeyBytes.byteLength === 0 || publicKeyBytes.toString("base64") !== publisher.publicKey) { + throw new PublicEvidenceValidationError("invalid_publisher", "publisher.publicKey must use canonical base64"); + } + const expectedKeyId = publicEvidenceId("publisher_key", { + algorithm: publisher.algorithm, + publicKey: publisher.publicKey, + }); + if (publisher.keyId !== expectedKeyId) { + throw new PublicEvidenceValidationError("publisher_key_id_mismatch", "publisher.keyId does not match public key"); + } + return { algorithm: "ed25519", keyId: publisher.keyId, publicKey: publisher.publicKey }; +} + +function validateArtifacts(artifacts: PublicArtifactV1[]): PublicArtifactV1[] { + if (!Array.isArray(artifacts) || artifacts.length > MAX_PUBLIC_BUNDLE_ARTIFACTS) { + throw new PublicEvidenceValidationError("array_too_large", `artifacts exceeds ${MAX_PUBLIC_BUNDLE_ARTIFACTS}`); + } + let aggregate = 0; + const ids = new Set(); + return artifacts.map((artifact, index) => { + const raw = artifact as unknown as Record; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] must be an object`); + } + if (Object.keys(raw).some((key) => !["artifactId", "artifactClass", "mediaType", "byteCount", "contentBase64"].includes(key))) { + throw new PublicEvidenceValidationError("unknown_field", `artifacts[${index}] contains unknown fields`); + } + if (!/^[0-9a-f]{64}$/.test(artifact.artifactId)) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactId is invalid`); + } + if (typeof artifact.artifactClass !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/.test(artifact.artifactClass)) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].artifactClass is invalid`); + } + if (typeof artifact.mediaType !== "string" || artifact.mediaType.length === 0 || artifact.mediaType.length > 256) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].mediaType is invalid`); + } + if (!Number.isInteger(artifact.byteCount) || artifact.byteCount < 0 || artifact.byteCount > MAX_PUBLIC_ARTIFACT_BYTES) { + throw new PublicEvidenceValidationError("artifact_too_large", `artifacts[${index}].byteCount is invalid`); + } + let bytes: Buffer; + try { + bytes = Buffer.from(artifact.contentBase64, "base64"); + } catch { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}].contentBase64 is invalid`); + } + if (bytes.byteLength !== artifact.byteCount || bytes.toString("base64") !== artifact.contentBase64) { + throw new PublicEvidenceValidationError("invalid_artifact", `artifacts[${index}] byte count or base64 is non-canonical`); + } + const expectedArtifactId = publicEvidenceId("artifact", { + artifactClass: artifact.artifactClass, + mediaType: artifact.mediaType, + byteCount: artifact.byteCount, + contentBase64: artifact.contentBase64, + }); + if (artifact.artifactId !== expectedArtifactId) { + throw new PublicEvidenceValidationError("artifact_id_mismatch", `artifacts[${index}].artifactId mismatch`); + } + aggregate += artifact.byteCount; + if (aggregate > MAX_PUBLIC_ARTIFACT_BYTES_TOTAL) { + throw new PublicEvidenceValidationError("artifact_aggregate_too_large", "artifact aggregate exceeds 1 MiB"); + } + if (ids.has(artifact.artifactId)) { + throw new PublicEvidenceValidationError("duplicate_id", "artifacts contains duplicate ids"); + } + ids.add(artifact.artifactId); + return { ...artifact }; + }); +} + +/** Validate all publisher-independent bundle content before any signing-key state is touched. */ +export function normalizePublicEvidenceContent(input: PublicEvidenceContentInput): PublicEvidenceContentInput { + if (!Array.isArray(input.records) || input.records.length > MAX_PUBLIC_BUNDLE_RECORDS) { + throw new PublicEvidenceValidationError("array_too_large", `records exceeds ${MAX_PUBLIC_BUNDLE_RECORDS}`); + } + const records = input.records.map(validatePublicEvidenceRecord).sort((a, b) => a.recordId.localeCompare(b.recordId)); + if (new Set(records.map((record) => record.recordId)).size !== records.length) { + throw new PublicEvidenceValidationError("duplicate_id", "records contains duplicate ids"); + } + const artifacts = validateArtifacts(input.artifacts).sort((a, b) => a.artifactId.localeCompare(b.artifactId)); + const artifactIds = new Set(artifacts.map((artifact) => artifact.artifactId)); + for (const record of records) { + for (const artifactId of record.artifactRefs ?? []) { + if (!artifactIds.has(artifactId)) { + throw new PublicEvidenceValidationError("artifact_ref_missing", `record ${record.recordId} references a missing public artifact`); + } + } + } + return { records, artifacts, createdDayUtc: utcDay(input.createdDayUtc) }; +} + +export function canonicalPublicEvidenceContent( + input: PublicEvidenceContentInput, +): { canonical: boolean; normalized: PublicEvidenceContentInput } { + const normalized = normalizePublicEvidenceContent(input); + const canonical = input.records.length === normalized.records.length + && input.artifacts.length === normalized.artifacts.length + && input.records.every((record, index) => record.recordId === normalized.records[index]!.recordId) + && input.artifacts.every((artifact, index) => artifact.artifactId === normalized.artifacts[index]!.artifactId); + return { canonical, normalized }; +} + +export function hasCanonicalPublicEvidenceOrder(input: PublicEvidenceContentInput): boolean { + return canonicalPublicEvidenceContent(input).canonical; +} + +function buildFromNormalizedContent( + normalized: PublicEvidenceContentInput, + publisherInput: PublicPublisherV1, +): PublicEvidenceBundleUnsignedV1 { + const publisher = validatePublisher(publisherInput); + const content = { + schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION, + createdDayUtc: normalized.createdDayUtc, + publisher, + records: normalized.records, + artifacts: normalized.artifacts, + }; + const bundleId = publicEvidenceId("bundle", content); + const bundleDigest = publicEvidenceId("bundle_digest", { ...content, bundleId }); + const bundle: PublicEvidenceBundleUnsignedV1 = { ...content, bundleId, bundleDigest }; + if (new TextEncoder().encode(jcsStringify(bundle)).byteLength > MAX_PUBLIC_BUNDLE_BYTES) { + throw new PublicEvidenceValidationError("bundle_too_large", "public bundle exceeds 2 MiB"); + } + return bundle; +} + +export function buildPublicEvidenceBundle(input: BuildPublicEvidenceBundleInput): PublicEvidenceBundleUnsignedV1 { + return buildFromNormalizedContent(normalizePublicEvidenceContent(input), input.publisher); +} + +export function expectedPublicBundleIdentityFromNormalized( + normalized: PublicEvidenceContentInput, + publisher: PublicPublisherV1, +): { bundleId: string; bundleDigest: string } { + const rebuilt = buildFromNormalizedContent(normalized, publisher); + return { bundleId: rebuilt.bundleId, bundleDigest: rebuilt.bundleDigest }; +} + +export function expectedPublicBundleIdentity(bundle: PublicEvidenceBundleUnsignedV1): { bundleId: string; bundleDigest: string } { + return expectedPublicBundleIdentityFromNormalized( + normalizePublicEvidenceContent({ + records: bundle.records, + artifacts: bundle.artifacts, + createdDayUtc: bundle.createdDayUtc, + }), + bundle.publisher, + ); +} diff --git a/src/lab/public/community-authority.ts b/src/lab/public/community-authority.ts new file mode 100644 index 000000000..cc9e73607 --- /dev/null +++ b/src/lab/public/community-authority.ts @@ -0,0 +1,133 @@ +import { loadCaseAuthority } from "../conformance/manifest"; +import { + FABRIC_COMPATIBILITY_VERSION, + FABRIC_SCENARIO_ID, + FABRIC_SCENARIO_VERSION, + FABRIC_SUITE_ID, + FABRIC_SUITE_VERSION, + FABRIC_TASK_CLASS_ID, + FABRIC_TASK_CLASS_VERSION, +} from "../fabric/constants"; +import { loadFabricCaseAuthority } from "../fabric/manifest"; +import { verifierManifestDigest } from "../fabric/subject"; +import { findPublicRouteRegistryEntry } from "./registry"; +import type { PublicEvidenceBundleV1, PublicEvidenceRecordV1, PublicRouteSubjectV1 } from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +let cachedCaseAuthority: ReturnType | null = null; +let cachedFabricCaseAuthority: ReturnType | null = null; +let cachedVerifierManifestDigest: string | null = null; + +function caseAuthority(): ReturnType { + cachedCaseAuthority ??= loadCaseAuthority(); + return cachedCaseAuthority; +} + +function fabricCaseAuthority(): ReturnType { + cachedFabricCaseAuthority ??= loadFabricCaseAuthority(); + return cachedFabricCaseAuthority; +} + +function reviewedVerifierManifestDigest(): string { + cachedVerifierManifestDigest ??= verifierManifestDigest(); + return cachedVerifierManifestDigest; +} + +function validateRouteAuthority(subject: PublicRouteSubjectV1): void { + const entry = findPublicRouteRegistryEntry(subject.providerId, subject.modelId); + if (!entry || !entry.adapterFamilies.includes(subject.adapterFamily)) { + throw new PublicEvidenceValidationError("public_authority", "public route is not in reviewed registry authority"); + } +} + +function validateAssertionAuthority( + record: PublicEvidenceRecordV1, + assertions: readonly { id: string; required: boolean }[], +): void { + const allowed = new Map(assertions.map((assertion) => [assertion.id, assertion.required] as const)); + if (allowed.size !== assertions.length) { + throw new PublicEvidenceValidationError("public_authority", "reviewed scenario assertion authority contains duplicates"); + } + if (record.assertions.length !== allowed.size) { + throw new PublicEvidenceValidationError( + "public_authority", + "public assertion set does not exactly match reviewed scenario authority", + ); + } + const seen = new Set(); + for (const assertion of record.assertions) { + if (seen.has(assertion.id)) { + throw new PublicEvidenceValidationError("public_authority", "public assertion set contains duplicate assertion ids"); + } + seen.add(assertion.id); + if (!allowed.has(assertion.id) || allowed.get(assertion.id) !== assertion.required) { + throw new PublicEvidenceValidationError( + "public_authority", + "public assertion id/required flag is not in reviewed scenario authority", + ); + } + } + for (const assertionId of allowed.keys()) { + if (!seen.has(assertionId)) { + throw new PublicEvidenceValidationError("public_authority", "public assertion set is missing reviewed scenario authority"); + } + } +} + +function validateTaskAuthority(record: PublicEvidenceRecordV1): void { + const fabricAuthority = fabricCaseAuthority(); + const caseRecord = fabricAuthority.cases.find((candidate) => candidate.id === FABRIC_SCENARIO_ID); + if ( + !caseRecord + || record.suiteId !== FABRIC_SUITE_ID + || record.suiteVersion !== FABRIC_SUITE_VERSION + || record.scenarioId !== FABRIC_SCENARIO_ID + || record.scenarioVersion !== FABRIC_SCENARIO_VERSION + || record.subject.subjectKind !== "task" + || record.subject.taskClassId !== FABRIC_TASK_CLASS_ID + || record.subject.taskClassVersion !== FABRIC_TASK_CLASS_VERSION + || record.subject.taskFixtureDigest !== caseRecord.fixture.digest + || record.subject.verifierManifestDigest !== reviewedVerifierManifestDigest() + || record.subject.fabricCompatibilityVersion !== FABRIC_COMPATIBILITY_VERSION + ) { + throw new PublicEvidenceValidationError("public_authority", "task scenario/verifier authority mismatch"); + } + validateAssertionAuthority(record, caseRecord.assertions); + validateRouteAuthority(record.subject.route); +} + +function validateScenarioAuthority(record: PublicEvidenceRecordV1): void { + if (record.evidenceLayer === "task_effectiveness") { + validateTaskAuthority(record); + return; + } + + const authority = caseAuthority(); + const caseRecord = authority.cases.find((candidate) => candidate.id === record.scenarioId); + if ( + !caseRecord + || caseRecord.suite !== record.suiteId + || record.scenarioVersion !== String(authority.manifestDefaults.version) + || record.suiteVersion !== String(authority.manifestDefaults.suiteVersion) + ) { + throw new PublicEvidenceValidationError("public_authority", "scenario/suite authority mismatch"); + } + validateAssertionAuthority(record, caseRecord.assertions); + + if (record.evidenceLayer === "live_route_compatibility") { + if (record.subject.subjectKind !== "route") { + throw new PublicEvidenceValidationError("public_authority", "live route subject mismatch"); + } + validateRouteAuthority(record.subject); + } +} + +/** Repository-owned authority gate used by both local signing and community imports. */ +export function validatePublicEvidenceAuthorities(records: readonly PublicEvidenceRecordV1[]): void { + for (const record of records) validateScenarioAuthority(record); +} + +export function validateCommunityEvidenceAuthorities(bundle: PublicEvidenceBundleV1): PublicEvidenceBundleV1 { + validatePublicEvidenceAuthorities(bundle.records); + return bundle; +} diff --git a/src/lab/public/file-safety.ts b/src/lab/public/file-safety.ts new file mode 100644 index 000000000..78134929c --- /dev/null +++ b/src/lab/public/file-safety.ts @@ -0,0 +1,155 @@ +import { + closeSync, + constants as fsConstants, + fstatSync, + fsyncSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + unlinkSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { isPrivateFileStageName } from "./private-file"; +import { PublicEvidenceValidationError } from "./validate"; + +const O_NOFOLLOW = (fsConstants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; + +export interface PrivateRegularFileReadOptions { + maxBytes: number; + errorCode: string; + errorMessage: string; + sizeErrorCode?: string; + sizeErrorMessage?: string; + requireMode600?: boolean; +} + +function sizeError(options: PrivateRegularFileReadOptions): PublicEvidenceValidationError { + return new PublicEvidenceValidationError( + options.sizeErrorCode ?? options.errorCode, + options.sizeErrorMessage ?? options.errorMessage, + ); +} + +/** + * Heal only the publication-specific hard link left behind when the final name was linked + * but the parent-directory durability check failed. The stage must be target-scoped and + * inode-identical to the final file; unrelated hard links remain and are rejected below. + */ +function recoverPublishedPrivateFileStage(path: string): void { + if (process.platform === "win32") return; + const finalStats = lstatSync(path); + if (finalStats.isSymbolicLink() || !finalStats.isFile() || finalStats.nlink <= 1) return; + + const dir = dirname(path); + const prefix = `.${basename(path)}.`; + const candidates: string[] = []; + for (const name of readdirSync(dir)) { + if (!name.startsWith(prefix) || !isPrivateFileStageName(name)) continue; + const stagePath = join(dir, name); + try { + const stageStats = lstatSync(stagePath); + if ( + stageStats.isFile() + && !stageStats.isSymbolicLink() + && stageStats.dev === finalStats.dev + && stageStats.ino === finalStats.ino + ) { + candidates.push(stagePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (candidates.length === 0) return; + + // The final directory entry already exists. Make that entry durable before deleting + // the recovery witness. A real fsync failure propagates and the strict read stays closed. + let dirFd: number | null = null; + try { + dirFd = openSync(dir, fsConstants.O_RDONLY); + fsyncSync(dirFd); + } finally { + if (dirFd !== null) closeSync(dirFd); + } + + for (const stagePath of candidates) { + try { + const stageStats = lstatSync(stagePath); + if ( + stageStats.isFile() + && !stageStats.isSymbolicLink() + && stageStats.dev === finalStats.dev + && stageStats.ino === finalStats.ino + ) { + unlinkSync(stagePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + +function withPrivateRegularFile( + path: string, + options: PrivateRegularFileReadOptions, + consume: (fd: number, size: number) => T, +): T { + let pathStats = lstatSync(path); + if (!pathStats.isSymbolicLink() && pathStats.isFile() && pathStats.nlink > 1) { + recoverPublishedPrivateFileStage(path); + pathStats = lstatSync(path); + } + if (pathStats.isSymbolicLink() || !pathStats.isFile() || pathStats.nlink !== 1) { + throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); + } + if (pathStats.size > options.maxBytes) throw sizeError(options); + + const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); + try { + const stats = fstatSync(fd); + if ( + !stats.isFile() + || stats.nlink !== 1 + || stats.dev !== pathStats.dev + || stats.ino !== pathStats.ino + ) { + throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); + } + if (stats.size > options.maxBytes) throw sizeError(options); + if (options.requireMode600 && process.platform !== "win32" && (stats.mode & 0o777) !== 0o600) { + throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); + } + return consume(fd, stats.size); + } finally { + closeSync(fd); + } +} + +/** + * Inspect a file only after proving that the pathname and checked descriptor refer to + * the same private regular file. This keeps quota scans descriptor-bound without + * reading every cached object into memory. + */ +export function privateRegularFileSize( + path: string, + options: PrivateRegularFileReadOptions, +): number { + return withPrivateRegularFile(path, options, (_fd, size) => size); +} + +/** + * Read bytes only after proving that the pathname and the consumed descriptor refer to + * the same private regular file. The lstat/dev+ino comparison keeps the protection on + * platforms where O_NOFOLLOW is unavailable instead of silently following a symlink. + */ +export function readPrivateRegularFile( + path: string, + options: PrivateRegularFileReadOptions, +): Buffer { + return withPrivateRegularFile(path, options, (fd) => { + const bytes = readFileSync(fd); + if (bytes.byteLength > options.maxBytes) throw sizeError(options); + return bytes; + }); +} diff --git a/src/lab/public/ids.ts b/src/lab/public/ids.ts new file mode 100644 index 000000000..ff7843754 --- /dev/null +++ b/src/lab/public/ids.ts @@ -0,0 +1,26 @@ +import { domainHash, jcsStringify } from "../digest"; + +export type PublicEvidenceIdKind = + | "subject" + | "record" + | "bundle" + | "bundle_digest" + | "artifact" + | "publisher_key" + | "revocation" + | "route_registry"; + +const PUBLIC_EVIDENCE_DOMAIN: Record = { + subject: "ocx-lab-public:subject:v1", + record: "ocx-lab-public:record:v1", + bundle: "ocx-lab-public:bundle:v1", + bundle_digest: "ocx-lab-public:bundle-digest:v1", + artifact: "ocx-lab-public:artifact:v1", + publisher_key: "ocx-lab-public:publisher-key:v1", + revocation: "ocx-lab-public:revocation:v1", + route_registry: "ocx-lab-public:route-registry:v1", +}; + +export function publicEvidenceId(kind: PublicEvidenceIdKind, payload: unknown): string { + return domainHash(PUBLIC_EVIDENCE_DOMAIN[kind], jcsStringify(payload)); +} diff --git a/src/lab/public/privacy.ts b/src/lab/public/privacy.ts new file mode 100644 index 000000000..7c963cf79 --- /dev/null +++ b/src/lab/public/privacy.ts @@ -0,0 +1,139 @@ +import { isIP } from "node:net"; +import type { + PublicArtifactV1, + PublicEvidenceBundleUnsignedV1, + PublicEvidenceBundleV1, + PublicEvidenceRecordV1, + PublicEvidenceSubjectV1, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const FORBIDDEN_PUBLIC_STRING_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [ + { label: "URL", pattern: /(?:https?|file):\/\//i }, + { label: "local path", pattern: /(?:[A-Za-z]:[\\/]|(?:^|[\\/])(?:Users|home)[\\/])/i }, + { label: "email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i }, + { label: "IP address", pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b|\[[0-9a-f:]{2,}\]/i }, + { label: "query string", pattern: /[?&][A-Za-z0-9_.~-]+=/ }, + { label: "authorization/header material", pattern: /\b(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|bearer)\b/i }, + { label: "credential", pattern: /\b(?:sk-[A-Za-z0-9_-]{8,}|gh[opusr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|AKIA[0-9A-Z]{12,})\b/ }, + { label: "private key", pattern: /-----BEGIN [^-]*PRIVATE KEY-----/i }, + { label: "local request/decision/Fabric id", pattern: /\b(?:request|decision|fabric)_[A-Za-z0-9_-]{6,}\b/i }, + { label: "account/project/tenant context", pattern: /\b(?:account|tenant|project|organization|deployment)[=:][^\s]+/i }, + { label: "precise timestamp", pattern: /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/ }, +]; + +const PUBLIC_TEXT_ARTIFACT_MEDIA_TYPES = new Set([ + "application/json", + "application/json; charset=utf-8", + "text/markdown", + "text/markdown; charset=utf-8", + "text/plain", + "text/plain; charset=utf-8", +]); + +function containsIpLiteral(value: string): boolean { + if (isIP(value) !== 0) return true; + for (const candidate of value.match(/[0-9A-Fa-f:]{2,}/g) ?? []) { + if (candidate.includes(":") && isIP(candidate) === 6) return true; + } + return false; +} + +function assertPrivacySafeString(value: string, field: string): void { + // `PUBLIC_IDENTIFIER` intentionally permits `:` for reviewed identifiers, so use + // Node's IP parser to validate colon-bearing candidates instead of rejecting them + // with a broad regex. This catches both whole-string and embedded IPv6 literals. + if (containsIpLiteral(value)) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field} contains forbidden IP address material`, + ); + } + for (const { label, pattern } of FORBIDDEN_PUBLIC_STRING_PATTERNS) { + if (pattern.test(value)) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field} contains forbidden ${label} material`, + ); + } + } +} + +function scanSubject(subject: PublicEvidenceSubjectV1, field: string): void { + if (subject.subjectKind === "protocol") { + assertPrivacySafeString(subject.compatibilityVersion, `${field}.compatibilityVersion`); + assertPrivacySafeString(subject.adapterFamily, `${field}.adapterFamily`); + assertPrivacySafeString(subject.inboundProtocol, `${field}.inboundProtocol`); + assertPrivacySafeString(subject.upstreamProtocol, `${field}.upstreamProtocol`); + assertPrivacySafeString(subject.surface, `${field}.surface`); + return; + } + if (subject.subjectKind === "route") { + assertPrivacySafeString(subject.providerId, `${field}.providerId`); + assertPrivacySafeString(subject.modelId, `${field}.modelId`); + assertPrivacySafeString(subject.adapterFamily, `${field}.adapterFamily`); + assertPrivacySafeString(subject.compatibilityVersion, `${field}.compatibilityVersion`); + return; + } + scanSubject(subject.route, `${field}.route`); + assertPrivacySafeString(subject.taskClassId, `${field}.taskClassId`); + assertPrivacySafeString(subject.taskClassVersion, `${field}.taskClassVersion`); + assertPrivacySafeString(subject.fabricCompatibilityVersion, `${field}.fabricCompatibilityVersion`); +} + +function scanArtifact(artifact: PublicArtifactV1, index: number): void { + const field = `bundle.artifacts[${index}]`; + assertPrivacySafeString(artifact.artifactClass, `${field}.artifactClass`); + assertPrivacySafeString(artifact.mediaType, `${field}.mediaType`); + + if (!PUBLIC_TEXT_ARTIFACT_MEDIA_TYPES.has(artifact.mediaType.toLowerCase())) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field}.mediaType is not in the closed public text-artifact set`, + ); + } + if (typeof artifact.contentBase64 !== "string") { + throw new PublicEvidenceValidationError("privacy_rejected", `${field}.contentBase64 is invalid`); + } + const bytes = Buffer.from(artifact.contentBase64, "base64"); + if (bytes.toString("base64") !== artifact.contentBase64 || bytes.byteLength !== artifact.byteCount) { + throw new PublicEvidenceValidationError( + "privacy_rejected", + `${field}.contentBase64 is non-canonical or does not match byteCount`, + ); + } + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new PublicEvidenceValidationError("privacy_rejected", `${field} is not valid UTF-8 text`); + } + assertPrivacySafeString(text, `${field}.content`); +} + +export function validatePublicEvidenceRecordPrivacy(record: PublicEvidenceRecordV1): void { + assertPrivacySafeString(record.suiteId, "record.suiteId"); + assertPrivacySafeString(record.suiteVersion, "record.suiteVersion"); + assertPrivacySafeString(record.scenarioId, "record.scenarioId"); + assertPrivacySafeString(record.scenarioVersion, "record.scenarioVersion"); + scanSubject(record.subject, "record.subject"); + for (const [index, assertion] of record.assertions.entries()) { + assertPrivacySafeString(assertion.id, `record.assertions[${index}].id`); + } + for (const [index, incident] of (record.incidentRefs ?? []).entries()) { + assertPrivacySafeString(incident.corpusId, `record.incidentRefs[${index}].corpusId`); + } +} + +/** + * Second-pass CL-10 export privacy boundary. Hashes, signatures and publisher public-key + * bytes are intentionally not pattern-scanned; every human-semantic public string and + * every final text artifact byte is scanned before local signing/storage or import. + */ +export function validatePublicEvidencePrivacy( + bundle: PublicEvidenceBundleUnsignedV1 | PublicEvidenceBundleV1, +): void { + assertPrivacySafeString(bundle.createdDayUtc, "bundle.createdDayUtc"); + for (const record of bundle.records) validatePublicEvidenceRecordPrivacy(record); + for (const [index, artifact] of bundle.artifacts.entries()) scanArtifact(artifact, index); +} diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts new file mode 100644 index 000000000..190f9a497 --- /dev/null +++ b/src/lab/public/private-file.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fsyncSync, + linkSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +export type PrivateFileCommitFault = "before_publish" | "parent_directory_sync" | null; +let privateFileCommitFaultForTests: PrivateFileCommitFault = null; +const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; + +function cleanup(path: string): void { + try { unlinkSync(path); } catch { /* absent/already removed */ } +} + +function pidDefinitelyDead(pid: number): boolean { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +function staleTempPrefix(finalPath: string): string { + return `.${basename(finalPath)}.`; +} + +function fsyncParentBestEffort(path: string): void { + if (process.platform === "win32") return; + let fd: number | null = null; + try { + fd = openSync(dirname(path), fsConstants.O_RDONLY); + fsyncSync(fd); + } catch { + // Cleanup durability is best-effort. Publication durability uses the strict + // fsyncParentForPublication path below and never swallows POSIX failures. + } finally { + if (fd !== null) closeSync(fd); + } +} + +function fsyncParentForPublication(path: string): void { + // Node does not provide a portable directory-fsync contract on Windows. The + // exclusive hard-link publication remains atomic there, while POSIX requires + // the parent directory sync before publication is reported as durable. + if (process.platform === "win32") return; + if (privateFileCommitFaultForTests === "parent_directory_sync") { + throw new Error("synthetic private-file parent directory sync failure"); + } + let fd: number | null = null; + try { + fd = openSync(dirname(path), fsConstants.O_RDONLY); + fsyncSync(fd); + } catch (error) { + if (error instanceof Error && error.message.includes("synthetic private-file")) throw error; + const code = (error as NodeJS.ErrnoException).code ?? "unknown"; + const wrapped = new Error(`private-file parent directory sync failed (${code})`); + (wrapped as Error & { cause?: unknown }).cause = error; + throw wrapped; + } finally { + if (fd !== null) closeSync(fd); + } +} + +export function isPrivateFileStageName(name: string): boolean { + return PRIVATE_STAGE_RE.test(name); +} + +/** Reclaim all private-file stages in a directory whose writer is definitely dead. */ +export function cleanupStalePrivateFileStagesInDir(dir: string): void { + let names: string[]; + try { + names = readdirSync(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + let changed = false; + for (const name of names) { + const match = PRIVATE_STAGE_RE.exec(name); + if (!match) continue; + const pid = Number(match[1]); + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid || !pidDefinitelyDead(pid)) continue; + try { + unlinkSync(join(dir, name)); + changed = true; + } catch { + // Another cleanup or writer may have removed it after enumeration. + } + } + if (changed) fsyncParentBestEffort(join(dir, ".")); +} + +/** Reclaim staging links from writers that are definitely no longer alive. */ +export function cleanupStalePrivateFileStages(finalPath: string): void { + cleanupStalePrivateFileStagesInDir(dirname(finalPath)); +} + +/** Remove only stage links that already reference the durable final inode. */ +function cleanupPublishedPrivateFileStages(finalPath: string): void { + let finalStats; + try { + finalStats = lstatSync(finalPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (!finalStats.isFile() || finalStats.isSymbolicLink()) return; + + const dir = dirname(finalPath); + const prefix = staleTempPrefix(finalPath); + let changed = false; + for (const name of readdirSync(dir)) { + if (!name.startsWith(prefix) || !PRIVATE_STAGE_RE.test(name)) continue; + const stagePath = join(dir, name); + try { + const stageStats = lstatSync(stagePath); + if (!stageStats.isFile() || stageStats.isSymbolicLink()) continue; + if (stageStats.dev !== finalStats.dev || stageStats.ino !== finalStats.ino) continue; + unlinkSync(stagePath); + changed = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (changed) fsyncParentBestEffort(finalPath); +} + +function writeAll(fd: number, bytes: Uint8Array): void { + let offset = 0; + while (offset < bytes.byteLength) { + const count = writeSync(fd, bytes, offset, bytes.byteLength - offset); + if (count <= 0) throw new Error("private file write made no progress"); + offset += count; + } +} + +/** + * Publish immutable mode-0600 bytes without ever exposing a partially-written final path. + * The caller owns EEXIST comparison semantics because some objects are idempotent and + * others are identity conflicts. Staging files are target-scoped and stale stages from + * definitely-dead writers are reclaimed on the next read or publication attempt. + */ +export function publishPrivateFileExclusive( + finalPath: string, + bytes: Uint8Array, +): { created: boolean } { + cleanupStalePrivateFileStages(finalPath); + const tempPath = join( + dirname(finalPath), + `${staleTempPrefix(finalPath)}${process.pid}.${randomUUID()}.tmp`, + ); + let fd: number | null = null; + let preservePublishedStage = false; + try { + fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + writeAll(fd, bytes); + fsyncSync(fd); + closeSync(fd); + fd = null; + + if (privateFileCommitFaultForTests === "before_publish") { + throw new Error("synthetic private-file commit failure before publish"); + } + + try { + linkSync(tempPath, finalPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + // A prior publication may have linked the final entry but failed while + // syncing the parent directory. Re-sync before reporting idempotent success, + // then remove only stages that are hard links to that durable final inode. + fsyncParentForPublication(finalPath); + cleanupPublishedPrivateFileStages(finalPath); + return { created: false }; + } + throw error; + } + try { + fsyncParentForPublication(finalPath); + } catch (error) { + // The final name exists, but POSIX durability was not established. Keep this + // exact hard-link stage so a retry can re-sync and then identify it by inode. + preservePublishedStage = true; + throw error; + } + return { created: true }; + } finally { + if (fd !== null) closeSync(fd); + if (!preservePublishedStage) { + cleanup(tempPath); + fsyncParentBestEffort(finalPath); + } + } +} + +export function readPublishedPrivateFile(path: string): Buffer { + cleanupStalePrivateFileStages(path); + return readFileSync(path); +} + +/** Test-only fault seam at the atomic publication point. Import this module directly in tests. */ +export function setPrivateFileCommitFaultForTests(fault: PrivateFileCommitFault): void { + privateFileCommitFaultForTests = fault; +} diff --git a/src/lab/public/project.ts b/src/lab/public/project.ts new file mode 100644 index 000000000..611002ac2 --- /dev/null +++ b/src/lab/public/project.ts @@ -0,0 +1,122 @@ +import type { CompatibilityVerdict } from "../constants"; +import type { ObservationEvent, ProtocolSubjectV1 } from "../events/types"; +import { validatePublicEvidenceAuthorities } from "./community-authority"; +import { publicEvidenceId } from "./ids"; +import { validatePublicEvidenceRecordPrivacy } from "./privacy"; +import { publicUtcDay } from "./time"; +import { + PUBLIC_ADAPTER_FAMILIES, + type PublicAdapterFamily, + type PublicEvidenceProjectionResult, + type PublicEvidenceRecordV1, + type PublicIncidentRefV1, + type PublicProtocolSubjectV1, +} from "./types"; +import { + isPublicIncidentRef, + PublicEvidenceValidationError, + validatePublicEvidenceRecord, +} from "./validate"; + +const PROJECTOR_INVARIANT_ERROR_CODES = new Set([ + "subject_id_mismatch", + "record_id_mismatch", + "public_selection_time", +]); + +export interface ProjectPublicEvidenceRecordInput { + observation: ObservationEvent; + verdict: CompatibilityVerdict; + incidentRefs?: string[]; + publicArtifactRefs?: string[]; +} + +function asPublicAdapterFamily(value: string): PublicAdapterFamily | undefined { + return (PUBLIC_ADAPTER_FAMILIES as readonly string[]).includes(value) + ? value as PublicAdapterFamily + : undefined; +} + +function projectProtocolSubject(subject: ProtocolSubjectV1): PublicProtocolSubjectV1 | undefined { + const adapterFamily = asPublicAdapterFamily(subject.effectiveAdapter); + if (!adapterFamily) return undefined; + return { + subjectKind: "protocol", + compatibilityVersion: subject.opencodexCompatibilityVersion, + adapterFamily, + inboundProtocol: subject.inboundProtocol, + upstreamProtocol: subject.upstreamProtocol, + surface: subject.surface, + }; +} + +function projectIncidentRefs(values: string[] | undefined): PublicIncidentRefV1[] | undefined { + if (values === undefined) return undefined; + if (values.some((value) => !isPublicIncidentRef(value))) return undefined; + return values.map((corpusId) => ({ corpusId })); +} + +/** + * Project one local observation into the closed public V1 record shape and apply the + * complete reviewed authority/privacy boundary before exposing it as exportable. + * + * Route and task observations deliberately fail closed here. Persisted RouteSubjectV1 + * contains installation-salted provider-instance and endpoint identity, so the exact + * public/default route cannot be proven from ledger bytes alone. Dropping those fields + * would broaden a private exact route into a misleading public claim. + */ +export function projectPublicEvidenceRecord( + input: ProjectPublicEvidenceRecordInput, +): PublicEvidenceProjectionResult { + const { observation } = input; + + if (observation.evidenceLayer === "live_route_compatibility" || observation.evidenceLayer === "task_effectiveness") { + return { status: "not_exportable", reason: "private_route_identity" }; + } + if (observation.evidenceLayer !== "protocol_conformance" || observation.subject.subjectKind !== "protocol") { + return { status: "not_exportable", reason: "unsupported_subject" }; + } + + const subject = projectProtocolSubject(observation.subject); + if (!subject) return { status: "not_exportable", reason: "unsupported_adapter_family" }; + + const incidentRefs = projectIncidentRefs(input.incidentRefs); + if (input.incidentRefs !== undefined && incidentRefs === undefined) { + return { status: "not_exportable", reason: "unsafe_public_field" }; + } + + try { + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId: Omit = { + subjectId, + evidenceLayer: "protocol_conformance", + suiteId: observation.suiteId, + suiteVersion: observation.suiteVersion, + scenarioId: observation.scenarioId, + scenarioVersion: observation.scenarioVersion, + verdict: input.verdict, + observedDayUtc: publicUtcDay(observation.completedAt), + subject, + assertions: observation.assertions.map((assertion) => ({ + id: assertion.id, + required: assertion.required, + passed: assertion.passed, + })), + ...(incidentRefs !== undefined ? { incidentRefs } : {}), + ...(input.publicArtifactRefs !== undefined ? { artifactRefs: [...input.publicArtifactRefs] } : {}), + }; + const record = validatePublicEvidenceRecord({ + recordId: publicEvidenceId("record", withoutRecordId), + ...withoutRecordId, + }); + validatePublicEvidenceAuthorities([record]); + validatePublicEvidenceRecordPrivacy(record); + return { status: "exportable", record }; + } catch (error) { + if (error instanceof PublicEvidenceValidationError) { + if (PROJECTOR_INVARIANT_ERROR_CODES.has(error.code)) throw error; + return { status: "not_exportable", reason: "unsafe_public_field" }; + } + throw error; + } +} diff --git a/src/lab/public/registry.ts b/src/lab/public/registry.ts new file mode 100644 index 000000000..27743b53b --- /dev/null +++ b/src/lab/public/registry.ts @@ -0,0 +1,43 @@ +import { publicEvidenceId } from "./ids"; +import type { + PublicAdapterFamily, + PublicRouteRegistryEntryV1, + PublicRouteRegistryManifestV1, +} from "./types"; + +// Repository-authoritative provider/model/adapter snapshot. The public manifest +// itself is independently content-addressed by manifestDigest below. +const PUBLIC_ROUTE_REGISTRY_SOURCE_COMMIT = "75a21417657ba5a3033198be0d8ae949de723d11"; + +const entries: PublicRouteRegistryEntryV1[] = [ + { + providerId: "openai", + modelId: "gpt-5.6-sol", + adapterFamilies: ["openai-responses"], + }, +]; + +const manifestWithoutDigest = { + schemaVersion: "public_route_registry_v1" as const, + registryVersion: "2026-08-13.v2", + sourceCommit: PUBLIC_ROUTE_REGISTRY_SOURCE_COMMIT, + entries, +}; + +export const PUBLIC_ROUTE_REGISTRY_V1: PublicRouteRegistryManifestV1 = Object.freeze({ + ...manifestWithoutDigest, + entries: Object.freeze(entries.map((entry) => Object.freeze({ + ...entry, + adapterFamilies: Object.freeze([...entry.adapterFamilies]) as unknown as PublicAdapterFamily[], + }))) as unknown as PublicRouteRegistryEntryV1[], + manifestDigest: publicEvidenceId("route_registry", manifestWithoutDigest), +}); + +export function findPublicRouteRegistryEntry( + providerId: string, + modelId: string, +): PublicRouteRegistryEntryV1 | undefined { + return PUBLIC_ROUTE_REGISTRY_V1.entries.find( + (entry) => entry.providerId === providerId && entry.modelId === modelId, + ); +} diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts new file mode 100644 index 000000000..6429445a2 --- /dev/null +++ b/src/lab/public/signature.ts @@ -0,0 +1,205 @@ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign as signBytes, + verify as verifyBytes, +} from "node:crypto"; +import { ensureLabDirs, labPublicPublisherKeyPath } from "../paths"; +import { hardenSecretPath } from "../../lib/windows-secret-acl"; +import { + buildPublicEvidenceBundle, + canonicalPublicEvidenceContent, + expectedPublicBundleIdentityFromNormalized, + normalizePublicEvidenceContent, + type BuildPublicEvidenceBundleInput, +} from "./bundle"; +import { validatePublicEvidenceAuthorities } from "./community-authority"; +import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety"; +import { publicEvidenceId } from "./ids"; +import { cleanupStalePrivateFileStages, publishPrivateFileExclusive } from "./private-file"; +import { validatePublicEvidencePrivacy, validatePublicEvidenceRecordPrivacy } from "./privacy"; +import type { + PublicEvidenceBundleV1, + PublicPublisherV1, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_PRIVATE_KEY_BYTES = 8 * 1024; +const PRIVATE_KEY_FILE_OPTIONS = { + maxBytes: MAX_PRIVATE_KEY_BYTES, + errorCode: "public_publisher_key_unsafe", + errorMessage: "public publisher key path is not a bounded private regular file with 0600 permissions", + requireMode600: true, +} as const; + +export interface PublicPublisherHandle { + publisher: PublicPublisherV1; + privateKeyPath: string; +} + +function publicKeyBase64(privateKeyPem: string): string { + const publicKey = createPublicKey(privateKeyPem); + return publicKey.export({ type: "spki", format: "der" }).toString("base64"); +} + +function publisherForPrivateKey(privateKeyPem: string): PublicPublisherV1 { + const publicKey = publicKeyBase64(privateKeyPem); + return { + algorithm: "ed25519", + keyId: publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey }), + publicKey, + }; +} + +function readRestrictedPrivateKey(path: string): string { + cleanupStalePrivateFileStages(path); + // Prove the pathname is the expected private regular file before applying any + // platform ACL operation, then fail closed if Windows per-user ACL hardening + // cannot be established. The helper is a no-op success on non-Windows. + privateRegularFileSize(path, PRIVATE_KEY_FILE_OPTIONS); + let hardened: { ok: boolean }; + try { + hardened = hardenSecretPath(path, { required: true }); + } catch { + hardened = { ok: false }; + } + if (!hardened.ok) { + throw new PublicEvidenceValidationError( + "public_publisher_key_unsafe", + "public publisher key ACL hardening did not complete", + ); + } + const pem = readPrivateRegularFile(path, PRIVATE_KEY_FILE_OPTIONS).toString("utf8"); + const key = createPrivateKey(pem); + if (key.asymmetricKeyType !== "ed25519") { + throw new Error("public publisher key must be Ed25519"); + } + return pem; +} + +function createPrivateKeyFile(path: string): string { + const { privateKey } = generateKeyPairSync("ed25519", { + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8")); + return readRestrictedPrivateKey(path); +} + +export function loadExistingPublicPublisher(configDir?: string): PublicPublisherHandle | null { + const privateKeyPath = labPublicPublisherKeyPath(configDir); + try { + const privateKeyPem = readRestrictedPrivateKey(privateKeyPath); + return { publisher: publisherForPrivateKey(privateKeyPem), privateKeyPath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +export function getOrCreatePublicPublisher(configDir?: string): PublicPublisherHandle { + ensureLabDirs(configDir); + const existing = loadExistingPublicPublisher(configDir); + if (existing) return existing; + const privateKeyPath = labPublicPublisherKeyPath(configDir); + const privateKeyPem = createPrivateKeyFile(privateKeyPath); + return { publisher: publisherForPrivateKey(privateKeyPem), privateKeyPath }; +} + +/** Centralized descriptor-bound signing primitive for the installation publisher key. */ +export function signPublicPublisherDigest(handle: PublicPublisherHandle, digestHex: string): string { + if (!/^[0-9a-f]{64}$/.test(digestHex)) { + throw new PublicEvidenceValidationError("invalid_digest", "publisher signing digest must be lowercase sha256 hex"); + } + const privateKeyPem = readRestrictedPrivateKey(handle.privateKeyPath); + return signBytes(null, Buffer.from(digestHex, "hex"), createPrivateKey(privateKeyPem)).toString("base64"); +} + +export interface SignPublicEvidenceBundleInput extends Omit { + configDir?: string; +} + +function assertLocalArtifactExportAuthority(input: SignPublicEvidenceBundleInput): void { + if (input.artifacts.length !== 0) { + throw new PublicEvidenceValidationError( + "public_artifact_authority_required", + "artifact bytes require reviewed public_export policy authority before local signing", + ); + } +} + +export function signPublicEvidenceBundle(input: SignPublicEvidenceBundleInput): PublicEvidenceBundleV1 { + // Validate every caller-controlled invariant before publisher identity state is touched. + assertLocalArtifactExportAuthority(input); + const normalized = normalizePublicEvidenceContent({ + records: input.records, + artifacts: input.artifacts, + createdDayUtc: input.createdDayUtc, + }); + validatePublicEvidenceAuthorities(normalized.records); + for (const record of normalized.records) validatePublicEvidenceRecordPrivacy(record); + + const handle = getOrCreatePublicPublisher(input.configDir); + const unsigned = buildPublicEvidenceBundle({ ...normalized, publisher: handle.publisher }); + validatePublicEvidencePrivacy(unsigned); + return { + ...unsigned, + signature: { + algorithm: "ed25519", + signedDigest: unsigned.bundleDigest, + signature: signPublicPublisherDigest(handle, unsigned.bundleDigest), + }, + }; +} + +export type PublicBundleVerificationResult = + | { status: "cryptographically_valid" } + | { status: "digest_invalid" } + | { status: "signature_invalid" } + | { status: "schema_rejected" }; + +export function verifyPublicEvidenceBundle(bundle: PublicEvidenceBundleV1): PublicBundleVerificationResult { + try { + const raw = bundle as unknown as Record; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { status: "schema_rejected" }; + const allowed = new Set([ + "schemaVersion", + "exportPolicyVersion", + "bundleId", + "createdDayUtc", + "publisher", + "records", + "artifacts", + "bundleDigest", + "signature", + ]); + if (Object.keys(raw).some((key) => !allowed.has(key))) return { status: "schema_rejected" }; + if (bundle.schemaVersion !== "public_evidence_bundle_v1" || bundle.exportPolicyVersion !== "public_export_policy_v1") { + return { status: "schema_rejected" }; + } + if (!bundle.signature || bundle.signature.algorithm !== "ed25519") return { status: "schema_rejected" }; + if (Object.keys(bundle.signature).some((key) => !["algorithm", "signedDigest", "signature"].includes(key))) { + return { status: "schema_rejected" }; + } + const canonical = canonicalPublicEvidenceContent(bundle); + if (!canonical.canonical) return { status: "schema_rejected" }; + const expected = expectedPublicBundleIdentityFromNormalized(canonical.normalized, bundle.publisher); + if (bundle.bundleId !== expected.bundleId || bundle.bundleDigest !== expected.bundleDigest) { + return { status: "digest_invalid" }; + } + if (bundle.signature.signedDigest !== bundle.bundleDigest) return { status: "signature_invalid" }; + const key = createPublicKey({ + key: Buffer.from(bundle.publisher.publicKey, "base64"), + type: "spki", + format: "der", + }); + if (key.asymmetricKeyType !== "ed25519") return { status: "signature_invalid" }; + const signature = Buffer.from(bundle.signature.signature, "base64"); + if (signature.toString("base64") !== bundle.signature.signature) return { status: "signature_invalid" }; + const valid = verifyBytes(null, Buffer.from(bundle.bundleDigest, "hex"), key, signature); + return valid ? { status: "cryptographically_valid" } : { status: "signature_invalid" }; + } catch { + return { status: "schema_rejected" }; + } +} diff --git a/src/lab/public/storage.ts b/src/lab/public/storage.ts new file mode 100644 index 000000000..8fb0a9617 --- /dev/null +++ b/src/lab/public/storage.ts @@ -0,0 +1,105 @@ +import { join } from "node:path"; +import { isSha256Hex, jcsStringify } from "../digest"; +import { ensureLabDirs } from "../paths"; +import { MAX_PUBLIC_BUNDLE_BYTES } from "./bundle"; +import { validatePublicEvidenceAuthorities } from "./community-authority"; +import { readPrivateRegularFile } from "./file-safety"; +import { cleanupStalePrivateFileStages, publishPrivateFileExclusive } from "./private-file"; +import { validatePublicEvidencePrivacy } from "./privacy"; +import { parseStrictPublicJson } from "./strict-json"; +import type { PublicEvidenceBundleV1 } from "./types"; +import { verifyPublicEvidenceBundle } from "./signature"; +import { PublicEvidenceValidationError } from "./validate"; + +function encodedBytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function bundlePath(bundleId: string, configDir?: string): string { + if (!isSha256Hex(bundleId)) throw new Error("public bundle id must be lowercase sha256 hex"); + return join(ensureLabDirs(configDir).exportDir, `${bundleId}.json`); +} + +function assertLocalArtifactExportAuthority(bundle: PublicEvidenceBundleV1): void { + if (bundle.artifacts.length !== 0) { + throw new PublicEvidenceValidationError( + "public_artifact_authority_required", + "artifact bytes require reviewed public_export policy authority before local export storage", + ); + } +} + +function readLocalExport(path: string): Buffer { + cleanupStalePrivateFileStages(path); + return readPrivateRegularFile(path, { + maxBytes: MAX_PUBLIC_BUNDLE_BYTES, + errorCode: "public_file_unsafe", + errorMessage: "public export is not a private regular file with 0600 permissions", + sizeErrorCode: "public_file_too_large", + sizeErrorMessage: "public bundle exceeds 2 MiB", + requireMode600: true, + }); +} + +function existingBody(path: string): string | null { + try { + return readLocalExport(path).toString("utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function validateLocalBundle(bundle: PublicEvidenceBundleV1): void { + const verification = verifyPublicEvidenceBundle(bundle); + if (verification.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError(verification.status, `public bundle verification failed: ${verification.status}`); + } + assertLocalArtifactExportAuthority(bundle); + validatePublicEvidenceAuthorities(bundle.records); + validatePublicEvidencePrivacy(bundle); +} + +export function storePublicEvidenceBundle( + bundle: PublicEvidenceBundleV1, + configDir?: string, +): { path: string; created: boolean } { + validateLocalBundle(bundle); + const body = jcsStringify(bundle) + "\n"; + if (encodedBytes(body) > MAX_PUBLIC_BUNDLE_BYTES) { + throw new PublicEvidenceValidationError("public_file_too_large", "public bundle exceeds 2 MiB"); + } + const path = bundlePath(bundle.bundleId, configDir); + const existing = existingBody(path); + if (existing !== null) { + if (existing === body) return { path, created: false }; + throw new PublicEvidenceValidationError("public_export_conflict", "public export id collision with different bytes"); + } + + const published = publishPrivateFileExclusive(path, Buffer.from(body, "utf8")); + if (!published.created) { + const raced = existingBody(path); + if (raced === body) return { path, created: false }; + throw new PublicEvidenceValidationError("public_export_conflict", "public export id collision with different bytes"); + } + return { path, created: true }; +} + +/** Backward-compatible local storage helper for callers that need the private path. */ +export function writePublicEvidenceBundle(bundle: PublicEvidenceBundleV1, configDir?: string): string { + return storePublicEvidenceBundle(bundle, configDir).path; +} + +export function readPublicEvidenceBundle(bundleId: string, configDir?: string): PublicEvidenceBundleV1 { + const bytes = readLocalExport(bundlePath(bundleId, configDir)); + const raw = parseStrictPublicJson(bytes, "public export", "public_file_json"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_file_json", "public export must contain a bundle object"); + } + const parsed = raw as PublicEvidenceBundleV1; + if (parsed.bundleId !== bundleId) { + throw new PublicEvidenceValidationError("public_file_identity", "public export filename does not match bundle id"); + } + validateLocalBundle(parsed); + return parsed; +} diff --git a/src/lab/public/strict-json.ts b/src/lab/public/strict-json.ts new file mode 100644 index 000000000..fe7599cb6 --- /dev/null +++ b/src/lab/public/strict-json.ts @@ -0,0 +1,198 @@ +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_PUBLIC_JSON_DEPTH = 8; +const MAX_PUBLIC_JSON_OBJECT_KEYS = 64; +const MAX_PUBLIC_JSON_ARRAY_ELEMENTS = 512; +const MAX_PUBLIC_JSON_STRING_BYTES = 384 * 1024; + +function isJsonWhitespace(value: string | undefined): boolean { + return value === " " || value === "\n" || value === "\r" || value === "\t"; +} + +function malformedJson(code: string, message: string): never { + throw new PublicEvidenceValidationError(code, message); +} + +function assertStrictPublicJsonShape(text: string, invalidCode: string): void { + let index = 0; + let depth = 0; + + function invalid(message: string): never { + return malformedJson(invalidCode, message); + } + + function skipWhitespace(): void { + while (isJsonWhitespace(text[index])) index += 1; + } + + function parseStringToken(): string { + if (text[index] !== '"') invalid("public JSON contains an invalid string token"); + const start = index; + index += 1; + let escaped = false; + while (index < text.length) { + const ch = text[index++]!; + if (escaped) { + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (ch === '"') { + if (Buffer.byteLength(text.slice(start + 1, index - 1), "utf8") > MAX_PUBLIC_JSON_STRING_BYTES) { + invalid(`public JSON string exceeds ${MAX_PUBLIC_JSON_STRING_BYTES} bytes`); + } + try { + const decoded = JSON.parse(text.slice(start, index)); + if (typeof decoded !== "string") invalid("public JSON contains an invalid string token"); + return decoded; + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + invalid("public JSON contains an invalid string token"); + } + } + if (ch.charCodeAt(0) < 0x20) invalid("public JSON contains an invalid control character"); + } + invalid("public JSON contains an unterminated string token"); + } + + function parseScalar(): void { + const start = index; + while (index < text.length) { + const ch = text[index]; + if (ch === "," || ch === "]" || ch === "}" || isJsonWhitespace(ch)) break; + index += 1; + } + if (start === index) invalid("public JSON contains an invalid value"); + try { + const parsed = JSON.parse(text.slice(start, index)); + if (parsed !== null && typeof parsed === "object") invalid("public JSON contains an invalid scalar value"); + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + invalid("public JSON contains an invalid scalar value"); + } + } + + function enterContainer(): void { + depth += 1; + if (depth > MAX_PUBLIC_JSON_DEPTH) { + invalid(`public JSON nesting depth exceeds ${MAX_PUBLIC_JSON_DEPTH}`); + } + } + + function parseArray(): void { + enterContainer(); + try { + index += 1; + skipWhitespace(); + if (text[index] === "]") { + index += 1; + return; + } + let elementCount = 0; + while (index < text.length) { + elementCount += 1; + if (elementCount > MAX_PUBLIC_JSON_ARRAY_ELEMENTS) { + invalid(`public JSON array exceeds ${MAX_PUBLIC_JSON_ARRAY_ELEMENTS} elements`); + } + parseValue(); + skipWhitespace(); + if (text[index] === "]") { + index += 1; + return; + } + if (text[index] !== ",") invalid("public JSON array is malformed"); + index += 1; + skipWhitespace(); + if (text[index] === "]") invalid("public JSON array contains a trailing comma"); + } + invalid("public JSON array is unterminated"); + } finally { + depth -= 1; + } + } + + function parseObject(): void { + enterContainer(); + try { + index += 1; + skipWhitespace(); + if (text[index] === "}") { + index += 1; + return; + } + const keys = new Set(); + while (index < text.length) { + if (text[index] !== '"') invalid("public JSON object key must be a string"); + const key = parseStringToken(); + if (keys.has(key)) { + throw new PublicEvidenceValidationError("duplicate_json_key", "duplicate JSON object key"); + } + keys.add(key); + if (keys.size > MAX_PUBLIC_JSON_OBJECT_KEYS) { + invalid(`public JSON object exceeds ${MAX_PUBLIC_JSON_OBJECT_KEYS} keys`); + } + skipWhitespace(); + if (text[index] !== ":") invalid("public JSON object is missing a colon"); + index += 1; + parseValue(); + skipWhitespace(); + if (text[index] === "}") { + index += 1; + return; + } + if (text[index] !== ",") invalid("public JSON object is malformed"); + index += 1; + skipWhitespace(); + if (text[index] === "}") invalid("public JSON object contains a trailing comma"); + } + invalid("public JSON object is unterminated"); + } finally { + depth -= 1; + } + } + + function parseValue(): void { + skipWhitespace(); + const ch = text[index]; + if (ch === "{") { + parseObject(); + return; + } + if (ch === "[") { + parseArray(); + return; + } + if (ch === '"') { + parseStringToken(); + return; + } + parseScalar(); + } + + skipWhitespace(); + if (index === text.length) invalid("public JSON is empty"); + parseValue(); + skipWhitespace(); + if (index !== text.length) invalid("public JSON contains trailing data"); +} + +export function parseStrictPublicJson( + bytes: Uint8Array, + label = "public JSON", + invalidCode = "public_json", +): unknown { + const buffer = Buffer.from(bytes); + const text = buffer.toString("utf8"); + if (!Buffer.from(text, "utf8").equals(buffer)) { + throw new PublicEvidenceValidationError(invalidCode, `${label} is not valid UTF-8 JSON`); + } + assertStrictPublicJsonShape(text, invalidCode); + try { + return JSON.parse(text); + } catch { + throw new PublicEvidenceValidationError(invalidCode, `${label} is not valid JSON`); + } +} diff --git a/src/lab/public/time.ts b/src/lab/public/time.ts new file mode 100644 index 000000000..9adc8727d --- /dev/null +++ b/src/lab/public/time.ts @@ -0,0 +1,19 @@ +import { PublicEvidenceValidationError } from "./validate"; + +/** Convert a bounded JavaScript timestamp into the public UTC day bucket. */ +export function publicUtcDay(timestampMs: number): string { + if (!Number.isInteger(timestampMs) || timestampMs < 0) { + throw new PublicEvidenceValidationError( + "public_selection_time", + "invalid observation completion timestamp", + ); + } + const date = new Date(timestampMs); + if (!Number.isFinite(date.getTime())) { + throw new PublicEvidenceValidationError( + "public_selection_time", + "invalid observation completion timestamp", + ); + } + return date.toISOString().slice(0, 10); +} diff --git a/src/lab/public/types.ts b/src/lab/public/types.ts new file mode 100644 index 000000000..7792753da --- /dev/null +++ b/src/lab/public/types.ts @@ -0,0 +1,171 @@ +import type { CompatibilityVerdict, EvidenceLayer } from "../constants"; + +export const PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION = "public_evidence_bundle_v1" as const; +export const PUBLIC_EXPORT_POLICY_VERSION = "public_export_policy_v1" as const; +export const PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION = "public_evidence_revocation_v1" as const; + +export const PUBLIC_ADAPTER_FAMILIES = [ + "openai-responses", + "openai-chat", + "anthropic-messages", +] as const; +export type PublicAdapterFamily = (typeof PUBLIC_ADAPTER_FAMILIES)[number]; + +export interface PublicRouteRegistryEntryV1 { + providerId: string; + modelId: string; + adapterFamilies: PublicAdapterFamily[]; +} + +export interface PublicRouteRegistryManifestV1 { + schemaVersion: "public_route_registry_v1"; + registryVersion: string; + sourceCommit: string; + entries: PublicRouteRegistryEntryV1[]; + manifestDigest: string; +} + +export interface PublicProtocolSubjectV1 { + subjectKind: "protocol"; + compatibilityVersion: string; + adapterFamily: PublicAdapterFamily; + inboundProtocol: string; + upstreamProtocol: string; + surface: string; +} + +export interface PublicRouteSubjectV1 { + subjectKind: "route"; + providerId: string; + modelId: string; + adapterFamily: PublicAdapterFamily; + compatibilityVersion: string; +} + +export interface PublicTaskSubjectV1 { + subjectKind: "task"; + route: PublicRouteSubjectV1; + taskClassId: string; + taskClassVersion: string; + taskFixtureDigest: string; + verifierManifestDigest: string; + fabricCompatibilityVersion: string; +} + +export type PublicEvidenceSubjectV1 = + | PublicProtocolSubjectV1 + | PublicRouteSubjectV1 + | PublicTaskSubjectV1; + +export interface PublicAssertionSummaryV1 { + id: string; + required: boolean; + passed: boolean; +} + +export interface PublicIncidentRefV1 { + corpusId: string; +} + +export interface PublicEvidenceRecordV1 { + recordId: string; + subjectId: string; + evidenceLayer: EvidenceLayer; + suiteId: string; + suiteVersion: string; + scenarioId: string; + scenarioVersion: string; + verdict: CompatibilityVerdict; + observedDayUtc: string; + subject: PublicEvidenceSubjectV1; + assertions: PublicAssertionSummaryV1[]; + incidentRefs?: PublicIncidentRefV1[]; + artifactRefs?: string[]; +} + +export interface PublicArtifactV1 { + artifactId: string; + artifactClass: string; + mediaType: string; + byteCount: number; + contentBase64: string; +} + +export interface PublicPublisherV1 { + algorithm: "ed25519"; + keyId: string; + publicKey: string; +} + +export interface PublicBundleSignatureV1 { + algorithm: "ed25519"; + signedDigest: string; + signature: string; +} + +export interface PublicEvidenceBundleUnsignedV1 { + schemaVersion: typeof PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION; + exportPolicyVersion: typeof PUBLIC_EXPORT_POLICY_VERSION; + bundleId: string; + createdDayUtc: string; + publisher: PublicPublisherV1; + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; + bundleDigest: string; +} + +export interface PublicEvidenceBundleV1 extends PublicEvidenceBundleUnsignedV1 { + signature: PublicBundleSignatureV1; +} + +export interface PublicEvidencePreviewBundleV1 { + schemaVersion: typeof PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION; + exportPolicyVersion: typeof PUBLIC_EXPORT_POLICY_VERSION; + createdDayUtc: string; + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; +} + +export type PublicRevocationReasonV1 = + | "publisher_retracted" + | "privacy_retraction" + | "evidence_invalidated" + | "superseded"; + +export interface PublicRevocationTargetV1 { + kind: "bundle" | "record"; + id: string; +} + +export interface PublicEvidenceRevocationV1 { + schemaVersion: typeof PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION; + revocationId: string; + issuedDayUtc: string; + publisher: PublicPublisherV1; + targets: PublicRevocationTargetV1[]; + reason: PublicRevocationReasonV1; + signature: PublicBundleSignatureV1; +} + +export type PublicRevocationVerificationResult = + | { status: "cryptographically_valid"; revocation: PublicEvidenceRevocationV1 } + | { status: "schema_rejected" | "digest_invalid" | "signature_invalid" | "publisher_mismatch" | "unknown_target"; detail?: string }; + +export interface CommunityEvidenceSummaryV1 { + trustClass: "community_untrusted_v1"; + status: "cryptographically_valid"; + bundleId: string; + publisherKeyId: string; + activeRecordCount: number; + revokedRecordCount: number; +} + +export type PublicProjectionNotExportableReason = + | "private_route_identity" + | "unsupported_subject" + | "unsafe_public_field" + | "unsupported_adapter_family"; + +export type PublicEvidenceProjectionResult = + | { status: "exportable"; record: PublicEvidenceRecordV1 } + | { status: "not_exportable"; reason: PublicProjectionNotExportableReason }; diff --git a/src/lab/public/validate.ts b/src/lab/public/validate.ts new file mode 100644 index 000000000..d7930404d --- /dev/null +++ b/src/lab/public/validate.ts @@ -0,0 +1,390 @@ +import { EVIDENCE_LAYERS, VERDICTS, type EvidenceLayer } from "../constants"; +import { isSha256Hex } from "../digest"; +import { publicEvidenceId } from "./ids"; +import { findPublicRouteRegistryEntry } from "./registry"; +import { + PUBLIC_ADAPTER_FAMILIES, + type PublicAdapterFamily, + type PublicAssertionSummaryV1, + type PublicEvidenceRecordV1, + type PublicEvidenceSubjectV1, + type PublicIncidentRefV1, + type PublicProtocolSubjectV1, + type PublicRouteRegistryEntryV1, + type PublicRouteRegistryManifestV1, + type PublicRouteSubjectV1, + type PublicTaskSubjectV1, +} from "./types"; + +const MAX_PUBLIC_STRING_BYTES = 4 * 1024; +const MAX_PUBLIC_ASSERTIONS = 64; +const MAX_PUBLIC_INCIDENT_REFS = 32; +const MAX_PUBLIC_ARTIFACT_REFS = 16; +const PUBLIC_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/; +const UTC_DAY = /^\d{4}-\d{2}-\d{2}$/; +const SOURCE_COMMIT = /^[0-9a-f]{40}$/; + +const PUBLIC_INCIDENT_CORPUS_IDS = new Set( + Array.from({ length: 21 }, (_, index) => `IC-${String(index + 1).padStart(3, "0")}`), +); + +export class PublicEvidenceValidationError extends Error { + override readonly name = "PublicEvidenceValidationError"; + + constructor(readonly code: string, message: string) { + super(message); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertObject(value: unknown, field: string): Record { + if (!isPlainObject(value)) { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be an object`); + } + return value; +} + +function assertKnownKeys( + raw: Record, + field: string, + allowed: readonly string[], +): void { + const allow = new Set(allowed); + for (const key of Object.keys(raw)) { + if (!allow.has(key)) { + throw new PublicEvidenceValidationError("unknown_field", `${field}.${key} is not public schema`); + } + } +} + +function assertString(value: unknown, field: string, max = MAX_PUBLIC_STRING_BYTES): string { + if (typeof value !== "string") { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be a string`); + } + if (value.includes("\0")) { + throw new PublicEvidenceValidationError("unsafe_public_field", `${field} contains NUL`); + } + if (new TextEncoder().encode(value).byteLength > max) { + throw new PublicEvidenceValidationError("field_too_large", `${field} exceeds ${max} bytes`); + } + return value; +} + +function assertPublicIdentifier(value: unknown, field: string): string { + const result = assertString(value, field, 256); + if (!PUBLIC_IDENTIFIER.test(result)) { + throw new PublicEvidenceValidationError("unsafe_public_field", `${field} is not a closed public identifier`); + } + return result; +} + +function assertBoolean(value: unknown, field: string): boolean { + if (value !== true && value !== false) { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be boolean`); + } + return value; +} + +function assertSha256(value: unknown, field: string): string { + const result = assertString(value, field, 64); + if (!isSha256Hex(result)) { + throw new PublicEvidenceValidationError("invalid_digest", `${field} must be lowercase sha256 hex`); + } + return result; +} + +function assertClosed( + value: unknown, + field: string, + allowed: readonly T[], +): T { + if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) { + throw new PublicEvidenceValidationError("closed_set", `${field} is not in the public closed set`); + } + return value as T; +} + +function assertUtcDay(value: unknown, field: string): string { + const result = assertString(value, field, 10); + if (!UTC_DAY.test(result)) { + throw new PublicEvidenceValidationError("invalid_day", `${field} must be YYYY-MM-DD`); + } + const parsed = new Date(`${result}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== result) { + throw new PublicEvidenceValidationError("invalid_day", `${field} must be a real UTC day`); + } + return result; +} + +function validateAdapterFamily(value: unknown, field: string): PublicAdapterFamily { + return assertClosed(value, field, PUBLIC_ADAPTER_FAMILIES); +} + +function validateProtocolSubject(rawValue: unknown): PublicProtocolSubjectV1 { + const raw = assertObject(rawValue, "subject"); + assertKnownKeys(raw, "subject", [ + "subjectKind", + "compatibilityVersion", + "adapterFamily", + "inboundProtocol", + "upstreamProtocol", + "surface", + ]); + if (raw.subjectKind !== "protocol") { + throw new PublicEvidenceValidationError("layer_subject_mismatch", "protocol layer requires protocol subject"); + } + return { + subjectKind: "protocol", + compatibilityVersion: assertPublicIdentifier(raw.compatibilityVersion, "subject.compatibilityVersion"), + adapterFamily: validateAdapterFamily(raw.adapterFamily, "subject.adapterFamily"), + inboundProtocol: assertPublicIdentifier(raw.inboundProtocol, "subject.inboundProtocol"), + upstreamProtocol: assertPublicIdentifier(raw.upstreamProtocol, "subject.upstreamProtocol"), + surface: assertPublicIdentifier(raw.surface, "subject.surface"), + }; +} + +function validateRouteSubject(rawValue: unknown): PublicRouteSubjectV1 { + const raw = assertObject(rawValue, "subject"); + assertKnownKeys(raw, "subject", [ + "subjectKind", + "providerId", + "modelId", + "adapterFamily", + "compatibilityVersion", + ]); + if (raw.subjectKind !== "route") { + throw new PublicEvidenceValidationError("layer_subject_mismatch", "route layer requires route subject"); + } + const providerId = assertPublicIdentifier(raw.providerId, "subject.providerId"); + const modelId = assertPublicIdentifier(raw.modelId, "subject.modelId"); + const adapterFamily = validateAdapterFamily(raw.adapterFamily, "subject.adapterFamily"); + const entry = findPublicRouteRegistryEntry(providerId, modelId); + if (!entry || !entry.adapterFamilies.includes(adapterFamily)) { + throw new PublicEvidenceValidationError("public_registry_rejected", "route is not in the reviewed public registry"); + } + return { + subjectKind: "route", + providerId, + modelId, + adapterFamily, + compatibilityVersion: assertPublicIdentifier(raw.compatibilityVersion, "subject.compatibilityVersion"), + }; +} + +function validateTaskSubject(rawValue: unknown): PublicTaskSubjectV1 { + const raw = assertObject(rawValue, "subject"); + assertKnownKeys(raw, "subject", [ + "subjectKind", + "route", + "taskClassId", + "taskClassVersion", + "taskFixtureDigest", + "verifierManifestDigest", + "fabricCompatibilityVersion", + ]); + if (raw.subjectKind !== "task") { + throw new PublicEvidenceValidationError("layer_subject_mismatch", "task layer requires task subject"); + } + return { + subjectKind: "task", + route: validateRouteSubject(raw.route), + taskClassId: assertPublicIdentifier(raw.taskClassId, "subject.taskClassId"), + taskClassVersion: assertPublicIdentifier(raw.taskClassVersion, "subject.taskClassVersion"), + taskFixtureDigest: assertSha256(raw.taskFixtureDigest, "subject.taskFixtureDigest"), + verifierManifestDigest: assertSha256(raw.verifierManifestDigest, "subject.verifierManifestDigest"), + fabricCompatibilityVersion: assertPublicIdentifier( + raw.fabricCompatibilityVersion, + "subject.fabricCompatibilityVersion", + ), + }; +} + +function validateSubject(raw: unknown, layer: EvidenceLayer): PublicEvidenceSubjectV1 { + if (layer === "protocol_conformance") return validateProtocolSubject(raw); + if (layer === "live_route_compatibility") return validateRouteSubject(raw); + if (layer === "task_effectiveness") return validateTaskSubject(raw); + const _exhaustive: never = layer; + throw new PublicEvidenceValidationError("unsupported_layer", String(_exhaustive)); +} + +function validateAssertion(rawValue: unknown, index: number): PublicAssertionSummaryV1 { + const raw = assertObject(rawValue, `assertions[${index}]`); + assertKnownKeys(raw, `assertions[${index}]`, ["id", "required", "passed"]); + return { + id: assertPublicIdentifier(raw.id, `assertions[${index}].id`), + required: assertBoolean(raw.required, `assertions[${index}].required`), + passed: assertBoolean(raw.passed, `assertions[${index}].passed`), + }; +} + +export function isPublicIncidentRef(value: unknown): value is string { + return typeof value === "string" && PUBLIC_INCIDENT_CORPUS_IDS.has(value); +} + +function validateIncidentRef(rawValue: unknown, index: number): PublicIncidentRefV1 { + const raw = assertObject(rawValue, `incidentRefs[${index}]`); + assertKnownKeys(raw, `incidentRefs[${index}]`, ["corpusId"]); + const corpusId = assertString(raw.corpusId, `incidentRefs[${index}].corpusId`, 6); + if (!isPublicIncidentRef(corpusId)) { + throw new PublicEvidenceValidationError("incident_ref_rejected", `${corpusId} is not in the reviewed corpus`); + } + return { corpusId }; +} + +function validateUniqueIds(rawValue: unknown, field: string, max: number): string[] { + if (!Array.isArray(rawValue)) { + throw new PublicEvidenceValidationError("invalid_type", `${field} must be an array`); + } + if (rawValue.length > max) { + throw new PublicEvidenceValidationError("array_too_large", `${field} exceeds ${max}`); + } + const values = rawValue.map((value, index) => assertSha256(value, `${field}[${index}]`)); + if (new Set(values).size !== values.length) { + throw new PublicEvidenceValidationError("duplicate_id", `${field} contains duplicates`); + } + return values; +} + +export function validatePublicEvidenceRecord(rawValue: unknown): PublicEvidenceRecordV1 { + const raw = assertObject(rawValue, "record"); + assertKnownKeys(raw, "record", [ + "recordId", + "subjectId", + "evidenceLayer", + "suiteId", + "suiteVersion", + "scenarioId", + "scenarioVersion", + "verdict", + "observedDayUtc", + "subject", + "assertions", + "incidentRefs", + "artifactRefs", + ]); + + const evidenceLayer = assertClosed(raw.evidenceLayer, "record.evidenceLayer", EVIDENCE_LAYERS); + const subject = validateSubject(raw.subject, evidenceLayer); + const subjectId = assertSha256(raw.subjectId, "record.subjectId"); + const expectedSubjectId = publicEvidenceId("subject", subject); + if (subjectId !== expectedSubjectId) { + throw new PublicEvidenceValidationError("subject_id_mismatch", "record.subjectId does not match public subject"); + } + + if (!Array.isArray(raw.assertions)) { + throw new PublicEvidenceValidationError("invalid_type", "record.assertions must be an array"); + } + if (raw.assertions.length > MAX_PUBLIC_ASSERTIONS) { + throw new PublicEvidenceValidationError("array_too_large", `record.assertions exceeds ${MAX_PUBLIC_ASSERTIONS}`); + } + const assertions = raw.assertions.map(validateAssertion); + + let incidentRefs: PublicIncidentRefV1[] | undefined; + if (raw.incidentRefs !== undefined) { + if (!Array.isArray(raw.incidentRefs)) { + throw new PublicEvidenceValidationError("invalid_type", "record.incidentRefs must be an array"); + } + if (raw.incidentRefs.length > MAX_PUBLIC_INCIDENT_REFS) { + throw new PublicEvidenceValidationError( + "array_too_large", + `record.incidentRefs exceeds ${MAX_PUBLIC_INCIDENT_REFS}`, + ); + } + incidentRefs = raw.incidentRefs.map(validateIncidentRef); + const ids = incidentRefs.map((ref) => ref.corpusId); + if (new Set(ids).size !== ids.length) { + throw new PublicEvidenceValidationError("duplicate_id", "record.incidentRefs contains duplicates"); + } + } + + const artifactRefs = raw.artifactRefs === undefined + ? undefined + : validateUniqueIds(raw.artifactRefs, "record.artifactRefs", MAX_PUBLIC_ARTIFACT_REFS); + + const withoutRecordId: Omit = { + subjectId, + evidenceLayer, + suiteId: assertPublicIdentifier(raw.suiteId, "record.suiteId"), + suiteVersion: assertPublicIdentifier(raw.suiteVersion, "record.suiteVersion"), + scenarioId: assertPublicIdentifier(raw.scenarioId, "record.scenarioId"), + scenarioVersion: assertPublicIdentifier(raw.scenarioVersion, "record.scenarioVersion"), + verdict: assertClosed(raw.verdict, "record.verdict", VERDICTS), + observedDayUtc: assertUtcDay(raw.observedDayUtc, "record.observedDayUtc"), + subject, + assertions, + ...(incidentRefs !== undefined ? { incidentRefs } : {}), + ...(artifactRefs !== undefined ? { artifactRefs } : {}), + }; + const recordId = assertSha256(raw.recordId, "record.recordId"); + const expectedRecordId = publicEvidenceId("record", withoutRecordId); + if (recordId !== expectedRecordId) { + throw new PublicEvidenceValidationError("record_id_mismatch", "record.recordId does not match public record"); + } + return { recordId, ...withoutRecordId }; +} + +function validateRegistryEntry(rawValue: unknown, index: number): PublicRouteRegistryEntryV1 { + const raw = assertObject(rawValue, `entries[${index}]`); + assertKnownKeys(raw, `entries[${index}]`, ["providerId", "modelId", "adapterFamilies"]); + if (!Array.isArray(raw.adapterFamilies) || raw.adapterFamilies.length === 0) { + throw new PublicEvidenceValidationError("invalid_registry", `entries[${index}].adapterFamilies must be non-empty`); + } + const adapterFamilies = raw.adapterFamilies.map((value, adapterIndex) => + validateAdapterFamily(value, `entries[${index}].adapterFamilies[${adapterIndex}]`) + ); + if (new Set(adapterFamilies).size !== adapterFamilies.length) { + throw new PublicEvidenceValidationError("duplicate_id", `entries[${index}].adapterFamilies contains duplicates`); + } + return { + providerId: assertPublicIdentifier(raw.providerId, `entries[${index}].providerId`), + modelId: assertPublicIdentifier(raw.modelId, `entries[${index}].modelId`), + adapterFamilies, + }; +} + +export function validatePublicRouteRegistryManifest(rawValue: unknown): PublicRouteRegistryManifestV1 { + const raw = assertObject(rawValue, "publicRouteRegistry"); + assertKnownKeys(raw, "publicRouteRegistry", [ + "schemaVersion", + "registryVersion", + "sourceCommit", + "entries", + "manifestDigest", + ]); + if (raw.schemaVersion !== "public_route_registry_v1") { + throw new PublicEvidenceValidationError("unsupported_version", "unsupported public route registry schema"); + } + const registryVersion = assertPublicIdentifier(raw.registryVersion, "publicRouteRegistry.registryVersion"); + const sourceCommit = assertString(raw.sourceCommit, "publicRouteRegistry.sourceCommit", 40); + if (!SOURCE_COMMIT.test(sourceCommit)) { + throw new PublicEvidenceValidationError("invalid_registry", "publicRouteRegistry.sourceCommit must be a commit SHA"); + } + if (!Array.isArray(raw.entries) || raw.entries.length === 0 || raw.entries.length > 512) { + throw new PublicEvidenceValidationError("invalid_registry", "publicRouteRegistry.entries must contain 1..512 entries"); + } + const entries = raw.entries.map(validateRegistryEntry); + const identities = entries.map((entry) => `${entry.providerId}\0${entry.modelId}`); + if (new Set(identities).size !== identities.length) { + throw new PublicEvidenceValidationError("duplicate_id", "publicRouteRegistry.entries contains duplicates"); + } + const manifestDigest = assertSha256(raw.manifestDigest, "publicRouteRegistry.manifestDigest"); + const expectedDigest = publicEvidenceId("route_registry", { + schemaVersion: "public_route_registry_v1", + registryVersion, + sourceCommit, + entries, + }); + if (manifestDigest !== expectedDigest) { + throw new PublicEvidenceValidationError("digest_invalid", "publicRouteRegistry.manifestDigest mismatch"); + } + return { + schemaVersion: "public_route_registry_v1", + registryVersion, + sourceCommit, + entries, + manifestDigest, + }; +} diff --git a/tests/lab-private-file-consumer-recovery.test.ts b/tests/lab-private-file-consumer-recovery.test.ts new file mode 100644 index 000000000..b42779597 --- /dev/null +++ b/tests/lab-private-file-consumer-recovery.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { publicEvidenceId } from "../src/lab/public/ids"; +import { isPrivateFileStageName, setPrivateFileCommitFaultForTests } from "../src/lab/public/private-file"; +import { getOrCreatePublicPublisher, signPublicEvidenceBundle } from "../src/lab/public/signature"; +import { storePublicEvidenceBundle } from "../src/lab/public/storage"; +import type { PublicEvidenceRecordV1 } from "../src/lab/public/types"; + +const roots: string[] = []; +afterEach(() => { + setPrivateFileCommitFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function root(): string { + const value = mkdtempSync(join(tmpdir(), "ocx-cl10-recovery-")); + roots.push(value); + return value; +} + +function record(): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const body = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", body), ...body }; +} + +test("publisher key recovers after a same-process parent-directory sync failure", () => { + if (process.platform === "win32") return; + const configDir = root(); + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => getOrCreatePublicPublisher(configDir)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(getOrCreatePublicPublisher(configDir).publisher.algorithm).toBe("ed25519"); + expect(readdirSync(join(configDir, "lab")).filter(isPrivateFileStageName)).toEqual([]); +}); + +test("public bundle storage recovers after a same-process parent-directory sync failure", () => { + if (process.platform === "win32") return; + const configDir = root(); + const bundle = signPublicEvidenceBundle({ records: [record()], artifacts: [], createdDayUtc: "2026-08-12", configDir }); + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => storePublicEvidenceBundle(bundle, configDir)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(storePublicEvidenceBundle(bundle, configDir).created).toBe(false); +}); diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts new file mode 100644 index 000000000..69ff1dcf6 --- /dev/null +++ b/tests/lab-private-file-durability.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + isPrivateFileStageName, + publishPrivateFileExclusive, + setPrivateFileCommitFaultForTests, +} from "../src/lab/public/private-file"; + +const roots: string[] = []; +const setCommitFault = setPrivateFileCommitFaultForTests as unknown as (fault: string | null) => void; + +afterEach(() => { + setPrivateFileCommitFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cl10-private-file-")); + roots.push(root); + return root; +} + +describe("CL-10 private-file durability", () => { + test("POSIX parent-directory sync failure preserves the published stage until retry", () => { + if (process.platform === "win32") return; + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("parent_directory_sync"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); + expect(existsSync(finalPath)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toHaveLength(1); + + setPrivateFileCommitFaultForTests(null); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: false }); + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); + + test("before-publish failure leaves no final path or staging entry and retry creates cleanly", () => { + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("before_publish"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/before publish/i); + expect(existsSync(finalPath)).toBe(false); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + + setPrivateFileCommitFaultForTests(null); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); + + test("Windows publication does not require parent-directory fsync", () => { + if (process.platform !== "win32") return; + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("parent_directory_sync"); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); +}); diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts new file mode 100644 index 000000000..168f2c8fb --- /dev/null +++ b/tests/lab-public-core-contract.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { buildPublicEvidenceBundle } from "../src/lab/public/bundle"; +import { publicEvidenceId } from "../src/lab/public/ids"; +import { validatePublicEvidencePrivacy } from "../src/lab/public/privacy"; +import { PUBLIC_ROUTE_REGISTRY_V1 } from "../src/lab/public/registry"; +import { signPublicEvidenceBundle, verifyPublicEvidenceBundle } from "../src/lab/public/signature"; +import { parseStrictPublicJson } from "../src/lab/public/strict-json"; +import type { PublicEvidenceBundleUnsignedV1 } from "../src/lab/public/types"; +import { validatePublicRouteRegistryManifest } from "../src/lab/public/validate"; + +const FIXED_PRIVATE_KEY = [ + `-----BEGIN PRIVATE ${"KEY"}-----`, + ["MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYH", "CAkKCwwNDg8QERITFBUWFxgZGhscHR4f"].join(""), + `-----END PRIVATE ${"KEY"}-----`, + "", +].join("\n"); +const FIXED_PUBLIC_KEY = "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="; +const REVIEWED_AUTHORITY_SOURCE_COMMIT = "75a21417657ba5a3033198be0d8ae949de723d11"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function installFixedPublisherKey(config: string): void { + const path = labPublicPublisherKeyPath(config); + mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 }); + writeFileSync(path, FIXED_PRIVATE_KEY, { encoding: "utf8", mode: 0o600 }); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +function fixedRecord() { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +describe("CL-10 public evidence core contract", () => { + test("freezes the RFC 8785/domain-separated bundle and Ed25519 vector", () => { + const config = configDir("ocx-cl10-core-wire-"); + installFixedPublisherKey(config); + const bundle = signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: config, + }); + + expect(bundle.publisher.publicKey).toBe(FIXED_PUBLIC_KEY); + expect(bundle.publisher.keyId).toBe("4d5a347afcc7a1ac8d2dd4e573f0fbca2d2e90dd472c35df5c72bf2d2afca08f"); + expect(bundle.records[0]!.recordId).toBe("5bec20821bbf01f831e74ba469e7f18481c1209fdef209c76f482105de3e406d"); + expect(bundle.bundleId).toBe("a7598b68a4cf884dc381b1d88111e74bfad5e74ceae2be8de55b88bac3250401"); + expect(bundle.bundleDigest).toBe("aeef2f3e64a131588f6a34aaea1172c352a0c803f838690c2d6ee652ca74fb87"); + expect(bundle.signature.signature).toBe("UAiI7Mz4/yIU5XjSuNZFSuyFPoAvGCy+x9cpTCwYKnFDq20AP6ipV3zowD3S4KP2iYfkXyHTMsMH3CEnz6lCBw=="); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + }); + + test("rejects non-canonical publisher Base64", () => { + const publicKey = `${FIXED_PUBLIC_KEY}\n`; + const publisher = { + algorithm: "ed25519" as const, + publicKey, + keyId: publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey }), + }; + expect(() => buildPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + publisher, + })).toThrow(/canonical base64/i); + }); + + test("strict JSON rejects duplicate decoded keys and bound violations before materialization", () => { + expect(() => parseStrictPublicJson(Buffer.from('{"a":1,"\\u0061":2}', "utf8"))) + .toThrow(/duplicate json object key/i); + expect(() => parseStrictPublicJson(Buffer.from(`${"[".repeat(9)}0${"]".repeat(9)}`, "utf8"))) + .toThrow(/nesting depth exceeds 8/i); + expect(() => parseStrictPublicJson(Buffer.from(`[${Array.from({ length: 513 }, () => "0").join(",")}]`, "utf8"))) + .toThrow(/array exceeds 512/i); + const wide = `{${Array.from({ length: 65 }, (_, index) => `"k${index}":0`).join(",")}}`; + expect(() => parseStrictPublicJson(Buffer.from(wide, "utf8"))).toThrow(/object exceeds 64/i); + }); + + test("local signing rejects artifact bytes before creating publisher state", () => { + const config = configDir("ocx-cl10-core-artifact-"); + const contentBase64 = Buffer.from("credential-canary-1234567890", "utf8").toString("base64"); + const artifact = { + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: Buffer.from(contentBase64, "base64").byteLength, + contentBase64, + }; + const artifactId = publicEvidenceId("artifact", artifact); + expect(() => signPublicEvidenceBundle({ + records: [], + artifacts: [{ artifactId, ...artifact }], + createdDayUtc: "2026-08-12", + configDir: config, + })).toThrow(/public_export/i); + expect(existsSync(labPublicPublisherKeyPath(config))).toBe(false); + }); + + test("privacy rejects embedded unbracketed IPv6 in artifact text", () => { + const bytes = Buffer.from("artifact 2001:db8::1 content", "utf8"); + const bundle = { + createdDayUtc: "2026-08-13", + records: [], + artifacts: [{ + artifactId: "0".repeat(64), + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: bytes.byteLength, + contentBase64: bytes.toString("base64"), + }], + } as unknown as PublicEvidenceBundleUnsignedV1; + expect(() => validatePublicEvidencePrivacy(bundle)).toThrow(/IP address|privacy/i); + }); + + test("pins the reviewed public route registry authority", () => { + const manifest = validatePublicRouteRegistryManifest(PUBLIC_ROUTE_REGISTRY_V1); + expect(manifest.registryVersion).toBe("2026-08-13.v2"); + expect(manifest.sourceCommit).toBe(REVIEWED_AUTHORITY_SOURCE_COMMIT); + expect(manifest.entries).toEqual([{ + providerId: "openai", + modelId: "gpt-5.6-sol", + adapterFamilies: ["openai-responses"], + }]); + }); +}); diff --git a/tests/lab-public-file-safety.test.ts b/tests/lab-public-file-safety.test.ts new file mode 100644 index 000000000..ae2ff6450 --- /dev/null +++ b/tests/lab-public-file-safety.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivateRegularFile } from "../src/lab/public/file-safety"; +import { PublicEvidenceValidationError } from "../src/lab/public/validate"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cl10-file-safety-")); + roots.push(root); + return root; +} + +test("descriptor-bound private reads reject a symlink even when O_NOFOLLOW is unavailable", () => { + const root = tempRoot(); + const target = join(root, "target.txt"); + const link = join(root, "link.txt"); + writeFileSync(target, "safe-bytes", { mode: 0o600 }); + try { + symlinkSync(target, link, "file"); + } catch (error) { + if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") return; + throw error; + } + + expect(() => readPrivateRegularFile(link, { + maxBytes: 1024, + errorCode: "unsafe_test_file", + errorMessage: "unsafe test file", + })).toThrow(PublicEvidenceValidationError); +}); diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts new file mode 100644 index 000000000..eaa4a0da2 --- /dev/null +++ b/tests/lab-public-security-regressions.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { jcsStringify } from "../src/lab/conformance/jcs"; +import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { getOrCreatePublicPublisher } from "../src/lab/public/signature"; +import { + resetHardenedStateForTests, + setIcaclsRunnerForTests, + setPlatformForTests, +} from "../src/lib/windows-secret-acl"; + +const roots: string[] = []; + +afterEach(() => { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + for (const root of roots.splice(0)) { + if (existsSync(root)) rmSync(root, { recursive: true, force: true }); + } +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +test("JCS rejects sparse JavaScript arrays instead of collapsing holes", () => { + const sparse = new Array(1); + expect(() => jcsStringify(sparse)).toThrow(/sparse|array hole/i); +}); + +test("publisher key creation applies required Windows secret ACL hardening to the final key path", () => { + const home = configDir("ocx-cl10-windows-publisher-acl-"); + const keyPath = labPublicPublisherKeyPath(home); + const calls: string[][] = []; + + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setIcaclsRunnerForTests((args) => { + calls.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + + expect(getOrCreatePublicPublisher(home).publisher.algorithm).toBe("ed25519"); + expect(existsSync(keyPath)).toBe(true); + expect(calls.some((args) => args[0] === keyPath && args.includes("/grant:r"))).toBe(true); + expect(calls.some((args) => args[0] === keyPath && args.includes("/inheritance:r"))).toBe(true); +}); From 5ad720161b4a2b34e5313c523965cf3e0084393c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:06:23 +0200 Subject: [PATCH 02/94] fix(lab): bound private file stage cleanup --- src/lab/public/private-file.ts | 42 ++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index 190f9a497..b62307188 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -6,16 +6,17 @@ import { linkSync, lstatSync, openSync, - readFileSync, readdirSync, unlinkSync, writeSync, } from "node:fs"; +import type { Stats } from "node:fs"; import { basename, dirname, join } from "node:path"; export type PrivateFileCommitFault = "before_publish" | "parent_directory_sync" | null; let privateFileCommitFaultForTests: PrivateFileCommitFault = null; const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; +export const PRIVATE_FILE_STAGE_RETENTION_MS = 24 * 60 * 60 * 1000; function cleanup(path: string): void { try { unlinkSync(path); } catch { /* absent/already removed */ } @@ -75,7 +76,31 @@ export function isPrivateFileStageName(name: string): boolean { return PRIVATE_STAGE_RE.test(name); } -/** Reclaim all private-file stages in a directory whose writer is definitely dead. */ +function isPrivateRegularStage(stats: Stats): boolean { + return stats.isFile() && !stats.isSymbolicLink(); +} + +function shouldReclaimPrivateFileStage(dir: string, name: string, nowMs: number): boolean { + const match = PRIVATE_STAGE_RE.exec(name); + if (!match) return false; + const pid = Number(match[1]); + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + + let stats: Stats; + try { + stats = lstatSync(join(dir, name)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + if (!isPrivateRegularStage(stats)) return false; + + const expired = nowMs - stats.mtimeMs > PRIVATE_FILE_STAGE_RETENTION_MS; + const dead = pid !== process.pid && pidDefinitelyDead(pid); + return expired || dead; +} + +/** Reclaim private-file stages whose writer is dead or whose crash witness is past the retention window. */ export function cleanupStalePrivateFileStagesInDir(dir: string): void { let names: string[]; try { @@ -84,12 +109,10 @@ export function cleanupStalePrivateFileStagesInDir(dir: string): void { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } + const nowMs = Date.now(); let changed = false; for (const name of names) { - const match = PRIVATE_STAGE_RE.exec(name); - if (!match) continue; - const pid = Number(match[1]); - if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid || !pidDefinitelyDead(pid)) continue; + if (!shouldReclaimPrivateFileStage(dir, name, nowMs)) continue; try { unlinkSync(join(dir, name)); changed = true; @@ -100,7 +123,7 @@ export function cleanupStalePrivateFileStagesInDir(dir: string): void { if (changed) fsyncParentBestEffort(join(dir, ".")); } -/** Reclaim staging links from writers that are definitely no longer alive. */ +/** Reclaim staging links from dead writers or expired crash witnesses. */ export function cleanupStalePrivateFileStages(finalPath: string): void { cleanupStalePrivateFileStagesInDir(dirname(finalPath)); } @@ -203,11 +226,6 @@ export function publishPrivateFileExclusive( } } -export function readPublishedPrivateFile(path: string): Buffer { - cleanupStalePrivateFileStages(path); - return readFileSync(path); -} - /** Test-only fault seam at the atomic publication point. Import this module directly in tests. */ export function setPrivateFileCommitFaultForTests(fault: PrivateFileCommitFault): void { privateFileCommitFaultForTests = fault; From 9336afe7e1317fb772f2f64367fc7d1920f26715 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:07:17 +0200 Subject: [PATCH 03/94] test(lab): cover expired private file stages --- tests/lab-private-file-durability.test.ts | 26 ++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts index 69ff1dcf6..781b31e75 100644 --- a/tests/lab-private-file-durability.test.ts +++ b/tests/lab-private-file-durability.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + cleanupStalePrivateFileStages, isPrivateFileStageName, + PRIVATE_FILE_STAGE_RETENTION_MS, publishPrivateFileExclusive, setPrivateFileCommitFaultForTests, } from "../src/lab/public/private-file"; @@ -56,6 +58,28 @@ describe("CL-10 private-file durability", () => { expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); }); + test("expired published crash witnesses are reclaimed without requiring a retry", () => { + if (process.platform === "win32") return; + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setCommitFault("parent_directory_sync"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); + setPrivateFileCommitFaultForTests(null); + + const stages = readdirSync(root).filter(isPrivateFileStageName); + expect(stages).toHaveLength(1); + const stagePath = join(root, stages[0]!); + const old = new Date(Date.now() - PRIVATE_FILE_STAGE_RETENTION_MS - 60_000); + utimesSync(stagePath, old, old); + + cleanupStalePrivateFileStages(finalPath); + + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); + test("Windows publication does not require parent-directory fsync", () => { if (process.platform !== "win32") return; const root = tempRoot(); From 57ff10fb360cfcfd3b8b9f70196532eb9612feb5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:35:42 +0200 Subject: [PATCH 04/94] fix(lab): bound strict public JSON input (#1641) --- src/lab/public/strict-json.ts | 8 ++++++++ tests/lab-public-core-contract.test.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lab/public/strict-json.ts b/src/lab/public/strict-json.ts index fe7599cb6..339c41797 100644 --- a/src/lab/public/strict-json.ts +++ b/src/lab/public/strict-json.ts @@ -1,5 +1,6 @@ import { PublicEvidenceValidationError } from "./validate"; +const MAX_PUBLIC_JSON_BYTES = 2 * 1024 * 1024; const MAX_PUBLIC_JSON_DEPTH = 8; const MAX_PUBLIC_JSON_OBJECT_KEYS = 64; const MAX_PUBLIC_JSON_ARRAY_ELEMENTS = 512; @@ -183,7 +184,14 @@ export function parseStrictPublicJson( bytes: Uint8Array, label = "public JSON", invalidCode = "public_json", + maxBytes = MAX_PUBLIC_JSON_BYTES, ): unknown { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new PublicEvidenceValidationError(invalidCode, `${label} byte limit is invalid`); + } + if (bytes.byteLength > maxBytes) { + throw new PublicEvidenceValidationError(invalidCode, `${label} exceeds ${maxBytes} bytes`); + } const buffer = Buffer.from(bytes); const text = buffer.toString("utf8"); if (!Buffer.from(text, "utf8").equals(buffer)) { diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts index 168f2c8fb..08827f01c 100644 --- a/tests/lab-public-core-contract.test.ts +++ b/tests/lab-public-core-contract.test.ts @@ -112,6 +112,8 @@ describe("CL-10 public evidence core contract", () => { .toThrow(/array exceeds 512/i); const wide = `{${Array.from({ length: 65 }, (_, index) => `"k${index}":0`).join(",")}}`; expect(() => parseStrictPublicJson(Buffer.from(wide, "utf8"))).toThrow(/object exceeds 64/i); + expect(() => parseStrictPublicJson(Buffer.alloc((2 * 1024 * 1024) + 1, 0x20))) + .toThrow(/exceeds 2097152 bytes/i); }); test("local signing rejects artifact bytes before creating publisher state", () => { @@ -159,4 +161,4 @@ describe("CL-10 public evidence core contract", () => { adapterFamilies: ["openai-responses"], }]); }); -}); +}); \ No newline at end of file From 88627a894b96b5be14f08c4a616aaaf151df63ff Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:26:35 +0200 Subject: [PATCH 05/94] fix(lab): harden private stages before publication --- src/lab/public/private-file.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index b62307188..b648ffaf6 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -18,6 +18,11 @@ let privateFileCommitFaultForTests: PrivateFileCommitFault = null; const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; export const PRIVATE_FILE_STAGE_RETENTION_MS = 24 * 60 * 60 * 1000; +export interface PrivateFilePublishOptions { + /** Validate or harden the fully-written stage before the final pathname becomes visible. */ + beforePublish?: (stagePath: string) => void; +} + function cleanup(path: string): void { try { unlinkSync(path); } catch { /* absent/already removed */ } } @@ -176,6 +181,7 @@ function writeAll(fd: number, bytes: Uint8Array): void { export function publishPrivateFileExclusive( finalPath: string, bytes: Uint8Array, + options: PrivateFilePublishOptions = {}, ): { created: boolean } { cleanupStalePrivateFileStages(finalPath); const tempPath = join( @@ -195,6 +201,10 @@ export function publishPrivateFileExclusive( throw new Error("synthetic private-file commit failure before publish"); } + // Secret callers can harden the stage while it is still unreachable through + // the final pathname. A failure here leaves no published object behind. + options.beforePublish?.(tempPath); + try { linkSync(tempPath, finalPath); } catch (error) { From 9c39523ef5a5758c9fd7b5dfbf976ad9fafa2e1a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:27:02 +0200 Subject: [PATCH 06/94] fix(lab): protect publisher key before final link --- src/lab/public/signature.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts index 6429445a2..698a5f5c2 100644 --- a/src/lab/public/signature.ts +++ b/src/lab/public/signature.ts @@ -52,15 +52,11 @@ function publisherForPrivateKey(privateKeyPem: string): PublicPublisherV1 { }; } -function readRestrictedPrivateKey(path: string): string { - cleanupStalePrivateFileStages(path); - // Prove the pathname is the expected private regular file before applying any - // platform ACL operation, then fail closed if Windows per-user ACL hardening - // cannot be established. The helper is a no-op success on non-Windows. +function requirePublisherKeyAcl(path: string, timeoutMemoKey = path): void { privateRegularFileSize(path, PRIVATE_KEY_FILE_OPTIONS); let hardened: { ok: boolean }; try { - hardened = hardenSecretPath(path, { required: true }); + hardened = hardenSecretPath(path, { required: true, timeoutMemoKey }); } catch { hardened = { ok: false }; } @@ -70,6 +66,14 @@ function readRestrictedPrivateKey(path: string): string { "public publisher key ACL hardening did not complete", ); } +} + +function readRestrictedPrivateKey(path: string): string { + cleanupStalePrivateFileStages(path); + // Prove the pathname is the expected private regular file before applying any + // platform ACL operation, then fail closed if Windows per-user ACL hardening + // cannot be established. The helper is a no-op success on non-Windows. + requirePublisherKeyAcl(path); const pem = readPrivateRegularFile(path, PRIVATE_KEY_FILE_OPTIONS).toString("utf8"); const key = createPrivateKey(pem); if (key.asymmetricKeyType !== "ed25519") { @@ -83,7 +87,11 @@ function createPrivateKeyFile(path: string): string { privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, }); - publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8")); + publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8"), { + // On Windows, harden the stage before the final key pathname is visible. A + // required ACL failure therefore cannot leave a newly-published key exposed. + beforePublish: stagePath => requirePublisherKeyAcl(stagePath, path), + }); return readRestrictedPrivateKey(path); } From 75f8a02376ece88484e43902957a47ef431aa41b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:27:14 +0200 Subject: [PATCH 07/94] test(lab): reject publisher key publication on ACL failure --- tests/lab-public-security-regressions.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index eaa4a0da2..4202a7a59 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -50,3 +50,20 @@ test("publisher key creation applies required Windows secret ACL hardening to th expect(calls.some((args) => args[0] === keyPath && args.includes("/grant:r"))).toBe(true); expect(calls.some((args) => args[0] === keyPath && args.includes("/inheritance:r"))).toBe(true); }); + +test("publisher key creation never publishes the final path when required Windows ACL hardening fails", () => { + const home = configDir("ocx-cl10-windows-publisher-acl-fail-"); + const keyPath = labPublicPublisherKeyPath(home); + + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setIcaclsRunnerForTests(() => ({ + success: false, + exitCode: 5, + timedOut: false, + stdout: "", + })); + + expect(() => getOrCreatePublicPublisher(home)).toThrow(/ACL hardening/i); + expect(existsSync(keyPath)).toBe(false); +}); From bc4ab53553d948198bb7d149c682d9760331ab23 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:39:53 +0200 Subject: [PATCH 08/94] test(lab): prepare private stages before secret writes --- tests/lab-public-security-regressions.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index 4202a7a59..9328e4f90 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -1,9 +1,10 @@ import { afterEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { jcsStringify } from "../src/lab/conformance/jcs"; import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { publishPrivateFileExclusive } from "../src/lab/public/private-file"; import { getOrCreatePublicPublisher } from "../src/lab/public/signature"; import { resetHardenedStateForTests, @@ -33,6 +34,17 @@ test("JCS rejects sparse JavaScript arrays instead of collapsing holes", () => { expect(() => jcsStringify(sparse)).toThrow(/sparse|array hole/i); }); +test("private publication prepares an empty stage before writing secret bytes", () => { + const root = configDir("ocx-cl10-private-stage-prepare-"); + const finalPath = join(root, "secret.bin"); + const observedSizes: number[] = []; + + expect(publishPrivateFileExclusive(finalPath, Buffer.from("secret", "utf8"), { + prepareStage: stagePath => observedSizes.push(statSync(stagePath).size), + })).toEqual({ created: true }); + expect(observedSizes).toEqual([0]); +}); + test("publisher key creation applies required Windows secret ACL hardening to the final key path", () => { const home = configDir("ocx-cl10-windows-publisher-acl-"); const keyPath = labPublicPublisherKeyPath(home); From 78740d62503284a3b9438615c6678d3c47c254df Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:40:21 +0200 Subject: [PATCH 09/94] fix(lab): harden private stages before writing secrets --- src/lab/public/private-file.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index b648ffaf6..ac21aac87 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -19,8 +19,8 @@ const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; export const PRIVATE_FILE_STAGE_RETENTION_MS = 24 * 60 * 60 * 1000; export interface PrivateFilePublishOptions { - /** Validate or harden the fully-written stage before the final pathname becomes visible. */ - beforePublish?: (stagePath: string) => void; + /** Validate or harden the empty stage before caller-controlled bytes are written. */ + prepareStage?: (stagePath: string) => void; } function cleanup(path: string): void { @@ -192,6 +192,9 @@ export function publishPrivateFileExclusive( let preservePublishedStage = false; try { fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + // Secret callers can harden the empty stage before any sensitive bytes exist. + // A failure here can therefore leave at most an empty cleanup witness. + options.prepareStage?.(tempPath); writeAll(fd, bytes); fsyncSync(fd); closeSync(fd); @@ -201,10 +204,6 @@ export function publishPrivateFileExclusive( throw new Error("synthetic private-file commit failure before publish"); } - // Secret callers can harden the stage while it is still unreachable through - // the final pathname. A failure here leaves no published object behind. - options.beforePublish?.(tempPath); - try { linkSync(tempPath, finalPath); } catch (error) { From f26f397f8f93a6bc0a59d56dc5a20cb1498b9d99 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:40:45 +0200 Subject: [PATCH 10/94] fix(lab): harden publisher stage before key bytes --- src/lab/public/signature.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts index 698a5f5c2..dc5712bf2 100644 --- a/src/lab/public/signature.ts +++ b/src/lab/public/signature.ts @@ -88,9 +88,9 @@ function createPrivateKeyFile(path: string): string { publicKeyEncoding: { type: "spki", format: "pem" }, }); publishPrivateFileExclusive(path, Buffer.from(privateKey, "utf8"), { - // On Windows, harden the stage before the final key pathname is visible. A - // required ACL failure therefore cannot leave a newly-published key exposed. - beforePublish: stagePath => requirePublisherKeyAcl(stagePath, path), + // On Windows, harden the empty stage before private key bytes are written. + // A required ACL failure therefore cannot strand secret bytes in a stage. + prepareStage: stagePath => requirePublisherKeyAcl(stagePath, path), }); return readRestrictedPrivateKey(path); } From d01a619ef69053c375798ab5f130f2de795729a2 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:13:50 +0200 Subject: [PATCH 11/94] fix(lab): address public evidence review findings --- src/lab/public/bundle.ts | 14 ++++++-- src/lab/public/project.ts | 3 ++ src/lab/public/time.ts | 9 ++++- ...lab-private-file-consumer-recovery.test.ts | 4 +-- tests/lab-private-file-durability.test.ts | 9 +++-- tests/lab-public-core-contract.test.ts | 34 ++++++++++++++++++- tests/lab-public-file-safety.test.ts | 17 +++++++++- tests/lab-public-security-regressions.test.ts | 21 ++++++++++++ 8 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/lab/public/bundle.ts b/src/lab/public/bundle.ts index 712a3c310..9bedd4112 100644 --- a/src/lab/public/bundle.ts +++ b/src/lab/public/bundle.ts @@ -26,6 +26,13 @@ export interface BuildPublicEvidenceBundleInput extends PublicEvidenceContentInp publisher: PublicPublisherV1; } +/** Deterministic, locale-independent code-unit ordering for canonical identity. */ +function compareCanonicalId(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + function utcDay(value: string): string { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { throw new PublicEvidenceValidationError("invalid_day", "createdDayUtc must be YYYY-MM-DD"); @@ -130,11 +137,14 @@ export function normalizePublicEvidenceContent(input: PublicEvidenceContentInput if (!Array.isArray(input.records) || input.records.length > MAX_PUBLIC_BUNDLE_RECORDS) { throw new PublicEvidenceValidationError("array_too_large", `records exceeds ${MAX_PUBLIC_BUNDLE_RECORDS}`); } - const records = input.records.map(validatePublicEvidenceRecord).sort((a, b) => a.recordId.localeCompare(b.recordId)); + const records = input.records + .map(validatePublicEvidenceRecord) + .sort((a, b) => compareCanonicalId(a.recordId, b.recordId)); if (new Set(records.map((record) => record.recordId)).size !== records.length) { throw new PublicEvidenceValidationError("duplicate_id", "records contains duplicate ids"); } - const artifacts = validateArtifacts(input.artifacts).sort((a, b) => a.artifactId.localeCompare(b.artifactId)); + const artifacts = validateArtifacts(input.artifacts) + .sort((a, b) => compareCanonicalId(a.artifactId, b.artifactId)); const artifactIds = new Set(artifacts.map((artifact) => artifact.artifactId)); for (const record of records) { for (const artifactId of record.artifactRefs ?? []) { diff --git a/src/lab/public/project.ts b/src/lab/public/project.ts index 611002ac2..7a86dacaa 100644 --- a/src/lab/public/project.ts +++ b/src/lab/public/project.ts @@ -117,6 +117,9 @@ export function projectPublicEvidenceRecord( if (PROJECTOR_INVARIANT_ERROR_CODES.has(error.code)) throw error; return { status: "not_exportable", reason: "unsafe_public_field" }; } + if (error instanceof TypeError && error.message.startsWith("jcsStringify:")) { + return { status: "not_exportable", reason: "unsafe_public_field" }; + } throw error; } } diff --git a/src/lab/public/time.ts b/src/lab/public/time.ts index 9adc8727d..3bc3fe284 100644 --- a/src/lab/public/time.ts +++ b/src/lab/public/time.ts @@ -1,8 +1,15 @@ import { PublicEvidenceValidationError } from "./validate"; +/** Largest timestamp whose ISO-8601 year still fits the four-digit YYYY form. */ +const MAX_PUBLIC_DAY_TIMESTAMP_MS = Date.UTC(9999, 11, 31, 23, 59, 59, 999); + /** Convert a bounded JavaScript timestamp into the public UTC day bucket. */ export function publicUtcDay(timestampMs: number): string { - if (!Number.isInteger(timestampMs) || timestampMs < 0) { + if ( + !Number.isInteger(timestampMs) + || timestampMs < 0 + || timestampMs > MAX_PUBLIC_DAY_TIMESTAMP_MS + ) { throw new PublicEvidenceValidationError( "public_selection_time", "invalid observation completion timestamp", diff --git a/tests/lab-private-file-consumer-recovery.test.ts b/tests/lab-private-file-consumer-recovery.test.ts index b42779597..83e6db5a7 100644 --- a/tests/lab-private-file-consumer-recovery.test.ts +++ b/tests/lab-private-file-consumer-recovery.test.ts @@ -53,7 +53,7 @@ test("publisher key recovers after a same-process parent-directory sync failure" if (process.platform === "win32") return; const configDir = root(); setPrivateFileCommitFaultForTests("parent_directory_sync"); - expect(() => getOrCreatePublicPublisher(configDir)).toThrow(); + expect(() => getOrCreatePublicPublisher(configDir)).toThrow(/directory.*sync|durab/i); setPrivateFileCommitFaultForTests(null); expect(getOrCreatePublicPublisher(configDir).publisher.algorithm).toBe("ed25519"); expect(readdirSync(join(configDir, "lab")).filter(isPrivateFileStageName)).toEqual([]); @@ -64,7 +64,7 @@ test("public bundle storage recovers after a same-process parent-directory sync const configDir = root(); const bundle = signPublicEvidenceBundle({ records: [record()], artifacts: [], createdDayUtc: "2026-08-12", configDir }); setPrivateFileCommitFaultForTests("parent_directory_sync"); - expect(() => storePublicEvidenceBundle(bundle, configDir)).toThrow(); + expect(() => storePublicEvidenceBundle(bundle, configDir)).toThrow(/directory.*sync|durab/i); setPrivateFileCommitFaultForTests(null); expect(storePublicEvidenceBundle(bundle, configDir).created).toBe(false); }); diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts index 781b31e75..01497a3e1 100644 --- a/tests/lab-private-file-durability.test.ts +++ b/tests/lab-private-file-durability.test.ts @@ -11,7 +11,6 @@ import { } from "../src/lab/public/private-file"; const roots: string[] = []; -const setCommitFault = setPrivateFileCommitFaultForTests as unknown as (fault: string | null) => void; afterEach(() => { setPrivateFileCommitFaultForTests(null); @@ -31,7 +30,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("parent_directory_sync"); + setPrivateFileCommitFaultForTests("parent_directory_sync"); expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); expect(existsSync(finalPath)).toBe(true); expect(readdirSync(root).filter(isPrivateFileStageName)).toHaveLength(1); @@ -47,7 +46,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("before_publish"); + setPrivateFileCommitFaultForTests("before_publish"); expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/before publish/i); expect(existsSync(finalPath)).toBe(false); expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); @@ -64,7 +63,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("parent_directory_sync"); + setPrivateFileCommitFaultForTests("parent_directory_sync"); expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); setPrivateFileCommitFaultForTests(null); @@ -86,7 +85,7 @@ describe("CL-10 private-file durability", () => { const finalPath = join(root, "bundle.json"); const bytes = Buffer.from("durable-public-evidence", "utf8"); - setCommitFault("parent_directory_sync"); + setPrivateFileCommitFaultForTests("parent_directory_sync"); expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); expect(readFileSync(finalPath).equals(bytes)).toBe(true); expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts index 08827f01c..8d28ccc86 100644 --- a/tests/lab-public-core-contract.test.ts +++ b/tests/lab-public-core-contract.test.ts @@ -9,6 +9,7 @@ import { validatePublicEvidencePrivacy } from "../src/lab/public/privacy"; import { PUBLIC_ROUTE_REGISTRY_V1 } from "../src/lab/public/registry"; import { signPublicEvidenceBundle, verifyPublicEvidenceBundle } from "../src/lab/public/signature"; import { parseStrictPublicJson } from "../src/lab/public/strict-json"; +import { publicUtcDay } from "../src/lab/public/time"; import type { PublicEvidenceBundleUnsignedV1 } from "../src/lab/public/types"; import { validatePublicRouteRegistryManifest } from "../src/lab/public/validate"; @@ -88,6 +89,33 @@ describe("CL-10 public evidence core contract", () => { expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); }); + test("pins canonical multi-record ordering into the bundle digest", () => { + const first = fixedRecord(); + const { recordId: _ignored, ...secondBody } = { + ...first, + scenarioId: "responses-core.protocol.response-shape", + }; + const second = { recordId: publicEvidenceId("record", secondBody), ...secondBody }; + const publisher = { + algorithm: "ed25519" as const, + publicKey: FIXED_PUBLIC_KEY, + keyId: publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey: FIXED_PUBLIC_KEY }), + }; + + const bundle = buildPublicEvidenceBundle({ + records: [first, second], + artifacts: [], + createdDayUtc: "2026-08-12", + publisher, + }); + + expect(bundle.records.map((record) => record.recordId)).toEqual([ + "2a2a2e8406e6ccac915b21e96558a7b89e49e52effe474bd2c861ad2f7459437", + "5bec20821bbf01f831e74ba469e7f18481c1209fdef209c76f482105de3e406d", + ]); + expect(bundle.bundleDigest).toBe("63fef67418ec196b480bba3865fba287cc92aa94a760e2e3648b0759c0be046e"); + }); + test("rejects non-canonical publisher Base64", () => { const publicKey = `${FIXED_PUBLIC_KEY}\n`; const publisher = { @@ -116,6 +144,10 @@ describe("CL-10 public evidence core contract", () => { .toThrow(/exceeds 2097152 bytes/i); }); + test("public UTC day rejects expanded-year timestamps", () => { + expect(() => publicUtcDay(Date.UTC(10_000, 0, 1))).toThrow(/completion timestamp/i); + }); + test("local signing rejects artifact bytes before creating publisher state", () => { const config = configDir("ocx-cl10-core-artifact-"); const contentBase64 = Buffer.from("credential-canary-1234567890", "utf8").toString("base64"); @@ -161,4 +193,4 @@ describe("CL-10 public evidence core contract", () => { adapterFamilies: ["openai-responses"], }]); }); -}); \ No newline at end of file +}); diff --git a/tests/lab-public-file-safety.test.ts b/tests/lab-public-file-safety.test.ts index ae2ff6450..6693e5481 100644 --- a/tests/lab-public-file-safety.test.ts +++ b/tests/lab-public-file-safety.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { linkSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readPrivateRegularFile } from "../src/lab/public/file-safety"; @@ -34,3 +34,18 @@ test("descriptor-bound private reads reject a symlink even when O_NOFOLLOW is un errorMessage: "unsafe test file", })).toThrow(PublicEvidenceValidationError); }); + +test("descriptor-bound private reads reject a file with an unrelated hard link", () => { + if (process.platform === "win32") return; + const root = tempRoot(); + const target = join(root, "target.txt"); + const alias = join(root, "alias.txt"); + writeFileSync(target, "safe-bytes", { mode: 0o600 }); + linkSync(target, alias); + + expect(() => readPrivateRegularFile(target, { + maxBytes: 1024, + errorCode: "unsafe_test_file", + errorMessage: "unsafe test file", + })).toThrow(PublicEvidenceValidationError); +}); diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index 9328e4f90..f94d6ace6 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -3,8 +3,10 @@ import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { jcsStringify } from "../src/lab/conformance/jcs"; +import type { ObservationEvent } from "../src/lab/events/types"; import { labPublicPublisherKeyPath } from "../src/lab/paths"; import { publishPrivateFileExclusive } from "../src/lab/public/private-file"; +import { projectPublicEvidenceRecord } from "../src/lab/public/project"; import { getOrCreatePublicPublisher } from "../src/lab/public/signature"; import { resetHardenedStateForTests, @@ -34,6 +36,25 @@ test("JCS rejects sparse JavaScript arrays instead of collapsing holes", () => { expect(() => jcsStringify(sparse)).toThrow(/sparse|array hole/i); }); +test("public projection maps JCS-invalid public fields to not_exportable", () => { + const observation = { + evidenceLayer: "protocol_conformance", + subject: { + subjectKind: "protocol", + effectiveAdapter: "openai-chat", + opencodexCompatibilityVersion: "2.13.0", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-\uD800", + }, + } as ObservationEvent; + + expect(projectPublicEvidenceRecord({ observation, verdict: "VERIFIED" })).toEqual({ + status: "not_exportable", + reason: "unsafe_public_field", + }); +}); + test("private publication prepares an empty stage before writing secret bytes", () => { const root = configDir("ocx-cl10-private-stage-prepare-"); const finalPath = join(root, "secret.bin"); From 5e4e9aa273149fc01949de36978fdf5b20754ea8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:14:55 +0200 Subject: [PATCH 12/94] test(lab): tighten review regressions --- tests/lab-public-core-contract.test.ts | 10 +++------- tests/lab-public-security-regressions.test.ts | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts index 8d28ccc86..c965227a0 100644 --- a/tests/lab-public-core-contract.test.ts +++ b/tests/lab-public-core-contract.test.ts @@ -40,7 +40,7 @@ function installFixedPublisherKey(config: string): void { if (process.platform !== "win32") chmodSync(path, 0o600); } -function fixedRecord() { +function fixedRecord(scenarioId = "responses-core.protocol.request-shape") { const subject = { subjectKind: "protocol" as const, compatibilityVersion: "2.13.0", @@ -55,7 +55,7 @@ function fixedRecord() { evidenceLayer: "protocol_conformance" as const, suiteId: "responses-core", suiteVersion: "1.0.0", - scenarioId: "responses-core.protocol.request-shape", + scenarioId, scenarioVersion: "1.0.0", verdict: "VERIFIED" as const, observedDayUtc: "2026-08-12", @@ -91,11 +91,7 @@ describe("CL-10 public evidence core contract", () => { test("pins canonical multi-record ordering into the bundle digest", () => { const first = fixedRecord(); - const { recordId: _ignored, ...secondBody } = { - ...first, - scenarioId: "responses-core.protocol.response-shape", - }; - const second = { recordId: publicEvidenceId("record", secondBody), ...secondBody }; + const second = fixedRecord("responses-core.protocol.response-shape"); const publisher = { algorithm: "ed25519" as const, publicKey: FIXED_PUBLIC_KEY, diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index f94d6ace6..2e7cc6a98 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -47,7 +47,7 @@ test("public projection maps JCS-invalid public fields to not_exportable", () => upstreamProtocol: "openai-chat", surface: "responses-\uD800", }, - } as ObservationEvent; + } as unknown as ObservationEvent; expect(projectPublicEvidenceRecord({ observation, verdict: "VERIFIED" })).toEqual({ status: "not_exportable", From b669369aa2d964158e85050141730b75faa9b23d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:46:40 +0200 Subject: [PATCH 13/94] fix(lab): address public evidence review findings --- src/lab/conformance/jcs.ts | 4 + src/lab/public/community-authority.ts | 64 ++++++++++++--- src/lab/public/privacy.ts | 6 +- src/lab/public/private-file.ts | 44 ++++++++--- src/lab/public/project.ts | 3 +- src/lab/public/registry.ts | 11 +-- src/lab/public/signature.ts | 10 ++- src/lab/public/types.ts | 5 +- src/lab/public/validate.ts | 7 +- tests/lab-private-file-durability.test.ts | 28 +++++++ tests/lab-public-core-contract.test.ts | 31 +++++++- tests/lab-public-security-regressions.test.ts | 77 +++++++++++++++++++ 12 files changed, 251 insertions(+), 39 deletions(-) diff --git a/src/lab/conformance/jcs.ts b/src/lab/conformance/jcs.ts index 6ef064ee0..eaf30b621 100644 --- a/src/lab/conformance/jcs.ts +++ b/src/lab/conformance/jcs.ts @@ -48,6 +48,10 @@ export function jcsStringify(value: unknown): string { return `[${value.map(jcsStringify).join(",")}]`; } if (typeof value === "object") { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("jcsStringify: only plain JSON objects are representable in JCS"); + } const obj = value as Record; const keys = Object.keys(obj).sort(); return `{${keys.map((key) => `${stringifyJcsString(key)}:${jcsStringify(obj[key])}`).join(",")}}`; diff --git a/src/lab/public/community-authority.ts b/src/lab/public/community-authority.ts index cc9e73607..2072d2960 100644 --- a/src/lab/public/community-authority.ts +++ b/src/lab/public/community-authority.ts @@ -14,13 +14,60 @@ import { findPublicRouteRegistryEntry } from "./registry"; import type { PublicEvidenceBundleV1, PublicEvidenceRecordV1, PublicRouteSubjectV1 } from "./types"; import { PublicEvidenceValidationError } from "./validate"; -let cachedCaseAuthority: ReturnType | null = null; +type ProtocolCaseAuthority = ReturnType; + +interface ProtocolAuthoritySnapshot { + scenarioVersion: string; + suiteVersion: string; + sourceCommit: string; + load: () => ProtocolCaseAuthority; +} + +// Public records are historical evidence. Never replace an authority entry when a +// protocol version advances: retain the old loader and append a new snapshot. +const PROTOCOL_AUTHORITY_SNAPSHOTS: readonly ProtocolAuthoritySnapshot[] = Object.freeze([ + Object.freeze({ + scenarioVersion: "1.0.0", + suiteVersion: "1.0.0", + sourceCommit: "3ad5bb6bd3f76f6879d84b78ea39edd3e01ec296", + load: loadCaseAuthority, + }), +]); + +const cachedCaseAuthorities = new Map(); let cachedFabricCaseAuthority: ReturnType | null = null; let cachedVerifierManifestDigest: string | null = null; -function caseAuthority(): ReturnType { - cachedCaseAuthority ??= loadCaseAuthority(); - return cachedCaseAuthority; +function protocolAuthorityKey(snapshot: ProtocolAuthoritySnapshot): string { + return `${snapshot.suiteVersion}\0${snapshot.scenarioVersion}`; +} + +function caseAuthorityFor(record: PublicEvidenceRecordV1): ProtocolCaseAuthority { + const snapshot = PROTOCOL_AUTHORITY_SNAPSHOTS.find((candidate) => + candidate.scenarioVersion === record.scenarioVersion + && candidate.suiteVersion === record.suiteVersion + ); + if (!snapshot) { + throw new PublicEvidenceValidationError( + "public_authority", + "scenario/suite authority version is not retained", + ); + } + + const key = protocolAuthorityKey(snapshot); + const cached = cachedCaseAuthorities.get(key); + if (cached) return cached; + + const authority = snapshot.load(); + if ( + String(authority.manifestDefaults.version) !== snapshot.scenarioVersion + || String(authority.manifestDefaults.suiteVersion) !== snapshot.suiteVersion + || authority.sourceCommit !== snapshot.sourceCommit + ) { + throw new Error("public protocol authority snapshot drift"); + } + cachedCaseAuthorities.set(key, authority); + return authority; } function fabricCaseAuthority(): ReturnType { @@ -102,14 +149,9 @@ function validateScenarioAuthority(record: PublicEvidenceRecordV1): void { return; } - const authority = caseAuthority(); + const authority = caseAuthorityFor(record); const caseRecord = authority.cases.find((candidate) => candidate.id === record.scenarioId); - if ( - !caseRecord - || caseRecord.suite !== record.suiteId - || record.scenarioVersion !== String(authority.manifestDefaults.version) - || record.suiteVersion !== String(authority.manifestDefaults.suiteVersion) - ) { + if (!caseRecord || caseRecord.suite !== record.suiteId) { throw new PublicEvidenceValidationError("public_authority", "scenario/suite authority mismatch"); } validateAssertionAuthority(record, caseRecord.assertions); diff --git a/src/lab/public/privacy.ts b/src/lab/public/privacy.ts index 7c963cf79..c215a4490 100644 --- a/src/lab/public/privacy.ts +++ b/src/lab/public/privacy.ts @@ -10,7 +10,11 @@ import { PublicEvidenceValidationError } from "./validate"; const FORBIDDEN_PUBLIC_STRING_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [ { label: "URL", pattern: /(?:https?|file):\/\//i }, - { label: "local path", pattern: /(?:[A-Za-z]:[\\/]|(?:^|[\\/])(?:Users|home)[\\/])/i }, + { + label: "local path", + pattern: + /(?:[A-Za-z]:[\\/]|\\\\[A-Za-z0-9._-]+\\|(?:^|[\s"'([{=:])\/(?:Users|home|root|tmp|var|opt|private|etc|mnt|media|srv|usr)(?:\/|$))/i, + }, { label: "email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i }, { label: "IP address", pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b|\[[0-9a-f:]{2,}\]/i }, { label: "query string", pattern: /[?&][A-Za-z0-9_.~-]+=/ }, diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index ac21aac87..65e98325f 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -15,6 +15,7 @@ import { basename, dirname, join } from "node:path"; export type PrivateFileCommitFault = "before_publish" | "parent_directory_sync" | null; let privateFileCommitFaultForTests: PrivateFileCommitFault = null; +let privateFileCleanupSyncFaultForTests = false; const PRIVATE_STAGE_RE = /^\..+\.(\d+)\.[0-9a-f-]{36}\.tmp$/; export const PRIVATE_FILE_STAGE_RETENTION_MS = 24 * 60 * 60 * 1000; @@ -47,27 +48,20 @@ function fsyncParentBestEffort(path: string): void { fd = openSync(dirname(path), fsConstants.O_RDONLY); fsyncSync(fd); } catch { - // Cleanup durability is best-effort. Publication durability uses the strict - // fsyncParentForPublication path below and never swallows POSIX failures. + // Cleanup durability is best-effort after unlink. Publication and crash-witness + // retirement use the strict path below and never swallow POSIX failures. } finally { if (fd !== null) closeSync(fd); } } -function fsyncParentForPublication(path: string): void { - // Node does not provide a portable directory-fsync contract on Windows. The - // exclusive hard-link publication remains atomic there, while POSIX requires - // the parent directory sync before publication is reported as durable. +function fsyncParentStrict(path: string): void { if (process.platform === "win32") return; - if (privateFileCommitFaultForTests === "parent_directory_sync") { - throw new Error("synthetic private-file parent directory sync failure"); - } let fd: number | null = null; try { fd = openSync(dirname(path), fsConstants.O_RDONLY); fsyncSync(fd); } catch (error) { - if (error instanceof Error && error.message.includes("synthetic private-file")) throw error; const code = (error as NodeJS.ErrnoException).code ?? "unknown"; const wrapped = new Error(`private-file parent directory sync failed (${code})`); (wrapped as Error & { cause?: unknown }).cause = error; @@ -77,6 +71,17 @@ function fsyncParentForPublication(path: string): void { } } +function fsyncParentForPublication(path: string): void { + // Node does not provide a portable directory-fsync contract on Windows. The + // exclusive hard-link publication remains atomic there, while POSIX requires + // the parent directory sync before publication is reported as durable. + if (process.platform === "win32") return; + if (privateFileCommitFaultForTests === "parent_directory_sync") { + throw new Error("synthetic private-file parent directory sync failure"); + } + fsyncParentStrict(path); +} + export function isPrivateFileStageName(name: string): boolean { return PRIVATE_STAGE_RE.test(name); } @@ -115,9 +120,19 @@ export function cleanupStalePrivateFileStagesInDir(dir: string): void { throw error; } const nowMs = Date.now(); + const reclaimable = names.filter((name) => shouldReclaimPrivateFileStage(dir, name, nowMs)); + if (reclaimable.length === 0) return; + + // A stale stage can be the hard-link witness for a final name that was linked + // before a crash or directory-sync failure. Make that final directory entry + // durable before removing any such witness. + if (process.platform !== "win32" && privateFileCleanupSyncFaultForTests) { + throw new Error("synthetic private-file cleanup parent directory sync failure"); + } + fsyncParentStrict(join(dir, ".")); + let changed = false; - for (const name of names) { - if (!shouldReclaimPrivateFileStage(dir, name, nowMs)) continue; + for (const name of reclaimable) { try { unlinkSync(join(dir, name)); changed = true; @@ -239,3 +254,8 @@ export function publishPrivateFileExclusive( export function setPrivateFileCommitFaultForTests(fault: PrivateFileCommitFault): void { privateFileCommitFaultForTests = fault; } + +/** Test-only fault seam for strict stale-stage cleanup durability. */ +export function setPrivateFileCleanupSyncFaultForTests(enabled: boolean): void { + privateFileCleanupSyncFaultForTests = enabled; +} diff --git a/src/lab/public/project.ts b/src/lab/public/project.ts index 7a86dacaa..77902d83d 100644 --- a/src/lab/public/project.ts +++ b/src/lab/public/project.ts @@ -21,7 +21,6 @@ import { const PROJECTOR_INVARIANT_ERROR_CODES = new Set([ "subject_id_mismatch", "record_id_mismatch", - "public_selection_time", ]); export interface ProjectPublicEvidenceRecordInput { @@ -115,7 +114,7 @@ export function projectPublicEvidenceRecord( } catch (error) { if (error instanceof PublicEvidenceValidationError) { if (PROJECTOR_INVARIANT_ERROR_CODES.has(error.code)) throw error; - return { status: "not_exportable", reason: "unsafe_public_field" }; + return { status: "not_exportable", reason: "unsafe_public_field", detailCode: error.code }; } if (error instanceof TypeError && error.message.startsWith("jcsStringify:")) { return { status: "not_exportable", reason: "unsafe_public_field" }; diff --git a/src/lab/public/registry.ts b/src/lab/public/registry.ts index 27743b53b..40372a4fe 100644 --- a/src/lab/public/registry.ts +++ b/src/lab/public/registry.ts @@ -1,8 +1,9 @@ import { publicEvidenceId } from "./ids"; -import type { - PublicAdapterFamily, - PublicRouteRegistryEntryV1, - PublicRouteRegistryManifestV1, +import { + PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, + type PublicAdapterFamily, + type PublicRouteRegistryEntryV1, + type PublicRouteRegistryManifestV1, } from "./types"; // Repository-authoritative provider/model/adapter snapshot. The public manifest @@ -18,7 +19,7 @@ const entries: PublicRouteRegistryEntryV1[] = [ ]; const manifestWithoutDigest = { - schemaVersion: "public_route_registry_v1" as const, + schemaVersion: PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, registryVersion: "2026-08-13.v2", sourceCommit: PUBLIC_ROUTE_REGISTRY_SOURCE_COMMIT, entries, diff --git a/src/lab/public/signature.ts b/src/lab/public/signature.ts index dc5712bf2..c88c903a0 100644 --- a/src/lab/public/signature.ts +++ b/src/lab/public/signature.ts @@ -55,16 +55,22 @@ function publisherForPrivateKey(privateKeyPem: string): PublicPublisherV1 { function requirePublisherKeyAcl(path: string, timeoutMemoKey = path): void { privateRegularFileSize(path, PRIVATE_KEY_FILE_OPTIONS); let hardened: { ok: boolean }; + let hardeningError: unknown; try { hardened = hardenSecretPath(path, { required: true, timeoutMemoKey }); - } catch { + } catch (error) { + hardeningError = error; hardened = { ok: false }; } if (!hardened.ok) { - throw new PublicEvidenceValidationError( + const failure = new PublicEvidenceValidationError( "public_publisher_key_unsafe", "public publisher key ACL hardening did not complete", ); + if (hardeningError !== undefined) { + (failure as Error & { cause?: unknown }).cause = hardeningError; + } + throw failure; } } diff --git a/src/lab/public/types.ts b/src/lab/public/types.ts index 7792753da..70d4b598f 100644 --- a/src/lab/public/types.ts +++ b/src/lab/public/types.ts @@ -3,6 +3,7 @@ import type { CompatibilityVerdict, EvidenceLayer } from "../constants"; export const PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION = "public_evidence_bundle_v1" as const; export const PUBLIC_EXPORT_POLICY_VERSION = "public_export_policy_v1" as const; export const PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION = "public_evidence_revocation_v1" as const; +export const PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION = "public_route_registry_v1" as const; export const PUBLIC_ADAPTER_FAMILIES = [ "openai-responses", @@ -18,7 +19,7 @@ export interface PublicRouteRegistryEntryV1 { } export interface PublicRouteRegistryManifestV1 { - schemaVersion: "public_route_registry_v1"; + schemaVersion: typeof PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION; registryVersion: string; sourceCommit: string; entries: PublicRouteRegistryEntryV1[]; @@ -168,4 +169,4 @@ export type PublicProjectionNotExportableReason = export type PublicEvidenceProjectionResult = | { status: "exportable"; record: PublicEvidenceRecordV1 } - | { status: "not_exportable"; reason: PublicProjectionNotExportableReason }; + | { status: "not_exportable"; reason: PublicProjectionNotExportableReason; detailCode?: string }; diff --git a/src/lab/public/validate.ts b/src/lab/public/validate.ts index d7930404d..ba562636a 100644 --- a/src/lab/public/validate.ts +++ b/src/lab/public/validate.ts @@ -4,6 +4,7 @@ import { publicEvidenceId } from "./ids"; import { findPublicRouteRegistryEntry } from "./registry"; import { PUBLIC_ADAPTER_FAMILIES, + PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, type PublicAdapterFamily, type PublicAssertionSummaryV1, type PublicEvidenceRecordV1, @@ -354,7 +355,7 @@ export function validatePublicRouteRegistryManifest(rawValue: unknown): PublicRo "entries", "manifestDigest", ]); - if (raw.schemaVersion !== "public_route_registry_v1") { + if (raw.schemaVersion !== PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION) { throw new PublicEvidenceValidationError("unsupported_version", "unsupported public route registry schema"); } const registryVersion = assertPublicIdentifier(raw.registryVersion, "publicRouteRegistry.registryVersion"); @@ -372,7 +373,7 @@ export function validatePublicRouteRegistryManifest(rawValue: unknown): PublicRo } const manifestDigest = assertSha256(raw.manifestDigest, "publicRouteRegistry.manifestDigest"); const expectedDigest = publicEvidenceId("route_registry", { - schemaVersion: "public_route_registry_v1", + schemaVersion: PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, registryVersion, sourceCommit, entries, @@ -381,7 +382,7 @@ export function validatePublicRouteRegistryManifest(rawValue: unknown): PublicRo throw new PublicEvidenceValidationError("digest_invalid", "publicRouteRegistry.manifestDigest mismatch"); } return { - schemaVersion: "public_route_registry_v1", + schemaVersion: PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, registryVersion, sourceCommit, entries, diff --git a/tests/lab-private-file-durability.test.ts b/tests/lab-private-file-durability.test.ts index 01497a3e1..80583aa53 100644 --- a/tests/lab-private-file-durability.test.ts +++ b/tests/lab-private-file-durability.test.ts @@ -7,6 +7,7 @@ import { isPrivateFileStageName, PRIVATE_FILE_STAGE_RETENTION_MS, publishPrivateFileExclusive, + setPrivateFileCleanupSyncFaultForTests, setPrivateFileCommitFaultForTests, } from "../src/lab/public/private-file"; @@ -14,6 +15,7 @@ const roots: string[] = []; afterEach(() => { setPrivateFileCommitFaultForTests(null); + setPrivateFileCleanupSyncFaultForTests(false); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -79,6 +81,32 @@ describe("CL-10 private-file durability", () => { expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); }); + test("stale cleanup keeps the crash witness when its pre-unlink directory sync fails", () => { + if (process.platform === "win32") return; + const root = tempRoot(); + const finalPath = join(root, "bundle.json"); + const bytes = Buffer.from("durable-public-evidence", "utf8"); + + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/directory.*sync|durab/i); + setPrivateFileCommitFaultForTests(null); + + const stages = readdirSync(root).filter(isPrivateFileStageName); + expect(stages).toHaveLength(1); + const stagePath = join(root, stages[0]!); + const old = new Date(Date.now() - PRIVATE_FILE_STAGE_RETENTION_MS - 60_000); + utimesSync(stagePath, old, old); + + setPrivateFileCleanupSyncFaultForTests(true); + expect(() => cleanupStalePrivateFileStages(finalPath)).toThrow(/cleanup.*directory.*sync/i); + expect(readFileSync(finalPath).equals(bytes)).toBe(true); + expect(readdirSync(root).filter(isPrivateFileStageName)).toHaveLength(1); + + setPrivateFileCleanupSyncFaultForTests(false); + cleanupStalePrivateFileStages(finalPath); + expect(readdirSync(root).filter(isPrivateFileStageName)).toEqual([]); + }); + test("Windows publication does not require parent-directory fsync", () => { if (process.platform !== "win32") return; const root = tempRoot(); diff --git a/tests/lab-public-core-contract.test.ts b/tests/lab-public-core-contract.test.ts index c965227a0..9705d5e37 100644 --- a/tests/lab-public-core-contract.test.ts +++ b/tests/lab-public-core-contract.test.ts @@ -4,13 +4,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { labPublicPublisherKeyPath } from "../src/lab/paths"; import { buildPublicEvidenceBundle } from "../src/lab/public/bundle"; +import { validatePublicEvidenceAuthorities } from "../src/lab/public/community-authority"; import { publicEvidenceId } from "../src/lab/public/ids"; import { validatePublicEvidencePrivacy } from "../src/lab/public/privacy"; import { PUBLIC_ROUTE_REGISTRY_V1 } from "../src/lab/public/registry"; import { signPublicEvidenceBundle, verifyPublicEvidenceBundle } from "../src/lab/public/signature"; import { parseStrictPublicJson } from "../src/lab/public/strict-json"; import { publicUtcDay } from "../src/lab/public/time"; -import type { PublicEvidenceBundleUnsignedV1 } from "../src/lab/public/types"; +import { + PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION, + type PublicEvidenceBundleUnsignedV1, +} from "../src/lab/public/types"; import { validatePublicRouteRegistryManifest } from "../src/lab/public/validate"; const FIXED_PRIVATE_KEY = [ @@ -112,6 +116,23 @@ describe("CL-10 public evidence core contract", () => { expect(bundle.bundleDigest).toBe("63fef67418ec196b480bba3865fba287cc92aa94a760e2e3648b0759c0be046e"); }); + test("retains protocol V1 authority as an explicit historical snapshot", () => { + const current = fixedRecord(); + expect(() => validatePublicEvidenceAuthorities([current])).not.toThrow(); + + const { recordId: _recordId, ...body } = current; + const unsupportedBody = { + ...body, + suiteVersion: "9.9.9", + scenarioVersion: "9.9.9", + }; + const unsupported = { + recordId: publicEvidenceId("record", unsupportedBody), + ...unsupportedBody, + }; + expect(() => validatePublicEvidenceAuthorities([unsupported])).toThrow(/not retained/i); + }); + test("rejects non-canonical publisher Base64", () => { const publicKey = `${FIXED_PUBLIC_KEY}\n`; const publisher = { @@ -181,6 +202,7 @@ describe("CL-10 public evidence core contract", () => { test("pins the reviewed public route registry authority", () => { const manifest = validatePublicRouteRegistryManifest(PUBLIC_ROUTE_REGISTRY_V1); + expect(manifest.schemaVersion).toBe(PUBLIC_ROUTE_REGISTRY_SCHEMA_VERSION); expect(manifest.registryVersion).toBe("2026-08-13.v2"); expect(manifest.sourceCommit).toBe(REVIEWED_AUTHORITY_SOURCE_COMMIT); expect(manifest.entries).toEqual([{ @@ -188,5 +210,12 @@ describe("CL-10 public evidence core contract", () => { modelId: "gpt-5.6-sol", adapterFamilies: ["openai-responses"], }]); + + expect(Object.isFrozen(PUBLIC_ROUTE_REGISTRY_V1)).toBe(true); + expect(Object.isFrozen(PUBLIC_ROUTE_REGISTRY_V1.entries)).toBe(true); + for (const entry of PUBLIC_ROUTE_REGISTRY_V1.entries) { + expect(Object.isFrozen(entry)).toBe(true); + expect(Object.isFrozen(entry.adapterFamilies)).toBe(true); + } }); }); diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index 2e7cc6a98..230b1fca8 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -6,8 +6,10 @@ import { jcsStringify } from "../src/lab/conformance/jcs"; import type { ObservationEvent } from "../src/lab/events/types"; import { labPublicPublisherKeyPath } from "../src/lab/paths"; import { publishPrivateFileExclusive } from "../src/lab/public/private-file"; +import { validatePublicEvidencePrivacy } from "../src/lab/public/privacy"; import { projectPublicEvidenceRecord } from "../src/lab/public/project"; import { getOrCreatePublicPublisher } from "../src/lab/public/signature"; +import type { PublicEvidenceBundleUnsignedV1 } from "../src/lab/public/types"; import { resetHardenedStateForTests, setIcaclsRunnerForTests, @@ -36,6 +38,18 @@ test("JCS rejects sparse JavaScript arrays instead of collapsing holes", () => { expect(() => jcsStringify(sparse)).toThrow(/sparse|array hole/i); }); +test("JCS rejects non-plain objects instead of collapsing canonical identity", () => { + const values: unknown[] = [ + new Date(0), + new Map([["a", 1]]), + new Set([1]), + new Uint8Array([1, 2, 3]), + ]; + for (const value of values) { + expect(() => jcsStringify(value)).toThrow(/plain JSON object/i); + } +}); + test("public projection maps JCS-invalid public fields to not_exportable", () => { const observation = { evidenceLayer: "protocol_conformance", @@ -55,6 +69,49 @@ test("public projection maps JCS-invalid public fields to not_exportable", () => }); }); +test("public projection drops invalid completion timestamps with a diagnostic code", () => { + const observation = { + evidenceLayer: "protocol_conformance", + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + completedAt: Date.UTC(10_000, 0, 1), + assertions: [], + subject: { + subjectKind: "protocol", + effectiveAdapter: "openai-chat", + opencodexCompatibilityVersion: "2.13.0", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }, + } as unknown as ObservationEvent; + + expect(projectPublicEvidenceRecord({ observation, verdict: "VERIFIED" })).toEqual({ + status: "not_exportable", + reason: "unsafe_public_field", + detailCode: "public_selection_time", + }); +}); + +test("public privacy rejects embedded POSIX absolute paths", () => { + const bytes = Buffer.from("diagnostic path=/var/folders/9k/opencodex/output.json", "utf8"); + const bundle = { + createdDayUtc: "2026-08-14", + records: [], + artifacts: [{ + artifactId: "0".repeat(64), + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: bytes.byteLength, + contentBase64: bytes.toString("base64"), + }], + } as unknown as PublicEvidenceBundleUnsignedV1; + + expect(() => validatePublicEvidencePrivacy(bundle)).toThrow(/local path|privacy/i); +}); + test("private publication prepares an empty stage before writing secret bytes", () => { const root = configDir("ocx-cl10-private-stage-prepare-"); const finalPath = join(root, "secret.bin"); @@ -100,3 +157,23 @@ test("publisher key creation never publishes the final path when required Window expect(() => getOrCreatePublicPublisher(home)).toThrow(/ACL hardening/i); expect(existsSync(keyPath)).toBe(false); }); + +test("publisher key ACL failures preserve their underlying cause", () => { + const home = configDir("ocx-cl10-windows-publisher-acl-cause-"); + + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setIcaclsRunnerForTests(() => { + throw new Error("synthetic icacls runner failure"); + }); + + let caught: unknown; + try { + getOrCreatePublicPublisher(home); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect((caught as Error & { cause?: unknown }).cause).toBeInstanceOf(Error); +}); From a69b19afe27ad93c603405e8b57a7dd05f1a9d58 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:05:13 +0200 Subject: [PATCH 14/94] fix(lab): close remaining POSIX path privacy gaps --- src/lab/public/privacy.ts | 2 +- tests/lab-public-security-regressions.test.ts | 39 ++++++++++++------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/lab/public/privacy.ts b/src/lab/public/privacy.ts index c215a4490..f4af27a53 100644 --- a/src/lab/public/privacy.ts +++ b/src/lab/public/privacy.ts @@ -13,7 +13,7 @@ const FORBIDDEN_PUBLIC_STRING_PATTERNS: ReadonlyArray<{ label: string; pattern: { label: "local path", pattern: - /(?:[A-Za-z]:[\\/]|\\\\[A-Za-z0-9._-]+\\|(?:^|[\s"'([{=:])\/(?:Users|home|root|tmp|var|opt|private|etc|mnt|media|srv|usr)(?:\/|$))/i, + /(?:[A-Za-z]:[\\/]|\\\\[A-Za-z0-9._-]+\\|(?:^|[\s"'([{=:])\/(?:Users|home|root|tmp|var|opt|private|etc|mnt|media|srv|usr|dev|run|Library|System|Applications|Volumes|bin|sbin|lib|lib64|proc|sys|boot|Network|cores|nix|snap|app)(?:\/|$))/i, }, { label: "email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i }, { label: "IP address", pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b|\[[0-9a-f:]{2,}\]/i }, diff --git a/tests/lab-public-security-regressions.test.ts b/tests/lab-public-security-regressions.test.ts index 230b1fca8..ee8642865 100644 --- a/tests/lab-public-security-regressions.test.ts +++ b/tests/lab-public-security-regressions.test.ts @@ -95,21 +95,30 @@ test("public projection drops invalid completion timestamps with a diagnostic co }); }); -test("public privacy rejects embedded POSIX absolute paths", () => { - const bytes = Buffer.from("diagnostic path=/var/folders/9k/opencodex/output.json", "utf8"); - const bundle = { - createdDayUtc: "2026-08-14", - records: [], - artifacts: [{ - artifactId: "0".repeat(64), - artifactClass: "verifier_summary", - mediaType: "text/plain", - byteCount: bytes.byteLength, - contentBase64: bytes.toString("base64"), - }], - } as unknown as PublicEvidenceBundleUnsignedV1; - - expect(() => validatePublicEvidencePrivacy(bundle)).toThrow(/local path|privacy/i); +test("public privacy rejects embedded POSIX absolute paths across common runtime roots", () => { + const localPaths = [ + "/var/folders/9k/opencodex/output.json", + "/dev/shm/opencodex.sock", + "/run/user/1000/opencodex/token", + "/Library/Application Support/opencodex/config.json", + ]; + + for (const localPath of localPaths) { + const bytes = Buffer.from(`diagnostic path=${localPath}`, "utf8"); + const bundle = { + createdDayUtc: "2026-08-14", + records: [], + artifacts: [{ + artifactId: "0".repeat(64), + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: bytes.byteLength, + contentBase64: bytes.toString("base64"), + }], + } as unknown as PublicEvidenceBundleUnsignedV1; + + expect(() => validatePublicEvidencePrivacy(bundle)).toThrow(/local path|privacy/i); + } }); test("private publication prepares an empty stage before writing secret bytes", () => { From 2a709df13e6236920896a18bbc762015348cccbf Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:05:40 +0200 Subject: [PATCH 15/94] feat(lab): CL-10 public evidence operator and community integration Rebase the reviewed CL-10 operator/community layer onto the current public-evidence core. This squashes the child history onto cl10-public-core while preserving the exact conflict-free GitHub merge tree, including the final review fixes. --- .../010_cl10_public_evidence_export.md | 771 ++++++++++++++++++ ...cl10_revocation_v1_anchor_clarification.md | 17 + ...-12-cl10-public-evidence-implementation.md | 156 ++++ .../2026-08-13-cl10-deep-review-hardening.md | 200 +++++ .../2026-08-14-cl10-final-review-closure.md | 31 + .../2026-08-12-cl10-public-evidence-design.md | 127 +++ gui/src/i18n/lab-translations.ts | 47 +- gui/src/pages/CompatibilityMatrix.tsx | 70 +- gui/src/pages/compatibility-matrix-api.ts | 110 ++- .../compatibility-community-evidence.test.ts | 141 ++++ src/cli/lab.ts | 131 +++ src/lab/index.ts | 16 + src/lab/ledger/purge.ts | 126 ++- src/lab/public/community-files.ts | 29 + src/lab/public/community.ts | 475 +++++++++++ src/lab/public/index.ts | 16 + src/lab/public/mutation-lock.ts | 413 ++++++++++ src/lab/public/operator.ts | 329 ++++++++ src/lab/public/origin-purge.ts | 62 ++ src/lab/public/origin.ts | 195 +++++ src/lab/public/purge-test-fault.ts | 17 + src/lab/public/purge.ts | 212 +++++ src/lab/public/revocation.ts | 246 ++++++ src/server/management/config-routes.ts | 7 +- src/server/management/context.ts | 3 + src/server/management/lab-routes.ts | 167 +++- tests/codex-catalog-sync-hardening.test.ts | 2 +- tests/helpers/startup-health.ts | 33 + tests/lab-community-evidence.test.ts | 223 +++++ tests/lab-community-filename-contract.test.ts | 123 +++ tests/lab-community-mutation-lock.test.ts | 125 +++ ...lab-community-publisher-continuity.test.ts | 120 +++ tests/lab-public-api-json.test.ts | 26 + tests/lab-public-artifact-policy.test.ts | 33 + ...lab-public-deep-review-regressions.test.ts | 248 ++++++ tests/lab-public-evidence.test.ts | 350 ++++++++ tests/lab-public-export-transaction.test.ts | 103 +++ ...ab-public-final-review-regressions.test.ts | 145 ++++ tests/lab-public-lifecycle-hardening.test.ts | 214 +++++ tests/lab-public-privacy-ipv6.test.ts | 22 + tests/lab-public-provenance-recovery.test.ts | 185 +++++ tests/lab-public-review-fixes.test.ts | 208 +++++ tests/lab-public-route-registry.test.ts | 20 + tests/lab-public-surfaces.test.ts | 327 ++++++++ tests/lab-public-wire-contract.test.ts | 132 +++ tests/settings-startup-health-seam.test.ts | 46 ++ tests/settings-stream-mode.test.ts | 15 +- 47 files changed, 6701 insertions(+), 113 deletions(-) create mode 100644 devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md create mode 100644 devlog/_plan/260807_compatibility_lab/011_cl10_revocation_v1_anchor_clarification.md create mode 100644 docs/superpowers/plans/2026-08-12-cl10-public-evidence-implementation.md create mode 100644 docs/superpowers/plans/2026-08-13-cl10-deep-review-hardening.md create mode 100644 docs/superpowers/plans/2026-08-14-cl10-final-review-closure.md create mode 100644 docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md create mode 100644 gui/tests/compatibility-community-evidence.test.ts create mode 100644 src/lab/public/community-files.ts create mode 100644 src/lab/public/community.ts create mode 100644 src/lab/public/index.ts create mode 100644 src/lab/public/mutation-lock.ts create mode 100644 src/lab/public/operator.ts create mode 100644 src/lab/public/origin-purge.ts create mode 100644 src/lab/public/origin.ts create mode 100644 src/lab/public/purge-test-fault.ts create mode 100644 src/lab/public/purge.ts create mode 100644 src/lab/public/revocation.ts create mode 100644 tests/helpers/startup-health.ts create mode 100644 tests/lab-community-evidence.test.ts create mode 100644 tests/lab-community-filename-contract.test.ts create mode 100644 tests/lab-community-mutation-lock.test.ts create mode 100644 tests/lab-community-publisher-continuity.test.ts create mode 100644 tests/lab-public-api-json.test.ts create mode 100644 tests/lab-public-artifact-policy.test.ts create mode 100644 tests/lab-public-deep-review-regressions.test.ts create mode 100644 tests/lab-public-evidence.test.ts create mode 100644 tests/lab-public-export-transaction.test.ts create mode 100644 tests/lab-public-final-review-regressions.test.ts create mode 100644 tests/lab-public-lifecycle-hardening.test.ts create mode 100644 tests/lab-public-privacy-ipv6.test.ts create mode 100644 tests/lab-public-provenance-recovery.test.ts create mode 100644 tests/lab-public-review-fixes.test.ts create mode 100644 tests/lab-public-route-registry.test.ts create mode 100644 tests/lab-public-surfaces.test.ts create mode 100644 tests/lab-public-wire-contract.test.ts create mode 100644 tests/settings-startup-health-seam.test.ts diff --git a/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md b/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md new file mode 100644 index 000000000..1f54b53e5 --- /dev/null +++ b/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md @@ -0,0 +1,771 @@ +# CL-10 - Public Evidence Export, Publishing, and Community Trust + +## Programme position + +**Repository:** `lidge-jun/opencodex` +**Integration target:** `dev` +**Branch:** `feat/cl-10-public-evidence-contract` +**Starting SHA:** `4fed8d3fe431ad23be83f3aff2af18ef8b8ecd71` +**CL-09 merge prerequisite:** satisfied by #1489 at `4fed8d3fe431ad23be83f3aff2af18ef8b8ecd71` + +CL-09 is merged. CL-10 is the final planned Compatibility Lab phase. + +This PR began contract-only and the contract was independently reviewed and accepted on 2026-08-12. CL-10.1 through CL-10.4 runtime implementation is now authorized on this branch. CL-10.5 remote publishing remains blocked until the exact transport/service contract in section 18 is independently accepted. + +--- + +# 1. Goal + +CL-10 answers: + +> How can a user deliberately export and, later, publish a narrowly allowlisted subset of Compatibility Lab evidence for community use without leaking installation-local identifiers, custom configuration, user data, credentials, or private operational metadata, and without letting untrusted community data silently affect local canonical verdicts or routing? + +The architecture is deliberately one-way at the local trust boundary: + +```text +Local canonical Lab evidence + | + | explicit export projection only + v +Public allowlist projector + | + +--> export privacy scan / fail closed + | + +--> export-scoped IDs + | + +--> optional public-export artifacts only + v +Canonical public bundle + | + +--> local preview/export + +--> explicit publish action, only after transport contract is accepted + v +Community bundle + | + +--> schema/digest/signature verification + +--> separate community trust/cache domain + | + X--> no write into local compatibility.jsonl + X--> no canonical verdict promotion/degradation + X--> no Routing Profile or Router Intelligence input + X--> no CL-08 scheduling input +``` + +CL-10 shares evidence. It does not transfer local authority. + +--- + +# 2. Existing authority carried forward + +CL-10 must preserve the existing CL-00 security/privacy contract, especially its `Local evidence versus public export` boundary: + +- public export uses a new allowlist-only schema; +- local subject/event/artifact IDs are replaced with export-scoped opaque IDs; +- endpoint and provider-instance fingerprints are omitted; +- local request, decision, and Fabric references are omitted; +- precise local paths, custom headers, project/location, account context, local errors, and raw latency traces are omitted; +- custom provider/model names are private by default; +- artifact bytes are exportable only when their policy explicitly allows `public_export`; +- export-specific secret/PII scanning is mandatory; +- unknown fields fail closed. + +CL-10 may tighten those rules. It must not weaken them silently. + +--- + +# 3. Hard CL-10 invariants + +CL-10 V1 must guarantee: + +```text +0 automatic telemetry upload +0 background publishing without an explicit user action +0 export of local subject/event/artifact/request/decision/Fabric identifiers +0 export of endpoint/provider-instance/custom-header/project/location fingerprints +0 export of credentials, account identity, prompts, responses, tool payloads, repository data, paths, or hidden reasoning +0 export of custom provider/model names unless a later reviewed public-registry authority explicitly permits them +0 community bundle writes into compatibility.jsonl +0 community evidence promotion/degradation of canonical local verdicts +0 community evidence influence on Routing Profiles or Router Intelligence +0 community evidence influence on CL-08 scheduling +0 combined local/community compatibility score +``` + +Export, publish, import, verification, or community-cache failure must not affect normal production request execution. + +--- + +# 4. Chosen approach + +Three approaches were considered. + +## 4.1 Chosen: deterministic public projection plus separate community trust domain + +Project local evidence into a new public schema containing only export-safe fields. Produce a canonical bundle with a digest and publisher signature. Community imports are verified and stored outside the local canonical evidence authority. + +Benefits: + +- privacy boundary is explicit and machine-testable; +- exported bytes are reproducible from the same local evidence and export policy; +- local IDs never leave the installation; +- community provenance can be verified without treating publisher claims as canonical truth; +- imported evidence cannot contaminate local verdicts or routing. + +## 4.2 Rejected: publish local Lab JSONL or SQLite rows directly + +The local schemas contain installation-scoped identifiers and fields whose local visibility does not imply public-export permission. Direct publication would make privacy depend on callers remembering ad-hoc redaction rules. + +## 4.3 Rejected: remote service as canonical evidence authority + +A hosted service may aggregate public bundles later, but it must not become the canonical authority for local Lab verdicts. OpenCodex must remain able to reproduce local verdicts from local canonical evidence without network access. + +--- + +# 5. Public exportability gate + +An observation is exportable only when all required public identity fields can be represented without private configuration. + +V1 exportable routes are limited to entries in the repo-reviewed `PublicRouteRegistryManifestV1` whose exported behavior identity is entirely composed from reviewed public fields. + +The public-route authority is a versioned, content-addressed repository artifact owned by OpenCodex, not a publisher-supplied assertion: + +```ts +interface PublicRouteRegistryManifestV1 { + schemaVersion: "public_route_registry_v1"; + registryVersion: string; + sourceCommit: string; + entries: PublicRouteRegistryEntryV1[]; + manifestDigest: string; +} + +interface PublicRouteRegistryEntryV1 { + providerId: string; + modelId: string; + adapterFamilies: Array<"openai-responses" | "openai-chat" | "anthropic-messages">; +} +``` + +CL-10.1 must ship and validate this manifest before any route-scoped record is exportable. The manifest may be updated only by reviewed repository changes with a new digest/version. Dynamic model discovery, cached catalogs, user configuration, imported bundles, and a matching spelling alone can never extend this authority. + +A route is not exportable when any behavior-relevant identity depends on a private/custom value, including: + +- custom provider instance or custom provider name; +- custom model ID or alias not in the reviewed public registry authority; +- non-default/custom endpoint identity; +- private/custom header behavior; +- project, location, tenant, deployment, organization, or account context; +- private-network destination behavior; +- any other local behavior fingerprint that cannot be represented publicly without weakening exact-route semantics. + +Failing this gate is `not_exportable`, not an error and not a compatibility verdict. + +CL-10 must never broaden exact local evidence into a more general public claim merely by dropping private route dimensions. + +--- + +# 6. Public evidence schema + +CL-10 introduces `PublicEvidenceBundleV1` as a closed, versioned, allowlist-only schema. + +Conceptually: + +```ts +interface PublicEvidenceBundleV1 { + schemaVersion: "public_evidence_bundle_v1"; + exportPolicyVersion: "public_export_policy_v1"; + bundleId: string; + createdDayUtc: string; + publisher: PublicPublisherV1; + records: PublicEvidenceRecordV1[]; + artifacts: PublicArtifactV1[]; + bundleDigest: string; + signature: PublicBundleSignatureV1; +} + +interface PublicEvidenceRecordV1 { + recordId: string; + subjectId: string; + evidenceLayer: "protocol_conformance" | "live_route_compatibility" | "task_effectiveness"; + suiteId: string; + suiteVersion: string; + scenarioId: string; + scenarioVersion: string; + verdict: "CLAIMED" | "PROBED" | "VERIFIED" | "DEGRADED" | "UNSUPPORTED" | "BLOCKED" | "UNKNOWN"; + observedDayUtc: string; + subject: PublicEvidenceSubjectV1; + assertions: PublicAssertionSummaryV1[]; + incidentRefs?: PublicIncidentRefV1[]; + artifactRefs?: string[]; +} + +type PublicEvidenceSubjectV1 = + | PublicProtocolSubjectV1 + | PublicRouteSubjectV1 + | PublicTaskSubjectV1; + +interface PublicIncidentRefV1 { + corpusId: string; // exact reviewed `IC-NNN` identifier only +} +``` + +The public runtime types are dedicated CL-10 types. They may import closed scalar unions such as the existing verdict/evidence-layer literals, but they must not alias, extend, spread, or serialize local ledger/query DTO interfaces. A compile-time TypeScript shape is not the security boundary: every export/import path must pass the dedicated runtime validator for the matching public schema version. + +`PublicEvidenceSubjectV1` is layer-matched: protocol records use only a public protocol descriptor, live-route records use only a public route descriptor backed by `PublicRouteRegistryManifestV1`, and task records use a public task descriptor that nests the same public route descriptor plus reviewed public task/verifier authority fields. A layer/subject-kind mismatch is `schema_rejected`. + +Unknown top-level or nested fields fail export and import validation. + +`incidentRefs` contain only exact reviewed corpus identifiers matching `^IC-[0-9]{3}$` that exist in the repository incident authority. They never contain the corpus entry's historical issue URLs, devlog paths, test paths, prose, or source metadata. `artifactRefs` contain only public artifact IDs present in the same bundle; local artifact digests/relative paths are forbidden. + +--- + +# 7. Export-scoped identity + +Local identifiers must never be serialized into a public bundle. + +`bundleId`, `recordId`, `subjectId`, and public artifact IDs are derived only from canonical export-safe bytes under explicit domain-separated SHA-256 inputs. They must have no reversible or keyed relationship to: + +- local `RouteSubjectV1.subjectId`; +- local observation/event IDs; +- local artifact digests when the artifact is not explicitly public-exportable; +- request IDs; +- route decision IDs; +- Fabric/task references; +- installation salt. + +A public subject ID may be deterministic across publishers only from fields that are already public in `PublicRouteDescriptorV1`. It must never include or hash a private local dimension. + +--- + +# 8. Public route descriptor + +`PublicRouteDescriptorV1` contains only reviewed public registry identity and protocol behavior needed to interpret a community record. + +At minimum it may contain: + +```ts +interface PublicRouteDescriptorV1 { + providerId: string; + modelId: string; + adapterFamily: "openai-responses" | "openai-chat" | "anthropic-messages"; + compatibilityVersion: string; +} +``` + +`providerId` and `modelId` must come from an explicit public-registry allowlist. A configured value matching the spelling of a public ID is insufficient if the effective route uses private behavior dimensions that make the public claim ambiguous. + +No endpoint, headers, project/location, provider-instance identifier, account identifier, credential class, quota plan, or private capability fingerprint is included. + +--- + +# 9. Time and diagnostic minimization + +Public records use UTC day buckets (`YYYY-MM-DD`), not precise local timestamps. + +V1 exports no raw request latency, token timing, transport phase trace, provider error message, local error code, or local failure string. + +Assertion summaries must use scenario-defined closed assertion IDs and bounded result enums. They must not contain arbitrary observed strings. + +If an existing scenario assertion cannot be represented without free-form/private output, that assertion is omitted only when the scenario contract permits a complete public summary without it; otherwise the record is `not_exportable`. + +--- + +# 10. Public artifact policy + +Local artifact visibility does not imply public-export permission. + +An artifact may appear in a public bundle only when all are true: + +1. its producer/scenario policy explicitly marks the artifact class `public_export`; +2. bytes are already synthetic/sanitized under Lab artifact rules; +3. CL-10 performs a second export-specific sanitizer and secret/PII scan; +4. the artifact satisfies public bundle size/type limits; +5. the public artifact digest is computed from the final exported bytes, not copied from a private/local reference by assumption. + +V1 does not export arbitrary text logs, provider errors, traces containing timing detail, task patches, terminal logs, repository content, or raw request/response shapes. + +--- + +# 11. Export privacy scanner + +Before a bundle can be written as publishable, CL-10 must run an export-specific fail-closed validator. + +It must reject: + +- unknown fields; +- strings outside field-specific bounds; +- token/credential canaries; +- email/account/project/tenant identifiers; +- URLs, local paths, IP addresses where identifying, query strings, header-like material, or authorization values; +- local Lab IDs and known request/decision/Fabric ID formats; +- custom provider/model identifiers; +- precise timestamps where only day buckets are allowed; +- artifact bytes not explicitly marked `public_export`. + +`bun run privacy:scan` remains defense in depth and is not a substitute for this validator. + +--- + +# 12. Consent and user control + +There is no automatic export or publishing. + +V1 user flow must be explicit: + +```text +select export scope + -> generate local preview + -> show included record/artifact counts and excluded/not_exportable counts + -> explicit export action + -> local canonical bundle + -> optional explicit publish action only if a publish transport is authorized +``` + +Generating a preview performs no network request. + +A publish action must require an explicit user action for the specific bundle. CL-10 V1 must not introduce an always-on telemetry toggle, silent background upload, startup upload, or production-request-path upload. + +Deleting local Lab data remains absolute locally. Public copies already distributed cannot be cryptographically erased, so revocation semantics are required separately. + +### Sensitive purge interaction + +CL-00 sensitive purge remains authoritative over CL-10 local copies. A purge whose closed action set includes `export` must fail closed until every affected local export/staging copy is removed. CL-10 must additionally remove any locally-originated copy of an affected bundle that has been imported into the local `community/` cache. Third-party community bundles are unrelated to the local sensitive bytes and are not deleted merely because they contain the same public route identity. + +A local sensitive purge never waits for network access. If an affected bundle was previously published, CL-10 records or emits a bounded signed `privacy_retraction` revocation for its public bundle/record IDs when the reviewed transport is available, but remote acknowledgement is not a prerequisite for completing the mandatory local purge. The purge must not retain sensitive bytes merely to construct a later revocation. + +--- + +# 13. Publisher provenance and signatures + +A published bundle must be self-verifying for integrity and publisher continuity without exposing account identity. + +CL-10 V1 uses an installation-local Ed25519 publisher key created only when the user first requests a publishable bundle or publication. + +The private key: + +- lives outside JSONL, SQLite, artifacts, export bundles, and community cache; +- uses secret-file permissions; +- is never logged or exposed through API/UI/CLI output; +- is never used for route-subject identity or local verdict derivation. + +The public bundle contains: + +```ts +interface PublicPublisherV1 { + algorithm: "ed25519"; + keyId: string; + publicKey: string; +} + +interface PublicBundleSignatureV1 { + algorithm: "ed25519"; + signedDigest: string; + signature: string; +} +``` + +`keyId` is a domain-separated SHA-256 digest of the public key. + +A valid signature proves only that the same publisher key signed those exact canonical bytes. It does not prove the evidence is honest, representative, current, or trustworthy. + +## 13.1 Frozen canonical byte and signature contract + +CL-10 V1 uses RFC 8785 JSON Canonicalization Scheme (JCS) as the only canonical JSON representation. Canonical JSON bytes are UTF-8 bytes of the JCS string. Raw serialized imports must be valid UTF-8 JSON and must reject duplicate decoded object member names before semantic object construction. Duplicate detection is semantic after JSON string escape decoding, so `"a"` and `"\u0061"` are the same member name and must fail closed if both appear in one object. + +All public hash identities use the exact construction: + +```text +H(domain, value) = SHA-256(UTF8(domain) || 0x00 || UTF8(JCS(value))) +``` + +No trailing NUL is added. The exact V1 domain strings are: + +```text +subject ocx-lab-public:subject:v1 +record ocx-lab-public:record:v1 +bundle ocx-lab-public:bundle:v1 +bundle_digest ocx-lab-public:bundle-digest:v1 +artifact ocx-lab-public:artifact:v1 +publisher_key ocx-lab-public:publisher-key:v1 +revocation ocx-lab-public:revocation:v1 +route_registry ocx-lab-public:route-registry:v1 +``` + +The bundle identity preimages are frozen as semantic objects before JCS: + +```text +C = { + schemaVersion, + exportPolicyVersion, + createdDayUtc, + publisher, + records, + artifacts +} + +bundleId = H("ocx-lab-public:bundle:v1", C) + +bundleDigest = H( + "ocx-lab-public:bundle-digest:v1", + { ...C, bundleId } +) +``` + +Therefore `bundleId` is excluded from its own preimage, and both `bundleDigest` and `signature` are excluded from the `bundleId` preimage. `bundleDigest` includes the computed `bundleId`, but excludes both `bundleDigest` and `signature`. A bundle signature is exactly: + +```text +signature.algorithm = "ed25519" +signature.signedDigest = bundleDigest +signature.signature = Base64(Ed25519.Sign(privateKey, HexDecode(bundleDigest))) +``` + +The signature input is exactly the raw 32 bytes produced by hex-decoding the 64-character lowercase SHA-256 `bundleDigest`. There is no additional signature prefix because the signed digest is already domain-separated by `ocx-lab-public:bundle-digest:v1`. + +Publisher identity is exactly: + +```text +keyId = H( + "ocx-lab-public:publisher-key:v1", + { algorithm: "ed25519", publicKey } +) +``` + +where `publicKey` is the canonical Base64 representation of the Ed25519 SPKI DER bytes. + +Revocations use the same construction with a separate domain. After canonical sorting and duplicate rejection of targets: + +```text +R = { + schemaVersion, + issuedDayUtc, + publisher, + targets, + reason +} + +revocationId = H("ocx-lab-public:revocation:v1", R) +signature.signedDigest = revocationId +signature.signature = Base64(Ed25519.Sign(privateKey, HexDecode(revocationId))) +``` + +`revocationId` and `signature` are excluded from `R`. This binds schema/version, exact publisher identity, target bundle/record IDs, issued day, and finite reason under the dedicated revocation domain. + +Verification order is normative and exact for raw imported bundles: + +1. enforce the serialized byte ceiling; +2. require valid UTF-8 and reject duplicate decoded JSON object member names before object construction; +3. parse JSON and enforce nesting, array, object-key, and string bounds; +4. enforce the closed schema/version/field rules and recompute `publisher.keyId`; +5. recompute public subject/record/artifact identities, references, `bundleId`, and `bundleDigest` from canonical public-safe fields; +6. require `signature.signedDigest === bundleDigest`; +7. decode the canonical Ed25519 SPKI key and Base64 signature and verify Ed25519 over `HexDecode(bundleDigest)`; +8. validate repository-owned public-route, suite, scenario, verifier, and Fabric authority references; +9. for revocations, bootstrap authority only from an already-verified target bundle and require the exact publisher algorithm, `keyId`, and public key plus valid target membership before applying the revocation; +10. persist only after every preceding applicable check succeeds. + +A fixed test vector must lock these byte-level semantics so serializer, hash-domain, field-set, digest, or signing changes cannot silently create a second V1 wire format. + +--- + +# 14. Community trust model + +Imported community evidence is a separate trust class: `community_untrusted_v1`. + +Verification checks: + +- closed schema version; +- size/structure limits; +- canonical bundle digest; +- publisher signature; +- public route allowlist; +- scenario/suite authority references; +- export-policy version; +- revocation status when available. + +Passing verification means `cryptographically_valid`, not `locally_verified`. + +Community evidence must not: + +- append to `compatibility.jsonl`; +- rebuild or alter local canonical verdicts; +- refresh local evidence freshness; +- satisfy Routing Profile compatibility requirements; +- change Router Intelligence eligibility or scoring; +- trigger CL-08 refresh work; +- merge with local evidence into a single score. + +The UI/API/CLI must label it explicitly as community evidence and distinguish signature validity from compatibility truth. + +--- + +# 15. Community storage boundary + +Community bundles, if persisted, live outside the local canonical Lab ledger in a separate non-authoritative object/cache domain under the Lab root. + +Conceptually: + +```text +~/.opencodex/lab/ + compatibility.jsonl # local canonical authority, unchanged + compatibility.sqlite # local disposable projection, unchanged + artifacts/ # local Lab artifacts, unchanged + exports/ # user-created public bundles + community/ # non-authoritative imported public bundles/cache +``` + +The community store must not reuse local event IDs or masquerade as local observations. + +Deleting `community/` loses only imported community context and has no effect on local verdict reproducibility. + +--- + +# 16. Import boundary + +CL-10 V1 import accepts only bounded bundle bytes through reviewed entry points. It must not dereference arbitrary embedded URLs, paths, artifact references, or publisher-controlled network locations. + +A bundle is parsed with strict byte, UTF-8, duplicate-object-member, nesting, array, object-key, and string limits before expensive signature or projection work. Duplicate decoded object member names are rejected before `JSON.parse`-style semantic object construction so parsers cannot silently collapse an ambiguous wire representation. + +Invalid bundles are rejected without partial persistence. + +Artifact content embedded in/imported with a bundle is accepted only for closed `public_export` artifact classes and is revalidated locally before storage. + +--- + +# 17. Revocation and deletion semantics + +CL-10 defines `PublicEvidenceRevocationV1` as a signed, bounded public statement from the same publisher key that signed the target bundle and references one or more bundle/record IDs plus a finite reason code. + +A consumer bootstraps revocation authority from the already-verified target bundle: `publisher.keyId` and the exact Ed25519 public key in the revocation must match that target bundle before the revocation signature is considered. V1 does not support cross-key revocation or key rotation. A key-rotation protocol requires a later reviewed schema version. + +A revocation contains its own domain-separated digest/ID, `issuedDayUtc`, at most 256 sorted unique target IDs, and no free-form reason text. Re-importing the exact same revocation ID and bytes is idempotent. The same revocation ID with different canonical bytes, duplicate target IDs, an unknown target, unsupported reason/version, or a publisher-key mismatch is rejected. Consumers may retain bounded revocations received before a referenced record only in a quarantined pending set with the same structural limits; they do not become effective until the matching publisher/target bundle is present and verified. + +Allowed reason classes include: + +- `publisher_retracted`; +- `privacy_retraction`; +- `evidence_invalidated`; +- `superseded`. + +A revocation never edits the original local Lab ledger. + +Community consumers mark matching imported records revoked and exclude them from default community summaries while preserving the revocation audit relation. + +Remote physical deletion is a transport/service concern and cannot replace cryptographic revocation semantics. + +--- + +# 18. Remote publishing boundary + +This contract freezes bundle, consent, signing, verification, and trust semantics before choosing a remote service. + +No network publishing implementation is authorized until the same CL-10 branch or a reviewed follow-up contract records: + +- the exact service origin(s); +- authentication model, if any; +- maximum request/body budgets; +- TLS and redirect policy; +- retry/idempotency semantics; +- server retention and deletion policy; +- abuse/rate-limit behavior; +- revocation endpoint semantics; +- server-side schema validation; +- operator ownership and privacy policy. + +The publisher must not accept an arbitrary user-supplied upload URL as a shortcut around this gate. + +A fixed reviewed service may aggregate community bundles later, but local OpenCodex behavior remains fully functional without it. + +--- + +# 19. Read surfaces + +CL-10 implementation should extend existing Lab surfaces rather than create an unrelated product area. CL-10.1 through CL-10.4 are authorized after the accepted contract review; this does not relax the remote-publishing gate. + +Planned surfaces after contract acceptance: + +- CLI preview/export/verify/community inspection commands under `ocx lab`; +- authenticated management API for preview/export metadata and local community inspection; +- Compatibility Matrix detail UI for clearly separated community context; +- explicit publish UI only after the remote publishing transport contract is accepted. + +The local Compatibility Matrix must never silently replace its canonical verdict with a community result. + +--- + +# 20. Bounds + +V1 hard export/import ceilings: + +```text +maximum records per bundle 256 +maximum public artifacts per bundle 16 +maximum bytes per public artifact 256 KiB +maximum aggregate public artifact data 1 MiB +maximum serialized bundle bytes 2 MiB +maximum assertion summaries per record 64 +maximum incident references per record 32 +maximum serialized string field 4 KiB +maximum JSON nesting depth 8 +maximum object keys 64 +maximum array elements 512 +``` + +Implementations may use lower limits. Raising a hard ceiling requires a reviewed contract change. + +--- + +# 21. Failure semantics + +Export and import use explicit non-verdict outcomes. + +At minimum: + +```text +exportable +not_exportable +privacy_rejected +schema_rejected +signature_invalid +digest_invalid +revoked +unsupported_version +storage_failure +transport_unavailable +publish_rejected +``` + +These outcomes must never be mapped to local compatibility `DEGRADED` or `UNSUPPORTED` verdicts. + +--- + +# 22. Security tests required before implementation acceptance + +CL-10 implementation must include adversarial tests for: + +- prompt/response/tool/repository/path canaries; +- API keys, OAuth tokens, cookies, authorization headers, and common secret formats; +- account/email/project/tenant/location canaries; +- local subject/event/artifact/request/decision/Fabric IDs; +- custom provider/model IDs; +- URLs/query strings/IP addresses/header dumps; +- precise timestamps and raw latency/error fields; +- unknown JSON fields at every public schema level; +- duplicate decoded JSON object member names, including escape-equivalent keys; +- malformed/oversized/deeply nested import bundles; +- invalid signatures and digests; +- a fixed RFC 8785/domain-separated bundle digest and Ed25519 signature vector; +- bundle replay/deduplication; +- revoked bundles; +- community evidence isolation from local verdicts, routing, and CL-08; +- deterministic export from identical local inputs; +- non-exportability when private route dimensions would be erased. + +--- + +# 23. Delivery sequence + +## CL-10.0 - Audit and contract + +Contract work completed on this PR before runtime implementation: + +- record CL-09 closure; +- freeze public exportability and privacy rules; +- freeze public bundle schema and export-scoped identity; +- freeze publisher-signature and community trust semantics; +- freeze consent, revocation, import isolation, and remote-publishing gate; +- define implementation sequence and validation requirements. + +Independent review accepted CL-10.0 on 2026-08-12. CL-10.1 through CL-10.4 are therefore authorized on this branch by explicit maintainer direction. CL-10.5 remains blocked by section 18. + +## CL-10.1 - Public projector and privacy validator + +Implement closed public DTOs, exportability checks, export-scoped IDs, deterministic canonicalization, and fail-closed privacy validation. + +## CL-10.2 - Public bundle storage and publisher signatures + +Implement local public-bundle storage plus publisher-key lifecycle, bundle digesting, Ed25519 signing, and verification. + +## CL-10.3 - Local preview/export surfaces + +Implement CLI/API/UI preview and explicit local export. No remote publishing yet. + +## CL-10.4 - Community import and quarantine/read surfaces + +Implement strict import/verification, separate non-authoritative community storage, revocation handling, and clearly labelled read surfaces. No routing/verdict integration. + +## CL-10.5 - Remote publishing transport + +Implement only after the exact remote-service contract in section 18 is completed and independently accepted. + +## CL-10.6 - Adversarial closure and programme acceptance + +Run privacy, trust, cross-platform, no-feedback, reproducibility, and independent review gates. On acceptance, mark Compatibility Lab CL-00 through CL-10 complete. + +--- + +# 24. Explicit non-goals + +CL-10 V1 must not implement: + +- automatic telemetry; +- background production evidence upload; +- raw local Lab ledger export; +- custom/private route publication; +- user prompt/response/tool/repository export; +- account-linked public identity; +- community evidence as canonical local evidence; +- community-driven Routing Profile or Router Intelligence behavior; +- community-driven CL-08 scheduling; +- global compatibility score or leaderboard that mixes incomparable evidence layers; +- arbitrary upload/download URLs; +- remote code/tool execution; +- public artifact classes without explicit `public_export` policy. + +--- + +# 25. Contract acceptance criteria + +CL-10.0 is accepted only when independent review agrees that: + +1. no local/private identifier is required by the public schema; +2. exact local evidence cannot be generalized into a misleading public claim by dropping private route dimensions; +3. exported fields are closed, bounded, versioned, and fail closed on unknown fields; +4. public artifacts require explicit opt-in policy and second-pass sanitization; +5. export/publish requires explicit user action and creates no automatic telemetry path; +6. publisher signatures prove integrity/continuity without being misrepresented as evidence truth; +7. imported community evidence is isolated from local canonical evidence, freshness, routing, and scheduling; +8. revocation semantics are defined independently of remote physical deletion; +9. remote transport remains gated until an exact service/security contract exists; +10. implementation tasks have adversarial privacy and trust tests sufficient to prevent silent boundary regression. + +--- + +# 26. Validation + +Contract PR minimum: + +```text +git diff --check +repository markdown / hygiene checks +CodeRabbit / independent review +``` + +Implementation phases must additionally run: + +```text +bun x tsc --noEmit +bun run privacy:scan +focused Lab export/import/signature tests +focused ledger/projection isolation tests +Routing Profile / Router Intelligence no-feedback regressions +CL-08 no-feedback regressions +CLI/API/GUI tests for implemented surfaces +cross-platform CI +``` + +--- + +# 27. Hard stop + +The CL-10 contract was independently accepted on 2026-08-12 and explicit maintainer direction authorizes CL-10.1 through CL-10.4 runtime implementation on this branch. + +No CL-10.5 remote publishing code, upload transport, remote fetch, or arbitrary network publication is authorized until section 18 has been completed with an exact reviewed transport contract and independently accepted. diff --git a/devlog/_plan/260807_compatibility_lab/011_cl10_revocation_v1_anchor_clarification.md b/devlog/_plan/260807_compatibility_lab/011_cl10_revocation_v1_anchor_clarification.md new file mode 100644 index 000000000..829e8aefa --- /dev/null +++ b/devlog/_plan/260807_compatibility_lab/011_cl10_revocation_v1_anchor_clarification.md @@ -0,0 +1,17 @@ +# CL-10 V1 revocation anchor clarification + +Status: normative clarification to `010_cl10_public_evidence_export.md` section 17. + +`PublicEvidenceRevocationV1` uses exactly one already-verified target bundle as its authority anchor. The revocation may contain between 1 and 256 sorted unique targets, but every target must resolve inside that one anchor bundle: + +- a `bundle` target must equal the anchor bundle ID; +- a `record` target must name a record contained by the anchor bundle; +- mixed bundle/record targets are allowed only when they all resolve inside the same anchor bundle; +- multiple distinct bundle IDs in one V1 revocation are not supported and must be rejected; +- targets spread across multiple bundles are not supported even when those bundles use the same publisher key. + +The publisher algorithm, key ID and exact public key in the revocation must match the already-verified anchor bundle before the revocation signature is authoritative. V1 therefore has no cross-key, key-rotation or multi-bundle authority bootstrap. + +The phrase "one or more bundle/record IDs" in section 17 describes the bounded target list, not multiple independent bundle authority contexts. Where that wording could be read as authorizing a single V1 revocation across multiple bundles, this clarification is authoritative. + +Supporting multi-bundle revocation requires a separately reviewed contract/schema revision that defines how all target bundles are supplied, verified, bounded and bound to the signing authority before persistence or application. diff --git a/docs/superpowers/plans/2026-08-12-cl10-public-evidence-implementation.md b/docs/superpowers/plans/2026-08-12-cl10-public-evidence-implementation.md new file mode 100644 index 000000000..5c58021ba --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-cl10-public-evidence-implementation.md @@ -0,0 +1,156 @@ +# CL-10 Public Evidence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement CL-10.1 through CL-10.4: deterministic privacy-safe public evidence projection, signed local bundles, explicit local export, and quarantined community import/read surfaces, while keeping remote publishing blocked. + +**Architecture:** Add a dedicated `src/lab/public/` boundary with independently versioned public types and strict validators. Public bundles are derived from valid local Lab evidence only after an exact exportability gate, signed with a local Ed25519 publisher key, and stored separately from the canonical ledger. Imported bundles are bounded, signature-checked, and stored only in a non-authoritative community domain that never feeds local verdicts, routing, or CL-08. + +**Tech Stack:** TypeScript, Bun tests, Node `crypto` Ed25519, existing Lab JSONL/SQLite/query/digest/path infrastructure, existing `ocx lab` CLI and authenticated management API, existing Compatibility Matrix UI/i18n. + +## Global Constraints + +- No automatic telemetry or background publishing. +- No remote publishing implementation in this plan; CL-10.5 remains blocked until an exact reviewed service contract exists. +- No local subject/event/artifact/request/decision/Fabric identifier may appear in a public bundle. +- Private/custom route dimensions make evidence `not_exportable`; they are never dropped to broaden a public claim. +- Public schemas are closed and independently versioned; unknown fields fail closed. +- Public route identity uses a repo-reviewed, versioned allowlist authority. Dynamic discovery/configuration cannot extend it. +- Public incident references are closed corpus IDs only; historical URLs/devlog paths are never exported. +- Community evidence is `community_untrusted_v1`, never canonical local evidence, freshness, routing, or CL-08 input. +- Sensitive purge removes affected generated exports and locally-originated community copies; network revocation is never a prerequisite for completing a local purge. +- Publisher signatures prove integrity/continuity only, not evidence truth. + +--- + +### Task 1: Freeze review amendments and implementation authority + +**Files:** +- Modify: `devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md` +- Modify: `docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md` + +**Interfaces:** +- Consumes: CL-00 purge/public-export contracts and merged CL-09 state. +- Produces: final CL-10.1–CL-10.4 runtime contract; CL-10.5 remains explicitly blocked. + +- [ ] **Step 1:** Add explicit purge/export/community-copy semantics consistent with CL-00 `purgeActions: export`. +- [ ] **Step 2:** Define `PublicRouteRegistryManifestV1` as the versioned local trust anchor for public provider/model identity. +- [ ] **Step 3:** Define bounded revocation bootstrap: target publisher key must match the original bundle publisher; duplicates are idempotent; conflicting replay fails closed; no V1 key rotation. +- [ ] **Step 4:** Replace arbitrary `incidentRefs` with closed `IC-NNN` references and require `artifactRefs` to resolve only to public artifact IDs in the same bundle. +- [ ] **Step 5:** Replace the route-only record assumption with a closed `PublicEvidenceSubjectV1` union for protocol/route/task evidence and require dedicated runtime validators/types. +- [ ] **Step 6:** Record that independent review accepted the contract and the user authorized CL-10.1–CL-10.4 runtime implementation on this PR; preserve the CL-10.5 transport hard stop. + +### Task 2: Public schema, registry authority, and privacy projector + +**Files:** +- Create: `src/lab/public/types.ts` +- Create: `src/lab/public/registry.ts` +- Create: `src/lab/public/validate.ts` +- Create: `src/lab/public/project.ts` +- Create: `src/lab/public/index.ts` +- Modify: `src/lab/index.ts` +- Test: `tests/lab-public-evidence.test.ts` + +**Interfaces:** +- Produces: `PublicEvidenceBundleUnsignedV1`, `PublicEvidenceRecordV1`, `PublicEvidenceSubjectV1`, `PublicRouteRegistryManifestV1`, `projectPublicEvidence()`, `validatePublicEvidenceBundle()`. + +- [ ] **Step 1: Write RED tests** for closed-schema rejection, deterministic public IDs/day buckets, protocol/route/task subject discrimination, exact route allowlist, private-route `not_exportable`, IC-only incident refs, no local ID leakage, and secret/PII canaries. +- [ ] **Step 2: Run focused test and verify expected RED failures.** + Run: `bun test tests/lab-public-evidence.test.ts` +- [ ] **Step 3: Implement minimal closed public types/registry/validator/projector.** + Public identities use domain-separated SHA-256 over JCS public-safe bytes. The registry manifest is repo-owned, versioned, digested, and cannot be supplied by an imported bundle as trust authority. +- [ ] **Step 4: Run focused test and verify GREEN.** + +### Task 3: Bundle digest/signature and local storage + +**Files:** +- Create: `src/lab/public/signature.ts` +- Create: `src/lab/public/storage.ts` +- Modify: `src/lab/paths.ts` +- Test: `tests/lab-public-evidence.test.ts` + +**Interfaces:** +- Produces: `getOrCreatePublicPublisher()`, `signPublicEvidenceBundle()`, `verifyPublicEvidenceBundle()`, `writePublicEvidenceBundle()`, `readPublicEvidenceBundle()`. + +- [ ] **Step 1: Write RED tests** for Ed25519 signing/verification, key-file permissions where enforceable, tamper rejection, deterministic bundle digest, bounded storage paths, and no private-key serialization. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement minimal key lifecycle, signing, verification, and safe local bundle storage.** +- [ ] **Step 4: Verify GREEN.** + +### Task 4: Revocation and community quarantine + +**Files:** +- Create: `src/lab/public/revocation.ts` +- Create: `src/lab/public/community.ts` +- Test: `tests/lab-public-evidence.test.ts` + +**Interfaces:** +- Produces: `PublicEvidenceRevocationV1`, `verifyPublicEvidenceRevocation()`, `importCommunityBundle()`, `listCommunityBundles()`. + +- [ ] **Step 1: Write RED tests** proving revocation accepts only the original bundle publisher key, duplicate identical revocations are idempotent, conflicting replay rejects, malformed/oversized bundles reject before persistence, and community import leaves canonical JSONL/SQLite verdict state unchanged. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement bounded revocation verification and separate community storage.** +- [ ] **Step 4: Verify GREEN.** + +### Task 5: Sensitive purge integration + +**Files:** +- Modify: `src/lab/ledger/purge.ts` +- Modify: `src/lab/paths.ts` +- Test: `tests/lab-public-evidence.test.ts` +- Test: `tests/lab-evidence-ledger.test.ts` + +**Interfaces:** +- Consumes: existing `purgeSensitiveEvidence()` and `purgeActions: export`. +- Produces: fail-closed removal of generated exports and locally-originated community copies affected by local sensitive evidence. + +- [ ] **Step 1: Write RED purge regression** showing an `export` purge removes CL-10 exports and local-origin community copies without requiring network access. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Extend purge-owned local directories/metadata minimally.** +- [ ] **Step 4: Run CL-10 and existing ledger purge tests.** + +### Task 6: Explicit CLI and management surfaces + +**Files:** +- Modify: `src/cli/lab.ts` +- Modify: `src/server/management/lab-routes.ts` +- Test: `tests/lab-public-evidence.test.ts` +- Test: relevant Lab CLI/management tests discovered in repository. + +**Interfaces:** +- CLI: local preview/export, bundle verify, community import/list. No publish command. +- API: authenticated preview/export/verify/community endpoints only. No remote transport. + +- [ ] **Step 1: Write RED CLI/API tests** for network-free preview, explicit export, verification, bounded community import, and absence of any publish endpoint/command. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement minimal surfaces using the public module APIs.** +- [ ] **Step 4: Verify focused CLI/API tests GREEN.** + +### Task 7: Compatibility Matrix community context + +**Files:** +- Modify: `gui/src/pages/compatibility-matrix-api.ts` +- Modify: `gui/src/pages/CompatibilityMatrix.tsx` +- Modify: locale catalog files under `gui/src/i18n/` as required by existing i18n rules. +- Test: existing Compatibility Lab GUI/i18n tests plus focused CL-10 additions. + +**Interfaces:** +- Produces: clearly labelled, read-only community context separate from canonical local verdict UI. + +- [ ] **Step 1: Write RED parser/render/i18n tests** proving community state is labelled non-authoritative and cannot replace the local verdict. +- [ ] **Step 2: Verify RED.** +- [ ] **Step 3: Implement the compact existing-detail-pane integration with no new product area.** +- [ ] **Step 4: Run GUI tests/lint/build GREEN.** + +### Task 8: Closure validation + +**Files:** +- Modify docs only if validation findings require factual updates. + +- [ ] **Step 1:** Run `bun test tests/lab-public-evidence.test.ts tests/lab-evidence-ledger.test.ts`. +- [ ] **Step 2:** Run `bun x tsc --noEmit`. +- [ ] **Step 3:** Run `bun run privacy:scan`. +- [ ] **Step 4:** Run relevant Lab query/ledger/CLI/GUI tests. +- [ ] **Step 5:** Run GUI lint/build and React Doctor. +- [ ] **Step 6:** Run full Cross-platform CI on the exact final PR head. +- [ ] **Step 7:** Confirm no remote publishing code, arbitrary URL transport, routing feedback, local-verdict feedback, or CL-08 feedback was introduced. diff --git a/docs/superpowers/plans/2026-08-13-cl10-deep-review-hardening.md b/docs/superpowers/plans/2026-08-13-cl10-deep-review-hardening.md new file mode 100644 index 000000000..e1ccfccbc --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-cl10-deep-review-hardening.md @@ -0,0 +1,200 @@ +# CL-10 Deep Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close all twelve adversarial findings from the post-CI CL-10 deep review, remove the catalog-timeout workaround, and make PR #1510 accurately describe the implemented CL-10.1 through CL-10.4 runtime scope. + +**Architecture:** Keep the existing `src/lab/public/` trust boundary and wire schema, but make verification canonical instead of normalizing attacker input, make community import no more permissive than local export, make revocation application publisher-scoped, and use crash-safe immutable-file publication. Public API/CLI DTOs remain separate from local operator metadata, and purge gains a bounded public-origin index so provenance does not depend on recovering mutable local files. + +**Tech Stack:** TypeScript, Bun tests, Node `crypto`/`fs`, existing Lab JCS/digest/path infrastructure, GitHub Actions. + +## Global Constraints + +- CL-10.5 remote publishing remains blocked and must not be implemented. +- No automatic telemetry, background publishing, arbitrary URL fetch, or community-to-local authority feedback. +- Keep `PublicEvidenceBundleV1` and revocation V1 domain strings frozen. +- New production behavior must be introduced test-first. +- Public bundle verification must reject non-canonical wire order rather than silently normalize it. +- Until a reviewed `public_export` artifact authority exists, both local and community V1 paths reject non-empty public artifacts. +- Publisher-key creation must not occur for invalid signing or revocation requests. +- Public management/CLI JSON must not disclose local filesystem paths or local Lab event IDs. +- Sensitive purge must remove locally-originated community copies even if the export or publisher key is damaged or missing. +- Exact-head GitHub Actions success is required before completion; do not merge. + +--- + +### Task 1: Add adversarial RED coverage + +**Files:** +- Create: `tests/lab-public-deep-review-regressions.test.ts` +- Modify: `tests/ci-workflows.test.ts` + +**Interfaces:** +- Consumes: current CL-10 public module APIs. +- Produces: failing tests for canonical array order, artifact quarantine, publisher-scoped record revocation, invalid-input key non-creation, JCS Unicode validity, exact assertion authority, cache quota, public DTO redaction, purge origin recovery, bounded duplicate-key diagnostics, IPv6 privacy rejection, and test-local catalog timeout behavior. + +- [ ] **Step 1:** Add one focused regression per finding using real public module behavior and deterministic test-only publisher keys where signatures are required. +- [ ] **Step 2:** Add a CI-policy regression that requires the catalog hardening test to own its timeout and forbids a catalog-specific timeout branch in the Linux batch runner. +- [ ] **Step 3:** Push tests only and verify the exact test-only head is red for the intended missing behavior. + +### Task 2: Canonical wire verification and JCS correctness + +**Files:** +- Modify: `src/lab/conformance/jcs.ts` +- Modify: `src/lab/public/bundle.ts` +- Modify: `src/lab/public/signature.ts` + +**Interfaces:** +- Produces: strict RFC-8785-compatible Unicode rejection and `verifyPublicEvidenceBundle()` rejection of non-canonical top-level record/artifact order. + +- [ ] **Step 1:** Reject lone UTF-16 surrogates in JCS strings and object keys. +- [ ] **Step 2:** Normalize bundle content once for local construction, but compare received record/artifact ordering against that normalized representation during verification. +- [ ] **Step 3:** Run the focused wire regressions green. + +### Task 3: Align community artifact/privacy authority + +**Files:** +- Modify: `src/lab/public/community.ts` + +**Interfaces:** +- Consumes: `validatePublicEvidencePrivacy()` and the current V1 artifact hard stop. +- Produces: community imports that reject all non-empty artifacts until reviewed authority exists and run the same second-pass privacy validator before persistence. + +- [ ] **Step 1:** Add a community-import gate before persistence. +- [ ] **Step 2:** Verify signed artifact-bearing external bundles are rejected and artifact-empty valid bundles still import. + +### Task 4: Make record revocation publisher-scoped + +**Files:** +- Modify: `src/lab/public/community.ts` +- Modify: `src/lab/public/revocation.ts` only if helper semantics need to be exposed. + +**Interfaces:** +- Produces: deterministic verification of record-only revocations against any matching verified bundle for the publisher and application to every matching record in that publisher's imported bundles. + +- [ ] **Step 1:** Resolve record-only revocation authority against a deterministic matching verified bundle instead of requiring exactly one bundle. +- [ ] **Step 2:** During listing, apply each verified revocation by publisher plus bundle/record target membership rather than binding it permanently to one bundle. +- [ ] **Step 3:** Verify a later bundle containing the same record remains revoked. + +### Task 5: Crash-safe immutable persistence and key lifecycle + +**Files:** +- Create: `src/lab/public/private-file.ts` +- Modify: `src/lab/public/signature.ts` +- Modify: `src/lab/public/storage.ts` +- Modify: `src/lab/public/community.ts` + +**Interfaces:** +- Produces: temp-file + file fsync + exclusive hard-link publication for immutable secret/public objects, deterministic EEXIST conflict handling, POSIX parent-directory durability before success is reported, an explicit Windows fallback where directory fsync is not portable, and test-only publication fault seams. + +- [ ] **Step 1:** Implement a small shared helper that writes a mode-0600 private temp file, fsyncs it, publishes it by exclusive hard link, fsyncs the parent directory on POSIX, and removes the temp name only after the publication durability boundary succeeds. On Windows, retain atomic exclusive publication without requiring unsupported directory fsync. +- [ ] **Step 2:** Migrate publisher-key creation, local exports, and community bundle/revocation persistence to the helper. +- [ ] **Step 3:** Verify an injected pre-publish failure leaves no final partial file, a POSIX parent-directory-sync failure is reported and can be recovered by an idempotent retry, and Windows publication does not depend on directory fsync. + +### Task 6: Validate before publisher-state mutation + +**Files:** +- Modify: `src/lab/public/bundle.ts` +- Modify: `src/lab/public/signature.ts` +- Modify: `src/lab/public/revocation.ts` + +**Interfaces:** +- Produces: a publisher-independent content-normalization function used before key access; revocation creation requires an existing matching local publisher key. + +- [ ] **Step 1:** Split public bundle content validation/normalization from publisher attachment. +- [ ] **Step 2:** Run closed-schema/day/record/authority/privacy validation before `getOrCreatePublicPublisher()`. +- [ ] **Step 3:** Add an existing-publisher loader and use it for revocation creation so foreign/invalid revocation attempts cannot create identity state. + +### Task 7: Exact assertion authority + +**Files:** +- Modify: `src/lab/public/community-authority.ts` + +**Interfaces:** +- Produces: exact one-to-one assertion-ID/required-flag coverage of the reviewed scenario authority. + +- [ ] **Step 1:** Reject duplicate assertion IDs. +- [ ] **Step 2:** Reject missing reviewed assertions as well as unknown ones. +- [ ] **Step 3:** Keep passed/failed values publisher-supplied evidence while freezing only identity/required authority. + +### Task 8: Bound community cache writes and read cost + +**Files:** +- Modify: `src/lab/public/community.ts` + +**Interfaces:** +- Produces: pre-create limits of 512 cache files and 64 MiB aggregate serialized bytes, with idempotent existing objects still readable/importable at the limit. + +- [ ] **Step 1:** Measure only descriptor-bound regular files without following symlinks. +- [ ] **Step 2:** Enforce count and aggregate-byte capacity before creating a new object. +- [ ] **Step 3:** Enforce the same bounds when listing so corrupted/external directory growth fails closed before bulk materialization. + +### Task 9: Separate public DTOs from local operator metadata + +**Files:** +- Modify: `src/lab/public/operator.ts` +- Modify: `src/cli/lab.ts` +- Modify: `src/server/management/lab-routes.ts` +- Modify: `tests/lab-public-surfaces.test.ts` + +**Interfaces:** +- Produces: preview/export results that expose exclusion indices/reasons and `stored.created` only, never local event IDs or filesystem paths. + +- [ ] **Step 1:** Replace public exclusion `eventId` with bounded `selectionIndex`. +- [ ] **Step 2:** Discard storage paths from public operator return values and CLI/API JSON. +- [ ] **Step 3:** Keep human CLI output useful without printing local absolute paths. + +### Task 10: Persist public-origin provenance for purge + +**Files:** +- Modify: `src/lab/paths.ts` +- Create: `src/lab/public/origin.ts` +- Modify: `src/lab/public/operator.ts` +- Modify: `src/lab/public/purge.ts` + +**Interfaces:** +- Produces: bounded immutable `public-origin-v1` markers containing only public publisherKeyId/bundleId identities. The origin marker is durably committed before a new local export file is published, so export success can never be reported without purge-owned provenance; an orphan marker after a later export failure is conservative and safe. Under retention pressure, markers without an exact community bundle copy may be reclaimed because no community object remains for that provenance marker to classify; markers backing retained community bundles are preserved. + +- [ ] **Step 1:** Commit the public origin identity before publishing the local export file; if export publication later fails, preserve the orphan marker so retry/purge can recover conservatively. +- [ ] **Step 2:** Make purge union origin markers with legacy recoverable export/key provenance. +- [ ] **Step 3:** Delete origin markers only after locally-originated community copies are removed, except bounded retention reclamation of markers with no exact community bundle copy. +- [ ] **Step 4:** Verify purge still succeeds if the export and publisher key are corrupted/missing. + +### Task 11: Harden diagnostics and privacy scanner + +**Files:** +- Modify: `src/lab/public/strict-json.ts` +- Modify: `src/lab/public/privacy.ts` + +**Interfaces:** +- Produces: constant-size duplicate-key errors and detection of unbracketed IPv6 literals in semantic public strings. + +- [ ] **Step 1:** Stop reflecting attacker-controlled duplicate key names in errors. +- [ ] **Step 2:** Add bounded IPv6-literal recognition without rejecting ordinary colon-bearing public identifiers such as versioned names. + +### Task 12: Move catalog timeout to the flaky test only + +**Files:** +- Modify: `tests/codex-catalog-sync-hardening.test.ts` +- Modify: `scripts/ci/run-bun-test-batches.sh` + +**Interfaces:** +- Produces: one 15-second Bun test timeout on the known degraded-provider case; all neighboring batch tests remain on the default timeout on Linux and macOS uses the same test-local timeout. + +- [ ] **Step 1:** Add `15_000` only to the degraded-provider test definition. +- [ ] **Step 2:** Remove catalog-specific timeout detection/variables from the batch runner. +- [ ] **Step 3:** Run CI-policy regression green. + +### Task 13: Exact-head closure and PR metadata + +**Files:** +- Modify PR #1510 title/body only after runtime verification. + +**Interfaces:** +- Produces: accurate ready-for-review description of CL-10.1 through CL-10.4 with CL-10.5 explicitly blocked. + +- [ ] **Step 1:** Run focused tests, typecheck/privacy/GUI gates via GitHub Actions on the exact final head. +- [ ] **Step 2:** Confirm Cross-platform CI and React Doctor are green on that exact head. +- [ ] **Step 3:** Update PR title to describe the runtime implementation rather than contract-only scope. +- [ ] **Step 4:** Replace the stale body with implemented scope, trust/privacy invariants, validation evidence, and the CL-10.5 hard stop. +- [ ] **Step 5:** Confirm PR remains open, unmerged, and ready for review. diff --git a/docs/superpowers/plans/2026-08-14-cl10-final-review-closure.md b/docs/superpowers/plans/2026-08-14-cl10-final-review-closure.md new file mode 100644 index 000000000..114f448c1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-cl10-final-review-closure.md @@ -0,0 +1,31 @@ +# CL-10 Final Review Closure + +This addendum records the runtime contracts added after the final deep review of PR #1510. It supplements the earlier CL-10 hardening plan and does not expand scope into CL-10.5 remote publishing. + +## Community mutation contention + +Public-evidence mutation remains serialized across processes, but a live non-reclaimable owner is now a fail-fast condition. Synchronous callers receive `PublicEvidenceValidationError` with code `community_cache_busy` instead of blocking the JavaScript agent while polling. + +The management API maps `community_cache_busy` to HTTP `503` and sets `Retry-After: 1`. Other public-evidence validation failures remain client errors. Stale-owner recovery, inode checks, exclusive reclaim claims, and ownership-safe release semantics are unchanged. + +## Sensitive purge semantics + +Once durable local provenance classifies a community cache pathname as locally originated, sensitive purge removes that exact pathname even if the cached object has become oversized, hardlinked, symlinked, or otherwise unreadable through normal community-object validation. + +Deletion uses pathname unlink semantics only. It does not follow a symlink target and does not remove another hardlink to the same inode. `ENOENT` is treated as already absent; other unlink failures remain errors. Origin markers are cleared only after the community deletion pass and directory durability boundary complete. + +## Revocation target errors + +A direct same-publisher bundle revocation whose target bundle is absent is normalized to `PublicEvidenceValidationError` code `revocation_target` with message `revocation target bundle not found`. The optimized direct-target path must not leak platform-specific filesystem `ENOENT` errors. + +## Regression requirements + +The closure is protected by focused tests that require: + +- live lock contention to return `community_cache_busy` in under 500 ms; +- the management community endpoint to return `503` plus `Retry-After: 1` for that contention; +- oversized locally-originated community copies to be removed during sensitive purge; +- hardlinked locally-originated cache pathnames to be removed while a peer hardlink survives; and +- missing direct revocation bundle targets to return stable `revocation_target` errors. + +Exact-head GitHub Actions success is required before this closure is considered verified. PR #1510 must remain open and unmerged during this review cycle. diff --git a/docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md b/docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md new file mode 100644 index 000000000..1495d76ed --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-cl10-public-evidence-design.md @@ -0,0 +1,127 @@ +# CL-10 Public Evidence Design + +## Status + +Design approved for contract drafting on 2026-08-12. Independent review accepted the contract on 2026-08-12, and explicit maintainer direction now authorizes CL-10.1 through CL-10.4 runtime implementation on this branch. CL-10.5 remote publishing remains blocked on an exact independently accepted transport/service contract. + +Base: `dev` at `4fed8d3fe431ad23be83f3aff2af18ef8b8ecd71`, the CL-09 merge commit from #1489. + +## Problem + +Compatibility Lab now has local protocol, live-route, task-effectiveness, automatic-refresh, and passive-production evidence. The remaining programme boundary is public sharing. + +Local evidence cannot be published directly because local schemas intentionally contain installation-scoped identity and operational metadata that is safe only inside the local trust domain. Community evidence also cannot be allowed to become canonical local truth merely because a remote bundle is syntactically valid or cryptographically signed. + +## Chosen design + +Use a deterministic, closed public projection with a separate community trust domain. + +```text +local canonical evidence + -> exportability gate + -> allowlist-only public projection + -> export privacy scan + -> export-scoped IDs + -> canonical bundle digest + -> pseudonymous publisher signature + -> explicit local export + -> optional explicit publish after transport contract acceptance + +community bundle + -> bounded parser + -> schema/digest/signature verification + -> non-authoritative community cache + -> clearly labelled read surface + -> never local verdict/routing/scheduling authority +``` + +## Key decisions + +### Public route identity + +A local route is exportable only when its behavior can be represented entirely through entries in the versioned, content-addressed, repo-reviewed `PublicRouteRegistryManifestV1`; dynamic discovery, config, and imported bundles cannot extend that authority. Private/custom endpoint, header, provider-instance, project/location, tenant, account, or custom model/provider dimensions make the route `not_exportable`. + +The exporter must never create a broader public claim by deleting a private dimension from an exact local route subject. + +### Public schema + +`PublicEvidenceBundleV1` is independently versioned and allowlist-only. Dedicated runtime validators enforce its closed types. Records use a layer-matched public subject union rather than assuming every evidence layer is a route. Incident references are closed `IC-NNN` corpus IDs only, and artifact references resolve only to public artifacts in the same bundle. + +Unknown fields fail closed on export and import. + +### IDs + +Local subject, event, artifact, request, decision, and Fabric IDs never leave the installation. Public IDs are derived only from canonical public-safe bytes under explicit domain-separated hashes. + +### Canonical bytes and signatures + +CL-10 V1 freezes RFC 8785 JSON Canonicalization Scheme (JCS) over UTF-8 as the canonical byte representation. Raw imported JSON must be valid UTF-8 and must reject duplicate decoded object member names before semantic object construction, including equivalent escaped spellings such as `"a"` and `"\u0061"`. + +Every public hash is: + +```text +H(domain, value) = SHA-256(UTF8(domain) || 0x00 || UTF8(JCS(value))) +``` + +The exact V1 domains are `ocx-lab-public:subject:v1`, `ocx-lab-public:record:v1`, `ocx-lab-public:bundle:v1`, `ocx-lab-public:bundle-digest:v1`, `ocx-lab-public:artifact:v1`, `ocx-lab-public:publisher-key:v1`, `ocx-lab-public:revocation:v1`, and `ocx-lab-public:route-registry:v1` for their corresponding identities. + +For a bundle, `C = {schemaVersion, exportPolicyVersion, createdDayUtc, publisher, records, artifacts}`. `bundleId = H("ocx-lab-public:bundle:v1", C)`. `bundleDigest = H("ocx-lab-public:bundle-digest:v1", {...C, bundleId})`. Therefore `bundleDigest` and `signature` are excluded from the bundle-digest preimage, and `bundleId`, `bundleDigest`, and `signature` are excluded from the bundle-ID preimage. Ed25519 signs the raw 32 bytes obtained by hex-decoding `bundleDigest`; `signature.signedDigest` must equal `bundleDigest` exactly. + +A revocation similarly hashes `R = {schemaVersion, issuedDayUtc, publisher, targets, reason}` under `ocx-lab-public:revocation:v1`; `revocationId` and `signature` are excluded from `R`, and Ed25519 signs the raw 32 bytes of `revocationId`. Targets are sorted and unique before hashing. + +Import verification order is fixed: byte cap; strict UTF-8 and duplicate-key rejection; JSON syntax/structural bounds; closed schema/version/field validation and publisher-key-ID recomputation; public identity/reference and bundle digest recomputation; `signedDigest` equality; Ed25519 key/signature decoding and verification; repository route/suite/scenario/Fabric authority validation; revocation bootstrap only against an already-verified exact target publisher/bundle; persistence only after every preceding check succeeds. + +### Artifacts + +Artifacts require explicit `public_export` policy. A second export sanitizer and secret/PII scan runs before public artifact hashing. Local visibility alone never authorizes export. + +### Consent + +There is no automatic telemetry. Preview is local and network-free. Export is explicit. Publishing is a second explicit action for a specific bundle and is not implemented until an exact remote-service contract is accepted. + +### Publisher provenance + +Publishable bundles use an installation-local Ed25519 publisher key. The public key provides pseudonymous continuity; the signature proves bundle integrity and signer continuity only. It does not prove that the compatibility claim is true. + +### Community trust + +Imported community evidence is `community_untrusted_v1`. A valid signature produces `cryptographically_valid`, not `locally_verified`. + +Community evidence cannot: + +- append to local `compatibility.jsonl`; +- alter local canonical verdicts or freshness; +- satisfy Routing Profile compatibility requirements; +- influence Router Intelligence; +- trigger CL-08 refresh scheduling; +- merge into a combined local/community score. + +### Revocation + +Publishers can issue signed revocations with finite reason codes. Revocation authority bootstraps from the exact publisher key embedded in the already-verified target bundle; V1 permits no cross-key revocation or key rotation, and duplicate identical revocations are idempotent while conflicting replay fails closed. Consumers suppress revoked records from default community summaries while retaining the audit relation. Remote deletion is transport-specific and does not replace revocation. CL-00 sensitive purge still removes every affected local generated export plus locally-originated community-cache copy fail-closed; local purge never depends on network acknowledgement. + +### Remote service + +Bundle semantics, signing, import, and trust are frozen before any network publishing implementation. A remote publisher requires a reviewed fixed service origin, authentication, TLS/redirect, request-budget, retry/idempotency, retention/deletion, revocation, abuse/rate-limit, and server-validation contract. Arbitrary upload URLs are forbidden. + +## Delivery decomposition + +1. **CL-10.0 Contract:** freeze privacy, exportability, schema, IDs, signatures, consent, trust, revocation, and transport gate. +2. **CL-10.1 Public projector:** closed DTOs, exportability rules, deterministic canonicalization, export privacy validator. +3. **CL-10.2 Bundle/signature substrate:** local exports, publisher-key lifecycle, digest/sign/verify. +4. **CL-10.3 Local surfaces:** preview and explicit local export via existing Lab CLI/API/UI conventions. +5. **CL-10.4 Community import:** bounded import, verification, separate community cache, revocation and labelled read surfaces. +6. **CL-10.5 Remote publishing:** only after exact service contract acceptance. +7. **CL-10.6 Closure:** adversarial privacy/trust tests, cross-platform validation, independent review, programme closure. + +## Validation expectations + +The implementation must include adversarial tests for secret/PII canaries, local IDs, private route dimensions, unknown fields, duplicate JSON object keys, oversized/deep bundles, invalid digest/signature, replay/deduplication, revocation, deterministic export, fixed canonical digest/signature vectors, and complete isolation from local verdicts/routing/CL-08. + +The contract review gate is satisfied. Runtime CL-10.1 through CL-10.4 may now land on this PR under TDD and full validation; CL-10.5 remote publishing remains out of scope. + +## Source of truth + +The detailed normative contract is: + +`devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md` diff --git a/gui/src/i18n/lab-translations.ts b/gui/src/i18n/lab-translations.ts index 3428a847d..75a21feb4 100644 --- a/gui/src/i18n/lab-translations.ts +++ b/gui/src/i18n/lab-translations.ts @@ -7,7 +7,12 @@ export type LabSupplementKey = | "artifact.present" | "artifact.corrupt" | "artifact.purged_unavailable" - | "selectVerdict"; + | "selectVerdict" + | "community.title" + | "community.notLocalVerdict" + | "community.bundles" + | "community.activeRecords" + | "community.revokedRecords"; const en: Record = { "lab.title": "Compatibility Lab", @@ -427,6 +432,11 @@ const supplements: Record> = { "artifact.corrupt": "Corrupt", "artifact.purged_unavailable": "Purged / unavailable", selectVerdict: "View verdict for {subject}", + "community.title": "Community evidence", + "community.notLocalVerdict": "Untrusted read-only context. Not included in this local verdict.", + "community.bundles": "Bundles", + "community.activeRecords": "Active records", + "community.revokedRecords": "Revoked records", }, de: { subjectKindUnknown: "Unbekannt", @@ -434,6 +444,11 @@ const supplements: Record> = { "artifact.corrupt": "Beschädigt", "artifact.purged_unavailable": "Gelöscht / nicht verfügbar", selectVerdict: "Urteil für {subject} anzeigen", + "community.title": "Community-Evidenz", + "community.notLocalVerdict": "Nicht vertrauenswürdiger Nur-Lese-Kontext. Nicht Teil dieses lokalen Urteils.", + "community.bundles": "Pakete", + "community.activeRecords": "Aktive Einträge", + "community.revokedRecords": "Widerrufene Einträge", }, ko: { subjectKindUnknown: "알 수 없음", @@ -441,6 +456,11 @@ const supplements: Record> = { "artifact.corrupt": "손상됨", "artifact.purged_unavailable": "삭제됨 / 사용할 수 없음", selectVerdict: "{subject}의 판정 보기", + "community.title": "커뮤니티 증거", + "community.notLocalVerdict": "신뢰되지 않는 읽기 전용 컨텍스트입니다. 이 로컬 판정에는 포함되지 않습니다.", + "community.bundles": "번들", + "community.activeRecords": "활성 레코드", + "community.revokedRecords": "폐기된 레코드", }, zh: { subjectKindUnknown: "未知", @@ -448,6 +468,11 @@ const supplements: Record> = { "artifact.corrupt": "已损坏", "artifact.purged_unavailable": "已清除 / 不可用", selectVerdict: "查看 {subject} 的判定", + "community.title": "社区证据", + "community.notLocalVerdict": "不受信任的只读上下文。不计入此本地判定。", + "community.bundles": "证据包", + "community.activeRecords": "有效记录", + "community.revokedRecords": "已撤销记录", }, "zh-TW": { subjectKindUnknown: "未知", @@ -455,6 +480,11 @@ const supplements: Record> = { "artifact.corrupt": "已損壞", "artifact.purged_unavailable": "已清除 / 不可用", selectVerdict: "查看 {subject} 的判定", + "community.title": "社群證據", + "community.notLocalVerdict": "不受信任的唯讀脈絡。不計入此本地判定。", + "community.bundles": "證據包", + "community.activeRecords": "有效記錄", + "community.revokedRecords": "已撤銷記錄", }, ru: { subjectKindUnknown: "Неизвестно", @@ -462,6 +492,11 @@ const supplements: Record> = { "artifact.corrupt": "Повреждён", "artifact.purged_unavailable": "Удалён / недоступен", selectVerdict: "Открыть вердикт для {subject}", + "community.title": "Данные сообщества", + "community.notLocalVerdict": "Недоверенный контекст только для чтения. Не входит в этот локальный вердикт.", + "community.bundles": "Пакеты", + "community.activeRecords": "Активные записи", + "community.revokedRecords": "Отозванные записи", }, ja: { subjectKindUnknown: "不明", @@ -469,6 +504,11 @@ const supplements: Record> = { "artifact.corrupt": "破損", "artifact.purged_unavailable": "削除済み / 利用不可", selectVerdict: "{subject} の判定を表示", + "community.title": "コミュニティ証拠", + "community.notLocalVerdict": "信頼されていない読み取り専用コンテキストです。このローカル判定には含まれません。", + "community.bundles": "バンドル", + "community.activeRecords": "有効なレコード", + "community.revokedRecords": "取り消されたレコード", }, tr: { subjectKindUnknown: "Bilinmiyor", @@ -476,6 +516,11 @@ const supplements: Record> = { "artifact.corrupt": "Bozuk", "artifact.purged_unavailable": "Temizlenmiş / kullanılamıyor", selectVerdict: "{subject} için kararı görüntüle", + "community.title": "Topluluk kanıtı", + "community.notLocalVerdict": "Güvenilmeyen salt okunur bağlam. Bu yerel karara dahil değildir.", + "community.bundles": "Paketler", + "community.activeRecords": "Etkin kayıtlar", + "community.revokedRecords": "Geri çekilen kayıtlar", }, }; diff --git a/gui/src/pages/CompatibilityMatrix.tsx b/gui/src/pages/CompatibilityMatrix.tsx index 9fcac9b12..48ca82685 100644 --- a/gui/src/pages/CompatibilityMatrix.tsx +++ b/gui/src/pages/CompatibilityMatrix.tsx @@ -9,6 +9,7 @@ import { fetchLabPageData, fetchMoreVerdicts, fetchVerdictDetail, + type CommunityEvidenceContextDto, type LabPageData, type VerdictDetailData, } from "./compatibility-matrix-api"; @@ -71,13 +72,7 @@ function localizedFetchError(e: unknown, fallback: string): string { return msg || fallback; } -function VerdictBadge({ - verdict, - caption, - label, - selected, - onSelect, -}: { +function VerdictBadge({ verdict, caption, label, selected, onSelect }: { verdict: CompatibilityVerdict; caption: string; label: string; @@ -101,12 +96,7 @@ function VerdictBadge({ ); } -function VerdictCell({ - rows, - t, - selectedKey, - onSelect, -}: { +function VerdictCell({ rows, t, selectedKey, onSelect }: { rows: VerdictDto[]; t: (key: TKey) => string; selectedKey: string | null; @@ -154,15 +144,27 @@ function StatusCards({ data, t, locale }: { ); } -function DetailPane({ - verdict, - detail, - loading, - error, - t, - locale, - onClose, -}: { +function CommunityEvidencePanel({ community, locale }: { + community: CommunityEvidenceContextDto | null; + locale: Parameters[0]; +}) { + if (!community || community.evidence.length === 0) return null; + const activeRecords = community.evidence.reduce((total, row) => total + row.activeRecordCount, 0); + const revokedRecords = community.evidence.reduce((total, row) => total + row.revokedRecordCount, 0); + return ( +
+

{labSupplement(locale, "community.title")}

+

{labSupplement(locale, "community.notLocalVerdict")}

+
+
{labSupplement(locale, "community.bundles")}
{community.evidence.length}
+
{labSupplement(locale, "community.activeRecords")}
{activeRecords}
+
{labSupplement(locale, "community.revokedRecords")}
{revokedRecords}
+
+
+ ); +} + +function DetailPane({ verdict, detail, loading, error, t, locale, onClose }: { verdict: VerdictDto; detail: VerdictDetailData | null; loading: boolean; @@ -252,11 +254,7 @@ function DetailPane({ ); } -export default function CompatibilityMatrix({ - apiBase, - active = true, - onCountChange, -}: { +export default function CompatibilityMatrix({ apiBase, active = true, onCountChange }: { apiBase: string; active?: boolean; onCountChange?: (count: number | null) => void; @@ -276,7 +274,6 @@ export default function CompatibilityMatrix({ const queryFilters = useMemo(() => verdictQueryFromFilters(filters), [filters]); const queryKey = JSON.stringify(queryFilters); - const fetchPage = useCallback( (signal: AbortSignal) => fetchLabPageData(apiBase, queryFilters, signal), [apiBase, queryFilters], @@ -306,13 +303,7 @@ export default function CompatibilityMatrix({ setDetailLoading(false); }, []); - useEffect(() => { - // A refreshed first page makes any in-flight cursor request stale. The associated - // appended-page state is identity-bound below, so it becomes invisible immediately - // without synchronously cascading state from this effect. - loadMoreRef.current?.abort(); - }, [surface.data]); - + useEffect(() => { loadMoreRef.current?.abort(); }, [surface.data]); useEffect(() => () => { loadMoreRef.current?.abort(); detailRequestRef.current?.abort(); @@ -333,9 +324,7 @@ export default function CompatibilityMatrix({ const reportedCount = useMemo(() => { if (!active || !surface.data?.status.projectionAvailable) return null; const total = surface.data.status.verdictCount; - return typeof total === "number" - ? total - : surface.data.verdicts.length + (validExtraPage?.verdicts.length ?? 0); + return typeof total === "number" ? total : surface.data.verdicts.length + (validExtraPage?.verdicts.length ?? 0); }, [active, surface.data, validExtraPage]); useEffect(() => { onCountChange?.(reportedCount); }, [onCountChange, reportedCount]); @@ -362,9 +351,7 @@ export default function CompatibilityMatrix({ const page = await fetchMoreVerdicts(apiBase, queryFilters, cursor, controller.signal); if (controller.signal.aborted) return; setExtraPage(current => { - const existing = current?.baseData === baseData && current.queryKey === startedKey - ? current.verdicts - : []; + const existing = current?.baseData === baseData && current.queryKey === startedKey ? current.verdicts : []; return { baseData, queryKey: startedKey, @@ -455,6 +442,7 @@ export default function CompatibilityMatrix({ {loadError && {loadError}} {projectionIncompatible && {t("lab.projectionIncompatible")}} {projectionUnavailable && !projectionIncompatible && } + {surface.data && } {surface.data && status?.projectionAvailable && !projectionIncompatible && (
diff --git a/gui/src/pages/compatibility-matrix-api.ts b/gui/src/pages/compatibility-matrix-api.ts index 139ba8594..7e2803097 100644 --- a/gui/src/pages/compatibility-matrix-api.ts +++ b/gui/src/pages/compatibility-matrix-api.ts @@ -132,19 +132,14 @@ async function collectPages( seen.add(next); cursor = next; } - // The server kept advancing correctly but exceeded the browser-side safety bound. - // Preserve the coherent prefix and report truncation separately instead of - // misclassifying a legitimate large dataset as a broken pagination contract. return { rows, truncated: true }; } export async function fetchAllSubjects(apiBase: string, signal: AbortSignal): Promise> { - return collectPages( - async cursor => { - const page = await fetchSubjectPage(apiBase, cursor, signal); - return { rows: page.subjects, hasMore: page.hasMore, nextCursor: page.nextCursor }; - }, - ); + return collectPages(async cursor => { + const page = await fetchSubjectPage(apiBase, cursor, signal); + return { rows: page.subjects, hasMore: page.hasMore, nextCursor: page.nextCursor }; + }); } export async function fetchSubjectDetail( @@ -181,12 +176,10 @@ async function fetchAllObservations( filters: { subjectId: string; layer?: string; suiteId?: string }, signal: AbortSignal, ): Promise> { - return collectPages( - async cursor => { - const page = await fetchObservationsPage(apiBase, filters, cursor, signal); - return { rows: page.observations, hasMore: page.hasMore, nextCursor: page.nextCursor }; - }, - ); + return collectPages(async cursor => { + const page = await fetchObservationsPage(apiBase, filters, cursor, signal); + return { rows: page.observations, hasMore: page.hasMore, nextCursor: page.nextCursor }; + }); } export async function fetchEventById(apiBase: string, eventId: string, signal: AbortSignal): Promise { @@ -248,6 +241,80 @@ export async function fetchPassiveProductionSummary( return parsePassiveProductionSummary(raw); } +export type CommunityEvidenceSummaryRowDto = { + trustClass: "community_untrusted_v1"; + status: "cryptographically_valid"; + bundleId: string; + publisherKeyId: string; + activeRecordCount: number; + revokedRecordCount: number; +}; + +export type CommunityEvidenceContextDto = { + evidence: CommunityEvidenceSummaryRowDto[]; + trustClass: "community_untrusted_v1"; + locallyVerified: false; +}; + +function hasOnlyKeys(raw: Record, allowed: readonly string[]): boolean { + const allowedSet = new Set(allowed); + return Object.keys(raw).every(key => allowedSet.has(key)); +} + +function isSha256Hex(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +export function parseCommunityEvidenceContext(raw: unknown): CommunityEvidenceContextDto | null { + if (!isPlainObject(raw) + || !hasOnlyKeys(raw, ["evidence", "trustClass", "locallyVerified"]) + || raw.trustClass !== "community_untrusted_v1" + || raw.locallyVerified !== false + || !Array.isArray(raw.evidence) + || raw.evidence.length > 4096) { + return null; + } + const evidence: CommunityEvidenceSummaryRowDto[] = []; + for (const value of raw.evidence) { + if (!isPlainObject(value) + || !hasOnlyKeys(value, [ + "trustClass", "status", "bundleId", "publisherKeyId", + "activeRecordCount", "revokedRecordCount", + ]) + || value.trustClass !== "community_untrusted_v1" + || value.status !== "cryptographically_valid" + || !isSha256Hex(value.bundleId) + || !isSha256Hex(value.publisherKeyId) + || !isNonNegativeInteger(value.activeRecordCount) + || !isNonNegativeInteger(value.revokedRecordCount)) { + return null; + } + evidence.push({ + trustClass: "community_untrusted_v1", + status: "cryptographically_valid", + bundleId: value.bundleId, + publisherKeyId: value.publisherKeyId, + activeRecordCount: value.activeRecordCount, + revokedRecordCount: value.revokedRecordCount, + }); + } + return { evidence, trustClass: "community_untrusted_v1", locallyVerified: false }; +} + +export async function fetchCommunityEvidenceContext( + apiBase: string, + signal: AbortSignal, +): Promise { + const raw = await fetchLabJson(apiBase, "/api/lab/public/community", signal); + const context = parseCommunityEvidenceContext(raw); + if (!context) throw invalidResponse(); + return context; +} + export type LabPageData = { status: LabStatusDto; verdicts: VerdictDto[]; @@ -255,6 +322,7 @@ export type LabPageData = { subjectsTruncated: boolean; hasMore: boolean; nextCursor?: string; + community: CommunityEvidenceContextDto | null; }; export async function fetchLabPageData( @@ -262,9 +330,15 @@ export async function fetchLabPageData( filters: VerdictQueryFilters, signal: AbortSignal, ): Promise { - const status = await fetchLabStatus(apiBase, signal); + const [status, community] = await Promise.all([ + fetchLabStatus(apiBase, signal), + fetchCommunityEvidenceContext(apiBase, signal).catch(error => { + if (signal.aborted) throw error; + return null; + }), + ]); if (!status.projectionAvailable) { - return { status, verdicts: [], subjects: [], subjectsTruncated: false, hasMore: false }; + return { status, verdicts: [], subjects: [], subjectsTruncated: false, hasMore: false, community }; } const [verdictPage, subjects] = await Promise.all([ fetchVerdictPage(apiBase, filters, undefined, signal), @@ -277,6 +351,7 @@ export async function fetchLabPageData( subjectsTruncated: subjects.truncated, hasMore: verdictPage.hasMore, nextCursor: verdictPage.nextCursor, + community, }; } @@ -316,7 +391,6 @@ async function mapSettledBounded( results.push(await mapper(limited[current]!)); } catch (error) { if (signal.aborted) throw error; - // Referenced events/artifacts are optional detail enrichment. Keep successful peers. } } }; diff --git a/gui/tests/compatibility-community-evidence.test.ts b/gui/tests/compatibility-community-evidence.test.ts new file mode 100644 index 000000000..6605e7c64 --- /dev/null +++ b/gui/tests/compatibility-community-evidence.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test"; +import { + fetchLabPageData, + fetchVerdictDetail, + parseCommunityEvidenceContext, + type CommunityEvidenceContextDto, +} from "../src/pages/compatibility-matrix-api"; +import type { VerdictDto } from "../src/pages/compatibility-matrix-shared"; +import { + LAB_CATALOG_OVERRIDES, + labSupplement, + type LabLocale, +} from "../src/i18n/lab-translations"; + +const LOCALES = Object.keys(LAB_CATALOG_OVERRIDES) as LabLocale[]; + +function validContext(): CommunityEvidenceContextDto { + return { + trustClass: "community_untrusted_v1", + locallyVerified: false, + evidence: [ + { + trustClass: "community_untrusted_v1", + status: "cryptographically_valid", + bundleId: "a".repeat(64), + publisherKeyId: "b".repeat(64), + activeRecordCount: 3, + revokedRecordCount: 1, + }, + ], + }; +} + +function json(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +test("Compatibility Matrix parses only bounded quarantined community evidence context", () => { + expect(parseCommunityEvidenceContext(validContext())).toEqual(validContext()); + expect(parseCommunityEvidenceContext({ ...validContext(), locallyVerified: true })).toBeNull(); + expect(parseCommunityEvidenceContext({ ...validContext(), trustClass: "local" })).toBeNull(); + expect(parseCommunityEvidenceContext({ ...validContext(), unexpected: true })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, unexpected: true }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, bundleId: "A".repeat(64) }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, publisherKeyId: "z".repeat(64) }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, activeRecordCount: -1 }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, activeRecordCount: 1.5 }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: [{ ...validContext().evidence[0]!, status: "locally_verified" }], + })).toBeNull(); + expect(parseCommunityEvidenceContext({ + ...validContext(), + evidence: Array.from({ length: 4097 }, () => validContext().evidence[0]!), + })).toBeNull(); +}); + +test("Compatibility Matrix community copy is localized and explicitly non-authoritative", () => { + for (const locale of LOCALES) { + expect(labSupplement(locale, "community.title")).toBeTruthy(); + expect(labSupplement(locale, "community.notLocalVerdict")).toBeTruthy(); + expect(labSupplement(locale, "community.bundles")).toBeTruthy(); + expect(labSupplement(locale, "community.activeRecords")).toBeTruthy(); + expect(labSupplement(locale, "community.revokedRecords")).toBeTruthy(); + } + expect(labSupplement("en", "community.notLocalVerdict")).toMatch(/untrusted|not included|local verdict/i); +}); + +test("community evidence is fetched once as page-global context, never as verdict detail", async () => { + const originalFetch = globalThis.fetch; + const requested: string[] = []; + const context = validContext(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.endsWith("/api/lab/status")) { + return json({ + projectionAvailable: true, + subjectCount: 0, + verdictCount: 0, + observationCount: 0, + eventCount: 0, + }); + } + if (url.includes("/api/lab/verdicts?")) return json({ verdicts: [], hasMore: false }); + if (url.includes("/api/lab/subjects?")) return json({ subjects: [], hasMore: false }); + if (url.endsWith("/api/lab/public/community")) return json(context); + if (url.endsWith("/api/lab/subjects/subject-alpha")) { + return json({ subject: { subjectKind: "protocol", subjectSchemaVersion: 1, inboundProtocol: "openai-chat" } }); + } + if (url.includes("/api/lab/observations?")) return json({ observations: [], hasMore: false }); + throw new Error(`unexpected optional detail request: ${url}`); + }) as typeof fetch; + + try { + const signal = new AbortController().signal; + const page = await fetchLabPageData("http://127.0.0.1:4096", {}, signal); + expect(page.community).toEqual(context); + expect(requested.filter(url => url.endsWith("/api/lab/public/community"))).toHaveLength(1); + + const verdict: VerdictDto = { + projectionKey: "v1", + subjectId: "subject-alpha", + evidenceLayer: "protocol_conformance", + suiteId: "responses-core", + suiteVersion: "1", + suiteManifestDigest: "digest-a", + projectionSpecVersion: "cl-02.v1", + verdict: "VERIFIED", + asOf: 1_700_000_000_000, + scenarioManifestDigests: [], + claimSourceDigest: null, + contributingEventIds: [], + contradictingEventIds: [], + notes: [], + }; + const detail = await fetchVerdictDetail("http://127.0.0.1:4096", verdict, signal); + expect(detail).not.toHaveProperty("community"); + expect(requested.filter(url => url.endsWith("/api/lab/public/community"))).toHaveLength(1); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/cli/lab.ts b/src/cli/lab.ts index 013b96327..2f968a47e 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -63,6 +63,14 @@ import { planManualLabRun } from "../lab/automation/planner"; import { listLabAutomationRuns } from "../lab/automation/runs-query"; import { LabAutomationError, type LabAutomationLayer } from "../lab/automation/types"; import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production"; +import { + exportLocalPublicEvidence, + importCommunityEvidenceFile, + listCommunityEvidenceContext, + previewLocalPublicEvidence, + verifyPublicEvidenceFile, + type PublicVerificationSummaryV1, +} from "../lab/public"; const USAGE = `Usage: ocx lab status [--json] @@ -76,6 +84,11 @@ const USAGE = `Usage: ocx lab artifacts [--status ] [--artifact-class ] [--limit ] [--cursor ] [--json] ocx lab artifact [--json] ocx lab catalog [--layer ] [--suite ] [--json] + ocx lab public preview --event [--event ...] [--json] + ocx lab public export --event [--event ...] [--json] + ocx lab public verify --file [--json] + ocx lab public import --file [--json] + ocx lab public community [--json] ocx lab automation status [--json] ocx lab automation enable [--protocol] [--live] [--json] ocx lab automation disable [--json] @@ -223,6 +236,120 @@ function runListLines(page: ReturnType): string[] return lines.length > 0 ? lines : ["No automation runs"]; } +function takeRepeatedOptions(args: string[], flag: string): string[] { + const values: string[] = []; + while (true) { + const index = args.indexOf(flag); + if (index < 0) break; + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new CliUsageError(`${flag} requires a value`, USAGE); + } + values.push(value); + args.splice(index, 2); + } + return values; +} + +function publicPreviewLines(result: ReturnType): string[] { + return [ + `Public evidence preview: ${result.bundle.records.length} exportable record(s)`, + `Excluded: ${result.excluded.length}`, + "Unsigned local preview; no publisher key or remote publish is created.", + ]; +} + +function publicExportLines(result: ReturnType): string[] { + return [ + "Public evidence exported locally", + `Bundle: ${result.bundle.bundleId}`, + `Publisher: ${result.bundle.publisher.keyId}`, + `Path: ${result.stored.path}`, + `Excluded: ${result.excluded.length}`, + "No remote publish occurred.", + ]; +} + +function publicVerificationLines(result: PublicVerificationSummaryV1): string[] { + if (result.status !== "cryptographically_valid") { + return [ + `Public evidence verification: ${result.status}`, + "Not locally verified.", + ...(result.detail ? [result.detail] : []), + ]; + } + return [ + "Public evidence verification: cryptographically valid", + `Bundle: ${result.bundleId}`, + `Publisher: ${result.publisherKeyId}`, + "Not locally verified. Signature validity proves integrity/continuity only.", + ]; +} + +function handlePublicLabCommand( + argv: string[], + wantsJson: boolean, + configDir: string, +): void { + const [action, ...restInput] = argv; + const rest = [...restInput]; + switch (action) { + case "preview": { + const eventIds = takeRepeatedOptions(rest, "--event"); + rejectArgs(rest, USAGE); + const result = previewLocalPublicEvidence({ eventIds }, configDir); + printData(result, wantsJson, publicPreviewLines(result)); + return; + } + case "export": { + const eventIds = takeRepeatedOptions(rest, "--event"); + rejectArgs(rest, USAGE); + const result = exportLocalPublicEvidence({ eventIds }, configDir); + printData(result, wantsJson, publicExportLines(result)); + return; + } + case "verify": { + const path = takeOption(rest, "--file"); + if (!path) throw new CliUsageError("public verify requires --file", USAGE); + rejectArgs(rest, USAGE); + const result = verifyPublicEvidenceFile(path); + printData(result, wantsJson, publicVerificationLines(result)); + if (result.status !== "cryptographically_valid") { + throw new Error(`public evidence verification failed: ${result.status}`); + } + return; + } + case "import": { + const path = takeOption(rest, "--file"); + if (!path) throw new CliUsageError("public import requires --file", USAGE); + rejectArgs(rest, USAGE); + const result = importCommunityEvidenceFile(path, configDir); + printData(result, wantsJson, [ + `Community evidence imported: ${result.bundleId}`, + `Publisher: ${result.publisherKeyId}`, + "Trust: community_untrusted_v1; not locally verified.", + ]); + return; + } + case "community": { + rejectArgs(rest, USAGE); + const result = listCommunityEvidenceContext(configDir); + const lines = result.evidence.length > 0 + ? result.evidence.map((row) => + `${row.bundleId} publisher=${row.publisherKeyId} active=${row.activeRecordCount} revoked=${row.revokedRecordCount}`, + ) + : ["No community evidence"]; + printData(result, wantsJson, [ + "Community evidence (untrusted, read-only context; not locally verified)", + ...lines, + ]); + return; + } + default: + throw new CliUsageError("unknown public subcommand", USAGE); + } +} + export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): Promise { return runCliAction(async () => { const configDir = deps.configDir ?? getConfigDir(); @@ -232,6 +359,10 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P try { switch (sub) { + case "public": { + handlePublicLabCommand(rest, wantsJson, configDir); + return; + } case "status": { rejectArgs(rest, USAGE); const status = queryLabStatus(configDir); diff --git a/src/lab/index.ts b/src/lab/index.ts index 783eb0552..7f7dad7b3 100644 --- a/src/lab/index.ts +++ b/src/lab/index.ts @@ -36,3 +36,19 @@ export * from "./subject/installation-salt"; export { CL03_LIVE_SUITES } from "./conformance/types"; export * from "./query"; export * from "./automation"; +export * from "./public/types"; +export { + exportLocalPublicEvidence, + importCommunityEvidenceFile, + importCommunityEvidenceValue, + listCommunityEvidenceContext, + previewLocalPublicEvidence, + summarizePublicEvidenceVerification, + verifyPublicEvidenceFile, + type LocalPublicExportV1, + type LocalPublicPreviewV1, + type PublicOperatorExclusionReason, + type PublicOperatorExclusionV1, + type PublicVerificationSummaryV1, +} from "./public/operator"; +export { PublicEvidenceValidationError } from "./public/validate"; diff --git a/src/lab/ledger/purge.ts b/src/lab/ledger/purge.ts index dd6b95853..ece0d788d 100644 --- a/src/lab/ledger/purge.ts +++ b/src/lab/ledger/purge.ts @@ -22,6 +22,7 @@ import { appendLabEvent, replayLabLedger } from "./store"; import { ensureLabDirs } from "../paths"; import { rebuildLabProjection } from "../projection/rebuild"; import { jcsStringify } from "../digest"; +import { purgeLocalPublicEvidenceCopies } from "../public/purge"; import { closeSync, existsSync, @@ -100,8 +101,6 @@ function atomicRewriteLedger(ledgerPath: string, events: LabEvent[]): void { closeSync(dirFd); } } catch (err) { - // The rename is already committed and visible. Report durability failure - // without pretending the ledger action can be rolled back. throw new PurgeError( "ledger_durability_failed", `ledger rewrite committed but directory fsync failed: ${err instanceof Error ? err.message : String(err)}`, @@ -142,6 +141,40 @@ function purgeBoundedDirectory(dirPath: string): void { } } +function normalizePurgeError(err: unknown, completed: readonly string[]): PurgeError { + if (err instanceof PurgeError) { + return new PurgeError( + err.code, + err.message, + [...new Set([...completed, ...err.completedActions])], + ); + } + return new PurgeError( + "purge_failed", + err instanceof Error ? err.message : String(err), + [...completed], + ); +} + +function buildPurgeTombstone( + req: SensitivePurgeRequest, + removeIds: ReadonlySet, + targetArtifactDigests: string[], + purgeActions: Array<(typeof PURGE_ACTIONS)[number]>, +): PurgeTombstoneEvent { + return validateLabEvent(assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "purge_tombstone" as const, + recordedAt: req.recordedAt ?? Date.now(), + producer: LAB_PRODUCER, + producerVersion: req.producerVersion ?? LAB_PRODUCER_VERSION, + targetEventIds: [...removeIds].sort(), + targetArtifactDigests, + reason: "sensitive_evidence" as const, + purgeActions, + })) as PurgeTombstoneEvent; +} + /** * Exceptional sensitive-evidence purge: * physically remove targeted JSONL lines and artifacts, append purge_tombstone, @@ -163,19 +196,6 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto explicitSensitive, ); - const tombstonePayload = { - schemaVersion: LAB_EVENT_SCHEMA_VERSION, - eventKind: "purge_tombstone" as const, - recordedAt: req.recordedAt ?? Date.now(), - producer: LAB_PRODUCER, - producerVersion: req.producerVersion ?? LAB_PRODUCER_VERSION, - targetEventIds: [...removeIds].sort(), - targetArtifactDigests, - reason: "sensitive_evidence" as const, - purgeActions, - }; - const tombstone = validateLabEvent(assignEventId(tombstonePayload)) as PurgeTombstoneEvent; - const deletionPlan = purgeActions.includes("artifact") ? artifactDeletionPlan(replay.events, index, removeIds, targetArtifactDigests) : { deletable: [], retainedExplicit: [] }; @@ -189,14 +209,24 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto let dir: TrustedArtifactDir | null = null; const completed: string[] = []; + let deferredExportError: PurgeError | null = null; + let operationError: PurgeError | null = null; + let tombstone: PurgeTombstoneEvent | null = null; + try { if (purgeActions.includes("scratch")) { purgeBoundedDirectory(paths.scratchDir); completed.push("scratch"); } if (purgeActions.includes("export")) { - purgeBoundedDirectory(paths.exportDir); - completed.push("export"); + try { + purgeLocalPublicEvidenceCopies(req.configDir); + completed.push("export"); + } catch (err) { + // Export deletion is independent from artifact/ledger/sqlite deletion. Keep + // deleting every other requested sensitive copy, then report this failure. + deferredExportError = normalizePurgeError(err, completed); + } } if (purgeActions.includes("artifact")) { @@ -207,35 +237,59 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto completed.push("artifact"); } - if (purgeActions.includes("ledger")) { - const kept: LabEvent[] = []; - for (const event of replay.events) { - if (removeIds.has(event.eventId)) continue; - kept.push(event); + // Never persist a tombstone claiming that export completed when the export purge + // failed. Other independent actions remain recordable and continue as requested. + const tombstoneActions = deferredExportError + ? purgeActions.filter((action) => action !== "export") + : purgeActions; + const hasTombstoneTarget = removeIds.size > 0 + || targetArtifactDigests.length > 0 + || tombstoneActions.includes("scratch") + || tombstoneActions.includes("export"); + + if (tombstoneActions.length > 0 && (hasTombstoneTarget || !deferredExportError)) { + tombstone = buildPurgeTombstone(req, removeIds, targetArtifactDigests, tombstoneActions); + if (purgeActions.includes("ledger")) { + const kept: LabEvent[] = []; + for (const event of replay.events) { + if (removeIds.has(event.eventId)) continue; + kept.push(event); + } + kept.push(tombstone); + atomicRewriteLedger(paths.ledgerPath, kept); + completed.push("ledger"); + } else { + appendLabEvent(paths.ledgerPath, tombstone); } - kept.push(tombstone); - atomicRewriteLedger(paths.ledgerPath, kept); - } else { - appendLabEvent(paths.ledgerPath, tombstone); } - completed.push("ledger"); if (purgeActions.includes("sqlite")) { rebuildLabProjection(req.configDir); completed.push("sqlite"); } - - return tombstone; } catch (err) { - if (err instanceof PurgeError) { - throw new PurgeError(err.code, err.message, [...completed, ...err.completedActions]); - } + operationError = normalizePurgeError(err, completed); + } finally { + if (dir) closeTrustedArtifactDir(dir); + } + + if (operationError && deferredExportError) { throw new PurgeError( "purge_failed", - err instanceof Error ? err.message : String(err), - completed, + `export purge failed: ${deferredExportError.message}; subsequent purge failure (${operationError.code}): ${operationError.message}`, + [...new Set([...completed, ...deferredExportError.completedActions, ...operationError.completedActions])], ); - } finally { - if (dir) closeTrustedArtifactDir(dir); } + if (operationError) throw operationError; + if (deferredExportError) { + throw new PurgeError( + deferredExportError.code, + deferredExportError.message, + [...new Set([...completed, ...deferredExportError.completedActions])], + ); + } + if (!tombstone) { + throw new PurgeError("purge_failed", "purge completed without a durable tombstone", completed); + } + return tombstone; } diff --git a/src/lab/public/community-files.ts b/src/lab/public/community-files.ts new file mode 100644 index 000000000..38d78f405 --- /dev/null +++ b/src/lab/public/community-files.ts @@ -0,0 +1,29 @@ +const COMMUNITY_ID_FRAGMENT = "[0-9a-f]{64}"; +const COMMUNITY_BUNDLE_FILE_RE = new RegExp( + `^bundle-(${COMMUNITY_ID_FRAGMENT})-(${COMMUNITY_ID_FRAGMENT})\\.json$`, +); +const COMMUNITY_REVOCATION_FILE_RE = new RegExp( + `^revocation-(${COMMUNITY_ID_FRAGMENT})\\.json$`, +); + +export interface CommunityBundleFileIdentity { + publisherKeyId: string; + bundleId: string; +} + +export function communityBundleFileName(publisherKeyId: string, bundleId: string): string { + return `bundle-${publisherKeyId}-${bundleId}.json`; +} + +export function communityRevocationFileName(revocationId: string): string { + return `revocation-${revocationId}.json`; +} + +export function parseCommunityBundleFileName(name: string): CommunityBundleFileIdentity | null { + const match = COMMUNITY_BUNDLE_FILE_RE.exec(name); + return match ? { publisherKeyId: match[1]!, bundleId: match[2]! } : null; +} + +export function isCommunityRevocationFileName(name: string): boolean { + return COMMUNITY_REVOCATION_FILE_RE.test(name); +} diff --git a/src/lab/public/community.ts b/src/lab/public/community.ts new file mode 100644 index 000000000..d71c70ef0 --- /dev/null +++ b/src/lab/public/community.ts @@ -0,0 +1,475 @@ +import { lstatSync, readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { jcsStringify } from "../digest"; +import { ensureLabDirs, labCommunityDir } from "../paths"; +import { validateCommunityEvidenceAuthorities } from "./community-authority"; +import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety"; +import { withPublicEvidenceMutationLock } from "./mutation-lock"; +import { recordLocalPublicOrigin } from "./origin"; +import { + cleanupStalePrivateFileStages, + cleanupStalePrivateFileStagesInDir, + isPrivateFileStageName, + publishPrivateFileExclusive, +} from "./private-file"; +import { validatePublicEvidencePrivacy } from "./privacy"; +import { verifyPublicEvidenceRevocation } from "./revocation"; +import { loadExistingPublicPublisher, verifyPublicEvidenceBundle } from "./signature"; +import { parseStrictPublicJson } from "./strict-json"; +import type { + CommunityEvidenceSummaryV1, + PublicEvidenceBundleV1, + PublicEvidenceRevocationV1, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_IMPORT_BYTES = 2 * 1024 * 1024; +const MAX_CACHE_FILES = 512; +const MAX_CACHE_BYTES = 64 * 1024 * 1024; +const MAX_DEPTH = 8; +const MAX_OBJECT_KEYS = 64; +const MAX_ARRAY_ELEMENTS = 512; +const MAX_GENERIC_STRING_BYTES = 384 * 1024; +const COMMUNITY_MUTATION_LOCK_NAME = ".mutation-lock"; +const COMMUNITY_BUNDLE_FILE_RE = /^bundle-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; +const COMMUNITY_REVOCATION_FILE_RE = /^revocation-([0-9a-f]{64})\.json$/; + +const COMMUNITY_FILE_OPTIONS = { + maxBytes: MAX_IMPORT_BYTES, + errorCode: "community_unsafe_target", + errorMessage: "community object is not a bounded private regular file", + sizeErrorCode: "community_size", + sizeErrorMessage: "community file exceeds bound", +} as const; + +type CommunitySummaryCache = { + directory: string; + fingerprint: string; + evidence: CommunityEvidenceSummaryV1[]; +}; + +let communitySummaryCache: CommunitySummaryCache | null = null; + +function assertId(value: string): string { + if (!/^[0-9a-f]{64}$/.test(value)) { + throw new PublicEvidenceValidationError("community_id", "community object id invalid"); + } + return value; +} + +function scanStructure(value: unknown, depth = 0): void { + if (depth > MAX_DEPTH) { + throw new PublicEvidenceValidationError("community_depth", "community JSON nesting depth exceeded"); + } + if (typeof value === "string") { + if (new TextEncoder().encode(value).byteLength > MAX_GENERIC_STRING_BYTES || value.includes("\0")) { + throw new PublicEvidenceValidationError("community_string", "community string invalid or oversized"); + } + return; + } + if (Array.isArray(value)) { + if (value.length > MAX_ARRAY_ELEMENTS) { + throw new PublicEvidenceValidationError("community_array", "community array bound exceeded"); + } + for (const item of value) scanStructure(item, depth + 1); + return; + } + if (value && typeof value === "object") { + const keys = Object.keys(value); + if (keys.length > MAX_OBJECT_KEYS) { + throw new PublicEvidenceValidationError("community_object", "community object key bound exceeded"); + } + for (const key of keys) { + if (new TextEncoder().encode(key).byteLength > 4096) { + throw new PublicEvidenceValidationError("community_key", "community key oversized"); + } + scanStructure((value as Record)[key], depth + 1); + } + } +} + +function boundedInput(raw: unknown): unknown { + let bytes: Buffer; + if (raw instanceof Uint8Array) { + bytes = Buffer.from(raw); + } else if (typeof raw === "string") { + bytes = Buffer.from(raw, "utf8"); + } else { + scanStructure(raw); + bytes = Buffer.from(jcsStringify(raw), "utf8"); + } + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community import exceeds 2 MiB"); + } + const parsed = parseStrictPublicJson(bytes, "community import"); + scanStructure(parsed); + return parsed; +} + +function assertCommunityArtifactAuthority(bundle: PublicEvidenceBundleV1): void { + if (bundle.artifacts.length !== 0) { + throw new PublicEvidenceValidationError( + "public_artifact_authority_required", + "community artifact bytes require reviewed public_export policy authority", + ); + } +} + +function verifiedBundle(raw: unknown): PublicEvidenceBundleV1 { + const result = verifyPublicEvidenceBundle(raw as PublicEvidenceBundleV1); + if (result.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError(result.status, "community bundle verification failed"); + } + const bundle = validateCommunityEvidenceAuthorities(raw as PublicEvidenceBundleV1); + assertCommunityArtifactAuthority(bundle); + validatePublicEvidencePrivacy(bundle); + return bundle; +} + +function bundleObjectPath(publisherKeyId: string, bundleId: string, configDir?: string): string { + return join(labCommunityDir(configDir), `bundle-${assertId(publisherKeyId)}-${assertId(bundleId)}.json`); +} + +function revocationObjectPath(revocationId: string, configDir?: string): string { + return join(labCommunityDir(configDir), `revocation-${assertId(revocationId)}.json`); +} + +function readBounded(path: string): Buffer { + cleanupStalePrivateFileStages(path); + return readPrivateRegularFile(path, COMMUNITY_FILE_OPTIONS); +} + +function cacheUsage(configDir?: string): { names: string[]; bytes: number } { + ensureLabDirs(configDir); + const dir = labCommunityDir(configDir); + cleanupStalePrivateFileStagesInDir(dir); + const names = readdirSync(dir) + .filter((name) => name !== COMMUNITY_MUTATION_LOCK_NAME && !isPrivateFileStageName(name)) + .sort(); + if (names.length > MAX_CACHE_FILES) { + throw new PublicEvidenceValidationError("community_cache_bound", "community cache file bound exceeded"); + } + let bytes = 0; + for (const name of names) { + bytes += privateRegularFileSize(join(dir, name), COMMUNITY_FILE_OPTIONS); + if (bytes > MAX_CACHE_BYTES) { + throw new PublicEvidenceValidationError("community_cache_bound", "community cache byte bound exceeded"); + } + } + return { names, bytes }; +} + +function assertCacheCanAdd(byteCount: number, configDir?: string): void { + const usage = cacheUsage(configDir); + if (usage.names.length >= MAX_CACHE_FILES || usage.bytes + byteCount > MAX_CACHE_BYTES) { + throw new PublicEvidenceValidationError("community_cache_bound", "community cache capacity exceeded"); + } +} + +function persistAtLocked( + path: string, + kind: "bundle" | "revocation", + value: unknown, + configDir?: string, + onCommit?: () => void, +): { path: string; created: boolean } { + const bytes = Buffer.from(jcsStringify(value), "utf8"); + if (bytes.byteLength > MAX_IMPORT_BYTES) { + throw new PublicEvidenceValidationError("community_size", "community object exceeds bound"); + } + + let created = false; + try { + try { + const existing = readBounded(path); + if (!existing.equals(bytes)) { + throw new PublicEvidenceValidationError("community_conflict", `${kind} identity already exists with different bytes`); + } + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + assertCacheCanAdd(bytes.byteLength, configDir); + const published = publishPrivateFileExclusive(path, bytes); + if (!published.created) { + const raced = readBounded(path); + if (!raced.equals(bytes)) { + throw new PublicEvidenceValidationError("community_conflict", `${kind} identity already exists with different bytes`); + } + } else { + created = true; + cacheUsage(configDir); + } + } + + onCommit?.(); + if (created) communitySummaryCache = null; + return { path, created }; + } catch (error) { + if (created) { + try { unlinkSync(path); } catch { /* preserve commit error */ } + communitySummaryCache = null; + } + throw error; + } +} + +function persistAt( + path: string, + kind: "bundle" | "revocation", + value: unknown, + configDir?: string, + onCommit?: () => void, +): { path: string; created: boolean } { + return withPublicEvidenceMutationLock( + configDir, + () => persistAtLocked(path, kind, value, configDir, onCommit), + ); +} + +function readJson(path: string): unknown { + const parsed = parseStrictPublicJson(readBounded(path), "stored community object"); + scanStructure(parsed); + return parsed; +} + +function files(configDir?: string): string[] { + return cacheUsage(configDir).names; +} + +function readVerifiedBundleAt(path: string): PublicEvidenceBundleV1 { + return verifiedBundle(readJson(path)); +} + +function bundleFromName(name: string, configDir?: string): PublicEvidenceBundleV1 | null { + const match = COMMUNITY_BUNDLE_FILE_RE.exec(name); + if (!match) return null; + const publisherKeyId = match[1]!; + const bundleId = match[2]!; + const bundle = readVerifiedBundleAt(bundleObjectPath(publisherKeyId, bundleId, configDir)); + if (bundle.bundleId !== bundleId || bundle.publisher.keyId !== publisherKeyId) { + throw new PublicEvidenceValidationError("community_identity_mismatch", "stored community bundle does not match filename identity"); + } + return bundle; +} + +function bundlesFromNames(names: readonly string[], configDir?: string): PublicEvidenceBundleV1[] { + const bundles: PublicEvidenceBundleV1[] = []; + for (const name of names) { + const bundle = bundleFromName(name, configDir); + if (bundle) bundles.push(bundle); + } + return bundles; +} + +function restoreOwnPublisherOrigin(bundle: PublicEvidenceBundleV1, configDir?: string): void { + const local = loadExistingPublicPublisher(configDir); + if (!local) return; + if ( + local.publisher.algorithm !== bundle.publisher.algorithm + || local.publisher.keyId !== bundle.publisher.keyId + || local.publisher.publicKey !== bundle.publisher.publicKey + ) return; + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, configDir); +} + +export function importCommunityEvidenceBundle( + raw: unknown, + configDir?: string, +): { created: boolean; status: "cryptographically_valid"; bundleId: string; publisherKeyId: string; path: string } { + const bundle = verifiedBundle(boundedInput(raw)); + ensureLabDirs(configDir); + const stored = persistAt( + bundleObjectPath(bundle.publisher.keyId, bundle.bundleId, configDir), + "bundle", + bundle, + configDir, + () => restoreOwnPublisherOrigin(bundle, configDir), + ); + return { ...stored, status: "cryptographically_valid", bundleId: bundle.bundleId, publisherKeyId: bundle.publisher.keyId }; +} + +function readCommunityEvidenceBundleForPublisherLocked( + bundleId: string, + publisherKeyId: string, + configDir?: string, +): PublicEvidenceBundleV1 { + const bundle = readVerifiedBundleAt(bundleObjectPath(publisherKeyId, bundleId, configDir)); + if (bundle.bundleId !== bundleId || bundle.publisher.keyId !== publisherKeyId) { + throw new PublicEvidenceValidationError("community_identity_mismatch", "stored community bundle does not match filename identity"); + } + return bundle; +} + +export function readCommunityEvidenceBundleForPublisher( + bundleId: string, + publisherKeyId: string, + configDir?: string, +): PublicEvidenceBundleV1 { + return withPublicEvidenceMutationLock( + configDir, + () => readCommunityEvidenceBundleForPublisherLocked(bundleId, publisherKeyId, configDir), + ); +} + +type RevocationMetadata = { + publisher?: { keyId?: unknown }; + targets?: Array<{ kind?: unknown; id?: unknown }>; +}; + +function resolveTargetBundle( + revocation: unknown, + bundles: readonly PublicEvidenceBundleV1[], +): PublicEvidenceBundleV1 { + if (!revocation || typeof revocation !== "object") { + throw new PublicEvidenceValidationError("revocation_target", "revocation target metadata unavailable"); + } + const raw = revocation as RevocationMetadata; + if (!Array.isArray(raw.targets) || typeof raw.publisher?.keyId !== "string") { + throw new PublicEvidenceValidationError("revocation_target", "revocation targets or publisher unavailable"); + } + const publisherKeyId = assertId(raw.publisher.keyId); + const publisherBundles = bundles + .filter((bundle) => bundle.publisher.keyId === publisherKeyId) + .sort((a, b) => a.bundleId.localeCompare(b.bundleId)); + const bundleTargets = raw.targets.filter((target) => target.kind === "bundle" && typeof target.id === "string"); + if (bundleTargets.length > 0) { + const targetIds = new Set(bundleTargets.map((target) => target.id)); + if (targetIds.size !== 1) { + throw new PublicEvidenceValidationError("revocation_target", "revocation bundle targets are ambiguous"); + } + const id = [...targetIds][0]!; + const candidate = publisherBundles.find((bundle) => bundle.bundleId === id); + if (!candidate) throw new PublicEvidenceValidationError("revocation_target", "revocation target bundle not found"); + return candidate; + } + const fullyMatching = publisherBundles.filter((bundle) => raw.targets!.every((target) => + target.kind === "record" && typeof target.id === "string" + && bundle.records.some((record) => record.recordId === target.id), + )); + if (fullyMatching.length === 0) { + throw new PublicEvidenceValidationError( + "revocation_target", + "revocation targets do not resolve to a verified bundle for the same publisher", + ); + } + return fullyMatching[0]!; +} + +function findTargetBundleLocked(revocation: unknown, configDir?: string): PublicEvidenceBundleV1 { + const names = files(configDir); + const raw = revocation as RevocationMetadata; + const publisherKeyId = typeof raw?.publisher?.keyId === "string" ? assertId(raw.publisher.keyId) : null; + const directBundleIds = Array.isArray(raw?.targets) + ? [...new Set(raw.targets.filter((target) => target.kind === "bundle" && typeof target.id === "string").map((target) => target.id as string))] + : []; + if (publisherKeyId && directBundleIds.length === 1) { + try { + return readCommunityEvidenceBundleForPublisherLocked( + assertId(directBundleIds[0]!), + publisherKeyId, + configDir, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new PublicEvidenceValidationError( + "revocation_target", + "revocation target bundle not found", + ); + } + throw error; + } + } + return resolveTargetBundle(revocation, bundlesFromNames(names, configDir)); +} + +export function importCommunityEvidenceRevocation( + raw: unknown, + configDir?: string, +): { created: boolean; status: "cryptographically_valid"; revocationId: string; path: string } { + const parsed = boundedInput(raw); + ensureLabDirs(configDir); + return withPublicEvidenceMutationLock(configDir, () => { + const targetBundle = findTargetBundleLocked(parsed, configDir); + const verified = verifyPublicEvidenceRevocation(parsed, targetBundle); + if (verified.status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError(verified.status, verified.detail ?? "community revocation verification failed"); + } + const stored = persistAtLocked( + revocationObjectPath(verified.revocation.revocationId, configDir), + "revocation", + verified.revocation, + configDir, + ); + return { ...stored, status: "cryptographically_valid", revocationId: verified.revocation.revocationId }; + }); +} + +function communityFingerprint(names: readonly string[], configDir?: string): string { + const dir = labCommunityDir(configDir); + return names.map((name) => { + const stat = lstatSync(join(dir, name)); + return [name, stat.dev, stat.ino, stat.mode, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); + }).join("\n"); +} + +function copySummaries(evidence: readonly CommunityEvidenceSummaryV1[]): CommunityEvidenceSummaryV1[] { + return evidence.map((row) => ({ ...row })); +} + +function listCommunityEvidenceLocked(configDir?: string): CommunityEvidenceSummaryV1[] { + const names = files(configDir); + const directory = labCommunityDir(configDir); + const fingerprint = communityFingerprint(names, configDir); + if (communitySummaryCache?.directory === directory && communitySummaryCache.fingerprint === fingerprint) { + return copySummaries(communitySummaryCache.evidence); + } + + const bundles = bundlesFromNames(names, configDir); + const revocations: PublicEvidenceRevocationV1[] = []; + + for (const name of names) { + if (!COMMUNITY_REVOCATION_FILE_RE.test(name)) continue; + const raw = readJson(join(directory, name)); + let targetBundle: PublicEvidenceBundleV1; + try { + targetBundle = resolveTargetBundle(raw, bundles); + } catch (error) { + if (error instanceof PublicEvidenceValidationError) continue; + throw error; + } + const verified = verifyPublicEvidenceRevocation(raw, targetBundle); + if (verified.status === "cryptographically_valid") revocations.push(verified.revocation); + } + + const evidence = bundles.map((bundle) => { + const revoked = new Set(); + const bundleRecordIds = new Set(bundle.records.map((record) => record.recordId)); + for (const revocation of revocations) { + if (revocation.publisher.keyId !== bundle.publisher.keyId + || revocation.publisher.publicKey !== bundle.publisher.publicKey) { + continue; + } + if (revocation.targets.some((target) => target.kind === "bundle" && target.id === bundle.bundleId)) { + for (const record of bundle.records) revoked.add(record.recordId); + } + for (const target of revocation.targets) { + if (target.kind === "record" && bundleRecordIds.has(target.id)) revoked.add(target.id); + } + } + return { + trustClass: "community_untrusted_v1" as const, + status: "cryptographically_valid" as const, + bundleId: bundle.bundleId, + publisherKeyId: bundle.publisher.keyId, + activeRecordCount: bundle.records.filter((record) => !revoked.has(record.recordId)).length, + revokedRecordCount: bundle.records.filter((record) => revoked.has(record.recordId)).length, + }; + }).sort((a, b) => a.bundleId.localeCompare(b.bundleId) || a.publisherKeyId.localeCompare(b.publisherKeyId)); + + communitySummaryCache = { directory, fingerprint, evidence: copySummaries(evidence) }; + return copySummaries(evidence); +} + +export function listCommunityEvidence(configDir?: string): CommunityEvidenceSummaryV1[] { + return withPublicEvidenceMutationLock(configDir, () => listCommunityEvidenceLocked(configDir)); +} diff --git a/src/lab/public/index.ts b/src/lab/public/index.ts new file mode 100644 index 000000000..e16890d88 --- /dev/null +++ b/src/lab/public/index.ts @@ -0,0 +1,16 @@ +export * from "./types"; +export * from "./ids"; +export * from "./registry"; +export * from "./validate"; +export * from "./privacy"; +export * from "./project"; +export * from "./bundle"; +export * from "./signature"; +export * from "./storage"; +export * from "./community-authority"; +export * from "./revocation"; +export * from "./community"; +export * from "./strict-json"; +export * from "./origin"; +export * from "./operator"; +export * from "./purge"; diff --git a/src/lab/public/mutation-lock.ts b/src/lab/public/mutation-lock.ts new file mode 100644 index 000000000..922bb6017 --- /dev/null +++ b/src/lab/public/mutation-lock.ts @@ -0,0 +1,413 @@ +import { randomUUID } from "node:crypto"; +import { + lstatSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { ensureLabDirs, labCommunityDir } from "../paths"; +import { readPrivateRegularFile } from "./file-safety"; +import { PublicEvidenceValidationError } from "./validate"; + +const PUBLIC_EVIDENCE_MUTATION_LOCK_NAME = ".mutation-lock"; +const PUBLIC_EVIDENCE_MUTATION_LOCK_OWNER = "owner.json"; +const PUBLIC_EVIDENCE_MUTATION_LOCK_RECLAIM = ".reclaim.json"; +const PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS = 24 * 60 * 60 * 1000; +const DETACHED_MUTATION_LOCK_RE = /^\.mutation-lock-(?:stale|release)-\d+-[0-9a-f-]{36}$/; +const MUTATION_LOCK_META_FILE_OPTIONS = { + maxBytes: 1024, + errorCode: "community_cache_lock", + errorMessage: "community cache mutation lock metadata is unsafe", + sizeErrorCode: "community_cache_lock", + sizeErrorMessage: "community cache mutation lock metadata exceeds its size bound", + requireMode600: true, +} as const; + +type MutationLockOwner = { + pid: number; + token: string; + createdAt: number; +}; + +type MutationLockReclaim = { + pid: number; + token: string; + createdAt: number; +}; + +type MutationLockDirectoryIdentity = { + dev: number; + ino: number; +}; + +function mutationLockPath(configDir?: string): string { + return join(labCommunityDir(configDir), PUBLIC_EVIDENCE_MUTATION_LOCK_NAME); +} + +function mutationLockOwnerPath(lockPath: string): string { + return join(lockPath, PUBLIC_EVIDENCE_MUTATION_LOCK_OWNER); +} + +function mutationLockReclaimPath(lockPath: string): string { + return join(lockPath, PUBLIC_EVIDENCE_MUTATION_LOCK_RECLAIM); +} + +function pidDefinitelyDead(pid: number): boolean { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +function readLockMetadata( + path: string, +): T | null { + try { + const bytes = readPrivateRegularFile(path, MUTATION_LOCK_META_FILE_OPTIONS); + const raw = JSON.parse(bytes.toString("utf8")) as Partial; + if ( + Number.isSafeInteger(raw.pid) + && Number(raw.pid) > 0 + && typeof raw.token === "string" + && /^[0-9a-f-]{36}$/.test(raw.token) + && Number.isSafeInteger(raw.createdAt) + && Number(raw.createdAt) > 0 + ) { + return { + pid: Number(raw.pid), + token: raw.token, + createdAt: Number(raw.createdAt), + } as T; + } + } catch { + // Incomplete metadata is handled conservatively by the age fallback where + // applicable. Normal acquisition never trusts malformed metadata. + } + return null; +} + +function readMutationLockOwner(lockPath: string): MutationLockOwner | null { + return readLockMetadata(mutationLockOwnerPath(lockPath)); +} + +function readMutationLockReclaim(lockPath: string): MutationLockReclaim | null { + return readLockMetadata(mutationLockReclaimPath(lockPath)); +} + +function assertMutationLockDirectory(lockPath: string) { + const stat = lstatSync(lockPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new PublicEvidenceValidationError( + "community_cache_lock", + "community cache mutation lock is not a directory", + ); + } + return stat; +} + +function sameDirectoryIdentity( + left: MutationLockDirectoryIdentity, + right: MutationLockDirectoryIdentity, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function currentDirectoryIdentity(lockPath: string): MutationLockDirectoryIdentity { + const stat = assertMutationLockDirectory(lockPath); + return { dev: stat.dev, ino: stat.ino }; +} + +function mutationLockIsReclaimable(lockPath: string, nowMs: number): boolean { + const stat = assertMutationLockDirectory(lockPath); + const owner = readMutationLockOwner(lockPath); + if (owner) { + // Never evict a recorded live owner based on age alone. Long operations or a + // suspended process must retain mutual exclusion until that process exits. + return pidDefinitelyDead(owner.pid); + } + // The only ownerless state is the tiny mkdir-to-owner publication window. Use + // a deliberately long fallback so a crashed acquisition can eventually heal + // without treating an ordinary pause as proof that the owner disappeared. + return nowMs - stat.mtimeMs > PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS; +} + +function unlinkIfPresent(path: string): void { + try { + unlinkSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +function cleanupDetachedMutationLocks(lockPath: string): void { + const dir = dirname(lockPath); + for (const name of readdirSync(dir)) { + if (!DETACHED_MUTATION_LOCK_RE.test(name)) continue; + // Detached lock directories are no longer authoritative once a new canonical + // lock has been acquired. Removing only their UUID-scoped names prevents them + // from leaking storage or being mistaken for community cache objects. + rmSync(join(dir, name), { recursive: true, force: true }); + } +} + +function publishMutationLockOwner( + lockPath: string, + owner: MutationLockOwner, + expectedDirectory: MutationLockDirectoryIdentity, +): void { + // Write through a unique temporary pathname first. If an ancient ownerless + // lock is reclaimed while this process was suspended, the inode check prevents + // this acquisition from publishing its owner metadata into a replacement lock. + const tempPath = join(lockPath, `.owner-${owner.token}.tmp`); + try { + writeFileSync(tempPath, JSON.stringify(owner), { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), expectedDirectory)) { + throw new PublicEvidenceValidationError( + "community_cache_lock", + "community cache mutation lock changed during owner publication", + ); + } + renameSync(tempPath, mutationLockOwnerPath(lockPath)); + if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), expectedDirectory)) { + throw new PublicEvidenceValidationError( + "community_cache_lock", + "community cache mutation lock changed after owner publication", + ); + } + const persisted = readMutationLockOwner(lockPath); + if (!persisted || persisted.pid !== owner.pid || persisted.token !== owner.token) { + throw new PublicEvidenceValidationError( + "community_cache_lock", + "community cache mutation lock owner publication was not durable", + ); + } + } finally { + // The rename normally makes this ENOENT. If the lock pathname was replaced, + // the UUID-scoped temporary name can be removed without touching successor state. + unlinkIfPresent(tempPath); + } +} + +function reclaimClaimIsRecoverable(lockPath: string, nowMs: number): boolean { + const claimPath = mutationLockReclaimPath(lockPath); + const claim = readMutationLockReclaim(lockPath); + if (claim) return pidDefinitelyDead(claim.pid); + try { + const stat = lstatSync(claimPath); + return nowMs - stat.mtimeMs > PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } +} + +function recoverStaleReclaimClaim(lockPath: string, nowMs: number): boolean { + if (!reclaimClaimIsRecoverable(lockPath, nowMs)) return false; + const claimPath = mutationLockReclaimPath(lockPath); + const quarantinePath = join(lockPath, `.reclaim-stale-${randomUUID()}.json`); + try { + renameSync(claimPath, quarantinePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + unlinkIfPresent(quarantinePath); + return true; +} + +function tryAcquireReclaimClaim( + lockPath: string, + nowMs: number, +): MutationLockReclaim | null { + for (let attempt = 0; attempt < 2; attempt += 1) { + const claim: MutationLockReclaim = { + pid: process.pid, + token: randomUUID(), + createdAt: nowMs, + }; + try { + writeFileSync(mutationLockReclaimPath(lockPath), JSON.stringify(claim), { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return claim; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return null; + if (code !== "EEXIST") throw error; + if (!recoverStaleReclaimClaim(lockPath, nowMs)) return null; + } + } + return null; +} + +function reclaimClaimStillOwned(lockPath: string, claim: MutationLockReclaim): boolean { + const current = readMutationLockReclaim(lockPath); + return current?.pid === claim.pid && current.token === claim.token; +} + +function releaseReclaimClaim(lockPath: string, claim: MutationLockReclaim): void { + if (!reclaimClaimStillOwned(lockPath, claim)) return; + unlinkIfPresent(mutationLockReclaimPath(lockPath)); +} + +function tryReclaimMutationLock(lockPath: string, nowMs: number): boolean { + // Capture the exact stale directory before the claim write changes its mtime. + // After claiming, revalidate the inode and owner instead of reusing an age check + // that our own .reclaim.json creation would make appear fresh. + if (!mutationLockIsReclaimable(lockPath, nowMs)) return false; + const staleDirectory = currentDirectoryIdentity(lockPath); + const claim = tryAcquireReclaimClaim(lockPath, nowMs); + if (!claim) return false; + + let moved = false; + try { + if (!reclaimClaimStillOwned(lockPath, claim)) return false; + if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), staleDirectory)) return false; + const currentOwner = readMutationLockOwner(lockPath); + if (currentOwner && !pidDefinitelyDead(currentOwner.pid)) return false; + if (!reclaimClaimStillOwned(lockPath, claim)) return false; + + const quarantinePath = join( + dirname(lockPath), + `.mutation-lock-stale-${process.pid}-${randomUUID()}`, + ); + try { + // Rename the exact claimed directory away from the canonical pathname before + // deleting it. A successor can create a new lock immediately afterwards, but + // cleanup is confined to this unique quarantine path and cannot delete it. + renameSync(lockPath, quarantinePath); + moved = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + rmSync(quarantinePath, { recursive: true, force: true }); + return true; + } finally { + if (!moved) releaseReclaimClaim(lockPath, claim); + } +} + +function discardUncommittedMutationLock( + lockPath: string, + expectedDirectory: MutationLockDirectoryIdentity, +): void { + try { + if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), expectedDirectory)) return; + const quarantinePath = join( + dirname(lockPath), + `.mutation-lock-release-${process.pid}-${randomUUID()}`, + ); + renameSync(lockPath, quarantinePath); + rmSync(quarantinePath, { recursive: true, force: true }); + } catch { + // Preserve the owner-publication error. An unrecoverable cleanup witness stays + // ownerless and can be reclaimed by the long incomplete-acquisition fallback. + } +} + +function releaseMutationLock( + lockPath: string, + owner: MutationLockOwner, + expectedDirectory: MutationLockDirectoryIdentity, +): void { + let currentDirectory: MutationLockDirectoryIdentity; + try { + currentDirectory = currentDirectoryIdentity(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (!sameDirectoryIdentity(currentDirectory, expectedDirectory)) return; + const current = readMutationLockOwner(lockPath); + if (!current || current.pid !== owner.pid || current.token !== owner.token) return; + + const quarantinePath = join( + dirname(lockPath), + `.mutation-lock-release-${process.pid}-${randomUUID()}`, + ); + try { + renameSync(lockPath, quarantinePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + rmSync(quarantinePath, { recursive: true, force: true }); +} + +/** Serialize public-evidence mutations across CLI/server processes with ownership-safe stale recovery. */ +export function withPublicEvidenceMutationLock( + configDir: string | undefined, + run: () => T, +): T { + ensureLabDirs(configDir); + const lockPath = mutationLockPath(configDir); + let owner: MutationLockOwner | null = null; + let ownedDirectory: MutationLockDirectoryIdentity | null = null; + + while (true) { + try { + mkdirSync(lockPath, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + try { + if (tryReclaimMutationLock(lockPath, Date.now())) continue; + } catch (reclaimError) { + if ((reclaimError as NodeJS.ErrnoException).code === "ENOENT") continue; + throw reclaimError; + } + throw new PublicEvidenceValidationError("community_cache_busy", "community cache is busy"); + } + + const directory = currentDirectoryIdentity(lockPath); + const candidate: MutationLockOwner = { + pid: process.pid, + token: randomUUID(), + createdAt: Date.now(), + }; + try { + publishMutationLockOwner(lockPath, candidate, directory); + } catch (error) { + discardUncommittedMutationLock(lockPath, directory); + throw error; + } + owner = candidate; + ownedDirectory = directory; + break; + } + + try { + cleanupDetachedMutationLocks(lockPath); + return run(); + } finally { + if (owner && ownedDirectory) releaseMutationLock(lockPath, owner, ownedDirectory); + } +} + +/** Test-only seam for stale-owner policy. This module is not barrel-exported. */ +export function publicEvidenceMutationLockIsReclaimableForTests( + configDir: string | undefined, + nowMs = Date.now(), +): boolean { + return mutationLockIsReclaimable(mutationLockPath(configDir), nowMs); +} + +/** Test-only seam for the exclusive stale-reclaimer claim. */ +export function publicEvidenceTryReclaimMutationLockForTests( + configDir: string | undefined, + nowMs = Date.now(), +): boolean { + return tryReclaimMutationLock(mutationLockPath(configDir), nowMs); +} diff --git a/src/lab/public/operator.ts b/src/lab/public/operator.ts new file mode 100644 index 000000000..7567f0d8a --- /dev/null +++ b/src/lab/public/operator.ts @@ -0,0 +1,329 @@ +import { replayLabLedger } from "../ledger/store"; +import { labLedgerPath } from "../paths"; +import { queryLabEvents, queryLabVerdicts } from "../query"; +import type { ObservationEvent } from "../events/types"; +import { + importCommunityEvidenceBundle, + listCommunityEvidence, +} from "./community"; +import { readPrivateRegularFile } from "./file-safety"; +import { withPublicEvidenceMutationLock } from "./mutation-lock"; +import { recordLocalPublicOrigin } from "./origin"; +import type { ProjectPublicEvidenceRecordInput } from "./project"; +import { projectPublicEvidenceRecord } from "./project"; +import { + signPublicEvidenceBundle, + verifyPublicEvidenceBundle, +} from "./signature"; +import { storePublicEvidenceBundle } from "./storage"; +import { parseStrictPublicJson } from "./strict-json"; +import { publicUtcDay } from "./time"; +import { PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, PUBLIC_EXPORT_POLICY_VERSION } from "./types"; +import type { + PublicEvidenceBundleV1, + PublicEvidencePreviewBundleV1, + PublicEvidenceRecordV1, + PublicProjectionNotExportableReason, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const MAX_OPERATOR_EVENTS = 256; +const MAX_PUBLIC_FILE_BYTES = 2 * 1024 * 1024; +const EMPTY_PREVIEW_DAY = "1970-01-01"; +const PRIVATE_STORAGE_LOCATOR = ""; + +export interface ProjectPublicEvidenceInput { + records: ProjectPublicEvidenceRecordInput[]; +} + +export function projectPublicEvidence(input: ProjectPublicEvidenceInput): { + bundle: PublicEvidencePreviewBundleV1; + excluded: Array<{ index: number; reason: PublicProjectionNotExportableReason }>; +} { + const records: PublicEvidenceRecordV1[] = []; + const excluded: Array<{ index: number; reason: PublicProjectionNotExportableReason }> = []; + let latestExportableCompletedAt: number | null = null; + + input.records.forEach((recordInput, index) => { + const projected = projectPublicEvidenceRecord(recordInput); + if (projected.status !== "exportable") { + excluded.push({ index, reason: projected.reason }); + return; + } + records.push(projected.record); + latestExportableCompletedAt = Math.max( + latestExportableCompletedAt ?? recordInput.observation.completedAt, + recordInput.observation.completedAt, + ); + }); + records.sort((a, b) => a.recordId.localeCompare(b.recordId)); + return { + bundle: { + schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, + exportPolicyVersion: PUBLIC_EXPORT_POLICY_VERSION, + createdDayUtc: latestExportableCompletedAt === null + ? EMPTY_PREVIEW_DAY + : publicUtcDay(latestExportableCompletedAt), + records, + artifacts: [], + }, + excluded, + }; +} + +export type PublicOperatorExclusionReason = + | PublicProjectionNotExportableReason + | "event_not_found" + | "not_observation" + | "event_excluded" + | "no_canonical_verdict"; + +export interface PublicOperatorExclusionV1 { + selectionIndex: number; + reason: PublicOperatorExclusionReason; +} + +export interface LocalPublicPreviewV1 { + bundle: PublicEvidencePreviewBundleV1; + excluded: PublicOperatorExclusionV1[]; +} + +export interface LocalPublicExportV1 { + bundle: PublicEvidenceBundleV1; + stored: { path: typeof PRIVATE_STORAGE_LOCATOR; created: boolean }; + excluded: PublicOperatorExclusionV1[]; +} + +export type PublicVerificationSummaryV1 = + | { status: "cryptographically_valid"; bundleId: string; publisherKeyId: string; locallyVerified: false } + | { status: "schema_rejected" | "digest_invalid" | "signature_invalid"; locallyVerified: false; detail?: string }; + +function assertOperatorEventIds(eventIds: readonly string[]): Array<{ eventId: string; selectionIndex: number }> { + if (eventIds.length === 0 || eventIds.length > MAX_OPERATOR_EVENTS) { + throw new PublicEvidenceValidationError( + "public_selection_limit", + `public evidence selection must contain 1..${MAX_OPERATOR_EVENTS} event ids`, + ); + } + const unique: Array<{ eventId: string; selectionIndex: number }> = []; + const seen = new Set(); + for (const [selectionIndex, eventId] of eventIds.entries()) { + if (!/^[0-9a-f]{64}$/.test(eventId)) { + throw new PublicEvidenceValidationError( + "public_selection_event_id", + "public evidence event ids must be lowercase sha256 hex", + ); + } + if (seen.has(eventId)) continue; + seen.add(eventId); + unique.push({ eventId, selectionIndex }); + } + return unique; +} + +function projectedObservationState( + eventIds: readonly string[], + configDir?: string, +): Map { + const pending = new Set(eventIds); + const state = new Map(); + let cursor: string | undefined; + + while (pending.size > 0) { + const page = queryLabEvents({ eventKind: "observation" }, cursor, 200, configDir); + for (const row of page.items) { + if (!pending.has(row.eventId)) continue; + state.set(row.eventId, { excluded: row.excluded }); + pending.delete(row.eventId); + } + if (!page.hasMore || !page.nextCursor) break; + cursor = page.nextCursor; + } + + return state; +} + +function canonicalVerdictsForObservations( + observations: readonly ObservationEvent[], + configDir?: string, +): Map { + const pending = new Map(observations.map((observation) => [observation.eventId, observation] as const)); + const verdictByEventId = new Map(); + let cursor: string | undefined; + + while (pending.size > 0) { + const page = queryLabVerdicts({}, cursor, 200, configDir); + for (const row of page.items) { + for (const eventId of row.contributingEventIds) { + const observation = pending.get(eventId); + if (!observation) continue; + if ( + row.subjectId !== observation.subjectId + || row.evidenceLayer !== observation.evidenceLayer + || row.suiteId !== observation.suiteId + || row.suiteVersion !== observation.suiteVersion + ) { + continue; + } + verdictByEventId.set(eventId, row.verdict); + pending.delete(eventId); + } + } + if (!page.hasMore || !page.nextCursor) break; + cursor = page.nextCursor; + } + + return verdictByEventId; +} + +export function previewLocalPublicEvidence( + input: { eventIds: readonly string[] }, + configDir?: string, +): LocalPublicPreviewV1 { + const selections = assertOperatorEventIds(input.eventIds); + const replay = replayLabLedger(labLedgerPath(configDir)); + const byId = new Map(replay.events.map((event) => [event.eventId, event] as const)); + const observationSelections: Array<{ observation: ObservationEvent; selectionIndex: number }> = []; + const excluded: PublicOperatorExclusionV1[] = []; + + for (const { eventId, selectionIndex } of selections) { + const event = byId.get(eventId); + if (!event) { + excluded.push({ selectionIndex, reason: "event_not_found" }); + continue; + } + if (event.eventKind !== "observation") { + excluded.push({ selectionIndex, reason: "not_observation" }); + continue; + } + observationSelections.push({ observation: event, selectionIndex }); + } + + if (observationSelections.length === 0) { + throw new PublicEvidenceValidationError("public_selection_empty", "public evidence selection contains no observation events"); + } + + const projectionByEventId = projectedObservationState( + observationSelections.map(({ observation }) => observation.eventId), + configDir, + ); + const candidates: Array<{ observation: ObservationEvent; selectionIndex: number }> = []; + for (const candidate of observationSelections) { + const projectedEvent = projectionByEventId.get(candidate.observation.eventId); + if (!projectedEvent) { + excluded.push({ selectionIndex: candidate.selectionIndex, reason: "event_not_found" }); + continue; + } + if (projectedEvent.excluded) { + excluded.push({ selectionIndex: candidate.selectionIndex, reason: "event_excluded" }); + continue; + } + candidates.push(candidate); + } + + const verdictByEventId = canonicalVerdictsForObservations( + candidates.map((candidate) => candidate.observation), + configDir, + ); + const projectInputs: ProjectPublicEvidenceRecordInput[] = []; + const projectSelectionIndices: number[] = []; + for (const { observation, selectionIndex } of candidates) { + const verdict = verdictByEventId.get(observation.eventId); + if (!verdict) { + excluded.push({ selectionIndex, reason: "no_canonical_verdict" }); + continue; + } + projectInputs.push({ observation, verdict }); + projectSelectionIndices.push(selectionIndex); + } + + const projected = projectPublicEvidence({ records: projectInputs }); + for (const row of projected.excluded) { + excluded.push({ selectionIndex: projectSelectionIndices[row.index]!, reason: row.reason }); + } + excluded.sort((a, b) => a.selectionIndex - b.selectionIndex); + return { bundle: projected.bundle, excluded }; +} + +export function exportLocalPublicEvidence( + input: { eventIds: readonly string[] }, + configDir?: string, +): LocalPublicExportV1 { + const preview = previewLocalPublicEvidence(input, configDir); + if (preview.bundle.records.length === 0) { + throw new PublicEvidenceValidationError("public_export_empty", "selected events produced no exportable public evidence records"); + } + const bundle = signPublicEvidenceBundle({ + records: preview.bundle.records, + artifacts: preview.bundle.artifacts, + createdDayUtc: preview.bundle.createdDayUtc, + configDir, + }); + return withPublicEvidenceMutationLock(configDir, () => { + // Commit purge-owned provenance first. If export publication later fails or the + // process crashes, an orphan marker is conservative and can be reclaimed later; + // the inverse state, a durable export without provenance, is not acceptable. + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, configDir); + const stored = storePublicEvidenceBundle(bundle, configDir); + return { + bundle, + stored: { path: PRIVATE_STORAGE_LOCATOR, created: stored.created }, + excluded: preview.excluded, + }; + }); +} + +export function summarizePublicEvidenceVerification(raw: unknown): PublicVerificationSummaryV1 { + const result = verifyPublicEvidenceBundle(raw as PublicEvidenceBundleV1); + if (result.status !== "cryptographically_valid") { + return { status: result.status, locallyVerified: false }; + } + const bundle = raw as PublicEvidenceBundleV1; + return { + status: "cryptographically_valid", + bundleId: bundle.bundleId, + publisherKeyId: bundle.publisher.keyId, + locallyVerified: false, + }; +} + +function readBoundedPublicFile(path: string): Buffer { + return readPrivateRegularFile(path, { + maxBytes: MAX_PUBLIC_FILE_BYTES, + errorCode: "public_file_unsafe", + errorMessage: "public evidence input must be a regular non-symlink file", + sizeErrorCode: "public_file_too_large", + sizeErrorMessage: "public evidence input exceeds 2 MiB", + }); +} + +function parsePublicFile(path: string): unknown { + return parseStrictPublicJson(readBoundedPublicFile(path), "public evidence input", "public_file_json"); +} + +export function verifyPublicEvidenceFile(path: string): PublicVerificationSummaryV1 { + return summarizePublicEvidenceVerification(parsePublicFile(path)); +} + +function finishCommunityImport( + stored: { path: string; created: boolean; bundleId: string; publisherKeyId: string }, +) { + const { path: _privatePath, ...imported } = stored; + return { ...imported, trustClass: "community_untrusted_v1" as const, locallyVerified: false as const }; +} + +export function importCommunityEvidenceFile(path: string, configDir?: string) { + return finishCommunityImport(importCommunityEvidenceBundle(readBoundedPublicFile(path), configDir)); +} + +export function importCommunityEvidenceValue(raw: unknown, configDir?: string) { + return finishCommunityImport(importCommunityEvidenceBundle(raw, configDir)); +} + +export function listCommunityEvidenceContext(configDir?: string) { + return { + evidence: listCommunityEvidence(configDir), + trustClass: "community_untrusted_v1" as const, + locallyVerified: false as const, + }; +} diff --git a/src/lab/public/origin-purge.ts b/src/lab/public/origin-purge.ts new file mode 100644 index 000000000..5ed6013d8 --- /dev/null +++ b/src/lab/public/origin-purge.ts @@ -0,0 +1,62 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { ensureLabDirs, labPublicOriginDir } from "../paths"; +import { readPrivateRegularFile } from "./file-safety"; +import { cleanupStalePrivateFileStagesInDir, isPrivateFileStageName } from "./private-file"; +import { parseStrictPublicJson } from "./strict-json"; + +const MAX_ORIGIN_BYTES = 1024; +const ORIGIN_RE = /^origin-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; + +export interface PurgeOriginIdentity { + publisherKeyId: string; + bundleId: string; +} + +/** + * Purge must salvage each provenance marker independently. A corrupt marker is untrusted + * and skipped, but it cannot hide later valid markers that are needed to classify local + * community copies after the export or publisher key is unavailable. The operational + * 1024-marker quota is deliberately not a read cutoff here: recovery must inspect every + * valid-format marker present after a race/crash instead of silently losing provenance. + */ +export function listValidPublicOriginsForPurge(configDir?: string): PurgeOriginIdentity[] { + ensureLabDirs(configDir); + const dir = labPublicOriginDir(configDir); + cleanupStalePrivateFileStagesInDir(dir); + const names = readdirSync(dir) + .filter((name) => !isPrivateFileStageName(name) && ORIGIN_RE.test(name)) + .sort(); + const identities: PurgeOriginIdentity[] = []; + + for (const name of names) { + const match = ORIGIN_RE.exec(name)!; + const expected = { publisherKeyId: match[1]!, bundleId: match[2]! }; + try { + const raw = parseStrictPublicJson( + readPrivateRegularFile(join(dir, name), { + maxBytes: MAX_ORIGIN_BYTES, + errorCode: "public_origin_unsafe", + errorMessage: "public origin marker is unsafe during purge", + sizeErrorCode: "public_origin_unsafe", + sizeErrorMessage: "public origin marker exceeds its size bound", + requireMode600: true, + }), + "public origin marker during purge", + "public_origin_json", + ); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const row = raw as Record; + if ( + Object.keys(row).sort().join(",") !== "bundleId,publisherKeyId,schemaVersion" + || row.schemaVersion !== "public_origin_v1" + || row.publisherKeyId !== expected.publisherKeyId + || row.bundleId !== expected.bundleId + ) continue; + identities.push(expected); + } catch { + // Salvage continues with the next marker. + } + } + return identities; +} diff --git a/src/lab/public/origin.ts b/src/lab/public/origin.ts new file mode 100644 index 000000000..396cb1d98 --- /dev/null +++ b/src/lab/public/origin.ts @@ -0,0 +1,195 @@ +import { lstatSync, readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { jcsStringify } from "../digest"; +import { ensureLabDirs, labCommunityDir, labExportDir, labPublicOriginDir } from "../paths"; +import { communityBundleFileName } from "./community-files"; +import { readPrivateRegularFile } from "./file-safety"; +import { + cleanupStalePrivateFileStagesInDir, + isPrivateFileStageName, + publishPrivateFileExclusive, +} from "./private-file"; +import { parseStrictPublicJson } from "./strict-json"; +import { PublicEvidenceValidationError } from "./validate"; + +// The community cache itself is capped at 512 files. Keeping twice that many origin +// markers leaves headroom for in-flight/local exports while allowing unreferenced +// provenance to be reclaimed instead of permanently locking future exports. +const MAX_ORIGINS = 1024; +const MAX_ORIGIN_BYTES = 1024; +const ORIGIN_RE = /^origin-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; + +export interface PublicOriginIdentityV1 { + publisherKeyId: string; + bundleId: string; +} + +function originPath(identity: PublicOriginIdentityV1, configDir?: string): string { + if (!/^[0-9a-f]{64}$/.test(identity.publisherKeyId) || !/^[0-9a-f]{64}$/.test(identity.bundleId)) { + throw new PublicEvidenceValidationError("public_origin_id", "public origin identity is invalid"); + } + return join( + labPublicOriginDir(configDir), + `origin-${identity.publisherKeyId}-${identity.bundleId}.json`, + ); +} + +function originBody(identity: PublicOriginIdentityV1): Buffer { + return Buffer.from(jcsStringify({ + schemaVersion: "public_origin_v1", + publisherKeyId: identity.publisherKeyId, + bundleId: identity.bundleId, + }), "utf8"); +} + +function readOrigin(path: string, expected?: PublicOriginIdentityV1): PublicOriginIdentityV1 { + const bytes = readPrivateRegularFile(path, { + maxBytes: MAX_ORIGIN_BYTES, + errorCode: "public_origin_unsafe", + errorMessage: "public origin marker is not a private regular file with 0600 permissions", + sizeErrorCode: "public_origin_unsafe", + sizeErrorMessage: "public origin marker exceeds its size bound", + requireMode600: true, + }); + const raw = parseStrictPublicJson(bytes, "public origin marker", "public_origin_json"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_origin_json", "public origin marker must be an object"); + } + const row = raw as Record; + if (Object.keys(row).sort().join(",") !== "bundleId,publisherKeyId,schemaVersion" + || row.schemaVersion !== "public_origin_v1" + || typeof row.publisherKeyId !== "string" + || typeof row.bundleId !== "string" + || !/^[0-9a-f]{64}$/.test(row.publisherKeyId) + || !/^[0-9a-f]{64}$/.test(row.bundleId)) { + throw new PublicEvidenceValidationError("public_origin_json", "public origin marker schema is invalid"); + } + const identity = { publisherKeyId: row.publisherKeyId, bundleId: row.bundleId }; + if (expected && (identity.publisherKeyId !== expected.publisherKeyId || identity.bundleId !== expected.bundleId)) { + throw new PublicEvidenceValidationError("public_origin_conflict", "public origin marker identity mismatch"); + } + return identity; +} + +function originNames(dir: string): string[] { + cleanupStalePrivateFileStagesInDir(dir); + return readdirSync(dir).filter((name) => !isPrivateFileStageName(name)).sort(); +} + +function pathExistsConservatively(path: string): boolean { + try { + lstatSync(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + return true; + } +} + +function communityBundlePath(identity: PublicOriginIdentityV1, configDir?: string): string { + return join( + labCommunityDir(configDir), + communityBundleFileName(identity.publisherKeyId, identity.bundleId), + ); +} + +function localExportPath(identity: PublicOriginIdentityV1, configDir?: string): string { + return join(labExportDir(configDir), `${identity.bundleId}.json`); +} + +/** + * Origin markers exist to recover local provenance for community copies when the export + * or publisher key is later unavailable. A marker is reclaimable only when neither the + * exact community copy nor its matching local export still exists. + */ +function reclaimUnreferencedOrigins( + dir: string, + preservePath: string, + configDir?: string, +): void { + for (const name of originNames(dir)) { + const match = ORIGIN_RE.exec(name); + if (!match) continue; + const path = join(dir, name); + if (path === preservePath) continue; + const identity = { publisherKeyId: match[1]!, bundleId: match[2]! }; + if (pathExistsConservatively(communityBundlePath(identity, configDir))) continue; + if (pathExistsConservatively(localExportPath(identity, configDir))) continue; + try { + unlinkSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + +export function recordLocalPublicOrigin(identity: PublicOriginIdentityV1, configDir?: string): void { + ensureLabDirs(configDir); + const dir = labPublicOriginDir(configDir); + const path = originPath(identity, configDir); + try { + readOrigin(path, identity); + return; + } catch (error) { + if (error instanceof PublicEvidenceValidationError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + let names = originNames(dir); + if (names.length >= MAX_ORIGINS) { + reclaimUnreferencedOrigins(dir, path, configDir); + names = originNames(dir); + } + if (names.length >= MAX_ORIGINS) { + throw new PublicEvidenceValidationError("public_origin_bound", "public origin marker bound exceeded"); + } + + const bytes = originBody(identity); + const published = publishPrivateFileExclusive(path, bytes); + if (!published.created) { + readOrigin(path, identity); + return; + } + + // Separate CLI processes can both observe one free slot before either publishes. + // Reclaim unreferenced history after publication, then remove only this call's marker + // if the directory still cannot converge inside the hard cap. + if (originNames(dir).length > MAX_ORIGINS) { + reclaimUnreferencedOrigins(dir, path, configDir); + if (originNames(dir).length > MAX_ORIGINS) { + try { unlinkSync(path); } catch { /* preserve the quota failure */ } + throw new PublicEvidenceValidationError("public_origin_bound", "public origin marker bound exceeded"); + } + } +} + +export function listLocalPublicOrigins(configDir?: string): PublicOriginIdentityV1[] { + ensureLabDirs(configDir); + const dir = labPublicOriginDir(configDir); + const names = originNames(dir); + if (names.length > MAX_ORIGINS) { + throw new PublicEvidenceValidationError("public_origin_bound", "public origin marker bound exceeded"); + } + const identities: PublicOriginIdentityV1[] = []; + for (const name of names) { + const match = ORIGIN_RE.exec(name); + if (!match) { + throw new PublicEvidenceValidationError("public_origin_unsafe", "unexpected public origin marker entry"); + } + const expected = { publisherKeyId: match[1]!, bundleId: match[2]! }; + identities.push(readOrigin(join(dir, name), expected)); + } + return identities; +} + +export function clearLocalPublicOrigins(configDir?: string): void { + ensureLabDirs(configDir); + const dir = labPublicOriginDir(configDir); + cleanupStalePrivateFileStagesInDir(dir); + for (const name of readdirSync(dir)) { + if (!ORIGIN_RE.test(name)) continue; + try { unlinkSync(join(dir, name)); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} diff --git a/src/lab/public/purge-test-fault.ts b/src/lab/public/purge-test-fault.ts new file mode 100644 index 000000000..63319d47a --- /dev/null +++ b/src/lab/public/purge-test-fault.ts @@ -0,0 +1,17 @@ +export type PublicEvidencePurgeFaultForTests = + | "before_export_delete" + | "export_directory_sync" + | null; + +let purgeFaultForTests: PublicEvidencePurgeFaultForTests = null; + +/** Internal deterministic fault seam. This module is intentionally not barrel-exported. */ +export function setPublicEvidencePurgeFaultForTests( + fault: PublicEvidencePurgeFaultForTests, +): void { + purgeFaultForTests = fault; +} + +export function publicEvidencePurgeFaultForTests(): PublicEvidencePurgeFaultForTests { + return purgeFaultForTests; +} diff --git a/src/lab/public/purge.ts b/src/lab/public/purge.ts new file mode 100644 index 000000000..76facc4f6 --- /dev/null +++ b/src/lab/public/purge.ts @@ -0,0 +1,212 @@ +import { createPrivateKey, createPublicKey } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fsyncSync, + openSync, + readdirSync, + rmSync, + unlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { + ensureLabDirs, + labCommunityDir, + labExportDir, + labPublicOriginDir, + labPublicPublisherKeyPath, +} from "../paths"; +import { + isCommunityRevocationFileName, + parseCommunityBundleFileName, +} from "./community-files"; +import { readPrivateRegularFile } from "./file-safety"; +import { publicEvidenceId } from "./ids"; +import { withPublicEvidenceMutationLock } from "./mutation-lock"; +import { clearLocalPublicOrigins } from "./origin"; +import { listValidPublicOriginsForPurge } from "./origin-purge"; +import { publicEvidencePurgeFaultForTests } from "./purge-test-fault"; +import { readPublicEvidenceBundle } from "./storage"; +import { parseStrictPublicJson } from "./strict-json"; + +const MAX_PRIVATE_KEY_BYTES = 8 * 1024; +const MAX_COMMUNITY_OBJECT_BYTES = 2 * 1024 * 1024; +const EXPORT_FILE_RE = /^([0-9a-f]{64})\.json$/; + +function syncPurgeDirectory(dir: string, label: "export" | "community" | "origin"): void { + if (process.platform === "win32") return; + if (label === "export" && publicEvidencePurgeFaultForTests() === "export_directory_sync") { + throw new Error("synthetic public export directory sync failure"); + } + let fd: number | null = null; + try { + fd = openSync(dir, fsConstants.O_RDONLY); + fsyncSync(fd); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`public ${label} purge directory sync failed: ${detail}`); + } finally { + if (fd !== null) closeSync(fd); + } +} + +/** + * Publisher provenance is useful only for classifying local community copies. A corrupt + * key must never block deletion of sensitive exports, so classification fails closed to + * "unknown publisher" while the purge continues. + */ +function readExistingPublisherKeyId(configDir?: string): string | null { + const path = labPublicPublisherKeyPath(configDir); + try { + const pem = readPrivateRegularFile(path, { + maxBytes: MAX_PRIVATE_KEY_BYTES, + errorCode: "public_publisher_key_unsafe", + errorMessage: "public publisher key is unsafe during purge", + requireMode600: true, + }).toString("utf8"); + if (!pem.includes("BEGIN PRIVATE KEY")) return null; + const privateKey = createPrivateKey(pem); + if (privateKey.asymmetricKeyType !== "ed25519") return null; + const publicKey = createPublicKey(pem); + const publicKeyDer = publicKey.export({ type: "spki", format: "der" }).toString("base64"); + return publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey: publicKeyDer }); + } catch { + return null; + } +} + +function publicIdentity(publisherKeyId: string, bundleId: string): string { + return `${publisherKeyId}:${bundleId}`; +} + +/** Best-effort legacy classification only. Malformed exports are still deleted below. */ +function localExportIdentities(configDir?: string): Set { + const identities = new Set(); + for (const entry of readdirSync(labExportDir(configDir), { withFileTypes: true })) { + const match = EXPORT_FILE_RE.exec(entry.name); + if (!match) continue; + try { + const bundle = readPublicEvidenceBundle(match[1]!, configDir); + identities.add(publicIdentity(bundle.publisher.keyId, bundle.bundleId)); + } catch { + // Durable origin markers are the primary provenance source. Never retain a + // malformed export merely because legacy recovery can no longer parse it. + } + } + return identities; +} + +function purgeAllExports(configDir?: string): number { + if (publicEvidencePurgeFaultForTests() === "before_export_delete") { + throw new Error("synthetic public export purge failure"); + } + let deleted = 0; + const exportDir = labExportDir(configDir); + for (const entry of readdirSync(exportDir, { withFileTypes: true })) { + rmSync(join(exportDir, entry.name), { recursive: entry.isDirectory(), force: true }); + deleted += 1; + } + // A previous attempt may already have removed all names but failed its directory + // fsync. Re-sync even when this retry deletes zero entries before reporting success. + syncPurgeDirectory(exportDir, "export"); + return deleted; +} + +/** + * Provenance classification happens before this call. Purge removes only the exact + * community cache pathname, so a symlink or hardlink cannot redirect deletion to a peer. + */ +function unlinkLocalCommunityFile(path: string): boolean { + try { + unlinkSync(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +function communityObjectPublisherKeyId(path: string): string | null { + try { + const raw = parseStrictPublicJson( + readPrivateRegularFile(path, { + maxBytes: MAX_COMMUNITY_OBJECT_BYTES, + errorCode: "community_unsafe_target", + errorMessage: "community object is unsafe during purge", + }), + "community object during purge", + ); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const publisher = (raw as { publisher?: unknown }).publisher; + if (!publisher || typeof publisher !== "object" || Array.isArray(publisher)) return null; + const keyId = (publisher as { keyId?: unknown }).keyId; + return typeof keyId === "string" && /^[0-9a-f]{64}$/.test(keyId) ? keyId : null; + } catch { + return null; + } +} + +function purgeLocalPublicEvidenceCopiesLocked(configDir?: string): { + deletedExports: number; + deletedCommunityBundles: number; + deletedCommunityRevocations: number; +} { + const exportedIdentities = localExportIdentities(configDir); + const localPublisherKeyIds = new Set(); + for (const origin of listValidPublicOriginsForPurge(configDir)) { + exportedIdentities.add(publicIdentity(origin.publisherKeyId, origin.bundleId)); + localPublisherKeyIds.add(origin.publisherKeyId); + } + const currentPublisherKeyId = readExistingPublisherKeyId(configDir); + if (currentPublisherKeyId) localPublisherKeyIds.add(currentPublisherKeyId); + const communityDir = labCommunityDir(configDir); + + // Sensitive local exports are the mandatory deletion target. Provenance is captured + // before this point, so cleanup remains possible even after the export bytes disappear. + const deletedExports = purgeAllExports(configDir); + + let deletedCommunityBundles = 0; + let deletedCommunityRevocations = 0; + for (const entry of readdirSync(communityDir, { withFileTypes: true })) { + const bundleIdentity = parseCommunityBundleFileName(entry.name); + if (bundleIdentity) { + const { publisherKeyId, bundleId } = bundleIdentity; + const locallyOriginated = exportedIdentities.has(publicIdentity(publisherKeyId, bundleId)) + || localPublisherKeyIds.has(publisherKeyId); + if (locallyOriginated && unlinkLocalCommunityFile(join(communityDir, entry.name))) { + deletedCommunityBundles += 1; + } + continue; + } + + if (isCommunityRevocationFileName(entry.name)) { + const path = join(communityDir, entry.name); + const publisherKeyId = communityObjectPublisherKeyId(path); + if (publisherKeyId && localPublisherKeyIds.has(publisherKeyId) + && unlinkLocalCommunityFile(path)) { + deletedCommunityRevocations += 1; + } + } + } + // As with exports, a retry after a failed directory fsync may have no remaining + // names to unlink. Re-sync the directory unconditionally before success. + syncPurgeDirectory(communityDir, "community"); + + // Markers are purge-owned public provenance only. Remove them last, then establish + // deletion durability before the caller may record an export purge tombstone. + clearLocalPublicOrigins(configDir); + syncPurgeDirectory(labPublicOriginDir(configDir), "origin"); + return { deletedExports, deletedCommunityBundles, deletedCommunityRevocations }; +} + +export function purgeLocalPublicEvidenceCopies(configDir?: string): { + deletedExports: number; + deletedCommunityBundles: number; + deletedCommunityRevocations: number; +} { + ensureLabDirs(configDir); + return withPublicEvidenceMutationLock( + configDir, + () => purgeLocalPublicEvidenceCopiesLocked(configDir), + ); +} diff --git a/src/lab/public/revocation.ts b/src/lab/public/revocation.ts new file mode 100644 index 000000000..e3146566f --- /dev/null +++ b/src/lab/public/revocation.ts @@ -0,0 +1,246 @@ +import { createPublicKey, verify as verifyBytes } from "node:crypto"; +import { publicEvidenceId } from "./ids"; +import { + loadExistingPublicPublisher, + signPublicPublisherDigest, + verifyPublicEvidenceBundle, +} from "./signature"; +import { + PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + type PublicEvidenceBundleV1, + type PublicEvidenceRevocationV1, + type PublicPublisherV1, + type PublicRevocationReasonV1, + type PublicRevocationTargetV1, + type PublicRevocationVerificationResult, +} from "./types"; +import { PublicEvidenceValidationError } from "./validate"; + +const REASONS = new Set([ + "publisher_retracted", + "privacy_retraction", + "evidence_invalidated", + "superseded", +]); +const MAX_TARGETS = 256; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function closedKeys(value: Record, keys: readonly string[]): boolean { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)) && keys.every((key) => key in value); +} + +function validId(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +function validDay(value: unknown): value is string { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function targetKey(target: PublicRevocationTargetV1): string { + return `${target.kind}:${target.id}`; +} + +function canonicalTargets(targets: readonly PublicRevocationTargetV1[]): PublicRevocationTargetV1[] { + if (targets.length === 0 || targets.length > MAX_TARGETS) { + throw new PublicEvidenceValidationError("revocation_targets", "revocation must contain 1..256 targets"); + } + const normalized = targets.map((target) => { + if ((target.kind !== "bundle" && target.kind !== "record") || !validId(target.id)) { + throw new PublicEvidenceValidationError("revocation_target", "invalid revocation target"); + } + return { kind: target.kind, id: target.id } as PublicRevocationTargetV1; + }).sort((a, b) => targetKey(a).localeCompare(targetKey(b))); + if (new Set(normalized.map(targetKey)).size !== normalized.length) { + throw new PublicEvidenceValidationError("revocation_target_duplicate", "revocation targets must be unique"); + } + return normalized; +} + +function samePublisher(a: PublicPublisherV1, b: PublicPublisherV1): boolean { + return a.algorithm === b.algorithm && a.keyId === b.keyId && a.publicKey === b.publicKey; +} + +function validateTargetsAgainstBundle(targets: readonly PublicRevocationTargetV1[], bundle: PublicEvidenceBundleV1): boolean { + const records = new Set(bundle.records.map((record) => record.recordId)); + return targets.every((target) => target.kind === "bundle" ? target.id === bundle.bundleId : records.has(target.id)); +} + +function revocationPayload( + issuedDayUtc: string, + publisher: PublicPublisherV1, + targets: PublicRevocationTargetV1[], + reason: PublicRevocationReasonV1, +): Record { + return { + schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + issuedDayUtc, + publisher, + targets, + reason, + }; +} + +export function createPublicEvidenceRevocation(input: { + configDir?: string; + targetBundle: PublicEvidenceBundleV1; + issuedDayUtc: string; + targets: PublicRevocationTargetV1[]; + reason: PublicRevocationReasonV1; +}): PublicEvidenceRevocationV1 { + // Validate the target and every caller-controlled field before touching publisher state. + if (verifyPublicEvidenceBundle(input.targetBundle).status !== "cryptographically_valid") { + throw new PublicEvidenceValidationError("revocation_target", "revocation target bundle is not cryptographically valid"); + } + if (!validDay(input.issuedDayUtc)) { + throw new PublicEvidenceValidationError("revocation_day", "issuedDayUtc is invalid"); + } + if (!REASONS.has(input.reason)) { + throw new PublicEvidenceValidationError("revocation_reason", "unsupported revocation reason"); + } + const targets = canonicalTargets(input.targets); + if (!validateTargetsAgainstBundle(targets, input.targetBundle)) { + throw new PublicEvidenceValidationError("revocation_target", "revocation target is unknown to target bundle"); + } + + const handle = loadExistingPublicPublisher(input.configDir); + if (!handle || !samePublisher(handle.publisher, input.targetBundle.publisher)) { + throw new PublicEvidenceValidationError( + "revocation_publisher", + "revocation requires the existing publisher key that signed the target bundle", + ); + } + const revocationId = publicEvidenceId( + "revocation", + revocationPayload(input.issuedDayUtc, handle.publisher, targets, input.reason), + ); + const signature = signPublicPublisherDigest(handle, revocationId); + return Object.freeze({ + schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + revocationId, + issuedDayUtc: input.issuedDayUtc, + publisher: handle.publisher, + targets, + reason: input.reason, + signature: Object.freeze({ algorithm: "ed25519" as const, signedDigest: revocationId, signature }), + }); +} + +export function verifyPublicEvidenceRevocation( + raw: unknown, + targetBundle: PublicEvidenceBundleV1, +): PublicRevocationVerificationResult { + try { + if (!isPlainObject(raw) || !closedKeys(raw, [ + "schemaVersion", "revocationId", "issuedDayUtc", "publisher", "targets", "reason", "signature", + ])) { + return { status: "schema_rejected", detail: "closed revocation schema mismatch" }; + } + if ( + raw.schemaVersion !== PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION + || !validId(raw.revocationId) + || !validDay(raw.issuedDayUtc) + || !REASONS.has(raw.reason as PublicRevocationReasonV1) + ) { + return { status: "schema_rejected", detail: "revocation version/id/day/reason invalid" }; + } + if ( + !isPlainObject(raw.publisher) + || !closedKeys(raw.publisher, ["algorithm", "keyId", "publicKey"]) + || raw.publisher.algorithm !== "ed25519" + || !validId(raw.publisher.keyId) + || typeof raw.publisher.publicKey !== "string" + || raw.publisher.publicKey.length > 1024 + ) { + return { status: "schema_rejected", detail: "revocation publisher invalid" }; + } + const publicKeyBytes = Buffer.from(raw.publisher.publicKey, "base64"); + if (publicKeyBytes.toString("base64") !== raw.publisher.publicKey) { + return { status: "schema_rejected", detail: "revocation publisher key is non-canonical" }; + } + const publisher: PublicPublisherV1 = { + algorithm: "ed25519", + keyId: raw.publisher.keyId, + publicKey: raw.publisher.publicKey, + }; + if (publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey: publisher.publicKey }) !== publisher.keyId) { + return { status: "schema_rejected", detail: "revocation publisher key id mismatch" }; + } + if (!samePublisher(publisher, targetBundle.publisher)) { + return { status: "publisher_mismatch", detail: "revocation publisher does not match target bundle" }; + } + if (!Array.isArray(raw.targets) || raw.targets.length === 0 || raw.targets.length > MAX_TARGETS) { + return { status: "schema_rejected", detail: "revocation targets invalid" }; + } + const targets: PublicRevocationTargetV1[] = []; + for (const [index, value] of raw.targets.entries()) { + if ( + !isPlainObject(value) + || !closedKeys(value, ["kind", "id"]) + || (value.kind !== "bundle" && value.kind !== "record") + || !validId(value.id) + ) { + return { status: "schema_rejected", detail: `revocation target ${index} invalid` }; + } + targets.push({ kind: value.kind, id: value.id }); + } + const canonical = canonicalTargets(targets); + if (canonical.some((target, index) => target.kind !== targets[index]!.kind || target.id !== targets[index]!.id)) { + return { status: "schema_rejected", detail: "revocation targets must be sorted" }; + } + if (!validateTargetsAgainstBundle(targets, targetBundle)) { + return { status: "unknown_target", detail: "revocation target not present in target bundle" }; + } + if ( + !isPlainObject(raw.signature) + || !closedKeys(raw.signature, ["algorithm", "signedDigest", "signature"]) + || raw.signature.algorithm !== "ed25519" + || raw.signature.signedDigest !== raw.revocationId + || typeof raw.signature.signature !== "string" + ) { + return { status: "schema_rejected", detail: "revocation signature schema invalid" }; + } + const expected = publicEvidenceId( + "revocation", + revocationPayload(raw.issuedDayUtc, publisher, targets, raw.reason as PublicRevocationReasonV1), + ); + if (expected !== raw.revocationId) { + return { status: "digest_invalid", detail: "revocation id does not match canonical bytes" }; + } + const key = createPublicKey({ key: publicKeyBytes, type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") { + return { status: "signature_invalid", detail: "revocation publisher key is not Ed25519" }; + } + const signatureBytes = Buffer.from(raw.signature.signature, "base64"); + if (signatureBytes.toString("base64") !== raw.signature.signature) { + return { status: "signature_invalid", detail: "revocation signature is non-canonical" }; + } + if (!verifyBytes(null, Buffer.from(raw.revocationId, "hex"), key, signatureBytes)) { + return { status: "signature_invalid", detail: "revocation signature invalid" }; + } + return { + status: "cryptographically_valid", + revocation: { + schemaVersion: PUBLIC_EVIDENCE_REVOCATION_SCHEMA_VERSION, + revocationId: raw.revocationId, + issuedDayUtc: raw.issuedDayUtc, + publisher, + targets, + reason: raw.reason as PublicRevocationReasonV1, + signature: { + algorithm: "ed25519", + signedDigest: raw.revocationId, + signature: raw.signature.signature, + }, + }, + }; + } catch (error) { + return { status: "schema_rejected", detail: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 0306a02f4..2e171587b 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -134,6 +134,7 @@ function publicVisionSidecarSettings( export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const readStartupHealth = deps.getCachedStartupHealth ?? getCachedStartupHealth; if (url.pathname === "/api/config" && req.method === "GET") { return jsonResponse(safeConfigDTO(config)); } @@ -189,7 +190,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise void; toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; createManagementConvergeCodex?: (config: Readonly) => ConvergeCodex; + /** Startup-health seam keeps route tests from launching platform probes. */ + getCachedStartupHealth?: (config: Pick) => Promise; /** * Persistence seam for route-level tests. Production leaves this unset and uses * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config diff --git a/src/server/management/lab-routes.ts b/src/server/management/lab-routes.ts index 5c0929e47..17177c817 100644 --- a/src/server/management/lab-routes.ts +++ b/src/server/management/lab-routes.ts @@ -42,6 +42,15 @@ import { queryLabVerdicts, queryPassiveProductionSignals, } from "../../lab/query"; +import { + exportLocalPublicEvidence, + importCommunityEvidenceValue, + listCommunityEvidenceContext, + parseStrictPublicJson, + previewLocalPublicEvidence, + summarizePublicEvidenceVerification, + PublicEvidenceValidationError, +} from "../../lab/public"; import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; @@ -155,7 +164,7 @@ function parseExecutionMode(raw: string | null, ctx: ManagementContext): Executi if (!raw) return undefined; const trimmed = raw.trim(); if (!EXECUTION_MODES.includes(trimmed as ExecutionMode)) { - return errorResponse("invalid_execution_mode", "executionMode must be a supported execution mode", 400, ctx); + return errorResponse("invalid_execution_mode", "executionMode must be a supported lab execution mode", 400, ctx); } return trimmed as ExecutionMode; } @@ -186,9 +195,154 @@ function paginatedEnvelope(page: { items: T[]; nextCursor?: string; hasMore: }; } +const MAX_PUBLIC_REQUEST_BYTES = 2 * 1024 * 1024; + +async function readBoundedPublicJson(req: Request): Promise { + const lengthRaw = req.headers.get("content-length"); + if (lengthRaw) { + const length = Number(lengthRaw); + if (!Number.isFinite(length) || length < 0 || length > MAX_PUBLIC_REQUEST_BYTES) { + throw new PublicEvidenceValidationError( + "public_request_too_large", + "public evidence request exceeds 2 MiB", + ); + } + } + if (!req.body) { + throw new PublicEvidenceValidationError("public_request_body", "JSON body is required"); + } + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_PUBLIC_REQUEST_BYTES) { + await reader.cancel(); + throw new PublicEvidenceValidationError( + "public_request_too_large", + "public evidence request exceeds 2 MiB", + ); + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return parseStrictPublicJson(bytes, "public evidence request"); +} + +function publicEventIds(raw: unknown): string[] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_request_body", "request body must be an object"); + } + const keys = Object.keys(raw); + if (keys.length !== 1 || keys[0] !== "eventIds") { + throw new PublicEvidenceValidationError("public_request_body", "only eventIds is accepted"); + } + const eventIds = (raw as { eventIds?: unknown }).eventIds; + if (!Array.isArray(eventIds) || !eventIds.every((value) => typeof value === "string")) { + throw new PublicEvidenceValidationError("public_request_body", "eventIds must be a string array"); + } + return eventIds as string[]; +} + +function publicBundleValue(raw: unknown): unknown { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new PublicEvidenceValidationError("public_request_body", "request body must be an object"); + } + const keys = Object.keys(raw); + if (keys.length !== 1 || keys[0] !== "bundle") { + throw new PublicEvidenceValidationError("public_request_body", "only bundle is accepted"); + } + return (raw as { bundle?: unknown }).bundle; +} + +function publicErrorResponse(err: unknown, ctx: ManagementContext): Response { + if (err instanceof PublicEvidenceValidationError) { + const status = err.code === "community_cache_busy" ? 503 : 400; + const response = errorResponse(err.code, err.message, status, ctx); + if (status === 503) response.headers.set("Retry-After", "1"); + return response; + } + const projected = projectionErrorResponse(err, ctx); + if (projected) return projected; + return errorResponse("public_evidence_internal", "internal public evidence failure", 500, ctx); +} + export async function handleLabRoutes(ctx: ManagementContext): Promise { const { url, req, config } = ctx; if (!url.pathname.startsWith("/api/lab")) return null; + + if (req.method === "GET" && url.pathname === "/api/lab/public/community") { + try { + return jsonResponse(listCommunityEvidenceContext(), 200, req, config); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + + if (req.method === "POST") { + if (url.pathname === "/api/lab/public/preview") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + previewLocalPublicEvidence({ eventIds: publicEventIds(body) }), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/export") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + exportLocalPublicEvidence({ eventIds: publicEventIds(body) }), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/verify") { + try { + const body = await readBoundedPublicJson(req); + const result = summarizePublicEvidenceVerification(publicBundleValue(body)); + return jsonResponse( + result, + result.status === "cryptographically_valid" ? 200 : 400, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + if (url.pathname === "/api/lab/public/community/import") { + try { + const body = await readBoundedPublicJson(req); + return jsonResponse( + importCommunityEvidenceValue(publicBundleValue(body)), + 200, + req, + config, + ); + } catch (err) { + return publicErrorResponse(err, ctx); + } + } + return null; + } + if (req.method !== "GET") return null; if (url.pathname === "/api/lab/status") { @@ -213,10 +367,7 @@ export async function handleLabRoutes(ctx: ManagementContext): Promise { expect(slugs).not.toContain("offline/disabled-model"); expect(slugs).not.toContain("removed/ghost"); expect(slugs).toContain("cursor/composer-2.5"); - }); + }, 15_000); test("drops legacy-signature ghost rows in both gather branches", () => { const catalogPath = join(codexHome, "catalog.json"); diff --git a/tests/helpers/startup-health.ts b/tests/helpers/startup-health.ts new file mode 100644 index 000000000..f7cb47d8c --- /dev/null +++ b/tests/helpers/startup-health.ts @@ -0,0 +1,33 @@ +import type { StartupHealth } from "../../src/codex/autostart-health"; + +export function startupHealthFixture(overrides: Partial = {}): StartupHealth { + return { + status: "native", + routingKind: "native", + routingInjected: false, + localRoutingDependency: false, + autostartEnabled: false, + rebootSafe: true, + protection: "none", + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + shimInstalled: false, + shimHealthy: false, + shimCoverage: "none", + serviceSupported: true, + platform: process.platform, + diagnosticStale: false, + recommendedCommand: null, + commands: { + installService: "ocx service install", + repairService: "ocx service repair", + installShim: "ocx codex-shim install", + restoreNative: "ocx restore", + }, + ...overrides, + }; +} diff --git a/tests/lab-community-evidence.test.ts b/tests/lab-community-evidence.test.ts new file mode 100644 index 000000000..1f94905c6 --- /dev/null +++ b/tests/lab-community-evidence.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + labLedgerPath, + labSqlitePath, + purgeSensitiveEvidence, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, +} from "../src/lab"; +import { + buildPublicEvidenceBundle, + createPublicEvidenceRevocation, + getOrCreatePublicPublisher, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + projectPublicEvidence, + publicEvidenceId, + signPublicEvidenceBundle, + signPublicPublisherDigest, + verifyPublicEvidenceRevocation, + writePublicEvidenceBundle, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix = "ocx-cl10-community-"): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function assertionsForScenario(scenarioId: string) { + const ids = scenarioId === "responses-core.protocol.sse-framing" + ? ["events", "text", "terminal"] + : ["method", "message", "temperature"]; + return ids.map((id) => ({ id, operator: "equals", required: true, passed: true })); +} + +function protocolObservation(scenarioId = "responses-core.protocol.request-shape"): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("PRIVATE-community-behavior"), + }; + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: Date.UTC(2026, 7, 12, 14, 37, 48), + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId, + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId: subjectIdForSubject(subject), + startedAt: Date.UTC(2026, 7, 12, 14, 37, 40), + completedAt: Date.UTC(2026, 7, 12, 14, 37, 41), + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: assertionsForScenario(scenarioId), + environment: {}, + artifactRefs: [], + }) as ObservationEvent; +} + +function signedBundle(config: string, scenarioId?: string) { + const projected = projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: protocolObservation(scenarioId), verdict: "VERIFIED" }], + }); + return signPublicEvidenceBundle({ + records: projected.bundle.records, + artifacts: projected.bundle.artifacts, + createdDayUtc: projected.bundle.createdDayUtc, + configDir: config, + }); +} + +function signedUnreviewedScenarioBundle(config: string) { + const projected = projectPublicEvidence({ + records: [{ observation: protocolObservation(), verdict: "VERIFIED" }], + }); + const baseRecord = projected.bundle.records[0]; + if (!baseRecord) throw new Error("expected reviewed source record"); + const { recordId: _recordId, ...baseFields } = baseRecord; + const withoutRecordId = { ...baseFields, scenarioId: "private.unknown.scenario" }; + const record = { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; + const handle = getOrCreatePublicPublisher(config); + const unsigned = buildPublicEvidenceBundle({ + records: [record], + artifacts: [], + createdDayUtc: projected.bundle.createdDayUtc, + publisher: handle.publisher, + }); + return { + ...unsigned, + signature: { + algorithm: "ed25519" as const, + signedDigest: unsigned.bundleDigest, + signature: signPublicPublisherDigest(handle, unsigned.bundleDigest), + }, + }; +} + +describe("CL-10 community quarantine", () => { + test("imports valid signed evidence without touching canonical Lab authority", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir); + const imported = importCommunityEvidenceBundle(bundle, consumerDir); + expect(imported).toMatchObject({ created: true, status: "cryptographically_valid", bundleId: bundle.bundleId }); + expect(existsSync(labLedgerPath(consumerDir))).toBe(false); + expect(existsSync(labSqlitePath(consumerDir))).toBe(false); + expect(listCommunityEvidence(consumerDir)).toEqual([expect.objectContaining({ + bundleId: bundle.bundleId, + status: "cryptographically_valid", + activeRecordCount: 1, + revokedRecordCount: 0, + })]); + expect(importCommunityEvidenceBundle(bundle, consumerDir).created).toBe(false); + }); + + test("rejects cryptographically valid but unknown scenario authority", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedUnreviewedScenarioBundle(publisherDir); + expect(() => importCommunityEvidenceBundle(bundle, consumerDir)).toThrow(/authority/i); + expect(listCommunityEvidence(consumerDir)).toEqual([]); + }); + + test("same-key revocation is verified, idempotent, and removes records from default community context", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir); + importCommunityEvidenceBundle(bundle, consumerDir); + + const revocation = createPublicEvidenceRevocation({ + configDir: publisherDir, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "evidence_invalidated", + targets: [{ kind: "record", id: bundle.records[0]!.recordId }], + }); + expect(verifyPublicEvidenceRevocation(revocation, bundle).status).toBe("cryptographically_valid"); + expect(importCommunityEvidenceRevocation(revocation, consumerDir).created).toBe(true); + expect(importCommunityEvidenceRevocation(revocation, consumerDir).created).toBe(false); + expect(listCommunityEvidence(consumerDir)[0]).toMatchObject({ activeRecordCount: 0, revokedRecordCount: 1 }); + }); + + test("rejects cross-key revocation and conflicting same-id bytes", () => { + const publisherDir = configDir("ocx-cl10-publisher-"); + const otherDir = configDir("ocx-cl10-other-"); + const consumerDir = configDir("ocx-cl10-consumer-"); + const bundle = signedBundle(publisherDir); + importCommunityEvidenceBundle(bundle, consumerDir); + expect(() => createPublicEvidenceRevocation({ + configDir: otherDir, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundle.bundleId }], + })).toThrow(/publisher/i); + + const revocation = createPublicEvidenceRevocation({ + configDir: publisherDir, + targetBundle: bundle, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundle.bundleId }], + }); + importCommunityEvidenceRevocation(revocation, consumerDir); + const conflict = { ...revocation, issuedDayUtc: "2026-08-13" }; + expect(() => importCommunityEvidenceRevocation(conflict, consumerDir)).toThrow(); + }); + + test("sensitive export purge removes local exports and local community copies but preserves third-party bundles", () => { + const consumerDir = configDir("ocx-cl10-consumer-"); + const thirdPartyDir = configDir("ocx-cl10-third-party-"); + const localBundle = signedBundle(consumerDir); + const localStored = writePublicEvidenceBundle(localBundle, consumerDir); + importCommunityEvidenceBundle(localBundle, consumerDir); + + const thirdPartyBundle = signedBundle(thirdPartyDir, "responses-core.protocol.sse-framing"); + importCommunityEvidenceBundle(thirdPartyBundle, consumerDir); + expect(listCommunityEvidence(consumerDir)).toHaveLength(2); + + purgeSensitiveEvidence({ + configDir: consumerDir, + targetArtifactDigests: [hex("sensitive-purge-target")], + purgeActions: ["export"], + recordedAt: Date.UTC(2026, 7, 12, 18, 0, 0), + }); + + expect(existsSync(localStored)).toBe(false); + expect(listCommunityEvidence(consumerDir).map((row) => row.bundleId)).toEqual([thirdPartyBundle.bundleId]); + }); +}); \ No newline at end of file diff --git a/tests/lab-community-filename-contract.test.ts b/tests/lab-community-filename-contract.test.ts new file mode 100644 index 000000000..594d654b2 --- /dev/null +++ b/tests/lab-community-filename-contract.test.ts @@ -0,0 +1,123 @@ +import { afterEach, expect, test } from "bun:test"; +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { labPublicOriginDir } from "../src/lab/paths"; +import { + communityBundleFileName, + communityRevocationFileName, +} from "../src/lab/public/community-files"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + purgeLocalPublicEvidenceCopies, + publicEvidenceId, + recordLocalPublicOrigin, + signPublicEvidenceBundle, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cl10-community-filenames-")); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function fixedRecord(): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +test("writer, origin retention, and purge share the community filename contract", () => { + const home = configDir(); + const bundle = signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + }); + const importedBundle = importCommunityEvidenceBundle(bundle, home); + expect(basename(importedBundle.path)).toBe( + communityBundleFileName(bundle.publisher.keyId, bundle.bundleId), + ); + + const revocation = createPublicEvidenceRevocation({ + configDir: home, + targetBundle: bundle, + issuedDayUtc: "2026-08-13", + targets: [{ kind: "bundle", id: bundle.bundleId }], + reason: "publisher_retracted", + }); + const importedRevocation = importCommunityEvidenceRevocation(revocation, home); + expect(basename(importedRevocation.path)).toBe( + communityRevocationFileName(revocation.revocationId), + ); + + recordLocalPublicOrigin({ + publisherKeyId: bundle.publisher.keyId, + bundleId: bundle.bundleId, + }, home); + + const originDir = labPublicOriginDir(home); + for (let index = 0; index < 1023; index += 1) { + writeFileSync( + join(originDir, `origin-${hex(`stale-publisher-${index}`)}-${hex(`stale-bundle-${index}`)}.json`), + "{}", + { mode: 0o600 }, + ); + } + recordLocalPublicOrigin({ + publisherKeyId: hex("new-publisher"), + bundleId: hex("new-bundle"), + }, home); + + expect(readdirSync(originDir)).toContain( + `origin-${bundle.publisher.keyId}-${bundle.bundleId}.json`, + ); + + const purged = purgeLocalPublicEvidenceCopies(home); + expect(purged.deletedCommunityBundles).toBe(1); + expect(purged.deletedCommunityRevocations).toBe(1); + expect(existsSync(importedBundle.path)).toBe(false); + expect(existsSync(importedRevocation.path)).toBe(false); +}); diff --git a/tests/lab-community-mutation-lock.test.ts b/tests/lab-community-mutation-lock.test.ts new file mode 100644 index 000000000..68ef4e7b6 --- /dev/null +++ b/tests/lab-community-mutation-lock.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureLabDirs, labCommunityDir } from "../src/lab/paths"; +import { listCommunityEvidence } from "../src/lab/public/community"; +import { + publicEvidenceMutationLockIsReclaimableForTests, + publicEvidenceTryReclaimMutationLockForTests, +} from "../src/lab/public/mutation-lock"; +import { PublicEvidenceValidationError } from "../src/lab/public/validate"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-community-lock-")); + roots.push(root); + ensureLabDirs(root); + return root; +} + +function createLiveOwnerLock(config: string): string { + const lockPath = join(labCommunityDir(config), ".mutation-lock"); + mkdirSync(lockPath, { mode: 0o700 }); + writeFileSync( + join(lockPath, "owner.json"), + JSON.stringify({ + pid: process.pid, + token: "00000000-0000-4000-8000-000000000000", + createdAt: Date.now(), + }), + { encoding: "utf8", mode: 0o600 }, + ); + return lockPath; +} + +describe("community mutation lock", () => { + test("recovers an ancient incomplete lock before reading committed cache state", () => { + const config = configDir(); + const lockPath = join(labCommunityDir(config), ".mutation-lock"); + mkdirSync(lockPath, { mode: 0o700 }); + const stale = new Date(Date.now() - (25 * 60 * 60 * 1000)); + utimesSync(lockPath, stale, stale); + + expect(listCommunityEvidence(config)).toEqual([]); + expect(existsSync(lockPath)).toBe(false); + }); + + test("does not reclaim an old lock while its recorded owner process is alive", () => { + const config = configDir(); + const lockPath = join(labCommunityDir(config), ".mutation-lock"); + mkdirSync(lockPath, { mode: 0o700 }); + writeFileSync( + join(lockPath, "owner.json"), + JSON.stringify({ + pid: process.pid, + token: "00000000-0000-4000-8000-000000000000", + createdAt: Date.now() - (25 * 60 * 60 * 1000), + }), + { encoding: "utf8", mode: 0o600 }, + ); + + expect(publicEvidenceMutationLockIsReclaimableForTests(config)).toBe(false); + expect(existsSync(lockPath)).toBe(true); + }); + + test("fails fast when a live owner holds the mutation lock", () => { + const config = configDir(); + const lockPath = createLiveOwnerLock(config); + const startedAt = performance.now(); + let failure: unknown; + + try { + listCommunityEvidence(config); + } catch (error) { + failure = error; + } + + const elapsedMs = performance.now() - startedAt; + expect(failure).toBeInstanceOf(PublicEvidenceValidationError); + expect((failure as PublicEvidenceValidationError).code).toBe("community_cache_busy"); + expect(elapsedMs).toBeLessThan(500); + expect(existsSync(lockPath)).toBe(true); + }); + + test("a competing reclaim claim prevents a second stale reclaimer from deleting the lock", () => { + const config = configDir(); + const lockPath = join(labCommunityDir(config), ".mutation-lock"); + mkdirSync(lockPath, { mode: 0o700 }); + const stale = new Date(Date.now() - (25 * 60 * 60 * 1000)); + utimesSync(lockPath, stale, stale); + writeFileSync( + join(lockPath, ".reclaim.json"), + JSON.stringify({ token: "00000000-0000-4000-8000-000000000000" }), + { encoding: "utf8", mode: 0o600 }, + ); + + expect(publicEvidenceTryReclaimMutationLockForTests(config)).toBe(false); + expect(existsSync(lockPath)).toBe(true); + }); + + test("cleans detached lock quarantine before scanning community quota state", () => { + const config = configDir(); + const quarantinePath = join( + labCommunityDir(config), + ".mutation-lock-release-123-00000000-0000-4000-8000-000000000000", + ); + mkdirSync(quarantinePath, { mode: 0o700 }); + writeFileSync(join(quarantinePath, "owner.json"), "stale", { mode: 0o600 }); + + expect(listCommunityEvidence(config)).toEqual([]); + expect(existsSync(quarantinePath)).toBe(false); + }); + + test("fails closed when the lock path is not a directory", () => { + const config = configDir(); + writeFileSync(join(labCommunityDir(config), ".mutation-lock"), "unsafe", "utf8"); + + expect(() => listCommunityEvidence(config)).toThrow(/mutation lock is not a directory/i); + }); +}); diff --git a/tests/lab-community-publisher-continuity.test.ts b/tests/lab-community-publisher-continuity.test.ts new file mode 100644 index 000000000..5652379e0 --- /dev/null +++ b/tests/lab-community-publisher-continuity.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, +} from "../src/lab"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + projectPublicEvidence, + signPublicEvidenceBundle, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function observation(): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("PRIVATE-publisher-continuity"), + }; + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: Date.UTC(2026, 7, 12, 14, 37, 48), + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId: subjectIdForSubject(subject), + startedAt: Date.UTC(2026, 7, 12, 14, 37, 40), + completedAt: Date.UTC(2026, 7, 12, 14, 37, 41), + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: [ + { id: "method", operator: "equals", required: true, passed: true }, + { id: "message", operator: "equals", required: true, passed: true }, + { id: "temperature", operator: "equals", required: true, passed: true }, + ], + environment: {}, + artifactRefs: [], + }) as ObservationEvent; +} + +function projectedBundle() { + return projectPublicEvidence({ + createdDayUtc: "2026-08-12", + records: [{ observation: observation(), verdict: "VERIFIED" }], + }).bundle; +} + +describe("CL-10 publisher continuity", () => { + test("same content from two publishers coexists and revokes independently", () => { + const publisherA = configDir("ocx-cl10-publisher-a-"); + const publisherB = configDir("ocx-cl10-publisher-b-"); + const consumer = configDir("ocx-cl10-consumer-"); + const unsigned = projectedBundle(); + const bundleA = signPublicEvidenceBundle({ ...unsigned, configDir: publisherA }); + const bundleB = signPublicEvidenceBundle({ ...unsigned, configDir: publisherB }); + + expect(bundleA.bundleId).not.toBe(bundleB.bundleId); + expect(bundleA.publisher.keyId).not.toBe(bundleB.publisher.keyId); + expect(importCommunityEvidenceBundle(bundleA, consumer).created).toBe(true); + expect(importCommunityEvidenceBundle(bundleB, consumer).created).toBe(true); + + let summaries = listCommunityEvidence(consumer); + expect(summaries).toHaveLength(2); + expect(new Set(summaries.map((row) => row.publisherKeyId)).size).toBe(2); + + const revocationA = createPublicEvidenceRevocation({ + configDir: publisherA, + targetBundle: bundleA, + issuedDayUtc: "2026-08-12", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: bundleA.bundleId }], + }); + expect(importCommunityEvidenceRevocation(revocationA, consumer).created).toBe(true); + + summaries = listCommunityEvidence(consumer); + const rowA = summaries.find((row) => row.publisherKeyId === bundleA.publisher.keyId)!; + const rowB = summaries.find((row) => row.publisherKeyId === bundleB.publisher.keyId)!; + expect(rowA).toMatchObject({ activeRecordCount: 0, revokedRecordCount: 1 }); + expect(rowB).toMatchObject({ activeRecordCount: 1, revokedRecordCount: 0 }); + }); +}); \ No newline at end of file diff --git a/tests/lab-public-api-json.test.ts b/tests/lab-public-api-json.test.ts new file mode 100644 index 000000000..79d6c2a95 --- /dev/null +++ b/tests/lab-public-api-json.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +const config = { port: 0, defaultProvider: "openai-apikey", providers: {} } as OcxConfig; + +describe("CL-10 management public JSON boundary", () => { + test("rejects duplicate decoded object keys before request object construction", async () => { + const req = new ManagementRequest("http://127.0.0.1/api/lab/public/community/import", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"bundle":{},"\\u0062undle":{}}', + }); + + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + + expect(response).not.toBeNull(); + expect(response!.status).toBe(400); + expect(await response!.json()).toMatchObject({ + error: { code: "duplicate_json_key" }, + }); + }); +}); diff --git a/tests/lab-public-artifact-policy.test.ts b/tests/lab-public-artifact-policy.test.ts new file mode 100644 index 000000000..527b96f1c --- /dev/null +++ b/tests/lab-public-artifact-policy.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { labPublicPublisherKeyPath } from "../src/lab"; +import { publicEvidenceId, signPublicEvidenceBundle } from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +test("CL-10 local signing rejects artifact bytes without reviewed public_export authority", () => { + const configDir = mkdtempSync(join(tmpdir(), "ocx-cl10-artifact-policy-")); + roots.push(configDir); + const contentBase64 = Buffer.from("credential-canary-1234567890", "utf8").toString("base64"); + const artifact = { + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: Buffer.from(contentBase64, "base64").byteLength, + contentBase64, + }; + const artifactId = publicEvidenceId("artifact", artifact); + + expect(() => signPublicEvidenceBundle({ + records: [], + artifacts: [{ artifactId, ...artifact }], + createdDayUtc: "2026-08-12", + configDir, + })).toThrow(/public_export/i); + + expect(existsSync(labPublicPublisherKeyPath(configDir))).toBe(false); +}); diff --git a/tests/lab-public-deep-review-regressions.test.ts b/tests/lab-public-deep-review-regressions.test.ts new file mode 100644 index 000000000..153324e03 --- /dev/null +++ b/tests/lab-public-deep-review-regressions.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { jcsStringify } from "../src/lab/conformance/jcs"; +import { labCommunityDir, labPublicPublisherKeyPath } from "../src/lab/paths"; +import { + buildPublicEvidenceBundle, + createPublicEvidenceRevocation, + getOrCreatePublicPublisher, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + parseStrictPublicJson, + publicEvidenceId, + signPublicEvidenceBundle, + signPublicPublisherDigest, + validatePublicEvidenceRecordPrivacy, + verifyPublicEvidenceBundle, + type PublicArtifactV1, + type PublicEvidenceBundleV1, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function fixedRecord(overrides: Partial> = {}): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + ...overrides, + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +function rebuildRecord(record: PublicEvidenceRecordV1, patch: Partial>): PublicEvidenceRecordV1 { + const { recordId: _recordId, ...base } = record; + const withoutRecordId = { ...base, ...patch }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId } as PublicEvidenceRecordV1; +} + +function signArbitraryBundle(input: { + configDir: string; + records: PublicEvidenceRecordV1[]; + artifacts?: PublicArtifactV1[]; + createdDayUtc?: string; +}): PublicEvidenceBundleV1 { + const handle = getOrCreatePublicPublisher(input.configDir); + const unsigned = buildPublicEvidenceBundle({ + records: input.records, + artifacts: input.artifacts ?? [], + createdDayUtc: input.createdDayUtc ?? "2026-08-12", + publisher: handle.publisher, + }); + return { + ...unsigned, + signature: { + algorithm: "ed25519", + signedDigest: unsigned.bundleDigest, + signature: signPublicPublisherDigest(handle, unsigned.bundleDigest), + }, + }; +} + +function publicArtifact(content: string): PublicArtifactV1 { + const contentBase64 = Buffer.from(content, "utf8").toString("base64"); + const body = { + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: Buffer.from(contentBase64, "base64").byteLength, + contentBase64, + }; + return { artifactId: publicEvidenceId("artifact", body), ...body }; +} + +describe("CL-10 deep-review trust regressions", () => { + test("verification rejects a signed bundle whose canonical record order was changed", () => { + const publisher = configDir("ocx-cl10-order-"); + const first = fixedRecord(); + const second = fixedRecord({ observedDayUtc: "2026-08-13" }); + const bundle = signArbitraryBundle({ configDir: publisher, records: [first, second] }); + expect(bundle.records).toHaveLength(2); + + const reordered = { ...bundle, records: [...bundle.records].reverse() }; + expect(reordered.records.map(row => row.recordId)).not.toEqual(bundle.records.map(row => row.recordId)); + expect(verifyPublicEvidenceBundle(reordered)).toEqual({ status: "schema_rejected" }); + }); + + test("community import rejects artifact bytes until reviewed public_export authority exists", () => { + const publisher = configDir("ocx-cl10-artifact-publisher-"); + const consumer = configDir("ocx-cl10-artifact-consumer-"); + const bundle = signArbitraryBundle({ + configDir: publisher, + records: [fixedRecord()], + artifacts: [publicArtifact("synthetic-safe-content")], + }); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + expect(() => importCommunityEvidenceBundle(bundle, consumer)).toThrow(/public_export|artifact.*authority/i); + }); + + test("record revocation remains effective when the same publisher later imports another bundle containing that record", () => { + const publisher = configDir("ocx-cl10-revoke-publisher-"); + const consumer = configDir("ocx-cl10-revoke-consumer-"); + const record = fixedRecord(); + const first = signPublicEvidenceBundle({ records: [record], artifacts: [], createdDayUtc: "2026-08-12", configDir: publisher }); + const second = signPublicEvidenceBundle({ records: [record], artifacts: [], createdDayUtc: "2026-08-13", configDir: publisher }); + expect(first.bundleId).not.toBe(second.bundleId); + + importCommunityEvidenceBundle(first, consumer); + const revocation = createPublicEvidenceRevocation({ + configDir: publisher, + targetBundle: first, + issuedDayUtc: "2026-08-13", + reason: "evidence_invalidated", + targets: [{ kind: "record", id: record.recordId }], + }); + importCommunityEvidenceRevocation(revocation, consumer); + importCommunityEvidenceBundle(second, consumer); + + const summaries = listCommunityEvidence(consumer); + expect(summaries.map((row) => row.bundleId)).toEqual([first.bundleId, second.bundleId].sort()); + expect(summaries).toEqual(expect.arrayContaining([ + expect.objectContaining({ bundleId: first.bundleId, activeRecordCount: 0, revokedRecordCount: 1 }), + expect.objectContaining({ bundleId: second.bundleId, activeRecordCount: 0, revokedRecordCount: 1 }), + ])); + }); + + test("invalid signing input fails before publisher identity is created", () => { + const home = configDir("ocx-cl10-invalid-sign-"); + expect(() => signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "not-a-day", + configDir: home, + })).toThrow(/day/i); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + }); + + test("foreign revocation attempt does not create a new publisher identity", () => { + const publisher = configDir("ocx-cl10-foreign-target-"); + const attacker = configDir("ocx-cl10-foreign-revoker-"); + const target = signPublicEvidenceBundle({ + records: [fixedRecord()], artifacts: [], createdDayUtc: "2026-08-12", configDir: publisher, + }); + expect(() => createPublicEvidenceRevocation({ + configDir: attacker, + targetBundle: target, + issuedDayUtc: "2026-08-13", + reason: "publisher_retracted", + targets: [{ kind: "bundle", id: target.bundleId }], + })).toThrow(/publisher|key/i); + expect(existsSync(labPublicPublisherKeyPath(attacker))).toBe(false); + }); + + test("JCS rejects lone UTF-16 surrogate code units", () => { + expect(() => jcsStringify("\uDEAD")).toThrow(/unicode|surrogate/i); + expect(() => jcsStringify({ ["\uDEAD"]: true })).toThrow(/unicode|surrogate/i); + }); + + test("reviewed assertion authority requires exact unique assertion coverage", () => { + const missingHome = configDir("ocx-cl10-assert-missing-"); + const duplicateHome = configDir("ocx-cl10-assert-duplicate-"); + const base = fixedRecord(); + const missing = rebuildRecord(base, { assertions: [] }); + const duplicate = rebuildRecord(base, { assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + { id: "method", required: true, passed: false }, + ] }); + + expect(() => signPublicEvidenceBundle({ + records: [missing], artifacts: [], createdDayUtc: "2026-08-12", configDir: missingHome, + })).toThrow(/assertion.*authority|missing.*assertion/i); + expect(existsSync(labPublicPublisherKeyPath(missingHome))).toBe(false); + + expect(() => signPublicEvidenceBundle({ + records: [duplicate], artifacts: [], createdDayUtc: "2026-08-12", configDir: duplicateHome, + })).toThrow(/assertion.*authority|duplicate.*assertion/i); + expect(existsSync(labPublicPublisherKeyPath(duplicateHome))).toBe(false); + }); + + test("community import enforces the cache file quota before creating another object", () => { + const publisher = configDir("ocx-cl10-cache-publisher-"); + const consumer = configDir("ocx-cl10-cache-consumer-"); + const community = labCommunityDir(consumer); + mkdirSync(community, { recursive: true, mode: 0o700 }); + for (let index = 0; index < 512; index += 1) { + writeFileSync(join(community, `occupied-${String(index).padStart(3, "0")}`), "x", { mode: 0o600 }); + } + const bundle = signPublicEvidenceBundle({ + records: [fixedRecord()], artifacts: [], createdDayUtc: "2026-08-12", configDir: publisher, + }); + expect(() => importCommunityEvidenceBundle(bundle, consumer)).toThrow(/cache.*bound|cache.*limit|capacity/i); + }); + + test("duplicate-key diagnostics are bounded and do not reflect attacker-controlled key contents", () => { + const key = `SECRET-${"x".repeat(64 * 1024)}`; + const raw = Buffer.from(`{${JSON.stringify(key)}:1,${JSON.stringify(key)}:2}`, "utf8"); + try { + parseStrictPublicJson(raw); + throw new Error("expected duplicate-key rejection"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message.length).toBeLessThan(256); + expect(message).not.toContain("SECRET-"); + } + }); + + test("privacy scanner rejects unbracketed IPv6 literals", () => { + const base = fixedRecord(); + const subject = { ...base.subject, surface: "2001:db8::1" }; + const subjectId = publicEvidenceId("subject", subject); + const record = rebuildRecord(base, { subject, subjectId }); + expect(() => validatePublicEvidenceRecordPrivacy(record)).toThrow(/IP address|privacy/i); + }); +}); diff --git a/tests/lab-public-evidence.test.ts b/tests/lab-public-evidence.test.ts new file mode 100644 index 000000000..da4f67849 --- /dev/null +++ b/tests/lab-public-evidence.test.ts @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + assignEventId, + subjectIdForSubject, + type ObservationEvent, + type ProtocolSubjectV1, + type RouteSubjectV1, +} from "../src/lab"; +import { labPublicPublisherKeyPath } from "../src/lab/paths"; +import { + PUBLIC_ROUTE_REGISTRY_V1, + PublicEvidenceValidationError, + buildPublicEvidenceBundle, + getOrCreatePublicPublisher, + isPublicIncidentRef, + projectPublicEvidence, + projectPublicEvidenceRecord, + publicEvidenceId, + readPublicEvidenceBundle, + signPublicEvidenceBundle, + validatePublicEvidenceRecord, + validatePublicRouteRegistryManifest, + verifyPublicEvidenceBundle, + writePublicEvidenceBundle, +} from "../src/lab/public"; + +const HOMES: string[] = []; +const DEFAULT_COMPLETED_AT = Date.UTC(2026, 7, 12, 14, 37, 41); + +function tempHome(): string { + const dir = join(tmpdir(), `ocx-lab-public-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + HOMES.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of HOMES.splice(0)) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function protocolObservation(completedAt = DEFAULT_COMPLETED_AT): ObservationEvent { + const subject: ProtocolSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "protocol", + opencodexCompatibilityVersion: "2.13.0", + effectiveAdapter: "openai-chat", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + behaviorFingerprint: hex("private-protocol-behavior"), + }; + const subjectId = subjectIdForSubject(subject); + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "observation" as const, + recordedAt: completedAt + 7_000, + producer: LAB_PRODUCER, + producerVersion: "2.13.0", + evidenceLayer: "protocol_conformance" as const, + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + scenarioManifestDigest: hex("scenario"), + suiteId: "responses-core", + suiteVersion: "1.0.0", + suiteManifestDigest: hex("suite"), + fixtureDigests: [hex("fixture")], + subject, + subjectId, + startedAt: completedAt - 1_000, + completedAt, + executionMode: "fixture" as const, + attempt: 1, + limits: { totalTimeoutMs: 1000 }, + outcome: "pass" as const, + assertions: [ + { + id: "method", + operator: "equals", + required: true, + passed: true, + expectedSummary: "CANARY-PRIVATE-EXPECTED", + observedSummary: "CANARY-PRIVATE-OBSERVED", + }, + { id: "message", operator: "equals", required: true, passed: true }, + { id: "temperature", operator: "equals", required: true, passed: true }, + ], + environment: { localPath: "C:\\Users\\private\\repo" }, + artifactRefs: [], + sourceRefs: ["request_1234567890", "decision_1234567890"], + }) as ObservationEvent; +} + +function routeObservation(completedAt = DEFAULT_COMPLETED_AT): ObservationEvent { + const subject: RouteSubjectV1 = { + subjectSchemaVersion: 1, + subjectKind: "route", + providerId: "openai", + providerInstanceFingerprint: hex("PRIVATE-provider-instance"), + clientModelId: "gpt-5.6-sol", + upstreamModelId: "gpt-5.6-sol", + effectiveAdapter: "openai-responses", + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-responses", + surface: "responses-http", + opencodexCompatibilityVersion: "2.13.0", + behaviorFingerprint: hex("PRIVATE-route-behavior"), + endpointFingerprint: hex("PRIVATE-endpoint"), + dependencies: [], + }; + const subjectId = subjectIdForSubject(subject); + return assignEventId({ + ...protocolObservation(completedAt), + eventId: undefined, + evidenceLayer: "live_route_compatibility" as const, + scenarioId: "responses-core.live.request-shape", + executionMode: "live" as const, + subject, + subjectId, + sourceRefs: ["request_PRIVATE", "decision_PRIVATE"], + }) as ObservationEvent; +} + +function exportedProtocolRecord() { + const result = projectPublicEvidenceRecord({ observation: protocolObservation(), verdict: "VERIFIED" }); + if (result.status !== "exportable") throw new Error("expected exportable protocol record"); + return result.record; +} + +function withRecomputedRecordId(record: ReturnType) { + const { recordId: _oldRecordId, ...withoutRecordId } = record; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +describe("CL-10 public authority", () => { + test("ships a closed, self-consistent public route registry manifest", () => { + const manifest = validatePublicRouteRegistryManifest(PUBLIC_ROUTE_REGISTRY_V1); + expect(manifest.schemaVersion).toBe("public_route_registry_v1"); + expect(manifest.entries.length).toBeGreaterThan(0); + expect(manifest.manifestDigest).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.entries.every((entry) => entry.providerId && entry.modelId)).toBe(true); + }); + + test("public incident references are closed corpus ids only", () => { + expect(isPublicIncidentRef("IC-001")).toBe(true); + expect(isPublicIncidentRef("IC-020")).toBe(true); + expect(isPublicIncidentRef("https://github.com/private/issue/1")).toBe(false); + expect(isPublicIncidentRef("devlog/_plan/private.md")).toBe(false); + expect(isPublicIncidentRef("IC-1")).toBe(false); + }); +}); + +describe("CL-10 public projection", () => { + test("projects protocol evidence without leaking local ids, diagnostics, or assertion text", () => { + const event = protocolObservation(); + const result = projectPublicEvidenceRecord({ observation: event, verdict: "VERIFIED" }); + expect(result.status).toBe("exportable"); + if (result.status !== "exportable") throw new Error("expected exportable protocol record"); + + expect(result.record.evidenceLayer).toBe("protocol_conformance"); + expect(result.record.subject.subjectKind).toBe("protocol"); + expect(result.record.observedDayUtc).toBe("2026-08-12"); + expect(result.record.subjectId).toMatch(/^[0-9a-f]{64}$/); + expect(result.record.subjectId).not.toBe(event.subjectId); + expect(result.record.recordId).toMatch(/^[0-9a-f]{64}$/); + expect(result.record.assertions).toEqual([ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ]); + + const serialized = JSON.stringify(result.record); + for (const canary of [ + event.subjectId, + event.eventId, + "CANARY-PRIVATE-EXPECTED", + "CANARY-PRIVATE-OBSERVED", + "C:\\Users\\private\\repo", + "request_1234567890", + "decision_1234567890", + (event.subject as ProtocolSubjectV1).behaviorFingerprint, + ]) { + expect(serialized).not.toContain(canary); + } + }); + + test("does not generalise a private exact route into a public claim", () => { + const event = routeObservation(); + const result = projectPublicEvidenceRecord({ observation: event, verdict: "PROBED" }); + expect(result).toEqual({ status: "not_exportable", reason: "private_route_identity" }); + }); + + test("derives bundle day only from records that survive exportability gates", () => { + const olderPublic = protocolObservation(Date.UTC(2026, 7, 12, 23, 59, 59)); + const newerPrivateRoute = routeObservation(Date.UTC(2026, 7, 13, 12, 0, 0)); + const projected = projectPublicEvidence({ + createdDayUtc: "2099-12-31", + records: [ + { observation: olderPublic, verdict: "VERIFIED" }, + { observation: newerPrivateRoute, verdict: "PROBED" }, + ], + }); + expect(projected.bundle.createdDayUtc).toBe("2026-08-12"); + expect(projected.bundle.records).toHaveLength(1); + expect(projected.excluded).toEqual([{ index: 1, reason: "private_route_identity" }]); + }); + + test("uses domain-separated deterministic public ids", () => { + const payload = { providerId: "openai", modelId: "gpt-5.6-sol" }; + const a = publicEvidenceId("subject", payload); + const b = publicEvidenceId("subject", payload); + const c = publicEvidenceId("record", payload); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + expect(a).not.toBe(c); + }); + + test("runtime validation rejects unknown public fields", () => { + const result = projectPublicEvidenceRecord({ observation: protocolObservation(), verdict: "VERIFIED" }); + if (result.status !== "exportable") throw new Error("expected exportable protocol record"); + const withUnknown = { ...result.record, localSubjectId: "PRIVATE" }; + expect(() => validatePublicEvidenceRecord(withUnknown)).toThrow(PublicEvidenceValidationError); + }); +}); + +describe("CL-10 public bundle and publisher", () => { + test("builds deterministic bundle ids and digests from public-safe bytes", () => { + const home = tempHome(); + const publisher = getOrCreatePublicPublisher(home).publisher; + const input = { + records: [exportedProtocolRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + publisher, + }; + const a = buildPublicEvidenceBundle(input); + const b = buildPublicEvidenceBundle(input); + expect(a.bundleId).toBe(b.bundleId); + expect(a.bundleDigest).toBe(b.bundleDigest); + expect(a.bundleId).toMatch(/^[0-9a-f]{64}$/); + expect(a.bundleDigest).toMatch(/^[0-9a-f]{64}$/); + expect(a.bundleId).not.toBe(a.bundleDigest); + }); + + test("creates one installation-local Ed25519 publisher key with restrictive permissions", () => { + const home = tempHome(); + const first = getOrCreatePublicPublisher(home); + const second = getOrCreatePublicPublisher(home); + expect(first.publisher).toEqual(second.publisher); + expect(first.publisher.algorithm).toBe("ed25519"); + expect(first.publisher.keyId).toMatch(/^[0-9a-f]{64}$/); + expect(first.publisher.publicKey.length).toBeGreaterThan(20); + const privateKey = readFileSync(first.privateKeyPath, "utf8"); + expect(privateKey).toContain("PRIVATE KEY"); + if (process.platform !== "win32") { + expect(statSync(first.privateKeyPath).mode & 0o777).toBe(0o600); + } + }); + + test("rejects unreviewed assertion authority before publisher key creation", () => { + const home = tempHome(); + const record = exportedProtocolRecord(); + const unauthorized = withRecomputedRecordId({ + ...record, + assertions: [{ id: "private-assertion-name", required: true, passed: true }], + }); + expect(() => signPublicEvidenceBundle({ + records: [unauthorized], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + })).toThrow(/assertion.*authority/i); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + }); + + test("rejects privacy-canary public fields before publisher key creation", () => { + const home = tempHome(); + const record = exportedProtocolRecord(); + if (record.subject.subjectKind !== "protocol") throw new Error("expected protocol public subject"); + const subject = { ...record.subject, surface: "https://private.example.test/path?token=secret" }; + const subjectId = publicEvidenceId("subject", subject); + const unsafe = withRecomputedRecordId({ ...record, subject, subjectId }); + expect(() => signPublicEvidenceBundle({ + records: [unsafe], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + })).toThrow(/closed public identifier|forbidden URL material/i); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + }); + + test("signs and verifies exact canonical bundle bytes without serializing private key material", () => { + const home = tempHome(); + const handle = getOrCreatePublicPublisher(home); + const bundle = signPublicEvidenceBundle({ + records: [exportedProtocolRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + }); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + const serialized = JSON.stringify(bundle); + expect(serialized).not.toContain(handle.privateKeyPath); + expect(serialized).not.toContain(readFileSync(handle.privateKeyPath, "utf8").trim()); + + const badDigest = { ...bundle, bundleDigest: hex("tampered-bundle") }; + expect(verifyPublicEvidenceBundle(badDigest)).toEqual({ status: "digest_invalid" }); + const badSignature = { + ...bundle, + signature: { ...bundle.signature, signature: Buffer.from("tampered").toString("base64") }, + }; + expect(verifyPublicEvidenceBundle(badSignature)).toEqual({ status: "signature_invalid" }); + }); + + test("writes and reads a bounded local export by public bundle id", () => { + const home = tempHome(); + const bundle = signPublicEvidenceBundle({ + records: [exportedProtocolRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: home, + }); + const path = writePublicEvidenceBundle(bundle, home); + expect(path).toBe(join(home, "lab", "export", `${bundle.bundleId}.json`)); + expect(readPublicEvidenceBundle(bundle.bundleId, home)).toEqual(bundle); + }); + + test("rejects non-object local export JSON with a validation error", () => { + const home = tempHome(); + const bundleId = "f".repeat(64); + const exportDir = join(home, "lab", "export"); + mkdirSync(exportDir, { recursive: true, mode: 0o700 }); + writeFileSync(join(exportDir, `${bundleId}.json`), "null\n", { encoding: "utf8", mode: 0o600 }); + expect(() => readPublicEvidenceBundle(bundleId, home)).toThrow(PublicEvidenceValidationError); + }); +}); \ No newline at end of file diff --git a/tests/lab-public-export-transaction.test.ts b/tests/lab-public-export-transaction.test.ts new file mode 100644 index 000000000..9a7113d5e --- /dev/null +++ b/tests/lab-public-export-transaction.test.ts @@ -0,0 +1,103 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + labExportDir, + labPublicOriginDir, + persistConformanceResult, + rebuildLabProjection, +} from "../src/lab"; +import { createArtifactStore } from "../src/lab/artifacts/store"; +import { resolveProtocolExecutionContext } from "../src/lab/conformance/executor"; +import { discoverScenarios, loadCaseAuthority } from "../src/lab/conformance/manifest"; +import type { CaseRecord } from "../src/lab/conformance/types"; +import { + buildPublicEvidenceBundle, + exportLocalPublicEvidence, + getOrCreatePublicPublisher, + previewLocalPublicEvidence, +} from "../src/lab/public"; +import { queryLabObservations } from "../src/lab/query"; + +const homes: string[] = []; + +afterEach(() => { + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); + +function tempHome(): string { + const home = join(tmpdir(), `ocx-cl10-export-transaction-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(home, { recursive: true, mode: 0o700 }); + homes.push(home); + return home; +} + +function syntheticPassResult(caseRecord: CaseRecord) { + return { + scenarioId: caseRecord.id, + suite: caseRecord.suite, + passed: true, + classification: "inconclusive" as const, + assertionResults: caseRecord.assertions.map((assertion) => ({ + id: assertion.id, + operator: assertion.operator, + required: assertion.required, + passed: true, + })), + diagnostics: [], + executionContext: resolveProtocolExecutionContext(caseRecord), + startedAt: 1_700_000_000_000, + completedAt: 1_700_000_001_000, + }; +} + +function seedProtocolProjection(home: string): string { + const authority = loadCaseAuthority(); + const scenario = discoverScenarios(authority, ["responses-core"]) + .find((candidate) => candidate.id === "responses-core.protocol.request-shape") + ?? discoverScenarios(authority, ["responses-core"])[0]; + if (!scenario) throw new Error("no responses-core protocol scenario available"); + const store = createArtifactStore(join(home, "lab", "artifacts")); + try { + persistConformanceResult(syntheticPassResult(scenario), scenario, authority, { + configDir: home, + recordedAt: 1_700_000_001_100, + artifactStore: store, + }); + } finally { + store.close(); + } + rebuildLabProjection(home); + const eventId = queryLabObservations( + { layer: "protocol_conformance", scenarioId: scenario.id }, + undefined, + 10, + home, + ).items[0]?.eventId; + if (!eventId) throw new Error("seeded observation missing"); + return eventId; +} + +test("a provenance failure rolls back a newly-created local public export", () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const preview = previewLocalPublicEvidence({ eventIds: [eventId] }, home); + const publisher = getOrCreatePublicPublisher(home).publisher; + const unsigned = buildPublicEvidenceBundle({ + records: preview.bundle.records, + artifacts: preview.bundle.artifacts, + createdDayUtc: preview.bundle.createdDayUtc, + publisher, + }); + const exportPath = join(labExportDir(home), `${unsigned.bundleId}.json`); + const originPath = join( + labPublicOriginDir(home), + `origin-${publisher.keyId}-${unsigned.bundleId}.json`, + ); + + // A directory at the marker pathname makes the provenance commit fail closed. + mkdirSync(originPath, { mode: 0o700 }); + expect(() => exportLocalPublicEvidence({ eventIds: [eventId] }, home)).toThrow(); + expect(existsSync(exportPath)).toBe(false); +}); diff --git a/tests/lab-public-final-review-regressions.test.ts b/tests/lab-public-final-review-regressions.test.ts new file mode 100644 index 000000000..7c13cca64 --- /dev/null +++ b/tests/lab-public-final-review-regressions.test.ts @@ -0,0 +1,145 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureLabDirs, labPublicOriginDir } from "../src/lab/paths"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + listLocalPublicOrigins, + purgeLocalPublicEvidenceCopies, + publicEvidenceId, + signPublicEvidenceBundle, + writePublicEvidenceBundle, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; +import { listValidPublicOriginsForPurge } from "../src/lab/public/origin-purge"; +import { setPublicEvidencePurgeFaultForTests } from "../src/lab/public/purge-test-fault"; + +const roots: string[] = []; + +afterEach(() => { + setPublicEvidencePurgeFaultForTests(null); + for (const root of roots.splice(0)) { + if (existsSync(root)) rmSync(root, { recursive: true, force: true }); + } +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function record(day = "2026-08-12"): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const body = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: day, + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", body), ...body }; +} + +function bundle(config: string, day = "2026-08-12") { + return signPublicEvidenceBundle({ + records: [record(day)], + artifacts: [], + createdDayUtc: day, + configDir: config, + }); +} + +test("raw community import restores provenance for an exact own-publisher bundle", () => { + const home = configDir("ocx-cl10-raw-own-import-"); + const own = bundle(home); + expect(listLocalPublicOrigins(home)).toEqual([]); + + importCommunityEvidenceBundle(own, home); + expect(listLocalPublicOrigins(home)).toEqual([{ + publisherKeyId: own.publisher.keyId, + bundleId: own.bundleId, + }]); +}); + +test("purge provenance salvage does not truncate valid markers at the operational quota", () => { + const home = configDir("ocx-cl10-purge-origin-overflow-"); + ensureLabDirs(home); + const dir = labPublicOriginDir(home); + + for (let index = 0; index < 1025; index += 1) { + const publisherKeyId = publicEvidenceId("publisher_key", { index }); + const bundleId = publicEvidenceId("bundle", { index }); + writeFileSync( + join(dir, `origin-${publisherKeyId}-${bundleId}.json`), + JSON.stringify({ schemaVersion: "public_origin_v1", publisherKeyId, bundleId }), + { mode: 0o600 }, + ); + } + + expect(listValidPublicOriginsForPurge(home)).toHaveLength(1025); +}); + +test("successful local export commits provenance before export storage", () => { + const source = readFileSync(new URL("../src/lab/public/operator.ts", import.meta.url), "utf8"); + const start = source.indexOf("export function exportLocalPublicEvidence"); + const end = source.indexOf("export function summarizePublicEvidenceVerification", start); + const block = source.slice(start, end); + const origin = block.indexOf("recordLocalPublicOrigin"); + const stored = block.indexOf("const stored = storePublicEvidenceBundle"); + + expect(origin).toBeGreaterThanOrEqual(0); + expect(stored).toBeGreaterThan(origin); +}); + +test("V1 revocation rejects targets that span multiple bundle anchors", () => { + const publisher = configDir("ocx-cl10-multibundle-rev-publisher-"); + const first = bundle(publisher, "2026-08-12"); + const second = bundle(publisher, "2026-08-13"); + + expect(() => createPublicEvidenceRevocation({ + configDir: publisher, + targetBundle: first, + issuedDayUtc: "2026-08-13", + reason: "superseded", + targets: [ + { kind: "bundle", id: first.bundleId }, + { kind: "bundle", id: second.bundleId }, + ], + })).toThrow(); +}); + +test("export purge keeps failing closed on retry until POSIX deletion durability is established", () => { + if (process.platform === "win32") return; + const home = configDir("ocx-cl10-export-delete-durability-"); + const own = bundle(home); + writePublicEvidenceBundle(own, home); + + setPublicEvidencePurgeFaultForTests("export_directory_sync"); + expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); + // The first attempt already removed the export pathname. A retry must still + // fsync the now-empty directory rather than reporting success without durability. + expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); + + setPublicEvidencePurgeFaultForTests(null); + expect(() => purgeLocalPublicEvidenceCopies(home)).not.toThrow(); +}); diff --git a/tests/lab-public-lifecycle-hardening.test.ts b/tests/lab-public-lifecycle-hardening.test.ts new file mode 100644 index 000000000..3c6acca4d --- /dev/null +++ b/tests/lab-public-lifecycle-hardening.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + labCommunityDir, + labExportDir, + labPublicPublisherKeyPath, +} from "../src/lab/paths"; +import { + publishPrivateFileExclusive, + setPrivateFileCommitFaultForTests, +} from "../src/lab/public/private-file"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + listCommunityEvidence, + listLocalPublicOrigins, + purgeLocalPublicEvidenceCopies, + publicEvidenceId, + recordLocalPublicOrigin, + signPublicEvidenceBundle, + writePublicEvidenceBundle, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + setPrivateFileCommitFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function fixedRecord(): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +function signedBundle(config: string, day = "2026-08-12") { + return signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: day, + configDir: config, + }); +} + +function addLivePrivateStages(dir: string, count: number): void { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + for (let index = 0; index < count; index += 1) { + const finalName = `bundle-${String(index).padStart(3, "0")}.json`; + writeFileSync( + join(dir, `.${finalName}.${process.pid}.${randomUUID()}.tmp`), + "stage", + { mode: 0o600 }, + ); + } +} + +describe("CL-10 public lifecycle hardening", () => { + test("exclusive private publication never exposes a partial final file", () => { + const root = configDir("ocx-cl10-atomic-"); + const finalPath = join(root, "object.json"); + const bytes = Buffer.from('{"ok":true}', "utf8"); + + setPrivateFileCommitFaultForTests("before_publish"); + expect(() => publishPrivateFileExclusive(finalPath, bytes)).toThrow(/synthetic.*commit failure/i); + expect(existsSync(finalPath)).toBe(false); + expect(readdirSync(root).filter((name) => name.endsWith(".tmp"))).toEqual([]); + + setPrivateFileCommitFaultForTests(null); + expect(publishPrivateFileExclusive(finalPath, bytes)).toEqual({ created: true }); + expect(existsSync(finalPath)).toBe(true); + }); + + test("private staging files do not consume the bounded community object quota", () => { + const publisher = configDir("ocx-cl10-stage-publisher-"); + const consumer = configDir("ocx-cl10-stage-consumer-"); + addLivePrivateStages(labCommunityDir(consumer), 512); + + const bundle = signedBundle(publisher); + expect(importCommunityEvidenceBundle(bundle, consumer)).toMatchObject({ + created: true, + status: "cryptographically_valid", + bundleId: bundle.bundleId, + }); + expect(listCommunityEvidence(consumer)).toEqual([ + expect.objectContaining({ bundleId: bundle.bundleId, activeRecordCount: 1 }), + ]); + }); + + test("durable origin provenance purges local community copies even after export and key corruption", () => { + const local = configDir("ocx-cl10-origin-local-"); + const thirdParty = configDir("ocx-cl10-origin-third-party-"); + const localBundle = signedBundle(local); + const thirdPartyBundle = signedBundle(thirdParty, "2026-08-13"); + + const localExportPath = writePublicEvidenceBundle(localBundle, local); + recordLocalPublicOrigin({ + publisherKeyId: localBundle.publisher.keyId, + bundleId: localBundle.bundleId, + }, local); + importCommunityEvidenceBundle(localBundle, local); + importCommunityEvidenceBundle(thirdPartyBundle, local); + + const localRevocation = createPublicEvidenceRevocation({ + configDir: local, + targetBundle: localBundle, + issuedDayUtc: "2026-08-13", + reason: "privacy_retraction", + targets: [{ kind: "bundle", id: localBundle.bundleId }], + }); + importCommunityEvidenceRevocation(localRevocation, local); + + expect(listLocalPublicOrigins(local)).toEqual([{ + publisherKeyId: localBundle.publisher.keyId, + bundleId: localBundle.bundleId, + }]); + + writeFileSync(localExportPath, "{", { encoding: "utf8" }); + unlinkSync(labPublicPublisherKeyPath(local)); + + expect(purgeLocalPublicEvidenceCopies(local)).toMatchObject({ + deletedCommunityBundles: 1, + deletedCommunityRevocations: 1, + }); + expect(readdirSync(labExportDir(local))).toEqual([]); + expect(listLocalPublicOrigins(local)).toEqual([]); + expect(listCommunityEvidence(local).map((row) => row.bundleId)).toEqual([thirdPartyBundle.bundleId]); + }); + + test("locally-originated hardlinked community path is removed without deleting its peer", () => { + const local = configDir("ocx-cl10-unsafe-community-purge-"); + const localBundle = signedBundle(local); + writePublicEvidenceBundle(localBundle, local); + recordLocalPublicOrigin({ + publisherKeyId: localBundle.publisher.keyId, + bundleId: localBundle.bundleId, + }, local); + const imported = importCommunityEvidenceBundle(localBundle, local); + const peerPath = join(local, "community-hardlink-witness.json"); + + linkSync(imported.path, peerPath); + + expect(purgeLocalPublicEvidenceCopies(local)).toMatchObject({ + deletedExports: 1, + deletedCommunityBundles: 1, + }); + expect(readdirSync(labExportDir(local))).toEqual([]); + expect(existsSync(imported.path)).toBe(false); + expect(existsSync(peerPath)).toBe(true); + }); + + test("duplicate-key revocation JSON is rejected before persistence", () => { + const publisher = configDir("ocx-cl10-dup-rev-publisher-"); + const consumer = configDir("ocx-cl10-dup-rev-consumer-"); + const bundle = signedBundle(publisher); + importCommunityEvidenceBundle(bundle, consumer); + const revocation = createPublicEvidenceRevocation({ + configDir: publisher, + targetBundle: bundle, + issuedDayUtc: "2026-08-13", + reason: "evidence_invalidated", + targets: [{ kind: "record", id: bundle.records[0]!.recordId }], + }); + const raw = JSON.stringify(revocation).replace( + '"schemaVersion":"public_evidence_revocation_v1"', + '"schemaVersion":"public_evidence_revocation_v1","schemaVersion":"public_evidence_revocation_v1"', + ); + + expect(() => importCommunityEvidenceRevocation(raw, consumer)).toThrow(/duplicate json object key/i); + expect(readdirSync(labCommunityDir(consumer)).filter((name) => name.startsWith("revocation-"))).toEqual([]); + }); +}); diff --git a/tests/lab-public-privacy-ipv6.test.ts b/tests/lab-public-privacy-ipv6.test.ts new file mode 100644 index 000000000..1b8da1dae --- /dev/null +++ b/tests/lab-public-privacy-ipv6.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { + validatePublicEvidencePrivacy, + type PublicEvidenceBundleUnsignedV1, +} from "../src/lab/public"; + +test("public artifact privacy rejects embedded unbracketed IPv6 literals", () => { + const bytes = Buffer.from("artifact 2001:db8::1 content", "utf8"); + const bundle = { + createdDayUtc: "2026-08-13", + records: [], + artifacts: [{ + artifactId: "0".repeat(64), + artifactClass: "verifier_summary", + mediaType: "text/plain", + byteCount: bytes.byteLength, + contentBase64: bytes.toString("base64"), + }], + } as unknown as PublicEvidenceBundleUnsignedV1; + + expect(() => validatePublicEvidencePrivacy(bundle)).toThrow(/IP address|privacy/i); +}); diff --git a/tests/lab-public-provenance-recovery.test.ts b/tests/lab-public-provenance-recovery.test.ts new file mode 100644 index 000000000..c3241ab6b --- /dev/null +++ b/tests/lab-public-provenance-recovery.test.ts @@ -0,0 +1,185 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { labPublicOriginDir } from "../src/lab/paths"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + importCommunityEvidenceValue, + listCommunityEvidence, + listLocalPublicOrigins, + purgeLocalPublicEvidenceCopies, + publicEvidenceId, + recordLocalPublicOrigin, + signPublicEvidenceBundle, + writePublicEvidenceBundle, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; +import { setPrivateFileCommitFaultForTests } from "../src/lab/public/private-file"; + +const roots: string[] = []; +afterEach(() => { + setPrivateFileCommitFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function fixedRecord(day = "2026-08-12"): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const body = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: day, + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", body), ...body }; +} + +function signedBundle(config: string, day = "2026-08-12") { + return signPublicEvidenceBundle({ + records: [fixedRecord(day)], + artifacts: [], + createdDayUtc: day, + configDir: config, + }); +} + +test("one corrupt origin marker does not discard later valid purge provenance", () => { + const home = configDir("ocx-cl10-origin-salvage-"); + const publisherA = configDir("ocx-cl10-origin-publisher-a-"); + const publisherB = configDir("ocx-cl10-origin-publisher-b-"); + const bundles = [signedBundle(publisherA), signedBundle(publisherB, "2026-08-13")]; + + for (const bundle of bundles) { + importCommunityEvidenceBundle(bundle, home); + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, home); + } + + const dir = labPublicOriginDir(home); + const names = readdirSync(dir).sort(); + expect(names).toHaveLength(2); + writeFileSync(join(dir, names[0]!), "{", { mode: 0o600 }); + const validName = names[1]!; + const validBundle = bundles.find((bundle) => validName.includes(bundle.bundleId))!; + + purgeLocalPublicEvidenceCopies(home); + expect(listCommunityEvidence(home).map((row) => row.bundleId)).not.toContain(validBundle.bundleId); +}); + +test("origin pressure preserves provenance while the matching local export exists", () => { + const home = configDir("ocx-cl10-origin-export-retain-"); + const bundle = signedBundle(home); + writePublicEvidenceBundle(bundle, home); + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, home); + + const dir = labPublicOriginDir(home); + for (let index = 0; index < 1023; index += 1) { + const publisherKeyId = publicEvidenceId("publisher_key", { seed: `old-publisher-${index}` }); + const bundleId = publicEvidenceId("bundle", { seed: `old-bundle-${index}` }); + writeFileSync(join(dir, `origin-${publisherKeyId}-${bundleId}.json`), "{}", { mode: 0o600 }); + } + + const next = { + publisherKeyId: publicEvidenceId("publisher_key", { seed: "next-publisher" }), + bundleId: publicEvidenceId("bundle", { seed: "next-bundle" }), + }; + recordLocalPublicOrigin(next, home); + + expect(existsSync(join(dir, `origin-${bundle.publisher.keyId}-${bundle.bundleId}.json`))).toBe(true); +}); + +test("operator import of an own verified bundle restores missing local-origin provenance", () => { + const home = configDir("ocx-cl10-origin-rehydrate-"); + const bundle = signedBundle(home); + expect(listLocalPublicOrigins(home)).toEqual([]); + + importCommunityEvidenceValue(bundle, home); + expect(listLocalPublicOrigins(home)).toEqual([{ + publisherKeyId: bundle.publisher.keyId, + bundleId: bundle.bundleId, + }]); +}); + +test("operator import of a third-party verified bundle does not create local-origin provenance", () => { + const home = configDir("ocx-cl10-origin-third-party-home-"); + const publisher = configDir("ocx-cl10-origin-third-party-publisher-"); + signedBundle(home); + const bundle = signedBundle(publisher); + + importCommunityEvidenceValue(bundle, home); + expect(listLocalPublicOrigins(home)).toEqual([]); +}); + +test("failed own-origin commit rolls back a newly imported community copy", () => { + const home = configDir("ocx-cl10-origin-rollback-"); + const bundle = signedBundle(home); + const dir = labPublicOriginDir(home); + for (let index = 0; index < 1024; index += 1) { + writeFileSync(join(dir, `occupied-${String(index).padStart(4, "0")}`), "x", { mode: 0o600 }); + } + + expect(() => importCommunityEvidenceValue(bundle, home)).toThrow(/origin marker bound/i); + expect(listCommunityEvidence(home)).toEqual([]); +}); + +test("origin and community persistence recover after same-process parent-directory sync failures", () => { + if (process.platform === "win32") return; + const home = configDir("ocx-cl10-origin-recovery-"); + const publisher = configDir("ocx-cl10-community-recovery-publisher-"); + const identity = { + publisherKeyId: publicEvidenceId("publisher_key", { seed: "recovery-publisher" }), + bundleId: publicEvidenceId("bundle", { seed: "recovery-bundle" }), + }; + + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => recordLocalPublicOrigin(identity, home)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(() => recordLocalPublicOrigin(identity, home)).not.toThrow(); + + const bundle = signedBundle(publisher); + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => importCommunityEvidenceBundle(bundle, home)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(importCommunityEvidenceBundle(bundle, home)).toMatchObject({ created: false, bundleId: bundle.bundleId }); +}); + +test("V1 revocations are bounded to one already-verified anchor bundle", () => { + const publisher = configDir("ocx-cl10-revocation-anchor-"); + const first = signedBundle(publisher, "2026-08-12"); + const second = signedBundle(publisher, "2026-08-13"); + + expect(() => createPublicEvidenceRevocation({ + configDir: publisher, + targetBundle: first, + issuedDayUtc: "2026-08-13", + reason: "superseded", + targets: [ + { kind: "bundle", id: first.bundleId }, + { kind: "bundle", id: second.bundleId }, + ], + })).toThrow(/target|unknown/i); +}); diff --git a/tests/lab-public-review-fixes.test.ts b/tests/lab-public-review-fixes.test.ts new file mode 100644 index 000000000..fced9d51e --- /dev/null +++ b/tests/lab-public-review-fixes.test.ts @@ -0,0 +1,208 @@ +import { afterEach, expect, test } from "bun:test"; +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureLabDirs, labPublicOriginDir } from "../src/lab/paths"; +import { purgeSensitiveEvidence } from "../src/lab/ledger/purge"; +import { replayLabLedger } from "../src/lab/ledger/store"; +import * as publicApi from "../src/lab/public"; +import { setPublicEvidencePurgeFaultForTests } from "../src/lab/public/purge-test-fault"; +import { + importCommunityEvidenceBundle, + importCommunityEvidenceRevocation, + purgeLocalPublicEvidenceCopies, + publicEvidenceId, + recordLocalPublicOrigin, + signPublicEvidenceBundle, + writePublicEvidenceBundle, + PublicEvidenceValidationError, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; + +const roots: string[] = []; +afterEach(() => { + setPublicEvidencePurgeFaultForTests(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function hex(seed: string): string { + return Bun.CryptoHasher.hash("sha256", seed, "hex"); +} + +function fixedRecord(): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +function signedBundle(config: string) { + return signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: config, + }); +} + +test("decoded community objects are depth-bounded before JCS canonicalization", () => { + const consumer = configDir("ocx-cl10-object-bound-"); + let raw: unknown = { leaf: true }; + for (let index = 0; index < 20_000; index += 1) raw = { nested: raw }; + + try { + importCommunityEvidenceBundle(raw, consumer); + throw new Error("expected bounded object rejection"); + } catch (error) { + expect(error).toBeInstanceOf(PublicEvidenceValidationError); + expect((error as PublicEvidenceValidationError).code).toBe("community_depth"); + } +}); + +test("public barrel does not expose private test fault setters", () => { + expect("setPrivateFileCommitFaultForTests" in publicApi).toBe(false); + expect("setPublicEvidencePurgeFaultForTests" in publicApi).toBe(false); +}); + +test("public origin quota stays bounded when unreclaimable unexpected entries fill it", () => { + const home = configDir("ocx-cl10-origin-bound-"); + ensureLabDirs(home); + const dir = labPublicOriginDir(home); + for (let index = 0; index < 1024; index += 1) { + writeFileSync(join(dir, `occupied-${String(index).padStart(4, "0")}`), "x", { mode: 0o600 }); + } + + expect(() => recordLocalPublicOrigin({ + publisherKeyId: hex("publisher-bound"), + bundleId: hex("bundle-bound"), + }, home)).toThrow(/origin marker bound/i); + expect(readdirSync(dir)).toHaveLength(1024); +}); + +test("public origin pressure reclaims markers with no community copy", () => { + const home = configDir("ocx-cl10-origin-reclaim-"); + ensureLabDirs(home); + const dir = labPublicOriginDir(home); + for (let index = 0; index < 1024; index += 1) { + const publisherKeyId = hex(`publisher-old-${index}`); + const bundleId = hex(`bundle-old-${index}`); + writeFileSync( + join(dir, `origin-${publisherKeyId}-${bundleId}.json`), + "{}", + { mode: 0o600 }, + ); + } + + const current = { publisherKeyId: hex("publisher-current"), bundleId: hex("bundle-current") }; + recordLocalPublicOrigin(current, home); + const names = readdirSync(dir); + expect(names).toHaveLength(1); + expect(names[0]).toBe(`origin-${current.publisherKeyId}-${current.bundleId}.json`); +}); + +test("corrupt origin provenance cannot retain mandatory local export bytes", () => { + const home = configDir("ocx-cl10-origin-corrupt-"); + const bundle = signedBundle(home); + writePublicEvidenceBundle(bundle, home); + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, home); + const originEntry = readdirSync(labPublicOriginDir(home))[0]!; + writeFileSync(join(labPublicOriginDir(home), originEntry), "{", { mode: 0o600 }); + + expect(purgeLocalPublicEvidenceCopies(home).deletedExports).toBe(1); + expect(readdirSync(ensureLabDirs(home).exportDir)).toEqual([]); +}); + +test("unsafe locally-originated community copies are removed without blocking sensitive export purge", () => { + const home = configDir("ocx-cl10-community-unsafe-"); + const bundle = signedBundle(home); + writePublicEvidenceBundle(bundle, home); + recordLocalPublicOrigin({ publisherKeyId: bundle.publisher.keyId, bundleId: bundle.bundleId }, home); + const imported = importCommunityEvidenceBundle(bundle, home); + writeFileSync(imported.path, Buffer.alloc(2 * 1024 * 1024 + 1, 0x78), { mode: 0o600 }); + + const result = purgeLocalPublicEvidenceCopies(home); + expect(result.deletedExports).toBe(1); + expect(result.deletedCommunityBundles).toBe(1); + expect(readdirSync(ensureLabDirs(home).exportDir)).toEqual([]); + expect(readdirSync(labPublicOriginDir(home))).toEqual([]); + expect(existsSync(imported.path)).toBe(false); +}); + +test("missing direct revocation bundle target reports stable revocation_target error", () => { + const home = configDir("ocx-cl10-missing-revocation-target-"); + ensureLabDirs(home); + + let failure: unknown; + try { + importCommunityEvidenceRevocation({ + publisher: { keyId: hex("missing-publisher") }, + targets: [{ kind: "bundle", id: hex("missing-bundle") }], + }, home); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(PublicEvidenceValidationError); + expect((failure as PublicEvidenceValidationError).code).toBe("revocation_target"); + expect((failure as Error).message).toBe("revocation target bundle not found"); +}); + +test("failed export purge is omitted from the durable tombstone action set", () => { + const home = configDir("ocx-cl10-tombstone-export-"); + const paths = ensureLabDirs(home); + writeFileSync(join(paths.scratchDir, "scratch.txt"), "scratch", { mode: 0o600 }); + writeFileSync(join(paths.exportDir, "sensitive.txt"), "sensitive", { mode: 0o600 }); + setPublicEvidencePurgeFaultForTests("before_export_delete"); + + let failure: unknown; + try { + purgeSensitiveEvidence({ + configDir: home, + purgeActions: ["export", "scratch"], + recordedAt: Date.UTC(2026, 7, 13, 6, 0, 0), + }); + } catch (error) { + failure = error; + } finally { + setPublicEvidencePurgeFaultForTests(null); + } + expect(failure).toBeInstanceOf(Error); + + const tombstones = replayLabLedger(paths.ledgerPath).events.filter((event) => event.eventKind === "purge_tombstone"); + expect(tombstones).toHaveLength(1); + expect(tombstones[0]!.purgeActions).toEqual(["scratch"]); +}); diff --git a/tests/lab-public-route-registry.test.ts b/tests/lab-public-route-registry.test.ts new file mode 100644 index 000000000..e13b8fdb6 --- /dev/null +++ b/tests/lab-public-route-registry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { PUBLIC_ROUTE_REGISTRY_V1, validatePublicRouteRegistryManifest } from "../src/lab/public"; + +const REVIEWED_AUTHORITY_SOURCE_COMMIT = "75a21417657ba5a3033198be0d8ae949de723d11"; + +describe("CL-10 public route registry authority", () => { + test("pins the reviewed OpenAI gpt-5.6-sol authority exactly", () => { + const manifest = validatePublicRouteRegistryManifest(PUBLIC_ROUTE_REGISTRY_V1); + + expect(manifest.registryVersion).toBe("2026-08-13.v2"); + expect(manifest.sourceCommit).toBe(REVIEWED_AUTHORITY_SOURCE_COMMIT); + expect(manifest.entries).toEqual([ + { + providerId: "openai", + modelId: "gpt-5.6-sol", + adapterFamilies: ["openai-responses"], + }, + ]); + }); +}); diff --git a/tests/lab-public-surfaces.test.ts b/tests/lab-public-surfaces.test.ts new file mode 100644 index 000000000..404112db4 --- /dev/null +++ b/tests/lab-public-surfaces.test.ts @@ -0,0 +1,327 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleLabCommand } from "../src/cli/lab"; +import { + labExportDir, + labPublicPublisherKeyPath, + persistConformanceResult, + rebuildLabProjection, +} from "../src/lab"; +import { labCommunityDir } from "../src/lab/paths"; +import { createArtifactStore } from "../src/lab/artifacts/store"; +import { resolveProtocolExecutionContext } from "../src/lab/conformance/executor"; +import { discoverScenarios, loadCaseAuthority } from "../src/lab/conformance/manifest"; +import type { CaseRecord } from "../src/lab/conformance/types"; +import { queryLabObservations } from "../src/lab/query"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest } from "./helpers/management-auth"; + +const HOMES: string[] = []; + +afterEach(() => { + for (const home of HOMES.splice(0)) rmSync(home, { recursive: true, force: true }); + delete process.env.OPENCODEX_HOME; +}); + +function tempHome(): string { + const home = join(tmpdir(), `ocx-cl10-surfaces-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(home, { recursive: true, mode: 0o700 }); + HOMES.push(home); + return home; +} + +function syntheticPassResult(caseRecord: CaseRecord) { + return { + scenarioId: caseRecord.id, + suite: caseRecord.suite, + passed: true, + classification: "inconclusive" as const, + assertionResults: caseRecord.assertions.map((assertion) => ({ + id: assertion.id, + operator: assertion.operator, + required: assertion.required, + passed: true, + observedSummary: "PRIVATE-CANARY-OBSERVED", + })), + diagnostics: ["PRIVATE-CANARY-DIAGNOSTIC"], + executionContext: resolveProtocolExecutionContext(caseRecord), + startedAt: 1_700_000_000_000, + completedAt: 1_700_000_001_000, + }; +} + +function seedProtocolProjection(home: string): string { + const authority = loadCaseAuthority(); + const scenario = discoverScenarios(authority, ["responses-core"]) + .find((candidate) => candidate.id === "responses-core.protocol.request-shape") + ?? discoverScenarios(authority, ["responses-core"])[0]; + if (!scenario) throw new Error("no responses-core protocol scenario available"); + const store = createArtifactStore(join(home, "lab", "artifacts")); + try { + persistConformanceResult(syntheticPassResult(scenario), scenario, authority, { + configDir: home, + recordedAt: 1_700_000_001_100, + artifactStore: store, + }); + } finally { + store.close(); + } + rebuildLabProjection(home); + const rows = queryLabObservations( + { layer: "protocol_conformance", scenarioId: scenario.id }, + undefined, + 10, + home, + ); + const eventId = rows.items[0]?.eventId; + if (!eventId) throw new Error("seeded observation missing"); + return eventId; +} + +function config(home: string): OcxConfig { + void home; + return { port: 0, defaultProvider: "openai-apikey", providers: {} } as OcxConfig; +} + +async function api( + home: string, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise { + process.env.OPENCODEX_HOME = home; + const req = new ManagementRequest(`http://127.0.0.1${path}`, { + method: init.method ?? "GET", + ...(init.body !== undefined + ? { headers: { "content-type": "application/json" }, body: JSON.stringify(init.body) } + : {}), + }); + const response = await handleManagementAPI(req, new URL(req.url), config(home), { + refreshCodexCatalog: async () => {}, + }); + expect(response).not.toBeNull(); + return response!; +} + +async function captureCli(argv: string[], home: string): Promise<{ code: number; stdout: string; stderr: string }> { + const stdout: string[] = []; + const stderr: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args: unknown[]) => { stdout.push(args.join(" ")); }; + console.error = (...args: unknown[]) => { stderr.push(args.join(" ")); }; + try { + return { + code: await handleLabCommand(argv, { configDir: home }), + stdout: stdout.join("\n"), + stderr: stderr.join("\n"), + }; + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +function installNetworkCanary(): () => void { + const original = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("CL10-NETWORK-CANARY"); + }) as typeof fetch; + return () => { globalThis.fetch = original; }; +} + +describe("CL-10 CLI local public evidence", () => { + test("preview is network-free, identifier-safe, and does not create publisher or export state", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const unknownEventId = "0".repeat(64); + const restoreFetch = installNetworkCanary(); + try { + const result = await captureCli([ + "public", "preview", "--event", eventId, "--event", unknownEventId, "--json", + ], home); + expect(result.code).toBe(0); + const body = JSON.parse(result.stdout) as { + bundle: { records: unknown[]; publisher?: unknown }; + excluded: Array<{ selectionIndex: number; reason: string; eventId?: string }>; + }; + expect(body.bundle.records).toHaveLength(1); + expect(body.bundle).not.toHaveProperty("publisher"); + expect(body.excluded).toEqual([{ selectionIndex: 1, reason: "event_not_found" }]); + expect(body.excluded[0]).not.toHaveProperty("eventId"); + expect(result.stdout).not.toContain(eventId); + expect(result.stdout).not.toContain(unknownEventId); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + expect(existsSync(labExportDir(home)) ? readdirSync(labExportDir(home)) : []).toEqual([]); + expect(result.stdout).not.toContain("PRIVATE-CANARY"); + } finally { + restoreFetch(); + } + }); + + test("explicit export signs and stores, then verify/import/community remain local", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const restoreFetch = installNetworkCanary(); + try { + const exported = await captureCli(["public", "export", "--event", eventId, "--json"], home); + expect(exported.code).toBe(0); + const exportBody = JSON.parse(exported.stdout) as { + bundle: { bundleId: string; publisher: { keyId: string } }; + stored: { path: string; created: boolean }; + }; + expect(exportBody.bundle.publisher.keyId).toMatch(/^[0-9a-f]{64}$/); + expect(exportBody.stored).toEqual({ path: "", created: true }); + expect(exported.stdout).not.toContain(home); + const privateExportPath = join(labExportDir(home), `${exportBody.bundle.bundleId}.json`); + expect(existsSync(privateExportPath)).toBe(true); + + const verified = await captureCli(["public", "verify", "--file", privateExportPath, "--json"], home); + expect(verified.code).toBe(0); + expect(JSON.parse(verified.stdout)).toMatchObject({ + status: "cryptographically_valid", + bundleId: exportBody.bundle.bundleId, + publisherKeyId: exportBody.bundle.publisher.keyId, + locallyVerified: false, + }); + + const ledgerBefore = readFileSync(join(home, "lab", "compatibility.jsonl")); + const sqliteBefore = readFileSync(join(home, "lab", "compatibility.sqlite")); + const imported = await captureCli(["public", "import", "--file", privateExportPath, "--json"], home); + expect(imported.code).toBe(0); + const importedBody = JSON.parse(imported.stdout) as Record; + expect(importedBody).toMatchObject({ + status: "cryptographically_valid", + trustClass: "community_untrusted_v1", + bundleId: exportBody.bundle.bundleId, + }); + expect(importedBody).not.toHaveProperty("path"); + expect(imported.stdout).not.toContain(home); + expect(readFileSync(join(home, "lab", "compatibility.jsonl")).equals(ledgerBefore)).toBe(true); + expect(readFileSync(join(home, "lab", "compatibility.sqlite")).equals(sqliteBefore)).toBe(true); + + const community = await captureCli(["public", "community", "--json"], home); + expect(community.code).toBe(0); + const communityBody = JSON.parse(community.stdout) as { evidence: Array<{ bundleId: string; trustClass: string }> }; + expect(communityBody.evidence).toEqual([ + expect.objectContaining({ bundleId: exportBody.bundle.bundleId, trustClass: "community_untrusted_v1" }), + ]); + } finally { + restoreFetch(); + } + }); + + test("has no publish command", async () => { + const home = tempHome(); + const result = await captureCli(["public", "publish", "--json"], home); + expect(result.code).toBe(2); + expect(result.stderr).toMatch(/unknown public subcommand|usage/i); + }); +}); + +describe("CL-10 management local public evidence", () => { + test("preview/export/verify/import/community are explicit authenticated local actions", async () => { + const home = tempHome(); + const eventId = seedProtocolProjection(home); + const restoreFetch = installNetworkCanary(); + try { + const preview = await api(home, "/api/lab/public/preview", { + method: "POST", + body: { eventIds: [eventId] }, + }); + expect(preview.status).toBe(200); + const previewBody = await preview.json() as { bundle: { records: unknown[]; publisher?: unknown } }; + expect(previewBody.bundle.records).toHaveLength(1); + expect(previewBody.bundle).not.toHaveProperty("publisher"); + expect(existsSync(labPublicPublisherKeyPath(home))).toBe(false); + + const exported = await api(home, "/api/lab/public/export", { + method: "POST", + body: { eventIds: [eventId] }, + }); + expect(exported.status).toBe(200); + const exportBody = await exported.json() as { + bundle: { bundleId: string; publisher: { keyId: string } }; + stored: { path: string; created: boolean }; + }; + expect(exportBody.stored).toEqual({ path: "", created: true }); + expect(JSON.stringify(exportBody)).not.toContain(home); + + const verified = await api(home, "/api/lab/public/verify", { + method: "POST", + body: { bundle: exportBody.bundle }, + }); + expect(verified.status).toBe(200); + expect(await verified.json()).toMatchObject({ + status: "cryptographically_valid", + bundleId: exportBody.bundle.bundleId, + locallyVerified: false, + }); + + const ledgerBefore = readFileSync(join(home, "lab", "compatibility.jsonl")); + const imported = await api(home, "/api/lab/public/community/import", { + method: "POST", + body: { bundle: exportBody.bundle }, + }); + expect(imported.status).toBe(200); + const importedBody = await imported.json() as Record; + expect(importedBody).toMatchObject({ + status: "cryptographically_valid", + trustClass: "community_untrusted_v1", + }); + expect(importedBody).not.toHaveProperty("path"); + expect(JSON.stringify(importedBody)).not.toContain(home); + expect(readFileSync(join(home, "lab", "compatibility.jsonl")).equals(ledgerBefore)).toBe(true); + + const community = await api(home, "/api/lab/public/community"); + expect(community.status).toBe(200); + expect(await community.json()).toMatchObject({ + evidence: [expect.objectContaining({ bundleId: exportBody.bundle.bundleId })], + }); + } finally { + restoreFetch(); + } + }); + + test("busy community lock is a prompt retryable service response", async () => { + const home = tempHome(); + const lockPath = join(labCommunityDir(home), ".mutation-lock"); + mkdirSync(lockPath, { recursive: true, mode: 0o700 }); + writeFileSync( + join(lockPath, "owner.json"), + JSON.stringify({ + pid: process.pid, + token: "00000000-0000-4000-8000-000000000000", + createdAt: Date.now(), + }), + { encoding: "utf8", mode: 0o600 }, + ); + + const startedAt = performance.now(); + const response = await api(home, "/api/lab/public/community"); + const elapsedMs = performance.now() - startedAt; + + expect(elapsedMs).toBeLessThan(500); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + expect(await response.json()).toMatchObject({ + error: { code: "community_cache_busy" }, + }); + }); + + test("does not expose a remote publish endpoint", async () => { + const home = tempHome(); + process.env.OPENCODEX_HOME = home; + const req = new ManagementRequest("http://127.0.0.1/api/lab/public/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const res = await handleManagementAPI(req, new URL(req.url), config(home), { + refreshCodexCatalog: async () => {}, + }); + expect(res).toBeNull(); + }); +}); diff --git a/tests/lab-public-wire-contract.test.ts b/tests/lab-public-wire-contract.test.ts new file mode 100644 index 000000000..8b730dc89 --- /dev/null +++ b/tests/lab-public-wire-contract.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildPublicEvidenceBundle, + importCommunityEvidenceBundle, + parseStrictPublicJson, + publicEvidenceId, + signPublicEvidenceBundle, + verifyPublicEvidenceBundle, +} from "../src/lab/public"; + +// Deterministic test-only key material is assembled at runtime so leak scanners do not +// mistake the fixture for a deployable private-key credential. +const FIXED_PRIVATE_KEY = [ + `-----BEGIN PRIVATE ${"KEY"}-----`, + ["MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYH", "CAkKCwwNDg8QERITFBUWFxgZGhscHR4f"].join(""), + `-----END PRIVATE ${"KEY"}-----`, + "", +].join("\n"); +const FIXED_PUBLIC_KEY = "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function installFixedPublisherKey(config: string): void { + const lab = join(config, "lab"); + mkdirSync(lab, { recursive: true, mode: 0o700 }); + const path = join(lab, "publisher-ed25519.pem"); + writeFileSync(path, FIXED_PRIVATE_KEY, { encoding: "utf8", mode: 0o600 }); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +function fixedRecord() { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const withoutRecordId = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: "2026-08-12", + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", withoutRecordId), ...withoutRecordId }; +} + +function fixedBundle(config: string) { + installFixedPublisherKey(config); + return signPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + configDir: config, + }); +} + +describe("CL-10 public wire contract", () => { + test("freezes the RFC 8785/domain-separated bundle and Ed25519 signature vector", () => { + const bundle = fixedBundle(configDir("ocx-cl10-wire-publisher-")); + + expect(bundle.publisher.publicKey).toBe(FIXED_PUBLIC_KEY); + expect(bundle.publisher.keyId).toBe("4d5a347afcc7a1ac8d2dd4e573f0fbca2d2e90dd472c35df5c72bf2d2afca08f"); + expect(bundle.records[0]!.subjectId).toBe("982a06b98a218df5ed68ae88f5f203e1911a3e875343c6ed8d5d0b74ff4c2b25"); + expect(bundle.records[0]!.recordId).toBe("5bec20821bbf01f831e74ba469e7f18481c1209fdef209c76f482105de3e406d"); + expect(bundle.bundleId).toBe("a7598b68a4cf884dc381b1d88111e74bfad5e74ceae2be8de55b88bac3250401"); + expect(bundle.bundleDigest).toBe("aeef2f3e64a131588f6a34aaea1172c352a0c803f838690c2d6ee652ca74fb87"); + expect(bundle.signature).toEqual({ + algorithm: "ed25519", + signedDigest: "aeef2f3e64a131588f6a34aaea1172c352a0c803f838690c2d6ee652ca74fb87", + signature: "UAiI7Mz4/yIU5XjSuNZFSuyFPoAvGCy+x9cpTCwYKnFDq20AP6ipV3zowD3S4KP2iYfkXyHTMsMH3CEnz6lCBw==", + }); + expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); + }); + + test("rejects non-canonical publisher public-key Base64", () => { + const publicKey = `${FIXED_PUBLIC_KEY}\n`; + const publisher = { + algorithm: "ed25519" as const, + publicKey, + keyId: publicEvidenceId("publisher_key", { algorithm: "ed25519", publicKey }), + }; + + expect(() => buildPublicEvidenceBundle({ + records: [fixedRecord()], + artifacts: [], + createdDayUtc: "2026-08-12", + publisher, + })).toThrow(/canonical base64/i); + }); + + test("rejects duplicate JSON object keys before community parsing", () => { + const publisherDir = configDir("ocx-cl10-wire-publisher-"); + const consumerDir = configDir("ocx-cl10-wire-consumer-"); + const bundle = fixedBundle(publisherDir); + const raw = JSON.stringify(bundle).replace( + '"schemaVersion":"public_evidence_bundle_v1"', + '"schemaVersion":"public_evidence_bundle_v1","schemaVersion":"public_evidence_bundle_v1"', + ); + + expect(() => importCommunityEvidenceBundle(raw, consumerDir)).toThrow(/duplicate json object key/i); + }); + + test("rejects public JSON deeper than the V1 import bound before JSON.parse materialization", () => { + const raw = Buffer.from(`${"[".repeat(9)}0${"]".repeat(9)}`, "utf8"); + expect(() => parseStrictPublicJson(raw)).toThrow(/nesting depth exceeds 8/i); + }); +}); diff --git a/tests/settings-startup-health-seam.test.ts b/tests/settings-startup-health-seam.test.ts new file mode 100644 index 000000000..657e49aed --- /dev/null +++ b/tests/settings-startup-health-seam.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { handleManagementAPI, type ManagementApiDeps } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest as Request } from "./helpers/management-auth"; +import { startupHealthFixture } from "./helpers/startup-health"; + +function baseConfig(): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret-value", + defaultModel: "gpt-test", + }, + }, + }; +} + +test("settings PUT uses the injected startup-health reader", async () => { + const config = baseConfig(); + let reads = 0; + const expectedHealth = startupHealthFixture({ diagnosticStale: true }); + const deps: ManagementApiDeps = { + saveConfigPreservingClaudeCode: () => {}, + getCachedStartupHealth: async () => { + reads += 1; + return expectedHealth; + }, + }; + const req = new Request("http://127.0.0.1:10100/api/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ streamMode: "eager-relay" }), + }); + + const response = await handleManagementAPI(req, new URL(req.url), config, deps); + + expect(response?.status).toBe(200); + expect(reads).toBe(1); + expect(await response!.json()).toMatchObject({ + startupHealth: { diagnosticStale: true, status: "native" }, + }); +}); diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 1902a1f6c..e78987183 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -29,9 +29,13 @@ import { usageSummaryRetainedStoreSnapshot, } from "../src/server/management/usage-summary-cache"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { startupHealthFixture } from "./helpers/startup-health"; let TEST_DIR = ""; const previousHome = process.env.OPENCODEX_HOME; +const readTestStartupHealth: NonNullable = async () => ( + startupHealthFixture() +); function baseConfig(): OcxConfig { return { @@ -58,12 +62,17 @@ function putSettings( headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); - return handleManagementAPI(req, new URL(req.url), config, deps); + return handleManagementAPI(req, new URL(req.url), config, { + getCachedStartupHealth: readTestStartupHealth, + ...deps, + }); } function getSettings(config: OcxConfig): Promise { const req = new Request("http://127.0.0.1:10100/api/settings"); - return handleManagementAPI(req, new URL(req.url), config); + return handleManagementAPI(req, new URL(req.url), config, { + getCachedStartupHealth: readTestStartupHealth, + }); } beforeEach(() => { @@ -84,7 +93,7 @@ afterEach(() => { try { rmSync(TEST_DIR, { recursive: true, force: true }); } catch { - /* Windows may briefly lock while a background startup-health probe exits */ + /* Windows may briefly retain file handles during test cleanup */ } } }); From 665530715e349cf0196ce8b9e4b60d3fe718c4c3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:45:18 +0200 Subject: [PATCH 16/94] fix(lab): scope public purge test faults --- src/lab/public/purge-test-fault.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lab/public/purge-test-fault.ts b/src/lab/public/purge-test-fault.ts index 63319d47a..f046b7594 100644 --- a/src/lab/public/purge-test-fault.ts +++ b/src/lab/public/purge-test-fault.ts @@ -5,11 +5,15 @@ export type PublicEvidencePurgeFaultForTests = let purgeFaultForTests: PublicEvidencePurgeFaultForTests = null; -/** Internal deterministic fault seam. This module is intentionally not barrel-exported. */ +/** Arms the internal deterministic fault seam and returns a scoped restore handle. */ export function setPublicEvidencePurgeFaultForTests( fault: PublicEvidencePurgeFaultForTests, -): void { +): () => void { + const previous = purgeFaultForTests; purgeFaultForTests = fault; + return () => { + purgeFaultForTests = previous; + }; } export function publicEvidencePurgeFaultForTests(): PublicEvidencePurgeFaultForTests { From 5d5a2396334b7f00e89e8cf5a870a9437225ecf6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:47:26 +0200 Subject: [PATCH 17/94] test(lab): cover actionable CodeRabbit regressions --- .../lab-public-coderabbit-regressions.test.ts | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tests/lab-public-coderabbit-regressions.test.ts diff --git a/tests/lab-public-coderabbit-regressions.test.ts b/tests/lab-public-coderabbit-regressions.test.ts new file mode 100644 index 000000000..23b270db5 --- /dev/null +++ b/tests/lab-public-coderabbit-regressions.test.ts @@ -0,0 +1,224 @@ +import { afterEach, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleLabCommand } from "../src/cli/lab"; +import { purgeSensitiveEvidence } from "../src/lab/ledger/purge"; +import { + ensureLabDirs, + labCommunityDir, + labPublicOriginDir, +} from "../src/lab/paths"; +import { + createPublicEvidenceRevocation, + importCommunityEvidenceBundle, + listCommunityEvidence, + publicEvidenceId, + recordLocalPublicOrigin, + signPublicEvidenceBundle, + writePublicEvidenceBundle, + type PublicEvidenceRecordV1, +} from "../src/lab/public"; +import { + publicEvidenceMutationLockIsReclaimableForTests, + publicEvidenceTryReclaimMutationLockForTests, +} from "../src/lab/public/mutation-lock"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function configDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +function record(day = "2026-08-12"): PublicEvidenceRecordV1 { + const subject = { + subjectKind: "protocol" as const, + compatibilityVersion: "2.13.0", + adapterFamily: "openai-chat" as const, + inboundProtocol: "openai-responses", + upstreamProtocol: "openai-chat", + surface: "responses-http", + }; + const subjectId = publicEvidenceId("subject", subject); + const body = { + subjectId, + evidenceLayer: "protocol_conformance" as const, + suiteId: "responses-core", + suiteVersion: "1.0.0", + scenarioId: "responses-core.protocol.request-shape", + scenarioVersion: "1.0.0", + verdict: "VERIFIED" as const, + observedDayUtc: day, + subject, + assertions: [ + { id: "method", required: true, passed: true }, + { id: "message", required: true, passed: true }, + { id: "temperature", required: true, passed: true }, + ], + }; + return { recordId: publicEvidenceId("record", body), ...body }; +} + +function bundle(home: string, records = [record()]) { + return signPublicEvidenceBundle({ + records, + artifacts: [], + createdDayUtc: "2026-08-14", + configDir: home, + }); +} + +async function captureCli(argv: string[], home: string): Promise<{ code: number; stdout: string; stderr: string }> { + const stdout: string[] = []; + const stderr: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args: unknown[]) => { stdout.push(args.join(" ")); }; + console.error = (...args: unknown[]) => { stderr.push(args.join(" ")); }; + try { + return { + code: await handleLabCommand(argv, { configDir: home }), + stdout: stdout.join("\n"), + stderr: stderr.join("\n"), + }; + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +test("ancient live-PID mutation owner becomes reclaimable after the absolute ceiling", () => { + const home = configDir("ocx-cl10-lock-owner-ceiling-"); + ensureLabDirs(home); + const lockPath = join(labCommunityDir(home), ".mutation-lock"); + mkdirSync(lockPath, { mode: 0o700 }); + const now = Date.now(); + writeFileSync( + join(lockPath, "owner.json"), + JSON.stringify({ + pid: process.pid, + token: "00000000-0000-4000-8000-000000000000", + createdAt: now - (8 * 24 * 60 * 60 * 1000), + }), + { mode: 0o600 }, + ); + + expect(publicEvidenceMutationLockIsReclaimableForTests(home, now)).toBe(true); +}); + +test("ancient live-PID reclaim claim cannot block stale lock recovery forever", () => { + const home = configDir("ocx-cl10-lock-claim-ceiling-"); + ensureLabDirs(home); + const lockPath = join(labCommunityDir(home), ".mutation-lock"); + mkdirSync(lockPath, { mode: 0o700 }); + const now = Date.now(); + const old = now - (8 * 24 * 60 * 60 * 1000); + writeFileSync( + join(lockPath, "owner.json"), + JSON.stringify({ + pid: process.pid, + token: "00000000-0000-4000-8000-000000000000", + createdAt: old, + }), + { mode: 0o600 }, + ); + writeFileSync( + join(lockPath, ".reclaim.json"), + JSON.stringify({ + pid: process.pid, + token: "11111111-1111-4111-8111-111111111111", + createdAt: old, + }), + { mode: 0o600 }, + ); + + expect(publicEvidenceTryReclaimMutationLockForTests(home, now)).toBe(true); + expect(existsSync(lockPath)).toBe(false); +}); + +test("foreign origin-directory entries do not consume marker quota", () => { + const home = configDir("ocx-cl10-origin-foreign-quota-"); + ensureLabDirs(home); + const dir = labPublicOriginDir(home); + for (let index = 0; index < 1024; index += 1) { + writeFileSync(join(dir, `foreign-${String(index).padStart(4, "0")}`), "x", { mode: 0o600 }); + } + const identity = { + publisherKeyId: publicEvidenceId("publisher_key", { seed: "quota-publisher" }), + bundleId: publicEvidenceId("bundle", { seed: "quota-bundle" }), + }; + + expect(() => recordLocalPublicOrigin(identity, home)).not.toThrow(); + expect(existsSync(join(dir, `origin-${identity.publisherKeyId}-${identity.bundleId}.json`))).toBe(true); +}); + +test("corrupt origin classification makes export purge report incomplete instead of clean success", () => { + const home = configDir("ocx-cl10-origin-purge-incomplete-"); + const signed = bundle(home); + const exportPath = writePublicEvidenceBundle(signed, home); + importCommunityEvidenceBundle(signed, home); + const originDir = labPublicOriginDir(home); + const marker = readdirSync(originDir).find((name) => name.startsWith("origin-")); + if (!marker) throw new Error("expected local origin marker"); + writeFileSync(join(originDir, marker), "{", { mode: 0o600 }); + + expect(() => purgeSensitiveEvidence({ + configDir: home, + purgeActions: ["export"], + recordedAt: Date.UTC(2026, 7, 14, 19, 0, 0), + })).toThrow(/origin|classification|incomplete/i); + expect(existsSync(exportPath)).toBe(false); + expect(listCommunityEvidence(home).map((row) => row.bundleId)).toContain(signed.bundleId); +}); + +test("failed CLI verification is a state failure and never prints command usage", async () => { + const home = configDir("ocx-cl10-cli-verify-failure-"); + const signed = bundle(home); + const tampered = { ...signed, bundleDigest: "0".repeat(64) }; + const path = join(home, "tampered.json"); + writeFileSync(path, JSON.stringify(tampered), { mode: 0o600 }); + + const result = await captureCli(["public", "verify", "--file", path], home); + expect(result.code).toBe(1); + expect(result.stdout).toMatch(/digest_invalid/i); + expect(result.stderr).toMatch(/verification failed.*digest_invalid/i); + expect(result.stderr).not.toMatch(/Usage:/i); +}); + +test("revocation canonicalization does not depend on localeCompare", () => { + const home = configDir("ocx-cl10-revocation-order-"); + const first = record("2026-08-12"); + const second = record("2026-08-13"); + const signed = bundle(home, [first, second]); + const originalLocaleCompare = String.prototype.localeCompare; + String.prototype.localeCompare = function localeCompareForbidden(): number { + throw new Error("localeCompare must not participate in signed canonicalization"); + }; + try { + expect(() => createPublicEvidenceRevocation({ + configDir: home, + targetBundle: signed, + issuedDayUtc: "2026-08-14", + reason: "superseded", + targets: [ + { kind: "record", id: second.recordId }, + { kind: "record", id: first.recordId }, + ], + })).not.toThrow(); + } finally { + String.prototype.localeCompare = originalLocaleCompare; + } +}); From 7a1e066ae3fd4ae9b93d6ea8e0d5403e4057a7c7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:52:46 +0200 Subject: [PATCH 18/94] fix(lab): bound mutation lock PID recovery --- src/lab/public/mutation-lock.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lab/public/mutation-lock.ts b/src/lab/public/mutation-lock.ts index 922bb6017..8b52012e2 100644 --- a/src/lab/public/mutation-lock.ts +++ b/src/lab/public/mutation-lock.ts @@ -17,6 +17,7 @@ const PUBLIC_EVIDENCE_MUTATION_LOCK_NAME = ".mutation-lock"; const PUBLIC_EVIDENCE_MUTATION_LOCK_OWNER = "owner.json"; const PUBLIC_EVIDENCE_MUTATION_LOCK_RECLAIM = ".reclaim.json"; const PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS = 24 * 60 * 60 * 1000; +const PUBLIC_EVIDENCE_MUTATION_LOCK_ABSOLUTE_STALE_MS = 7 * 24 * 60 * 60 * 1000; const DETACHED_MUTATION_LOCK_RE = /^\.mutation-lock-(?:stale|release)-\d+-[0-9a-f-]{36}$/; const MUTATION_LOCK_META_FILE_OPTIONS = { maxBytes: 1024, @@ -65,6 +66,10 @@ function pidDefinitelyDead(pid: number): boolean { } } +function exceedsAbsoluteLockAge(createdAt: number, nowMs: number): boolean { + return nowMs - createdAt > PUBLIC_EVIDENCE_MUTATION_LOCK_ABSOLUTE_STALE_MS; +} + function readLockMetadata( path: string, ): T | null { @@ -127,9 +132,9 @@ function mutationLockIsReclaimable(lockPath: string, nowMs: number): boolean { const stat = assertMutationLockDirectory(lockPath); const owner = readMutationLockOwner(lockPath); if (owner) { - // Never evict a recorded live owner based on age alone. Long operations or a - // suspended process must retain mutual exclusion until that process exits. - return pidDefinitelyDead(owner.pid); + // A live PID is strong evidence only while the recorded ownership generation is + // reasonably recent. The absolute ceiling recovers from PID reuse after a crash. + return pidDefinitelyDead(owner.pid) || exceedsAbsoluteLockAge(owner.createdAt, nowMs); } // The only ownerless state is the tiny mkdir-to-owner publication window. Use // a deliberately long fallback so a crashed acquisition can eventually heal @@ -201,7 +206,9 @@ function publishMutationLockOwner( function reclaimClaimIsRecoverable(lockPath: string, nowMs: number): boolean { const claimPath = mutationLockReclaimPath(lockPath); const claim = readMutationLockReclaim(lockPath); - if (claim) return pidDefinitelyDead(claim.pid); + if (claim) { + return pidDefinitelyDead(claim.pid) || exceedsAbsoluteLockAge(claim.createdAt, nowMs); + } try { const stat = lstatSync(claimPath); return nowMs - stat.mtimeMs > PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS; @@ -276,7 +283,11 @@ function tryReclaimMutationLock(lockPath: string, nowMs: number): boolean { if (!reclaimClaimStillOwned(lockPath, claim)) return false; if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), staleDirectory)) return false; const currentOwner = readMutationLockOwner(lockPath); - if (currentOwner && !pidDefinitelyDead(currentOwner.pid)) return false; + if ( + currentOwner + && !pidDefinitelyDead(currentOwner.pid) + && !exceedsAbsoluteLockAge(currentOwner.createdAt, nowMs) + ) return false; if (!reclaimClaimStillOwned(lockPath, claim)) return false; const quarantinePath = join( From 6c0db161c93ee9d713acb90deeaccaf5d68fc9cd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:53:10 +0200 Subject: [PATCH 19/94] fix(lab): align public origin quota accounting --- src/lab/public/origin.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/lab/public/origin.ts b/src/lab/public/origin.ts index 396cb1d98..c0d3b9f2d 100644 --- a/src/lab/public/origin.ts +++ b/src/lab/public/origin.ts @@ -71,9 +71,18 @@ function readOrigin(path: string, expected?: PublicOriginIdentityV1): PublicOrig return identity; } +/** Marker names only. Quota accounting, reclaim, and listing must agree on this set. */ function originNames(dir: string): string[] { cleanupStalePrivateFileStagesInDir(dir); - return readdirSync(dir).filter((name) => !isPrivateFileStageName(name)).sort(); + return readdirSync(dir) + .filter((name) => !isPrivateFileStageName(name) && ORIGIN_RE.test(name)) + .sort(); +} + +function foreignOriginNames(dir: string): string[] { + return readdirSync(dir) + .filter((name) => !isPrivateFileStageName(name) && !ORIGIN_RE.test(name)) + .sort(); } function pathExistsConservatively(path: string): boolean { @@ -108,8 +117,7 @@ function reclaimUnreferencedOrigins( configDir?: string, ): void { for (const name of originNames(dir)) { - const match = ORIGIN_RE.exec(name); - if (!match) continue; + const match = ORIGIN_RE.exec(name)!; const path = join(dir, name); if (path === preservePath) continue; const identity = { publisherKeyId: match[1]!, bundleId: match[2]! }; @@ -170,12 +178,12 @@ export function listLocalPublicOrigins(configDir?: string): PublicOriginIdentity if (names.length > MAX_ORIGINS) { throw new PublicEvidenceValidationError("public_origin_bound", "public origin marker bound exceeded"); } + if (foreignOriginNames(dir).length > 0) { + throw new PublicEvidenceValidationError("public_origin_unsafe", "unexpected public origin marker entry"); + } const identities: PublicOriginIdentityV1[] = []; for (const name of names) { - const match = ORIGIN_RE.exec(name); - if (!match) { - throw new PublicEvidenceValidationError("public_origin_unsafe", "unexpected public origin marker entry"); - } + const match = ORIGIN_RE.exec(name)!; const expected = { publisherKeyId: match[1]!, bundleId: match[2]! }; identities.push(readOrigin(join(dir, name), expected)); } From 73d2cc237b8fc3a83a913e06ef916bcf0ac6a978 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:53:36 +0200 Subject: [PATCH 20/94] fix(lab): report incomplete purge provenance --- src/lab/public/origin-purge.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/lab/public/origin-purge.ts b/src/lab/public/origin-purge.ts index 5ed6013d8..57e6919b8 100644 --- a/src/lab/public/origin-purge.ts +++ b/src/lab/public/origin-purge.ts @@ -13,6 +13,11 @@ export interface PurgeOriginIdentity { bundleId: string; } +export interface PurgeOriginRecovery { + identities: PurgeOriginIdentity[]; + skipped: number; +} + /** * Purge must salvage each provenance marker independently. A corrupt marker is untrusted * and skipped, but it cannot hide later valid markers that are needed to classify local @@ -20,7 +25,7 @@ export interface PurgeOriginIdentity { * 1024-marker quota is deliberately not a read cutoff here: recovery must inspect every * valid-format marker present after a race/crash instead of silently losing provenance. */ -export function listValidPublicOriginsForPurge(configDir?: string): PurgeOriginIdentity[] { +export function recoverPublicOriginsForPurge(configDir?: string): PurgeOriginRecovery { ensureLabDirs(configDir); const dir = labPublicOriginDir(configDir); cleanupStalePrivateFileStagesInDir(dir); @@ -28,6 +33,7 @@ export function listValidPublicOriginsForPurge(configDir?: string): PurgeOriginI .filter((name) => !isPrivateFileStageName(name) && ORIGIN_RE.test(name)) .sort(); const identities: PurgeOriginIdentity[] = []; + let skipped = 0; for (const name of names) { const match = ORIGIN_RE.exec(name)!; @@ -45,18 +51,29 @@ export function listValidPublicOriginsForPurge(configDir?: string): PurgeOriginI "public origin marker during purge", "public_origin_json", ); - if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + skipped += 1; + continue; + } const row = raw as Record; if ( Object.keys(row).sort().join(",") !== "bundleId,publisherKeyId,schemaVersion" || row.schemaVersion !== "public_origin_v1" || row.publisherKeyId !== expected.publisherKeyId || row.bundleId !== expected.bundleId - ) continue; + ) { + skipped += 1; + continue; + } identities.push(expected); } catch { + skipped += 1; // Salvage continues with the next marker. } } - return identities; + return { identities, skipped }; +} + +export function listValidPublicOriginsForPurge(configDir?: string): PurgeOriginIdentity[] { + return recoverPublicOriginsForPurge(configDir).identities; } From b5d35abca1f584a8041accd1401b92363135ddc5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:53:59 +0200 Subject: [PATCH 21/94] fix(lab): fail incomplete public purge classification --- src/lab/public/purge.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lab/public/purge.ts b/src/lab/public/purge.ts index 76facc4f6..01a063496 100644 --- a/src/lab/public/purge.ts +++ b/src/lab/public/purge.ts @@ -24,10 +24,11 @@ import { readPrivateRegularFile } from "./file-safety"; import { publicEvidenceId } from "./ids"; import { withPublicEvidenceMutationLock } from "./mutation-lock"; import { clearLocalPublicOrigins } from "./origin"; -import { listValidPublicOriginsForPurge } from "./origin-purge"; +import { recoverPublicOriginsForPurge } from "./origin-purge"; import { publicEvidencePurgeFaultForTests } from "./purge-test-fault"; import { readPublicEvidenceBundle } from "./storage"; import { parseStrictPublicJson } from "./strict-json"; +import { PublicEvidenceValidationError } from "./validate"; const MAX_PRIVATE_KEY_BYTES = 8 * 1024; const MAX_COMMUNITY_OBJECT_BYTES = 2 * 1024 * 1024; @@ -152,8 +153,9 @@ function purgeLocalPublicEvidenceCopiesLocked(configDir?: string): { deletedCommunityRevocations: number; } { const exportedIdentities = localExportIdentities(configDir); + const originRecovery = recoverPublicOriginsForPurge(configDir); const localPublisherKeyIds = new Set(); - for (const origin of listValidPublicOriginsForPurge(configDir)) { + for (const origin of originRecovery.identities) { exportedIdentities.add(publicIdentity(origin.publisherKeyId, origin.bundleId)); localPublisherKeyIds.add(origin.publisherKeyId); } @@ -192,6 +194,15 @@ function purgeLocalPublicEvidenceCopiesLocked(configDir?: string): { // names to unlink. Re-sync the directory unconditionally before success. syncPurgeDirectory(communityDir, "community"); + if (originRecovery.skipped > 0) { + // Preserve provenance markers for operator recovery. Sensitive exports are already + // durably gone, but unknown community copies cannot be reported as fully purged. + throw new PublicEvidenceValidationError( + "public_origin_incomplete", + `public origin classification incomplete: ${originRecovery.skipped} marker(s) could not be validated`, + ); + } + // Markers are purge-owned public provenance only. Remove them last, then establish // deletion durability before the caller may record an export purge tombstone. clearLocalPublicOrigins(configDir); From 4c2f84f9e4cf4a93f2cd475611bff29f941e1f19 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:54:33 +0200 Subject: [PATCH 22/94] fix(lab): bound public verdict lookup and canonical ordering --- src/lab/public/operator.ts | 66 ++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/src/lab/public/operator.ts b/src/lab/public/operator.ts index 7567f0d8a..edcf15de8 100644 --- a/src/lab/public/operator.ts +++ b/src/lab/public/operator.ts @@ -56,7 +56,7 @@ export function projectPublicEvidence(input: ProjectPublicEvidenceInput): { recordInput.observation.completedAt, ); }); - records.sort((a, b) => a.recordId.localeCompare(b.recordId)); + records.sort((a, b) => a.recordId < b.recordId ? -1 : a.recordId > b.recordId ? 1 : 0); return { bundle: { schemaVersion: PUBLIC_EVIDENCE_BUNDLE_SCHEMA_VERSION, @@ -96,7 +96,7 @@ export interface LocalPublicExportV1 { export type PublicVerificationSummaryV1 = | { status: "cryptographically_valid"; bundleId: string; publisherKeyId: string; locallyVerified: false } - | { status: "schema_rejected" | "digest_invalid" | "signature_invalid"; locallyVerified: false; detail?: string }; + | { status: "schema_rejected" | "digest_invalid" | "signature_invalid"; locallyVerified: false }; function assertOperatorEventIds(eventIds: readonly string[]): Array<{ eventId: string; selectionIndex: number }> { if (eventIds.length === 0 || eventIds.length > MAX_OPERATOR_EVENTS) { @@ -147,30 +147,54 @@ function canonicalVerdictsForObservations( observations: readonly ObservationEvent[], configDir?: string, ): Map { - const pending = new Map(observations.map((observation) => [observation.eventId, observation] as const)); const verdictByEventId = new Map(); - let cursor: string | undefined; + const groups = new Map(); - while (pending.size > 0) { - const page = queryLabVerdicts({}, cursor, 200, configDir); - for (const row of page.items) { - for (const eventId of row.contributingEventIds) { - const observation = pending.get(eventId); - if (!observation) continue; - if ( - row.subjectId !== observation.subjectId - || row.evidenceLayer !== observation.evidenceLayer - || row.suiteId !== observation.suiteId - || row.suiteVersion !== observation.suiteVersion - ) { - continue; + for (const observation of observations) { + const key = `${observation.subjectId}\0${observation.evidenceLayer}\0${observation.suiteId}`; + const existing = groups.get(key); + if (existing) existing.observations.push(observation); + else groups.set(key, { + subjectId: observation.subjectId, + evidenceLayer: observation.evidenceLayer, + suiteId: observation.suiteId, + observations: [observation], + }); + } + + for (const group of groups.values()) { + const pending = new Map(group.observations.map((observation) => [observation.eventId, observation] as const)); + let cursor: string | undefined; + while (pending.size > 0) { + const page = queryLabVerdicts({ + subjectId: group.subjectId, + layer: group.evidenceLayer, + suiteId: group.suiteId, + }, cursor, 200, configDir); + for (const row of page.items) { + for (const eventId of row.contributingEventIds) { + const observation = pending.get(eventId); + if (!observation) continue; + if ( + row.subjectId !== observation.subjectId + || row.evidenceLayer !== observation.evidenceLayer + || row.suiteId !== observation.suiteId + || row.suiteVersion !== observation.suiteVersion + ) { + continue; + } + verdictByEventId.set(eventId, row.verdict); + pending.delete(eventId); } - verdictByEventId.set(eventId, row.verdict); - pending.delete(eventId); } + if (!page.hasMore || !page.nextCursor) break; + cursor = page.nextCursor; } - if (!page.hasMore || !page.nextCursor) break; - cursor = page.nextCursor; } return verdictByEventId; From cda04fe356ffd490afdd33efd5fdd9c475e49800 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:54:59 +0200 Subject: [PATCH 23/94] fix(lab): make revocation ordering locale independent --- src/lab/public/revocation.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lab/public/revocation.ts b/src/lab/public/revocation.ts index e3146566f..58fdd0efb 100644 --- a/src/lab/public/revocation.ts +++ b/src/lab/public/revocation.ts @@ -47,6 +47,12 @@ function targetKey(target: PublicRevocationTargetV1): string { return `${target.kind}:${target.id}`; } +function compareCanonicalText(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + function canonicalTargets(targets: readonly PublicRevocationTargetV1[]): PublicRevocationTargetV1[] { if (targets.length === 0 || targets.length > MAX_TARGETS) { throw new PublicEvidenceValidationError("revocation_targets", "revocation must contain 1..256 targets"); @@ -56,7 +62,7 @@ function canonicalTargets(targets: readonly PublicRevocationTargetV1[]): PublicR throw new PublicEvidenceValidationError("revocation_target", "invalid revocation target"); } return { kind: target.kind, id: target.id } as PublicRevocationTargetV1; - }).sort((a, b) => targetKey(a).localeCompare(targetKey(b))); + }).sort((a, b) => compareCanonicalText(targetKey(a), targetKey(b))); if (new Set(normalized.map(targetKey)).size !== normalized.length) { throw new PublicEvidenceValidationError("revocation_target_duplicate", "revocation targets must be unique"); } From 34d04455591144c06b0130d7fa7341e6bc790e94 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:56:47 +0200 Subject: [PATCH 24/94] fix(cli): classify public verification failures correctly --- src/cli/lab.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli/lab.ts b/src/cli/lab.ts index 2f968a47e..bb50958a4 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -275,7 +275,6 @@ function publicVerificationLines(result: PublicVerificationSummaryV1): string[] return [ `Public evidence verification: ${result.status}`, "Not locally verified.", - ...(result.detail ? [result.detail] : []), ]; } return [ @@ -315,7 +314,7 @@ function handlePublicLabCommand( const result = verifyPublicEvidenceFile(path); printData(result, wantsJson, publicVerificationLines(result)); if (result.status !== "cryptographically_valid") { - throw new Error(`public evidence verification failed: ${result.status}`); + throw new RuntimeApiError(`public evidence verification failed: ${result.status}`, 422, result); } return; } @@ -592,7 +591,7 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P throw new CliUsageError(`unknown lab subcommand: ${sub}`, USAGE); } } catch (err) { - if (err instanceof CliUsageError) throw err; + if (err instanceof CliUsageError || err instanceof RuntimeApiError) throw err; if (err instanceof LabProjectionUnavailableError || err instanceof LabProjectionIncompatibleError) { throw new LabStateError(labErrorMessage(err)); } From 620679889430ce700634ac92ee3b988b711d8e4c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:57:58 +0200 Subject: [PATCH 25/94] test(lab): exercise community revocation conflict path --- tests/lab-community-evidence.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/lab-community-evidence.test.ts b/tests/lab-community-evidence.test.ts index 1f94905c6..2e72aa561 100644 --- a/tests/lab-community-evidence.test.ts +++ b/tests/lab-community-evidence.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -13,6 +13,7 @@ import { type ObservationEvent, type ProtocolSubjectV1, } from "../src/lab"; +import { labCommunityDir } from "../src/lab/paths"; import { buildPublicEvidenceBundle, createPublicEvidenceRevocation, @@ -173,7 +174,7 @@ describe("CL-10 community quarantine", () => { expect(listCommunityEvidence(consumerDir)[0]).toMatchObject({ activeRecordCount: 0, revokedRecordCount: 1 }); }); - test("rejects cross-key revocation and conflicting same-id bytes", () => { + test("rejects cross-key revocation and conflicting same-id stored bytes", () => { const publisherDir = configDir("ocx-cl10-publisher-"); const otherDir = configDir("ocx-cl10-other-"); const consumerDir = configDir("ocx-cl10-consumer-"); @@ -194,9 +195,9 @@ describe("CL-10 community quarantine", () => { reason: "publisher_retracted", targets: [{ kind: "bundle", id: bundle.bundleId }], }); - importCommunityEvidenceRevocation(revocation, consumerDir); - const conflict = { ...revocation, issuedDayUtc: "2026-08-13" }; - expect(() => importCommunityEvidenceRevocation(conflict, consumerDir)).toThrow(); + const conflictPath = join(labCommunityDir(consumerDir), `revocation-${revocation.revocationId}.json`); + writeFileSync(conflictPath, JSON.stringify({ conflicting: true }), { mode: 0o600 }); + expect(() => importCommunityEvidenceRevocation(revocation, consumerDir)).toThrow(/identity.*different bytes|conflict/i); }); test("sensitive export purge removes local exports and local community copies but preserves third-party bundles", () => { From 7983c7c7aaad206955221d2a9d869b2808961435 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:58:32 +0200 Subject: [PATCH 26/94] test(lab): make duplicate-key regression effective --- tests/lab-public-deep-review-regressions.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/lab-public-deep-review-regressions.test.ts b/tests/lab-public-deep-review-regressions.test.ts index 153324e03..d2cd112d6 100644 --- a/tests/lab-public-deep-review-regressions.test.ts +++ b/tests/lab-public-deep-review-regressions.test.ts @@ -227,15 +227,17 @@ describe("CL-10 deep-review trust regressions", () => { test("duplicate-key diagnostics are bounded and do not reflect attacker-controlled key contents", () => { const key = `SECRET-${"x".repeat(64 * 1024)}`; const raw = Buffer.from(`{${JSON.stringify(key)}:1,${JSON.stringify(key)}:2}`, "utf8"); + let failure: unknown; try { parseStrictPublicJson(raw); - throw new Error("expected duplicate-key rejection"); } catch (error) { - expect(error).toBeInstanceOf(Error); - const message = (error as Error).message; - expect(message.length).toBeLessThan(256); - expect(message).not.toContain("SECRET-"); + failure = error; } + expect(failure).toBeInstanceOf(Error); + const message = (failure as Error).message; + expect(message).toMatch(/duplicate json object key/i); + expect(message.length).toBeLessThan(256); + expect(message).not.toContain("SECRET-"); }); test("privacy scanner rejects unbracketed IPv6 literals", () => { From fe295bce7cb3a5be3adc79ad67d3daf292bdee98 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:59:14 +0200 Subject: [PATCH 27/94] test(lab): make private-key leak canary effective --- tests/lab-public-evidence.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/lab-public-evidence.test.ts b/tests/lab-public-evidence.test.ts index da4f67849..ec8302183 100644 --- a/tests/lab-public-evidence.test.ts +++ b/tests/lab-public-evidence.test.ts @@ -315,7 +315,12 @@ describe("CL-10 public bundle and publisher", () => { expect(verifyPublicEvidenceBundle(bundle)).toEqual({ status: "cryptographically_valid" }); const serialized = JSON.stringify(bundle); expect(serialized).not.toContain(handle.privateKeyPath); - expect(serialized).not.toContain(readFileSync(handle.privateKeyPath, "utf8").trim()); + const pemBodyLines = readFileSync(handle.privateKeyPath, "utf8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("-----")); + expect(pemBodyLines.length).toBeGreaterThan(0); + for (const line of pemBodyLines) expect(serialized).not.toContain(line); const badDigest = { ...bundle, bundleDigest: hex("tampered-bundle") }; expect(verifyPublicEvidenceBundle(badDigest)).toEqual({ status: "digest_invalid" }); From 99faa65d2effd1b1b6824d04bd2c54281e75315f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:59:32 +0200 Subject: [PATCH 28/94] test(lab): describe provenance failure accurately --- tests/lab-public-export-transaction.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/lab-public-export-transaction.test.ts b/tests/lab-public-export-transaction.test.ts index 9a7113d5e..4a99e341b 100644 --- a/tests/lab-public-export-transaction.test.ts +++ b/tests/lab-public-export-transaction.test.ts @@ -79,7 +79,7 @@ function seedProtocolProjection(home: string): string { return eventId; } -test("a provenance failure rolls back a newly-created local public export", () => { +test("a provenance failure prevents local public export publication", () => { const home = tempHome(); const eventId = seedProtocolProjection(home); const preview = previewLocalPublicEvidence({ eventIds: [eventId] }, home); @@ -98,6 +98,6 @@ test("a provenance failure rolls back a newly-created local public export", () = // A directory at the marker pathname makes the provenance commit fail closed. mkdirSync(originPath, { mode: 0o700 }); - expect(() => exportLocalPublicEvidence({ eventIds: [eventId] }, home)).toThrow(); + expect(() => exportLocalPublicEvidence({ eventIds: [eventId] }, home)).toThrow(/public origin marker/i); expect(existsSync(exportPath)).toBe(false); }); From 124d1e09b6c30af30b4c5e44e1899789153c2f5e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:59:51 +0200 Subject: [PATCH 29/94] test(lab): remove duplicate source-shape regressions --- ...ab-public-final-review-regressions.test.ts | 64 ++++++------------- 1 file changed, 18 insertions(+), 46 deletions(-) diff --git a/tests/lab-public-final-review-regressions.test.ts b/tests/lab-public-final-review-regressions.test.ts index 7c13cca64..7618cbe60 100644 --- a/tests/lab-public-final-review-regressions.test.ts +++ b/tests/lab-public-final-review-regressions.test.ts @@ -1,10 +1,9 @@ import { afterEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ensureLabDirs, labPublicOriginDir } from "../src/lab/paths"; import { - createPublicEvidenceRevocation, importCommunityEvidenceBundle, listLocalPublicOrigins, purgeLocalPublicEvidenceCopies, @@ -99,47 +98,20 @@ test("purge provenance salvage does not truncate valid markers at the operationa expect(listValidPublicOriginsForPurge(home)).toHaveLength(1025); }); -test("successful local export commits provenance before export storage", () => { - const source = readFileSync(new URL("../src/lab/public/operator.ts", import.meta.url), "utf8"); - const start = source.indexOf("export function exportLocalPublicEvidence"); - const end = source.indexOf("export function summarizePublicEvidenceVerification", start); - const block = source.slice(start, end); - const origin = block.indexOf("recordLocalPublicOrigin"); - const stored = block.indexOf("const stored = storePublicEvidenceBundle"); - - expect(origin).toBeGreaterThanOrEqual(0); - expect(stored).toBeGreaterThan(origin); -}); - -test("V1 revocation rejects targets that span multiple bundle anchors", () => { - const publisher = configDir("ocx-cl10-multibundle-rev-publisher-"); - const first = bundle(publisher, "2026-08-12"); - const second = bundle(publisher, "2026-08-13"); - - expect(() => createPublicEvidenceRevocation({ - configDir: publisher, - targetBundle: first, - issuedDayUtc: "2026-08-13", - reason: "superseded", - targets: [ - { kind: "bundle", id: first.bundleId }, - { kind: "bundle", id: second.bundleId }, - ], - })).toThrow(); -}); - -test("export purge keeps failing closed on retry until POSIX deletion durability is established", () => { - if (process.platform === "win32") return; - const home = configDir("ocx-cl10-export-delete-durability-"); - const own = bundle(home); - writePublicEvidenceBundle(own, home); - - setPublicEvidencePurgeFaultForTests("export_directory_sync"); - expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); - // The first attempt already removed the export pathname. A retry must still - // fsync the now-empty directory rather than reporting success without durability. - expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); - - setPublicEvidencePurgeFaultForTests(null); - expect(() => purgeLocalPublicEvidenceCopies(home)).not.toThrow(); -}); +test.skipIf(process.platform === "win32")( + "export purge keeps failing closed on retry until POSIX deletion durability is established", + () => { + const home = configDir("ocx-cl10-export-delete-durability-"); + const own = bundle(home); + writePublicEvidenceBundle(own, home); + + setPublicEvidencePurgeFaultForTests("export_directory_sync"); + expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); + // The first attempt already removed the export pathname. A retry must still + // fsync the now-empty directory rather than reporting success without durability. + expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); + + setPublicEvidencePurgeFaultForTests(null); + expect(() => purgeLocalPublicEvidenceCopies(home)).not.toThrow(); + }, +); From af73ffcb7efe8f6434b157535828bc193aefe732 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:00:22 +0200 Subject: [PATCH 30/94] test(lab): harden provenance recovery regressions --- tests/lab-public-provenance-recovery.test.ts | 59 ++++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/tests/lab-public-provenance-recovery.test.ts b/tests/lab-public-provenance-recovery.test.ts index c3241ab6b..fab186c4e 100644 --- a/tests/lab-public-provenance-recovery.test.ts +++ b/tests/lab-public-provenance-recovery.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { labPublicOriginDir } from "../src/lab/paths"; +import { labExportDir, labPublicOriginDir } from "../src/lab/paths"; import { createPublicEvidenceRevocation, importCommunityEvidenceBundle, @@ -84,9 +84,10 @@ test("one corrupt origin marker does not discard later valid purge provenance", expect(names).toHaveLength(2); writeFileSync(join(dir, names[0]!), "{", { mode: 0o600 }); const validName = names[1]!; - const validBundle = bundles.find((bundle) => validName.includes(bundle.bundleId))!; + const validBundle = bundles.find((bundle) => validName.includes(bundle.bundleId)); + if (!validBundle) throw new Error(`no bundle matches origin marker ${validName}`); - purgeLocalPublicEvidenceCopies(home); + expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/origin.*classification.*incomplete/i); expect(listCommunityEvidence(home).map((row) => row.bundleId)).not.toContain(validBundle.bundleId); }); @@ -138,34 +139,44 @@ test("failed own-origin commit rolls back a newly imported community copy", () = const home = configDir("ocx-cl10-origin-rollback-"); const bundle = signedBundle(home); const dir = labPublicOriginDir(home); + const exportDir = labExportDir(home); for (let index = 0; index < 1024; index += 1) { - writeFileSync(join(dir, `occupied-${String(index).padStart(4, "0")}`), "x", { mode: 0o600 }); + const publisherKeyId = publicEvidenceId("publisher_key", { seed: `occupied-publisher-${index}` }); + const bundleId = publicEvidenceId("bundle", { seed: `occupied-bundle-${index}` }); + writeFileSync( + join(dir, `origin-${publisherKeyId}-${bundleId}.json`), + JSON.stringify({ schemaVersion: "public_origin_v1", publisherKeyId, bundleId }), + { mode: 0o600 }, + ); + writeFileSync(join(exportDir, `${bundleId}.json`), "retained", { mode: 0o600 }); } expect(() => importCommunityEvidenceValue(bundle, home)).toThrow(/origin marker bound/i); expect(listCommunityEvidence(home)).toEqual([]); }); -test("origin and community persistence recover after same-process parent-directory sync failures", () => { - if (process.platform === "win32") return; - const home = configDir("ocx-cl10-origin-recovery-"); - const publisher = configDir("ocx-cl10-community-recovery-publisher-"); - const identity = { - publisherKeyId: publicEvidenceId("publisher_key", { seed: "recovery-publisher" }), - bundleId: publicEvidenceId("bundle", { seed: "recovery-bundle" }), - }; - - setPrivateFileCommitFaultForTests("parent_directory_sync"); - expect(() => recordLocalPublicOrigin(identity, home)).toThrow(); - setPrivateFileCommitFaultForTests(null); - expect(() => recordLocalPublicOrigin(identity, home)).not.toThrow(); - - const bundle = signedBundle(publisher); - setPrivateFileCommitFaultForTests("parent_directory_sync"); - expect(() => importCommunityEvidenceBundle(bundle, home)).toThrow(); - setPrivateFileCommitFaultForTests(null); - expect(importCommunityEvidenceBundle(bundle, home)).toMatchObject({ created: false, bundleId: bundle.bundleId }); -}); +test.skipIf(process.platform === "win32")( + "origin and community persistence recover after same-process parent-directory sync failures", + () => { + const home = configDir("ocx-cl10-origin-recovery-"); + const publisher = configDir("ocx-cl10-community-recovery-publisher-"); + const identity = { + publisherKeyId: publicEvidenceId("publisher_key", { seed: "recovery-publisher" }), + bundleId: publicEvidenceId("bundle", { seed: "recovery-bundle" }), + }; + + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => recordLocalPublicOrigin(identity, home)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(() => recordLocalPublicOrigin(identity, home)).not.toThrow(); + + const bundle = signedBundle(publisher); + setPrivateFileCommitFaultForTests("parent_directory_sync"); + expect(() => importCommunityEvidenceBundle(bundle, home)).toThrow(); + setPrivateFileCommitFaultForTests(null); + expect(importCommunityEvidenceBundle(bundle, home)).toMatchObject({ created: false, bundleId: bundle.bundleId }); + }, +); test("V1 revocations are bounded to one already-verified anchor bundle", () => { const publisher = configDir("ocx-cl10-revocation-anchor-"); From d5d7e59625ff764a67734ece12b503f7942f8616 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:01:46 +0200 Subject: [PATCH 31/94] test(lab): derive wire fixture key path --- tests/lab-public-wire-contract.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/lab-public-wire-contract.test.ts b/tests/lab-public-wire-contract.test.ts index 8b730dc89..7259c2849 100644 --- a/tests/lab-public-wire-contract.test.ts +++ b/tests/lab-public-wire-contract.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname } from "node:path"; +import { labPublicPublisherKeyPath } from "../src/lab/paths"; import { buildPublicEvidenceBundle, importCommunityEvidenceBundle, @@ -33,9 +34,8 @@ function configDir(prefix: string): string { } function installFixedPublisherKey(config: string): void { - const lab = join(config, "lab"); - mkdirSync(lab, { recursive: true, mode: 0o700 }); - const path = join(lab, "publisher-ed25519.pem"); + const path = labPublicPublisherKeyPath(config); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, FIXED_PRIVATE_KEY, { encoding: "utf8", mode: 0o600 }); if (process.platform !== "win32") chmodSync(path, 0o600); } From 67ed25e678a713c1a1d7800da7bc81bd2a5f14d6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:02:11 +0200 Subject: [PATCH 32/94] test(lab): keep temp path helper import --- tests/lab-public-wire-contract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lab-public-wire-contract.test.ts b/tests/lab-public-wire-contract.test.ts index 7259c2849..6ed82e826 100644 --- a/tests/lab-public-wire-contract.test.ts +++ b/tests/lab-public-wire-contract.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import { labPublicPublisherKeyPath } from "../src/lab/paths"; import { buildPublicEvidenceBundle, From 25b4c242481a804accd339b8fc0a76068a0572c6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:02:44 +0200 Subject: [PATCH 33/94] test(lab): isolate corrupt-origin purge provenance --- tests/lab-public-coderabbit-regressions.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/lab-public-coderabbit-regressions.test.ts b/tests/lab-public-coderabbit-regressions.test.ts index 23b270db5..354203752 100644 --- a/tests/lab-public-coderabbit-regressions.test.ts +++ b/tests/lab-public-coderabbit-regressions.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, readdirSync, rmSync, + unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,6 +16,7 @@ import { ensureLabDirs, labCommunityDir, labPublicOriginDir, + labPublicPublisherKeyPath, } from "../src/lab/paths"; import { createPublicEvidenceRevocation, @@ -174,6 +176,10 @@ test("corrupt origin classification makes export purge report incomplete instead const marker = readdirSync(originDir).find((name) => name.startsWith("origin-")); if (!marker) throw new Error("expected local origin marker"); writeFileSync(join(originDir, marker), "{", { mode: 0o600 }); + // Remove the two fallback provenance sources so the corrupt marker is the only + // evidence that can classify the matching community copy as locally originated. + writeFileSync(exportPath, "{", { mode: 0o600 }); + unlinkSync(labPublicPublisherKeyPath(home)); expect(() => purgeSensitiveEvidence({ configDir: home, From f887c2a56ca5f43d782b60ce44dbcf51098d22a8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:04:49 +0200 Subject: [PATCH 34/94] test(lab): align origin regressions with corrected semantics --- tests/lab-public-review-fixes.test.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/lab-public-review-fixes.test.ts b/tests/lab-public-review-fixes.test.ts index fced9d51e..8802d28a9 100644 --- a/tests/lab-public-review-fixes.test.ts +++ b/tests/lab-public-review-fixes.test.ts @@ -98,7 +98,7 @@ test("public barrel does not expose private test fault setters", () => { expect("setPublicEvidencePurgeFaultForTests" in publicApi).toBe(false); }); -test("public origin quota stays bounded when unreclaimable unexpected entries fill it", () => { +test("foreign origin entries do not consume marker quota but remain explicitly unsafe to list", () => { const home = configDir("ocx-cl10-origin-bound-"); ensureLabDirs(home); const dir = labPublicOriginDir(home); @@ -106,11 +106,13 @@ test("public origin quota stays bounded when unreclaimable unexpected entries fi writeFileSync(join(dir, `occupied-${String(index).padStart(4, "0")}`), "x", { mode: 0o600 }); } - expect(() => recordLocalPublicOrigin({ + const current = { publisherKeyId: hex("publisher-bound"), bundleId: hex("bundle-bound"), - }, home)).toThrow(/origin marker bound/i); - expect(readdirSync(dir)).toHaveLength(1024); + }; + expect(() => recordLocalPublicOrigin(current, home)).not.toThrow(); + expect(readdirSync(dir)).toHaveLength(1025); + expect(() => publicApi.listLocalPublicOrigins(home)).toThrow(/unexpected public origin marker entry/i); }); test("public origin pressure reclaims markers with no community copy", () => { @@ -134,7 +136,7 @@ test("public origin pressure reclaims markers with no community copy", () => { expect(names[0]).toBe(`origin-${current.publisherKeyId}-${current.bundleId}.json`); }); -test("corrupt origin provenance cannot retain mandatory local export bytes", () => { +test("corrupt origin provenance cannot retain mandatory local export bytes and reports incomplete classification", () => { const home = configDir("ocx-cl10-origin-corrupt-"); const bundle = signedBundle(home); writePublicEvidenceBundle(bundle, home); @@ -142,7 +144,14 @@ test("corrupt origin provenance cannot retain mandatory local export bytes", () const originEntry = readdirSync(labPublicOriginDir(home))[0]!; writeFileSync(join(labPublicOriginDir(home), originEntry), "{", { mode: 0o600 }); - expect(purgeLocalPublicEvidenceCopies(home).deletedExports).toBe(1); + let failure: unknown; + try { + purgeLocalPublicEvidenceCopies(home); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(PublicEvidenceValidationError); + expect((failure as PublicEvidenceValidationError).code).toBe("public_origin_incomplete"); expect(readdirSync(ensureLabDirs(home).exportDir)).toEqual([]); }); From 8e77cd6150afd1fbd79c64abe785b159ccafe914 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:05:42 +0200 Subject: [PATCH 35/94] test(lab): cover deferred export purge branches --- tests/lab-public-review-fixes.test.ts | 48 +++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/lab-public-review-fixes.test.ts b/tests/lab-public-review-fixes.test.ts index 8802d28a9..82c9c701c 100644 --- a/tests/lab-public-review-fixes.test.ts +++ b/tests/lab-public-review-fixes.test.ts @@ -195,7 +195,7 @@ test("failed export purge is omitted from the durable tombstone action set", () const paths = ensureLabDirs(home); writeFileSync(join(paths.scratchDir, "scratch.txt"), "scratch", { mode: 0o600 }); writeFileSync(join(paths.exportDir, "sensitive.txt"), "sensitive", { mode: 0o600 }); - setPublicEvidencePurgeFaultForTests("before_export_delete"); + const restoreFault = setPublicEvidencePurgeFaultForTests("before_export_delete"); let failure: unknown; try { @@ -207,7 +207,7 @@ test("failed export purge is omitted from the durable tombstone action set", () } catch (error) { failure = error; } finally { - setPublicEvidencePurgeFaultForTests(null); + restoreFault(); } expect(failure).toBeInstanceOf(Error); @@ -215,3 +215,47 @@ test("failed export purge is omitted from the durable tombstone action set", () expect(tombstones).toHaveLength(1); expect(tombstones[0]!.purgeActions).toEqual(["scratch"]); }); + +test("failed export plus ledger does not persist a targetless tombstone", () => { + const home = configDir("ocx-cl10-tombstone-export-ledger-"); + const paths = ensureLabDirs(home); + const restoreFault = setPublicEvidencePurgeFaultForTests("before_export_delete"); + let failure: unknown; + try { + purgeSensitiveEvidence({ + configDir: home, + purgeActions: ["export", "ledger"], + recordedAt: Date.UTC(2026, 7, 14, 6, 0, 0), + }); + } catch (error) { + failure = error; + } finally { + restoreFault(); + } + + expect(failure).toBeInstanceOf(Error); + expect(replayLabLedger(paths.ledgerPath).events).toEqual([]); +}); + +test("failed export plus sqlite still rebuilds projection from the unchanged ledger", () => { + const home = configDir("ocx-cl10-tombstone-export-sqlite-"); + const paths = ensureLabDirs(home); + expect(existsSync(paths.sqlitePath)).toBe(false); + const restoreFault = setPublicEvidencePurgeFaultForTests("before_export_delete"); + let failure: unknown; + try { + purgeSensitiveEvidence({ + configDir: home, + purgeActions: ["export", "sqlite"], + recordedAt: Date.UTC(2026, 7, 14, 6, 5, 0), + }); + } catch (error) { + failure = error; + } finally { + restoreFault(); + } + + expect(failure).toBeInstanceOf(Error); + expect(replayLabLedger(paths.ledgerPath).events).toEqual([]); + expect(existsSync(paths.sqlitePath)).toBe(true); +}); From 730aa0d6883a3a5935f3f03fcc59e5594ab78f39 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:11:12 +0200 Subject: [PATCH 36/94] fix CL-01 negative-control accounting --- src/lab/conformance/runner.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lab/conformance/runner.ts b/src/lab/conformance/runner.ts index 864f88726..a28c92430 100644 --- a/src/lab/conformance/runner.ts +++ b/src/lab/conformance/runner.ts @@ -1,7 +1,7 @@ import { discoverScenarios, loadCaseAuthority } from "./manifest"; import { runScenario } from "./executor"; import { buildNegativeControls } from "./negative-controls"; -import type { ScenarioRunResult } from "./types"; +import type { CaseRecord, ScenarioRunResult } from "./types"; import { CL01_SUITES } from "./types"; export interface ConformanceRunSummary { @@ -19,6 +19,8 @@ export interface NegativeControlRunSummary extends ConformanceRunSummary { rejected: number; } +type ScenarioRunner = (caseRecord: CaseRecord) => Promise; + export async function runConformanceSuite( suites: readonly string[] = CL01_SUITES, ): Promise { @@ -33,15 +35,21 @@ export async function runConformanceSuite( return { total: results.length, passed, failed: results.length - passed, results }; } -export async function runNegativeControls(): Promise { +export async function runNegativeControls( + execute: ScenarioRunner = runScenario, +): Promise { const authority = loadCaseAuthority(); const scenarios = buildNegativeControls(discoverScenarios(authority)); if (scenarios.length === 0) throw new Error("harness_failure: no negative controls discovered"); const results: ScenarioRunResult[] = []; for (const scenario of scenarios) { - results.push(await runScenario(scenario)); + results.push(await execute(scenario)); } - const rejected = results.filter((r) => !r.passed).length; + const rejected = results.filter((r) => ( + !r.passed + && r.classification === "protocol_failure" + && r.secondaryCode === "deterministic_assertion" + )).length; return { total: results.length, passed: rejected, From 3ecd63fa0b6df30f36d8550911e6a25163f80058 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:11:23 +0200 Subject: [PATCH 37/94] test CL-01 harness failure isolation --- tests/lab-conformance-runner-failures.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/lab-conformance-runner-failures.test.ts diff --git a/tests/lab-conformance-runner-failures.test.ts b/tests/lab-conformance-runner-failures.test.ts new file mode 100644 index 000000000..52414b6ed --- /dev/null +++ b/tests/lab-conformance-runner-failures.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { runScenario } from "../src/lab/conformance/executor"; +import { NEGATIVE_CONTROL_FIXTURES } from "../src/lab/conformance/negative-controls"; +import { runNegativeControls } from "../src/lab/conformance/runner"; + +describe("CL-01 negative-control failure accounting", () => { + test("does not count harness failures as rejected negative controls", async () => { + let injected = false; + const summary = await runNegativeControls(async (scenario) => { + const result = await runScenario(scenario); + if (injected) return result; + injected = true; + return { + ...result, + passed: false, + classification: "harness_failure", + secondaryCode: "execution_error", + assertionResults: [], + diagnostics: ["synthetic harness failure"], + }; + }); + + expect(summary.total).toBe(NEGATIVE_CONTROL_FIXTURES.length); + expect(summary.rejected).toBe(summary.total - 1); + expect(summary.passed).toBe(summary.rejected); + expect(summary.failed).toBe(1); + expect(summary.results.some((result) => result.classification === "harness_failure")).toBe(true); + }, 120000); + + test("counts deterministic protocol failures as rejected negative controls", async () => { + const summary = await runNegativeControls(); + + expect(summary.total).toBe(NEGATIVE_CONTROL_FIXTURES.length); + expect(summary.rejected).toBe(summary.total); + expect(summary.failed).toBe(0); + for (const result of summary.results) { + expect(result.passed).toBe(false); + expect(result.classification).toBe("protocol_failure"); + expect(result.secondaryCode).toBe("deterministic_assertion"); + } + }, 120000); +}); From d3886c1e45c7c84bf684f849617783615ccd0fc9 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:13:39 +0200 Subject: [PATCH 38/94] refactor(lab): share community bundle filename contract --- src/lab/public/community.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lab/public/community.ts b/src/lab/public/community.ts index d71c70ef0..4a6d0d6b1 100644 --- a/src/lab/public/community.ts +++ b/src/lab/public/community.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { jcsStringify } from "../digest"; import { ensureLabDirs, labCommunityDir } from "../paths"; import { validateCommunityEvidenceAuthorities } from "./community-authority"; +import { communityBundleFileName } from "./community-files"; import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety"; import { withPublicEvidenceMutationLock } from "./mutation-lock"; import { recordLocalPublicOrigin } from "./origin"; @@ -127,7 +128,10 @@ function verifiedBundle(raw: unknown): PublicEvidenceBundleV1 { } function bundleObjectPath(publisherKeyId: string, bundleId: string, configDir?: string): string { - return join(labCommunityDir(configDir), `bundle-${assertId(publisherKeyId)}-${assertId(bundleId)}.json`); + return join( + labCommunityDir(configDir), + communityBundleFileName(assertId(publisherKeyId), assertId(bundleId)), + ); } function revocationObjectPath(revocationId: string, configDir?: string): string { From fa7846a835aad47d9033aa7431e983908a7343bc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:15:06 +0200 Subject: [PATCH 39/94] docs(lab): fix sensitive purge heading level --- .../260807_compatibility_lab/010_cl10_public_evidence_export.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md b/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md index 1f54b53e5..2aadba896 100644 --- a/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md +++ b/devlog/_plan/260807_compatibility_lab/010_cl10_public_evidence_export.md @@ -326,7 +326,7 @@ A publish action must require an explicit user action for the specific bundle. C Deleting local Lab data remains absolute locally. Public copies already distributed cannot be cryptographically erased, so revocation semantics are required separately. -### Sensitive purge interaction +## Sensitive purge interaction CL-00 sensitive purge remains authoritative over CL-10 local copies. A purge whose closed action set includes `export` must fail closed until every affected local export/staging copy is removed. CL-10 must additionally remove any locally-originated copy of an affected bundle that has been imported into the local `community/` cache. Third-party community bundles are unrelated to the local sensitive bytes and are not deleted merely because they contain the same public route identity. From de9b5ff46c1ef6414d8b84a4180f5079d9411c6f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:23:24 +0200 Subject: [PATCH 40/94] fix CL-01 negative-control execution identity --- src/lab/conformance/runner.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lab/conformance/runner.ts b/src/lab/conformance/runner.ts index a28c92430..cc8bdcc9e 100644 --- a/src/lab/conformance/runner.ts +++ b/src/lab/conformance/runner.ts @@ -1,6 +1,6 @@ import { discoverScenarios, loadCaseAuthority } from "./manifest"; import { runScenario } from "./executor"; -import { buildNegativeControls } from "./negative-controls"; +import { baseCaseForNegativeControl, buildNegativeControls } from "./negative-controls"; import type { CaseRecord, ScenarioRunResult } from "./types"; import { CL01_SUITES } from "./types"; @@ -43,7 +43,10 @@ export async function runNegativeControls( if (scenarios.length === 0) throw new Error("harness_failure: no negative controls discovered"); const results: ScenarioRunResult[] = []; for (const scenario of scenarios) { - results.push(await execute(scenario)); + const baseCase = baseCaseForNegativeControl(scenario.id, authority.cases); + const executionScenario = baseCase ? { ...scenario, id: baseCase.id } : scenario; + const result = await execute(executionScenario); + results.push({ ...result, scenarioId: scenario.id }); } const rejected = results.filter((r) => ( !r.passed From 28cf62aa97af1a3b0dbe8dcc33fb909702866023 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:28:00 +0200 Subject: [PATCH 41/94] test: report unexpected CL-01 negative-control classifications --- tests/lab-conformance-runner-failures.test.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/lab-conformance-runner-failures.test.ts b/tests/lab-conformance-runner-failures.test.ts index 52414b6ed..47fc707bd 100644 --- a/tests/lab-conformance-runner-failures.test.ts +++ b/tests/lab-conformance-runner-failures.test.ts @@ -3,6 +3,21 @@ import { runScenario } from "../src/lab/conformance/executor"; import { NEGATIVE_CONTROL_FIXTURES } from "../src/lab/conformance/negative-controls"; import { runNegativeControls } from "../src/lab/conformance/runner"; +function unexpectedNegativeControlResults( + results: Awaited>["results"], +): string[] { + return results + .filter((result) => ( + result.passed + || result.classification !== "protocol_failure" + || result.secondaryCode !== "deterministic_assertion" + )) + .map((result) => ( + `${result.scenarioId}: ${result.classification}/${result.secondaryCode ?? "none"}` + + (result.diagnostics.length > 0 ? ` ${result.diagnostics.join(";")}` : "") + )); +} + describe("CL-01 negative-control failure accounting", () => { test("does not count harness failures as rejected negative controls", async () => { let injected = false; @@ -24,19 +39,15 @@ describe("CL-01 negative-control failure accounting", () => { expect(summary.rejected).toBe(summary.total - 1); expect(summary.passed).toBe(summary.rejected); expect(summary.failed).toBe(1); - expect(summary.results.some((result) => result.classification === "harness_failure")).toBe(true); + expect(summary.results.filter((result) => result.classification === "harness_failure")).toHaveLength(1); }, 120000); test("counts deterministic protocol failures as rejected negative controls", async () => { const summary = await runNegativeControls(); expect(summary.total).toBe(NEGATIVE_CONTROL_FIXTURES.length); + expect(unexpectedNegativeControlResults(summary.results)).toEqual([]); expect(summary.rejected).toBe(summary.total); expect(summary.failed).toBe(0); - for (const result of summary.results) { - expect(result.passed).toBe(false); - expect(result.classification).toBe("protocol_failure"); - expect(result.secondaryCode).toBe("deterministic_assertion"); - } }, 120000); }); From 6d471f395b48a8fd1f9424177fda3a9f82b1a9f4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:30:04 +0200 Subject: [PATCH 42/94] test(lab): restore purge fault in finally --- .../lab-public-final-review-regressions.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/lab-public-final-review-regressions.test.ts b/tests/lab-public-final-review-regressions.test.ts index 7618cbe60..be3765700 100644 --- a/tests/lab-public-final-review-regressions.test.ts +++ b/tests/lab-public-final-review-regressions.test.ts @@ -105,13 +105,15 @@ test.skipIf(process.platform === "win32")( const own = bundle(home); writePublicEvidenceBundle(own, home); - setPublicEvidencePurgeFaultForTests("export_directory_sync"); - expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); - // The first attempt already removed the export pathname. A retry must still - // fsync the now-empty directory rather than reporting success without durability. - expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); - - setPublicEvidencePurgeFaultForTests(null); + const restoreFault = setPublicEvidencePurgeFaultForTests("export_directory_sync"); + try { + expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); + // The first attempt already removed the export pathname. A retry must still + // fsync the now-empty directory rather than reporting success without durability. + expect(() => purgeLocalPublicEvidenceCopies(home)).toThrow(/directory|sync|durab/i); + } finally { + restoreFault(); + } expect(() => purgeLocalPublicEvidenceCopies(home)).not.toThrow(); }, ); From d5ea3a383084e329e2cb03c9101af570caecc238 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:30:24 +0200 Subject: [PATCH 43/94] fix(gui): allow HTTP protocol literal in i18n lint --- gui/.eslint/i18n-allowlist.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/gui/.eslint/i18n-allowlist.ts b/gui/.eslint/i18n-allowlist.ts index 83974db5a..141b45180 100644 --- a/gui/.eslint/i18n-allowlist.ts +++ b/gui/.eslint/i18n-allowlist.ts @@ -46,6 +46,7 @@ const TECHNICAL_UNITS = new Set([ "Mo", "Mi", "Fr", + "HTTP", // IEC binary unit rendered next to a formatted number; a unit symbol, not UI prose. "GiB", ]); From 1c59fb18d307266548d376d2fdc9064bd954f01c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:33:08 +0200 Subject: [PATCH 44/94] fix CL-01 tool-result negative control --- src/lab/conformance/negative-controls.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lab/conformance/negative-controls.ts b/src/lab/conformance/negative-controls.ts index 66ab70f4e..3391e9669 100644 --- a/src/lab/conformance/negative-controls.ts +++ b/src/lab/conformance/negative-controls.ts @@ -50,11 +50,15 @@ export const NEGATIVE_CONTROL_FIXTURES: Array<{ }, { id: "negative.tool-result-order", - defect: "invalid tool-result ordering", + defect: "invalid tool-result correlation after chat-history repair", mutate: (c) => ({ ...c, id: "negative.tool-result-order", - assertions: [{ id: "result", operator: "tool_result_correlates", selector: "/upstream/requests", expected: { call: "/client/response/toolCalls/0/id", result: "/upstream/requests/1/json/messages/1/tool_call_id" }, required: true }], + // Chat history hardening closes the unmatched call with a synthetic result, then + // re-emits the orphan result behind a synthetic assistant call. Inspect the actual + // supplied result at the end of that repaired pair rather than the synthetic close, + // otherwise the negative control accidentally validates the repair and passes. + assertions: [{ id: "result", operator: "tool_result_correlates", selector: "/upstream/requests", expected: { call: "/client/response/toolCalls/0/id", result: "/upstream/requests/1/json/messages/3/tool_call_id" }, required: true }], fixture: { ...c.fixture, bytesUtf8: JSON.stringify({ From b69934cfa5787a69828bf94ee355fee2462901b4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:33:26 +0200 Subject: [PATCH 45/94] test(lab): cover ledger mutation locking --- tests/lab-ledger-mutation-lock.test.ts | 186 +++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/lab-ledger-mutation-lock.test.ts diff --git a/tests/lab-ledger-mutation-lock.test.ts b/tests/lab-ledger-mutation-lock.test.ts new file mode 100644 index 000000000..cc1044812 --- /dev/null +++ b/tests/lab-ledger-mutation-lock.test.ts @@ -0,0 +1,186 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + appendLabEvent, + appendLabEventIfAbsent, + assignEventId, + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + LAB_PRODUCER_VERSION, + purgeSensitiveEvidence, + replayLabLedger, +} from "../src/lab"; +import type { InvalidationEvent } from "../src/lab/events/types"; + +const HOMES: string[] = []; + +function tempHome(): string { + const dir = join(tmpdir(), `ocx-lab-ledger-lock-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + HOMES.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of HOMES.splice(0)) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +function hash(value: string): string { + return Bun.CryptoHasher.hash("sha256", value, "hex"); +} + +function invalidation(seed: string, recordedAt = 1_700_000_000_000): InvalidationEvent { + return assignEventId({ + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "invalidation" as const, + recordedAt, + producer: LAB_PRODUCER, + producerVersion: LAB_PRODUCER_VERSION, + targetEventIds: [hash(`target:${seed}`)], + reason: "manual_correction" as const, + }) as InvalidationEvent; +} + +async function waitForPath(path: string): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + if (existsSync(path)) return; + await Bun.sleep(10); + } + throw new Error(`timed out waiting for child marker ${path}`); +} + +async function waitForChild(child: ReturnType): Promise { + const result = await Promise.race([ + child.exited.then((exitCode) => ({ exitCode })), + Bun.sleep(5_000).then(() => null), + ]); + if (!result) { + child.kill(); + await child.exited; + throw new Error("timed out waiting for ledger-lock child"); + } + if (result.exitCode !== 0) { + const stderr = await new Response(child.stderr).text().catch(() => ""); + throw new Error(`ledger-lock child exited ${result.exitCode}: ${stderr}`); + } +} + +function spawnLiveLock( + ledgerPath: string, + readyPath: string, + releaseMarkerPath: string, +): ReturnType { + const lockPath = `${ledgerPath}.lock`; + const childSource = ` + import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; + import { dirname } from "node:path"; + mkdirSync(dirname(${JSON.stringify(lockPath)}), { recursive: true, mode: 0o700 }); + writeFileSync( + ${JSON.stringify(lockPath)}, + JSON.stringify({ pid: process.pid, createdAt: Date.now(), token: "live-holder" }), + { mode: 0o600 }, + ); + writeFileSync(${JSON.stringify(readyPath)}, "ready"); + Bun.sleepSync(250); + writeFileSync(${JSON.stringify(releaseMarkerPath)}, "releasing"); + unlinkSync(${JSON.stringify(lockPath)}); + `; + return Bun.spawn([process.execPath, "-e", childSource], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); +} + +test("appendLabEvent waits for the shared ledger mutation lock", async () => { + const home = tempHome(); + const ledgerPath = join(home, "lab", "compatibility.jsonl"); + const readyPath = join(home, "holder-ready"); + const releaseMarkerPath = join(home, "holder-releasing"); + const child = spawnLiveLock(ledgerPath, readyPath, releaseMarkerPath); + + try { + await waitForPath(readyPath); + const event = invalidation("append-lock"); + appendLabEvent(ledgerPath, event); + expect(existsSync(releaseMarkerPath)).toBe(true); + expect(replayLabLedger(ledgerPath).events.some((row) => row.eventId === event.eventId)).toBe(true); + } finally { + await waitForChild(child); + } +}); + +test("appendLabEventIfAbsent immediately recovers a lock owned by an exited process", async () => { + const home = tempHome(); + const ledgerPath = join(home, "lab", "compatibility.jsonl"); + const lockPath = `${ledgerPath}.lock`; + const readyPath = join(home, "dead-lock-written"); + mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 }); + + const childSource = ` + import { writeFileSync } from "node:fs"; + writeFileSync( + ${JSON.stringify(lockPath)}, + JSON.stringify({ pid: process.pid, createdAt: Date.now(), token: "dead-holder" }), + { mode: 0o600 }, + ); + writeFileSync(${JSON.stringify(readyPath)}, "ready"); + `; + const child = Bun.spawn([process.execPath, "-e", childSource], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + await waitForPath(readyPath); + await waitForChild(child); + + const event = invalidation("dead-lock"); + expect(appendLabEventIfAbsent(ledgerPath, event)).toBe(true); + expect(existsSync(lockPath)).toBe(false); + expect(replayLabLedger(ledgerPath).events.some((row) => row.eventId === event.eventId)).toBe(true); +}); + +test("sensitive purge waits for the ledger mutation lock before rewriting", async () => { + const home = tempHome(); + const ledgerPath = join(home, "lab", "compatibility.jsonl"); + const event = invalidation("purge-lock"); + appendLabEvent(ledgerPath, event); + + const readyPath = join(home, "purge-holder-ready"); + const releaseMarkerPath = join(home, "purge-holder-releasing"); + const child = spawnLiveLock(ledgerPath, readyPath, releaseMarkerPath); + + try { + await waitForPath(readyPath); + purgeSensitiveEvidence({ + configDir: home, + targetEventIds: [event.eventId], + targetArtifactDigests: [], + purgeActions: ["ledger"], + recordedAt: 1_700_000_000_100, + }); + expect(existsSync(releaseMarkerPath)).toBe(true); + const replay = replayLabLedger(ledgerPath); + expect(replay.events.some((row) => row.eventId === event.eventId)).toBe(false); + expect(replay.events.some((row) => row.eventKind === "purge_tombstone")).toBe(true); + } finally { + await waitForChild(child); + try { + unlinkSync(`${ledgerPath}.lock`); + } catch { + /* ignore */ + } + } +}); From 1ee2d51f39df526b7d7d33711773dd48315eaf6c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:34:09 +0200 Subject: [PATCH 46/94] fix(lab): serialize ledger mutations --- src/lab/ledger/store.ts | 54 +++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/lab/ledger/store.ts b/src/lab/ledger/store.ts index 14baf2c94..3c53756a8 100644 --- a/src/lab/ledger/store.ts +++ b/src/lab/ledger/store.ts @@ -25,6 +25,11 @@ export interface LedgerStore { replay(): ReplayResult; } +export interface LedgerMutationContext { + replay(): ReplayResult; + append(event: LabEvent): void; +} + const LEDGER_LOCK_STALE_MS = 60_000; const LEDGER_LOCK_WAIT_MS = 5_000; @@ -36,10 +41,7 @@ interface LedgerLockMeta { /** Block synchronously for the given duration (ledger lock retry only). */ function sleepSyncMs(ms: number): void { - const end = Date.now() + ms; - while (Date.now() < end) { - /* spin */ - } + Bun.sleepSync(ms); } /** Read pid, createdAt, and token metadata from a ledger lock file, if well-formed. */ @@ -83,8 +85,7 @@ function isLedgerLockStale(lockPath: string): boolean { return false; } } - if (isLockHolderAlive(meta.pid)) return false; - return Date.now() - meta.createdAt > LEDGER_LOCK_STALE_MS; + return !isLockHolderAlive(meta.pid); } /** Create a ledger lock file exclusively, recovering stale locks when needed. */ @@ -156,11 +157,10 @@ function withLedgerLock(ledgerPath: string, fn: () => T): T { } } -/** Durable append of one validated event as a single JSONL line + fsync. */ -export function appendLabEvent(ledgerPath: string, event: LabEvent): void { - const validated = validateLabEvent(event); +/** Durable append of one already-validated event as a single JSONL line + fsync. */ +function appendValidatedLabEvent(ledgerPath: string, event: LabEvent): void { mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 }); - const line = `${jcsStringify(validated)}\n`; + const line = `${jcsStringify(event)}\n`; const bytes = new TextEncoder().encode(line); const fd = openSync(ledgerPath, "a", 0o600); try { @@ -179,21 +179,43 @@ export function appendLabEvent(ledgerPath: string, event: LabEvent): void { } /** - * Append only when eventId is absent. Uses an exclusive lock file plus a - * process-local event-id index refreshed under the lock. + * Serialize a ledger read-modify-write transaction with all ordinary appends. + * The callback receives lock-aware replay and append operations so callers do + * not have to reacquire the non-reentrant lock. + */ +export function withLedgerMutation( + ledgerPath: string, + fn: (mutation: LedgerMutationContext) => T, +): T { + return withLedgerLock(ledgerPath, () => fn({ + replay: () => replayLabLedger(ledgerPath), + append: (event) => appendValidatedLabEvent(ledgerPath, validateLabEvent(event)), + })); +} + +/** Durable append of one validated event as a single JSONL line + fsync. */ +export function appendLabEvent(ledgerPath: string, event: LabEvent): void { + const validated = validateLabEvent(event); + withLedgerMutation(ledgerPath, (mutation) => { + mutation.append(validated); + }); +} + +/** + * Append only when eventId is absent. Uses an exclusive lock file and refreshes + * the event-id index under the same mutation lock used by every ledger writer. */ export function appendLabEventIfAbsent(ledgerPath: string, event: LabEvent): boolean { const validated = validateLabEvent(event); - return withLedgerLock(ledgerPath, () => { - // Refresh from disk under the lock so concurrent writers are visible. + return withLedgerMutation(ledgerPath, (mutation) => { const fresh = new Set(); if (existsSync(ledgerPath)) { - for (const row of replayLabLedger(ledgerPath).events) { + for (const row of mutation.replay().events) { fresh.add(row.eventId); } } if (fresh.has(validated.eventId)) return false; - appendLabEvent(ledgerPath, validated); + mutation.append(validated); return true; }); } From c0187effe468f51ce202f144bfb3328c64b6cbff Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:34:47 +0200 Subject: [PATCH 47/94] fix(lab): lock sensitive purge transactions --- src/lab/ledger/purge.ts | 137 ++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 63 deletions(-) diff --git a/src/lab/ledger/purge.ts b/src/lab/ledger/purge.ts index dd6b95853..6c770d087 100644 --- a/src/lab/ledger/purge.ts +++ b/src/lab/ledger/purge.ts @@ -18,7 +18,7 @@ import { expandSensitiveArtifactEventTargets, } from "./artifact-refs"; import { buildInvalidationIndex } from "./invalidation"; -import { appendLabEvent, replayLabLedger } from "./store"; +import { withLedgerMutation } from "./store"; import { ensureLabDirs } from "../paths"; import { rebuildLabProjection } from "../projection/rebuild"; import { jcsStringify } from "../digest"; @@ -153,73 +153,86 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto const targetArtifactDigests = [...(req.targetArtifactDigests ?? [])].sort(); const purgeActions = [...(req.purgeActions ?? PURGE_ACTIONS)].sort(); const explicitSensitive = new Set(targetArtifactDigests); - - const replay = replayLabLedger(paths.ledgerPath); - const index = buildInvalidationIndex(replay.events); - const removeIds = expandSensitiveArtifactEventTargets( - replay.events, - index, - new Set(targetEventIds), - explicitSensitive, - ); - - const tombstonePayload = { - schemaVersion: LAB_EVENT_SCHEMA_VERSION, - eventKind: "purge_tombstone" as const, - recordedAt: req.recordedAt ?? Date.now(), - producer: LAB_PRODUCER, - producerVersion: req.producerVersion ?? LAB_PRODUCER_VERSION, - targetEventIds: [...removeIds].sort(), - targetArtifactDigests, - reason: "sensitive_evidence" as const, - purgeActions, - }; - const tombstone = validateLabEvent(assignEventId(tombstonePayload)) as PurgeTombstoneEvent; - - const deletionPlan = purgeActions.includes("artifact") - ? artifactDeletionPlan(replay.events, index, removeIds, targetArtifactDigests) - : { deletable: [], retainedExplicit: [] }; - - if (deletionPlan.retainedExplicit.length > 0) { - throw new PurgeError( - "sensitive_bytes_retained", - `explicit sensitive artifacts remain required: ${deletionPlan.retainedExplicit.join(",")}`, - ); - } - - let dir: TrustedArtifactDir | null = null; const completed: string[] = []; + try { - if (purgeActions.includes("scratch")) { - purgeBoundedDirectory(paths.scratchDir); - completed.push("scratch"); - } - if (purgeActions.includes("export")) { - purgeBoundedDirectory(paths.exportDir); - completed.push("export"); - } + const tombstone = withLedgerMutation(paths.ledgerPath, (ledger) => { + // Replay and plan under the same lock as every append. Otherwise an event + // appended after this snapshot can be lost by the atomic rename or can + // start referencing an artifact after the deletion plan was calculated. + const replay = ledger.replay(); + const index = buildInvalidationIndex(replay.events); + const removeIds = expandSensitiveArtifactEventTargets( + replay.events, + index, + new Set(targetEventIds), + explicitSensitive, + ); - if (purgeActions.includes("artifact")) { - if (deletionPlan.deletable.length > 0) { - dir = openTrustedArtifactDir(paths.artifactsDir); - deleteArtifactsFailClosed(dir, deletionPlan.deletable); + const tombstonePayload = { + schemaVersion: LAB_EVENT_SCHEMA_VERSION, + eventKind: "purge_tombstone" as const, + recordedAt: req.recordedAt ?? Date.now(), + producer: LAB_PRODUCER, + producerVersion: req.producerVersion ?? LAB_PRODUCER_VERSION, + targetEventIds: [...removeIds].sort(), + targetArtifactDigests, + reason: "sensitive_evidence" as const, + purgeActions, + }; + const tombstone = validateLabEvent(assignEventId(tombstonePayload)) as PurgeTombstoneEvent; + + const deletionPlan = purgeActions.includes("artifact") + ? artifactDeletionPlan(replay.events, index, removeIds, targetArtifactDigests) + : { deletable: [], retainedExplicit: [] }; + + if (deletionPlan.retainedExplicit.length > 0) { + throw new PurgeError( + "sensitive_bytes_retained", + `explicit sensitive artifacts remain required: ${deletionPlan.retainedExplicit.join(",")}`, + ); } - completed.push("artifact"); - } - if (purgeActions.includes("ledger")) { - const kept: LabEvent[] = []; - for (const event of replay.events) { - if (removeIds.has(event.eventId)) continue; - kept.push(event); + if (purgeActions.includes("scratch")) { + purgeBoundedDirectory(paths.scratchDir); + completed.push("scratch"); } - kept.push(tombstone); - atomicRewriteLedger(paths.ledgerPath, kept); - } else { - appendLabEvent(paths.ledgerPath, tombstone); - } - completed.push("ledger"); + if (purgeActions.includes("export")) { + purgeBoundedDirectory(paths.exportDir); + completed.push("export"); + } + + let dir: TrustedArtifactDir | null = null; + try { + if (purgeActions.includes("artifact")) { + if (deletionPlan.deletable.length > 0) { + dir = openTrustedArtifactDir(paths.artifactsDir); + deleteArtifactsFailClosed(dir, deletionPlan.deletable); + } + completed.push("artifact"); + } + + if (purgeActions.includes("ledger")) { + const kept: LabEvent[] = []; + for (const event of replay.events) { + if (removeIds.has(event.eventId)) continue; + kept.push(event); + } + kept.push(tombstone); + atomicRewriteLedger(paths.ledgerPath, kept); + } else { + ledger.append(tombstone); + } + completed.push("ledger"); + } finally { + if (dir) closeTrustedArtifactDir(dir); + } + + return tombstone; + }); + // SQLite is disposable and rebuildLabProjection replays the canonical + // ledger again, so it does not need to extend the mutation lock duration. if (purgeActions.includes("sqlite")) { rebuildLabProjection(req.configDir); completed.push("sqlite"); @@ -235,7 +248,5 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto err instanceof Error ? err.message : String(err), completed, ); - } finally { - if (dir) closeTrustedArtifactDir(dir); } } From 164881edb1b13ac7d15511bb7b1fe5c07cf3b9fa Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:42:08 +0200 Subject: [PATCH 48/94] fix(lab): keep artifact publication in ledger transactions --- src/lab/ledger/store.ts | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/lab/ledger/store.ts b/src/lab/ledger/store.ts index 3c53756a8..812f9b972 100644 --- a/src/lab/ledger/store.ts +++ b/src/lab/ledger/store.ts @@ -28,6 +28,7 @@ export interface LedgerStore { export interface LedgerMutationContext { replay(): ReplayResult; append(event: LabEvent): void; + appendIfAbsent(event: LabEvent): boolean; } const LEDGER_LOCK_STALE_MS = 60_000; @@ -180,17 +181,25 @@ function appendValidatedLabEvent(ledgerPath: string, event: LabEvent): void { /** * Serialize a ledger read-modify-write transaction with all ordinary appends. - * The callback receives lock-aware replay and append operations so callers do - * not have to reacquire the non-reentrant lock. + * The callback receives lock-aware operations so callers that also publish + * artifacts can keep artifact writes and the corresponding event atomic with + * respect to sensitive purge. */ export function withLedgerMutation( ledgerPath: string, fn: (mutation: LedgerMutationContext) => T, ): T { - return withLedgerLock(ledgerPath, () => fn({ - replay: () => replayLabLedger(ledgerPath), - append: (event) => appendValidatedLabEvent(ledgerPath, validateLabEvent(event)), - })); + return withLedgerLock(ledgerPath, () => { + const replay = () => replayLabLedger(ledgerPath); + const append = (event: LabEvent) => appendValidatedLabEvent(ledgerPath, validateLabEvent(event)); + const appendIfAbsent = (event: LabEvent): boolean => { + const validated = validateLabEvent(event); + if (replay().events.some((row) => row.eventId === validated.eventId)) return false; + appendValidatedLabEvent(ledgerPath, validated); + return true; + }; + return fn({ replay, append, appendIfAbsent }); + }); } /** Durable append of one validated event as a single JSONL line + fsync. */ @@ -202,22 +211,12 @@ export function appendLabEvent(ledgerPath: string, event: LabEvent): void { } /** - * Append only when eventId is absent. Uses an exclusive lock file and refreshes - * the event-id index under the same mutation lock used by every ledger writer. + * Append only when eventId is absent. Uses the same mutation lock as every + * other ledger writer so the presence check and append are one transaction. */ export function appendLabEventIfAbsent(ledgerPath: string, event: LabEvent): boolean { const validated = validateLabEvent(event); - return withLedgerMutation(ledgerPath, (mutation) => { - const fresh = new Set(); - if (existsSync(ledgerPath)) { - for (const row of mutation.replay().events) { - fresh.add(row.eventId); - } - } - if (fresh.has(validated.eventId)) return false; - mutation.append(validated); - return true; - }); + return withLedgerMutation(ledgerPath, (mutation) => mutation.appendIfAbsent(validated)); } function processLine( From 6ec2dea92755d41bc2164524c1744a1b8eaa4150 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:43:02 +0200 Subject: [PATCH 49/94] fix(lab): lock live artifact persistence --- src/lab/observe/from-live.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lab/observe/from-live.ts b/src/lab/observe/from-live.ts index 857874ed4..08145d927 100644 --- a/src/lab/observe/from-live.ts +++ b/src/lab/observe/from-live.ts @@ -5,7 +5,7 @@ import { LAB_EVENT_SCHEMA_VERSION, LAB_PRODUCER, LAB_PRODUCER_VERSION, OBSERVATI import { fixtureDigest, scenarioManifestDigest, subjectIdForSubject, suiteManifestDigest } from "../digest"; import type { FailureRecordV1, ObservationEvent } from "../events/types"; import { assignEventId } from "../events/validate"; -import { appendLabEvent } from "../ledger/store"; +import { withLedgerMutation } from "../ledger/store"; import { ensureLabDirs } from "../paths"; import type { CaseAuthority, CaseRecord } from "../conformance/types"; import { trustedLiveResultRetryable } from "../live/executor"; @@ -106,6 +106,12 @@ export function observationFromLiveResult(result: LiveScenarioRunResult, caseRec export function persistLiveResult(result: LiveScenarioRunResult, caseRecord: CaseRecord, authority: CaseAuthority, opts: PersistLiveOptions = {}): PersistedLiveObservation { const paths = ensureLabDirs(opts.configDir); const ownsStore = !opts.artifactStore; const store = opts.artifactStore ?? createArtifactStore(paths.artifactsDir); - try { const { event } = observationFromLiveResult(result, caseRecord, authority, { ...opts, artifactStore: store }); appendLabEvent(paths.ledgerPath, event); return { event, ledgerPath: paths.ledgerPath }; } + try { + return withLedgerMutation(paths.ledgerPath, (ledger) => { + const { event } = observationFromLiveResult(result, caseRecord, authority, { ...opts, artifactStore: store }); + ledger.append(event); + return { event, ledgerPath: paths.ledgerPath }; + }); + } finally { if (ownsStore) store.close(); } } From b1c8e375923d987008b083b651038b0a4bec419a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:43:28 +0200 Subject: [PATCH 50/94] fix(lab): lock conformance artifact persistence --- src/lab/observe/from-conformance.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lab/observe/from-conformance.ts b/src/lab/observe/from-conformance.ts index ba353f305..486391e04 100644 --- a/src/lab/observe/from-conformance.ts +++ b/src/lab/observe/from-conformance.ts @@ -17,7 +17,7 @@ import { } from "../digest"; import type { ObservationEvent } from "../events/types"; import { assignEventId } from "../events/validate"; -import { appendLabEvent } from "../ledger/store"; +import { withLedgerMutation } from "../ledger/store"; import { ensureLabDirs } from "../paths"; import type { CaseAuthority, @@ -285,12 +285,14 @@ export function persistConformanceResult( const ownsStore = !opts.artifactStore; const store = opts.artifactStore ?? createArtifactStore(paths.artifactsDir); try { - const { event } = observationFromConformanceResult(result, caseRecord, authority, { - ...opts, - artifactStore: store, + return withLedgerMutation(paths.ledgerPath, (ledger) => { + const { event } = observationFromConformanceResult(result, caseRecord, authority, { + ...opts, + artifactStore: store, + }); + ledger.append(event); + return { event, ledgerPath: paths.ledgerPath }; }); - appendLabEvent(paths.ledgerPath, event); - return { event, ledgerPath: paths.ledgerPath }; } finally { if (ownsStore) store.close(); } From aba59bbe1d32036f90f8541d00302b6c8a1c2eb5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:44:11 +0200 Subject: [PATCH 51/94] fix(lab): lock fabric artifact persistence --- src/lab/fabric/observe.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lab/fabric/observe.ts b/src/lab/fabric/observe.ts index 7e93d13fd..dc843bad4 100644 --- a/src/lab/fabric/observe.ts +++ b/src/lab/fabric/observe.ts @@ -12,7 +12,7 @@ import { fixtureDigest, isSha256Hex, jcsStringify } from "../digest"; import type { ObservationEvent, RouteSubjectV1, TaskSubjectV1 } from "../events/types"; import { LabValidationError } from "../events/errors"; import { assignEventId, validateSubject } from "../events/validate"; -import { appendLabEventIfAbsent } from "../ledger/store"; +import { withLedgerMutation } from "../ledger/store"; import { ensureLabDirs } from "../paths"; import { FABRIC_EVIDENCE_LAYER, @@ -430,9 +430,11 @@ function persistFabricOutcome( const ownsStore = !opts.artifactStore; const store = opts.artifactStore ?? createArtifactStore(paths.artifactsDir); try { - const { event } = observationFromFabricOutcome(outcome, { ...opts, artifactStore: store }); - appendLabEventIfAbsent(paths.ledgerPath, event); - return { event, ledgerPath: paths.ledgerPath }; + return withLedgerMutation(paths.ledgerPath, (ledger) => { + const { event } = observationFromFabricOutcome(outcome, { ...opts, artifactStore: store }); + ledger.appendIfAbsent(event); + return { event, ledgerPath: paths.ledgerPath }; + }); } finally { if (ownsStore) store.close(); } From 52c92e80074a2cbf44c831d57fe24f67b7508ea5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:44:49 +0200 Subject: [PATCH 52/94] test(lab): cover atomic artifact publication --- tests/lab-ledger-mutation-lock.test.ts | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/lab-ledger-mutation-lock.test.ts b/tests/lab-ledger-mutation-lock.test.ts index cc1044812..a26a3f6be 100644 --- a/tests/lab-ledger-mutation-lock.test.ts +++ b/tests/lab-ledger-mutation-lock.test.ts @@ -6,12 +6,18 @@ import { appendLabEvent, appendLabEventIfAbsent, assignEventId, + createArtifactStore, LAB_EVENT_SCHEMA_VERSION, LAB_PRODUCER, LAB_PRODUCER_VERSION, + persistConformanceResult, purgeSensitiveEvidence, replayLabLedger, } from "../src/lab"; +import type { ArtifactStore } from "../src/lab/artifacts/store"; +import { discoverScenarios, loadCaseAuthority } from "../src/lab/conformance/manifest"; +import { resolveProtocolExecutionContext } from "../src/lab/conformance/executor"; +import type { CaseRecord } from "../src/lab/conformance/types"; import type { InvalidationEvent } from "../src/lab/events/types"; const HOMES: string[] = []; @@ -49,6 +55,26 @@ function invalidation(seed: string, recordedAt = 1_700_000_000_000): Invalidatio }) as InvalidationEvent; } +function syntheticPassResult(caseRecord: CaseRecord) { + return { + scenarioId: caseRecord.id, + suite: caseRecord.suite, + passed: true, + classification: "inconclusive" as const, + assertionResults: caseRecord.assertions.map((assertion) => ({ + id: assertion.id, + operator: assertion.operator, + required: assertion.required, + passed: true, + observedSummary: "ok", + })), + diagnostics: [], + executionContext: resolveProtocolExecutionContext(caseRecord), + startedAt: 999, + completedAt: 1000, + }; +} + async function waitForPath(path: string): Promise { for (let attempt = 0; attempt < 500; attempt += 1) { if (existsSync(path)) return; @@ -152,6 +178,35 @@ test("appendLabEventIfAbsent immediately recovers a lock owned by an exited proc expect(replayLabLedger(ledgerPath).events.some((row) => row.eventId === event.eventId)).toBe(true); }); +test("canonical persistence publishes artifacts while holding the ledger mutation lock", () => { + const home = tempHome(); + const ledgerPath = join(home, "lab", "compatibility.jsonl"); + const authority = loadCaseAuthority(); + const caseRecord = discoverScenarios(authority, ["responses-core"]).find( + (candidate) => candidate.id === "responses-core.protocol.request-shape", + )!; + const realStore = createArtifactStore(join(home, "lab", "artifacts")); + const guardedStore: ArtifactStore = { + ...realStore, + put(input) { + expect(existsSync(`${ledgerPath}.lock`)).toBe(true); + return realStore.put(input); + }, + }; + + try { + const { event } = persistConformanceResult( + syntheticPassResult(caseRecord), + caseRecord, + authority, + { configDir: home, recordedAt: 1000, artifactStore: guardedStore }, + ); + expect(replayLabLedger(ledgerPath).events.some((row) => row.eventId === event.eventId)).toBe(true); + } finally { + realStore.close(); + } +}); + test("sensitive purge waits for the ledger mutation lock before rewriting", async () => { const home = tempHome(); const ledgerPath = join(home, "lab", "compatibility.jsonl"); From 1fe1eb586fd41a37c88aeb75a13b9bb7ce08f79a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:49:29 +0200 Subject: [PATCH 53/94] fix(lab): make stale ledger recovery ownership-safe --- src/lab/ledger/store.ts | 161 ++++++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 41 deletions(-) diff --git a/src/lab/ledger/store.ts b/src/lab/ledger/store.ts index 812f9b972..da105ba53 100644 --- a/src/lab/ledger/store.ts +++ b/src/lab/ledger/store.ts @@ -89,6 +89,79 @@ function isLedgerLockStale(lockPath: string): boolean { return !isLockHolderAlive(meta.pid); } +/** Write lock ownership metadata to a newly created exclusive lock file. */ +function writeLedgerLockMeta(fd: number, token: string): void { + const metadataBytes = Buffer.from(JSON.stringify({ + pid: process.pid, + createdAt: Date.now(), + token, + }), "utf8"); + let written = 0; + while (written < metadataBytes.byteLength) { + const n = writeSync(fd, metadataBytes, written, metadataBytes.byteLength - written); + if (n <= 0) { + throw new LabValidationError("short_write", "ledger lock metadata write incomplete"); + } + written += n; + } +} + +/** Release a lock file only when the token still matches the path owner. */ +function releaseLedgerLock(lockPath: string, lockFd: number, token: string): void { + try { + closeSync(lockFd); + } catch { + /* ignore */ + } + try { + const meta = readLedgerLockMeta(lockPath); + if (meta?.token === token) unlinkSync(lockPath); + } catch { + /* best-effort */ + } +} + +/** + * Recover one stale lock while holding a separate recovery mutex. + * + * The recovery mutex prevents two waiters from both observing the same stale + * owner and then unlinking each other's replacement lock. If a process dies + * while holding the recovery mutex, acquisition fails closed instead of + * guessing ownership of that mutex. + */ +function recoverStaleLedgerLock(lockPath: string): boolean { + const recoveryPath = `${lockPath}.recovery`; + const token = randomBytes(16).toString("hex"); + let recoveryFd: number; + try { + recoveryFd = openSync( + recoveryPath, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, + 0o600, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + + try { + writeLedgerLockMeta(recoveryFd, token); + } catch (error) { + releaseLedgerLock(recoveryPath, recoveryFd, token); + throw error; + } + + try { + // Re-check after taking the recovery mutex. Another waiter may already + // have recovered the old lock and installed a live replacement. + if (!existsSync(lockPath) || !isLedgerLockStale(lockPath)) return false; + unlinkSync(lockPath); + return true; + } finally { + releaseLedgerLock(recoveryPath, recoveryFd, token); + } +} + /** Create a ledger lock file exclusively, recovering stale locks when needed. */ function tryAcquireLedgerLock(lockPath: string, deadline: number): { fd: number; token: string } { while (Date.now() < deadline) { @@ -99,30 +172,19 @@ function tryAcquireLedgerLock(lockPath: string, deadline: number): { fd: number; } catch (error) { if (existsSync(lockPath) && isLedgerLockStale(lockPath)) { try { - unlinkSync(lockPath); - } catch (unlinkError) { - if (Date.now() >= deadline) throw unlinkError; - sleepSyncMs(10); + if (recoverStaleLedgerLock(lockPath)) continue; + } catch (recoveryError) { + if (Date.now() >= deadline) throw recoveryError; } - continue; } if (Date.now() >= deadline) throw error; sleepSyncMs(10); continue; } try { - const metadataBytes = Buffer.from(JSON.stringify({ pid: process.pid, createdAt: Date.now(), token }), "utf8"); - const written = writeSync(fd, metadataBytes); - if (written !== metadataBytes.byteLength) { - throw new LabValidationError("short_write", "ledger lock metadata write incomplete"); - } + writeLedgerLockMeta(fd, token); } catch (error) { - closeSync(fd); - try { - unlinkSync(lockPath); - } catch { - /* best-effort */ - } + releaseLedgerLock(lockPath, fd, token); throw error; } return { fd, token }; @@ -130,21 +192,6 @@ function tryAcquireLedgerLock(lockPath: string, deadline: number): { fd: number; throw new Error("ledger lock acquisition timed out"); } -/** Release a ledger lock only when the token still matches the lock file. */ -function releaseLedgerLock(lockPath: string, lockFd: number, token: string): void { - try { - closeSync(lockFd); - } catch { - /* ignore */ - } - try { - const meta = readLedgerLockMeta(lockPath); - if (meta?.token === token) unlinkSync(lockPath); - } catch { - /* best-effort */ - } -} - /** Run a ledger mutation while holding the compatibility ledger lock file. */ function withLedgerLock(ledgerPath: string, fn: () => T): T { const lockPath = `${ledgerPath}.lock`; @@ -179,34 +226,67 @@ function appendValidatedLabEvent(ledgerPath: string, event: LabEvent): void { } } +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" && value !== null) || typeof value === "function" + ) && typeof (value as { then?: unknown }).then === "function"; +} + /** * Serialize a ledger read-modify-write transaction with all ordinary appends. - * The callback receives lock-aware operations so callers that also publish - * artifacts can keep artifact writes and the corresponding event atomic with - * respect to sensitive purge. + * The callback is intentionally synchronous. Mutation methods become invalid + * as soon as the callback returns, so an accidental async continuation cannot + * write after the lock has been released. */ export function withLedgerMutation( ledgerPath: string, fn: (mutation: LedgerMutationContext) => T, ): T { return withLedgerLock(ledgerPath, () => { - const replay = () => replayLabLedger(ledgerPath); - const append = (event: LabEvent) => appendValidatedLabEvent(ledgerPath, validateLabEvent(event)); + let active = true; + const requireActive = () => { + if (!active) { + throw new LabValidationError( + "inactive_ledger_mutation", + "ledger mutation context used after its lock was released", + ); + } + }; + const replay = () => { + requireActive(); + return replayLabLedger(ledgerPath); + }; + const append = (event: LabEvent) => { + requireActive(); + appendValidatedLabEvent(ledgerPath, validateLabEvent(event)); + }; const appendIfAbsent = (event: LabEvent): boolean => { + requireActive(); const validated = validateLabEvent(event); if (replay().events.some((row) => row.eventId === validated.eventId)) return false; appendValidatedLabEvent(ledgerPath, validated); return true; }; - return fn({ replay, append, appendIfAbsent }); + + try { + const result = fn({ replay, append, appendIfAbsent }); + if (isThenable(result)) { + throw new LabValidationError( + "async_ledger_mutation", + "ledger mutation callback must be synchronous", + ); + } + return result; + } finally { + active = false; + } }); } /** Durable append of one validated event as a single JSONL line + fsync. */ export function appendLabEvent(ledgerPath: string, event: LabEvent): void { - const validated = validateLabEvent(event); withLedgerMutation(ledgerPath, (mutation) => { - mutation.append(validated); + mutation.append(event); }); } @@ -215,8 +295,7 @@ export function appendLabEvent(ledgerPath: string, event: LabEvent): void { * other ledger writer so the presence check and append are one transaction. */ export function appendLabEventIfAbsent(ledgerPath: string, event: LabEvent): boolean { - const validated = validateLabEvent(event); - return withLedgerMutation(ledgerPath, (mutation) => mutation.appendIfAbsent(validated)); + return withLedgerMutation(ledgerPath, (mutation) => mutation.appendIfAbsent(event)); } function processLine( From 1df92c2c8f216e4b415f5c80fb165951ee9949cd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:50:04 +0200 Subject: [PATCH 54/94] test(lab): cover ledger lock lifecycle guards --- tests/lab-ledger-mutation-lock.test.ts | 33 +++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/lab-ledger-mutation-lock.test.ts b/tests/lab-ledger-mutation-lock.test.ts index a26a3f6be..15266ca5c 100644 --- a/tests/lab-ledger-mutation-lock.test.ts +++ b/tests/lab-ledger-mutation-lock.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { @@ -13,6 +13,7 @@ import { persistConformanceResult, purgeSensitiveEvidence, replayLabLedger, + withLedgerMutation, } from "../src/lab"; import type { ArtifactStore } from "../src/lab/artifacts/store"; import { discoverScenarios, loadCaseAuthority } from "../src/lab/conformance/manifest"; @@ -175,9 +176,33 @@ test("appendLabEventIfAbsent immediately recovers a lock owned by an exited proc const event = invalidation("dead-lock"); expect(appendLabEventIfAbsent(ledgerPath, event)).toBe(true); expect(existsSync(lockPath)).toBe(false); + expect(existsSync(`${lockPath}.recovery`)).toBe(false); expect(replayLabLedger(ledgerPath).events.some((row) => row.eventId === event.eventId)).toBe(true); }); +test("withLedgerMutation rejects async callbacks and invalidates their context", async () => { + const home = tempHome(); + const ledgerPath = join(home, "lab", "compatibility.jsonl"); + const event = invalidation("async-callback"); + let continuation: Promise | undefined; + let continuationError: unknown; + + expect(() => withLedgerMutation(ledgerPath, (mutation) => { + continuation = (async () => { + await Bun.sleep(1); + mutation.append(event); + })().catch((error) => { + continuationError = error; + }); + return continuation; + })).toThrow("ledger mutation callback must be synchronous"); + + await continuation; + expect(continuationError).toBeInstanceOf(Error); + expect((continuationError as Error).message).toContain("after its lock was released"); + expect(replayLabLedger(ledgerPath).events.some((row) => row.eventId === event.eventId)).toBe(false); +}); + test("canonical persistence publishes artifacts while holding the ledger mutation lock", () => { const home = tempHome(); const ledgerPath = join(home, "lab", "compatibility.jsonl"); @@ -230,12 +255,8 @@ test("sensitive purge waits for the ledger mutation lock before rewriting", asyn const replay = replayLabLedger(ledgerPath); expect(replay.events.some((row) => row.eventId === event.eventId)).toBe(false); expect(replay.events.some((row) => row.eventKind === "purge_tombstone")).toBe(true); + expect(existsSync(`${ledgerPath}.lock`)).toBe(false); } finally { await waitForChild(child); - try { - unlinkSync(`${ledgerPath}.lock`); - } catch { - /* ignore */ - } } }); From d3481306bf5b93dfb89ce670bc323f71821d18fd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:51:58 +0200 Subject: [PATCH 55/94] test(lab): assert persistence lock release --- tests/lab-ledger-mutation-lock.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lab-ledger-mutation-lock.test.ts b/tests/lab-ledger-mutation-lock.test.ts index 15266ca5c..d659ca4d7 100644 --- a/tests/lab-ledger-mutation-lock.test.ts +++ b/tests/lab-ledger-mutation-lock.test.ts @@ -227,6 +227,7 @@ test("canonical persistence publishes artifacts while holding the ledger mutatio { configDir: home, recordedAt: 1000, artifactStore: guardedStore }, ); expect(replayLabLedger(ledgerPath).events.some((row) => row.eventId === event.eventId)).toBe(true); + expect(existsSync(`${ledgerPath}.lock`)).toBe(false); } finally { realStore.close(); } From 981b868a93d196af35b9c6811437c2ba3a4b2324 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:59:36 +0200 Subject: [PATCH 56/94] fix(lab): clean up uninitialised ledger locks --- src/lab/ledger/store.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lab/ledger/store.ts b/src/lab/ledger/store.ts index da105ba53..cd1d37fc4 100644 --- a/src/lab/ledger/store.ts +++ b/src/lab/ledger/store.ts @@ -106,6 +106,20 @@ function writeLedgerLockMeta(fd: number, token: string): void { } } +/** Discard a lock whose exclusive creator failed before publishing ownership metadata. */ +function discardUninitialisedLedgerLock(lockPath: string, lockFd: number): void { + try { + closeSync(lockFd); + } catch { + /* ignore */ + } + try { + unlinkSync(lockPath); + } catch { + /* best-effort */ + } +} + /** Release a lock file only when the token still matches the path owner. */ function releaseLedgerLock(lockPath: string, lockFd: number, token: string): void { try { @@ -147,7 +161,7 @@ function recoverStaleLedgerLock(lockPath: string): boolean { try { writeLedgerLockMeta(recoveryFd, token); } catch (error) { - releaseLedgerLock(recoveryPath, recoveryFd, token); + discardUninitialisedLedgerLock(recoveryPath, recoveryFd); throw error; } @@ -184,7 +198,7 @@ function tryAcquireLedgerLock(lockPath: string, deadline: number): { fd: number; try { writeLedgerLockMeta(fd, token); } catch (error) { - releaseLedgerLock(lockPath, fd, token); + discardUninitialisedLedgerLock(lockPath, fd); throw error; } return { fd, token }; @@ -515,4 +529,4 @@ export function openLedgerStore(configDir?: string): LedgerStore { export function defaultLedgerPath(configDir?: string): string { return labLedgerPath(configDir); -} +} \ No newline at end of file From d9655f31bac884d92bfe769a1c44b47bea35b302 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:00:08 +0200 Subject: [PATCH 57/94] fix(lab): preserve live transport failure classes --- src/lib/lab-live-pinned-sender.ts | 21 +++-- src/lib/pinned-http.ts | 66 +++++++++++--- tests/lab-live-pinned-timeouts.test.ts | 117 +++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 tests/lab-live-pinned-timeouts.test.ts diff --git a/src/lib/lab-live-pinned-sender.ts b/src/lib/lab-live-pinned-sender.ts index d25966d3a..0c336511b 100644 --- a/src/lib/lab-live-pinned-sender.ts +++ b/src/lib/lab-live-pinned-sender.ts @@ -19,22 +19,33 @@ export function createLabAuthorizedPinnedSender( headers, maxBytes: limits.maxOutputBytes, connectTimeoutMs: limits.connectTimeoutMs, - idleTimeoutMs: Math.min(limits.firstByteTimeoutMs, limits.inactivityTimeoutMs), + firstByteTimeoutMs: limits.firstByteTimeoutMs, + inactivityTimeoutMs: limits.inactivityTimeoutMs, rejectUnauthorized: true, context: "Lab provider response", }; let response: Response; + let body: string; try { response = request.method === "POST" ? await pinnedHttpPost(url, pinned, request.body ?? "", signal, options) : await pinnedHttpGet(url, pinned, signal, options); + body = await response.text(); } catch (error) { - if (error instanceof PinnedHttpError && error.code === "connect_timeout") { - throw new TransportError("connect_timeout", "pinned provider connection timed out"); + if (error instanceof PinnedHttpError) { + switch (error.code) { + case "connect_timeout": + throw new TransportError("connect_timeout", "pinned provider connection timed out"); + case "first_byte_timeout": + throw new TransportError("first_byte_timeout", "pinned provider first byte timed out"); + case "inactivity_timeout": + throw new TransportError("inactivity_timeout", "pinned provider response stalled"); + case "output_byte_limit": + throw new TransportError("output_byte_limit", "pinned provider response exceeded byte budget"); + } } throw error; } - const body = await response.text(); const responseHeaders: Record = {}; for (const headerName of LAB_RESPONSE_HEADER_ALLOWLIST) { const value = response.headers.get(headerName); @@ -42,4 +53,4 @@ export function createLabAuthorizedPinnedSender( } return { status: response.status, headers: responseHeaders, body }; }; -} \ No newline at end of file +} diff --git a/src/lib/pinned-http.ts b/src/lib/pinned-http.ts index 247c94581..67ea84143 100644 --- a/src/lib/pinned-http.ts +++ b/src/lib/pinned-http.ts @@ -3,7 +3,11 @@ import https from "node:https"; export type PinnedAddress = { address: string; family: number }; -export type PinnedHttpErrorCode = "connect_timeout"; +export type PinnedHttpErrorCode = + | "connect_timeout" + | "first_byte_timeout" + | "inactivity_timeout" + | "output_byte_limit"; export class PinnedHttpError extends Error { override readonly name = "PinnedHttpError"; @@ -15,6 +19,11 @@ export interface PinnedHttpRequestOptions { maxBytes?: number; /** Optional deadline for establishing the TCP connection and, for HTTPS, completing TLS. */ connectTimeoutMs?: number; + /** Optional deadline from connection establishment until response headers arrive. */ + firstByteTimeoutMs?: number; + /** Optional maximum idle interval between response-body chunks. */ + inactivityTimeoutMs?: number; + /** @deprecated Use firstByteTimeoutMs and inactivityTimeoutMs. */ idleTimeoutMs?: number; rejectUnauthorized?: boolean; context?: string; @@ -37,7 +46,9 @@ function pinnedHttpRequest( } const context = options?.context ?? "request"; const connectTimeoutMs = options?.connectTimeoutMs; - const idleTimeoutMs = options?.idleTimeoutMs ?? 60_000; + const legacyIdleTimeoutMs = options?.idleTimeoutMs ?? 60_000; + const firstByteTimeoutMs = options?.firstByteTimeoutMs ?? legacyIdleTimeoutMs; + const inactivityTimeoutMs = options?.inactivityTimeoutMs ?? legacyIdleTimeoutMs; const maxBytes = options?.maxBytes; const headers = new Headers(options?.headers); headers.set("host", parsed.host); @@ -56,17 +67,30 @@ function pinnedHttpRequest( let settled = false; let req: ClientRequest | undefined; let connectTimer: ReturnType | undefined; + let firstByteTimer: ReturnType | undefined; const clearConnectTimer = () => { if (connectTimer !== undefined) clearTimeout(connectTimer); connectTimer = undefined; }; + const clearFirstByteTimer = () => { + if (firstByteTimer !== undefined) clearTimeout(firstByteTimer); + firstByteTimer = undefined; + }; const fail = (error: unknown) => { clearConnectTimer(); + clearFirstByteTimer(); try { req?.destroy(); } catch { /* ignore */ } if (settled) return; settled = true; reject(error instanceof Error ? error : new Error(String(error))); }; + const startFirstByteTimer = () => { + clearFirstByteTimer(); + firstByteTimer = setTimeout( + () => fail(new PinnedHttpError("first_byte_timeout", `${context} first byte timed out`)), + firstByteTimeoutMs, + ); + }; const requestOptions: RequestOptions & { servername?: string } = { protocol: parsed.protocol, hostname: parsed.hostname, @@ -101,6 +125,7 @@ function pinnedHttpRequest( const onResponse = (response: IncomingMessage) => { clearConnectTimer(); + clearFirstByteTimer(); const status = response.statusCode ?? 0; const responseHeaders = new Headers(); for (const [key, value] of Object.entries(response.headers)) { @@ -124,8 +149,8 @@ function pinnedHttpRequest( let received = 0; const stream = new ReadableStream({ start(controller) { - response.setTimeout(idleTimeoutMs, () => { - const error = new Error(`${context} stalled`); + response.setTimeout(inactivityTimeoutMs, () => { + const error = new PinnedHttpError("inactivity_timeout", `${context} stalled`); fail(error); try { controller.error(error); } catch { /* closed */ } }); @@ -133,7 +158,7 @@ function pinnedHttpRequest( const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; received += buffer.byteLength; if (maxBytes !== undefined && received > maxBytes) { - const error = new Error(`${context} exceeds ${maxBytes} byte cap`); + const error = new PinnedHttpError("output_byte_limit", `${context} exceeds ${maxBytes} byte cap`); fail(error); try { controller.error(error); } catch { /* closed */ } return; @@ -163,20 +188,37 @@ function pinnedHttpRequest( const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted")); signal?.addEventListener("abort", onAbort, { once: true }); req.on("socket", (socket) => { - if (!socket.connecting || connectTimeoutMs === undefined) return; const connectedEvent = parsed.protocol === "https:" ? "secureConnect" : "connect"; - connectTimer = setTimeout(() => fail(new PinnedHttpError("connect_timeout", `${context} connect timed out`)), connectTimeoutMs); - socket.once(connectedEvent, clearConnectTimer); - socket.once("error", clearConnectTimer); - socket.once("close", clearConnectTimer); + if (!socket.connecting) { + startFirstByteTimer(); + return; + } + if (connectTimeoutMs !== undefined) { + connectTimer = setTimeout( + () => fail(new PinnedHttpError("connect_timeout", `${context} connect timed out`)), + connectTimeoutMs, + ); + } + socket.once(connectedEvent, () => { + clearConnectTimer(); + startFirstByteTimer(); + }); + socket.once("error", () => { + clearConnectTimer(); + clearFirstByteTimer(); + }); + socket.once("close", () => { + clearConnectTimer(); + clearFirstByteTimer(); + }); }); - req.setTimeout(idleTimeoutMs, () => fail(new Error(`${context} timed out`))); req.on("error", error => { signal?.removeEventListener("abort", onAbort); fail(error); }); req.on("close", () => { clearConnectTimer(); + clearFirstByteTimer(); signal?.removeEventListener("abort", onAbort); }); req.end(body); @@ -208,4 +250,4 @@ export function pinnedHttpPost( options?: PinnedHttpRequestOptions, ): Promise { return pinnedHttpRequest(url, pinned, "POST", body, signal, options); -} \ No newline at end of file +} diff --git a/tests/lab-live-pinned-timeouts.test.ts b/tests/lab-live-pinned-timeouts.test.ts new file mode 100644 index 000000000..4e9a37bfb --- /dev/null +++ b/tests/lab-live-pinned-timeouts.test.ts @@ -0,0 +1,117 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { afterEach, describe, expect, test } from "bun:test"; +import { createLabAuthorizedPinnedSender } from "../src/lib/lab-live-pinned-sender"; +import type { LabCredentialLeaseV1, LabDestinationV1, LiveRunConfig } from "../src/lab/live/types"; + +const SERVERS: Server[] = []; + +afterEach(async () => { + for (const server of SERVERS.splice(0)) { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +async function listen(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(handler); + SERVERS.push(server); + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("loopback test server did not expose a TCP port")); + return; + } + resolve(address.port); + }); + }); +} + +const BASE_LIMITS: LiveRunConfig = { + totalTimeoutMs: 1_000, + connectTimeoutMs: 250, + firstByteTimeoutMs: 30, + inactivityTimeoutMs: 30, + maxRequests: 2, + maxInputBytes: 1024, + maxOutputBytes: 1024, + maxOutputTokens: 1024, + maxToolCalls: 8, + maxMemoryBytes: 64 * 1024 * 1024, + maxChildProcesses: 0, + maxArtifacts: 4, + perArtifactBytes: 64 * 1024, + aggregateArtifactBytes: 256 * 1024, +}; + +function destination(port: number): LabDestinationV1 { + return { + scheme: "http", + host: "lab-timeout.invalid", + port, + basePath: "", + sniHost: "lab-timeout.invalid", + addresses: [{ address: "127.0.0.1", family: 4 }], + privateNetwork: true, + fingerprint: "a".repeat(64), + }; +} + +async function send(port: number, limitOverrides: Partial = {}) { + const sender = createLabAuthorizedPinnedSender(() => ({})); + return await sender( + {} as LabCredentialLeaseV1, + destination(port), + { address: "127.0.0.1", family: 4 }, + { method: "POST", path: "/", body: "{}" }, + new AbortController().signal, + { ...BASE_LIMITS, ...limitOverrides }, + ); +} + +describe("CL-03 pinned live transport failure classification", () => { + test("preserves first-byte timeout as a transport timeout", async () => { + const port = await listen((_req, res) => { + setTimeout(() => { + if (res.destroyed) return; + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }, 150); + }); + + await expect(send(port, { firstByteTimeoutMs: 30, inactivityTimeoutMs: 250 })).rejects.toMatchObject({ + name: "TransportError", + code: "first_byte_timeout", + }); + }); + + test("preserves response inactivity as inactivity_timeout", async () => { + const port = await listen((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.write("{\"ok\":"); + setTimeout(() => { + if (!res.destroyed) res.end("true}"); + }, 150); + }); + + await expect(send(port, { firstByteTimeoutMs: 250, inactivityTimeoutMs: 30 })).rejects.toMatchObject({ + name: "TransportError", + code: "inactivity_timeout", + }); + }); + + test("preserves the output byte ceiling as output_byte_limit", async () => { + const port = await listen((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("x".repeat(128)); + }); + + await expect(send(port, { maxOutputBytes: 16 })).rejects.toMatchObject({ + name: "TransportError", + code: "output_byte_limit", + }); + }); +}); From ecdbbdadfc46815dea0c159fd97e373de2280cb8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:30:09 +0200 Subject: [PATCH 58/94] fix(lab): preserve legacy pinned HTTP idle timeout --- src/lib/pinned-http.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/pinned-http.ts b/src/lib/pinned-http.ts index 67ea84143..64b84efba 100644 --- a/src/lib/pinned-http.ts +++ b/src/lib/pinned-http.ts @@ -47,6 +47,8 @@ function pinnedHttpRequest( const context = options?.context ?? "request"; const connectTimeoutMs = options?.connectTimeoutMs; const legacyIdleTimeoutMs = options?.idleTimeoutMs ?? 60_000; + const usesLegacyIdleTimeout = options?.firstByteTimeoutMs === undefined + && options?.inactivityTimeoutMs === undefined; const firstByteTimeoutMs = options?.firstByteTimeoutMs ?? legacyIdleTimeoutMs; const inactivityTimeoutMs = options?.inactivityTimeoutMs ?? legacyIdleTimeoutMs; const maxBytes = options?.maxBytes; @@ -190,7 +192,7 @@ function pinnedHttpRequest( req.on("socket", (socket) => { const connectedEvent = parsed.protocol === "https:" ? "secureConnect" : "connect"; if (!socket.connecting) { - startFirstByteTimer(); + if (!usesLegacyIdleTimeout) startFirstByteTimer(); return; } if (connectTimeoutMs !== undefined) { @@ -201,7 +203,7 @@ function pinnedHttpRequest( } socket.once(connectedEvent, () => { clearConnectTimer(); - startFirstByteTimer(); + if (!usesLegacyIdleTimeout) startFirstByteTimer(); }); socket.once("error", () => { clearConnectTimer(); @@ -212,6 +214,9 @@ function pinnedHttpRequest( clearFirstByteTimer(); }); }); + if (usesLegacyIdleTimeout) { + req.setTimeout(legacyIdleTimeoutMs, () => fail(new Error(`${context} timed out`))); + } req.on("error", error => { signal?.removeEventListener("abort", onAbort); fail(error); From 8c1f49765aee8cf7bc4d20924537a9d7128b0741 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:15:30 +0200 Subject: [PATCH 59/94] fix(lab): validate management read filters --- src/server/management/lab-routes.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/server/management/lab-routes.ts b/src/server/management/lab-routes.ts index 5c0929e47..6286a2908 100644 --- a/src/server/management/lab-routes.ts +++ b/src/server/management/lab-routes.ts @@ -14,6 +14,7 @@ */ import { + ARTIFACT_CLASSES, EVIDENCE_LAYERS, EXECUTION_MODES, EVENT_KINDS, @@ -323,9 +324,10 @@ export async function handleLabRoutes(ctx: ManagementContext): Promise Date: Sat, 15 Aug 2026 00:15:41 +0200 Subject: [PATCH 60/94] test(lab): cover invalid management read filters --- tests/lab-read-filter-validation.test.ts | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/lab-read-filter-validation.test.ts diff --git a/tests/lab-read-filter-validation.test.ts b/tests/lab-read-filter-validation.test.ts new file mode 100644 index 000000000..48d93eaa0 --- /dev/null +++ b/tests/lab-read-filter-validation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import type { OcxConfig } from "../src/types"; +import { handleManagementAPI } from "../src/server/management-api"; +import { ManagementRequest } from "./helpers/management-auth"; + +const config = { providers: {} } as OcxConfig; + +async function apiGet(path: string): Promise { + const req = new ManagementRequest(`http://127.0.0.1${path}`, { method: "GET" }); + const response = await handleManagementAPI(req, new URL(req.url), config); + expect(response).not.toBeNull(); + return response!; +} + +describe("Compatibility Lab management read filter validation", () => { + test("rejects invalid excluded values instead of silently dropping the filter", async () => { + const response = await apiGet("/api/lab/events?excluded=maybe"); + expect(response.status).toBe(400); + const body = await response.json() as { error: { code: string } }; + expect(body.error.code).toBe("invalid_excluded"); + }); + + test("rejects unsupported artifact classes instead of querying with arbitrary values", async () => { + const response = await apiGet("/api/lab/artifacts?artifactClass=not-real"); + expect(response.status).toBe(400); + const body = await response.json() as { error: { code: string } }; + expect(body.error.code).toBe("invalid_artifact_class"); + }); +}); From c715534f22871e834df6a7b0812909ddca97ba72 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:18:22 +0200 Subject: [PATCH 61/94] fix(lab): propagate pinned body failures --- src/lib/pinned-http.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/lib/pinned-http.ts b/src/lib/pinned-http.ts index 64b84efba..97e8d91a9 100644 --- a/src/lib/pinned-http.ts +++ b/src/lib/pinned-http.ts @@ -151,28 +151,35 @@ function pinnedHttpRequest( let received = 0; const stream = new ReadableStream({ start(controller) { - response.setTimeout(inactivityTimeoutMs, () => { - const error = new PinnedHttpError("inactivity_timeout", `${context} stalled`); - fail(error); + let bodySettled = false; + const failBody = (error: Error) => { + if (bodySettled) return; + bodySettled = true; try { controller.error(error); } catch { /* closed */ } + try { response.destroy(); } catch { /* ignore */ } + try { req?.destroy(); } catch { /* ignore */ } + }; + + response.setTimeout(inactivityTimeoutMs, () => { + failBody(new PinnedHttpError("inactivity_timeout", `${context} stalled`)); }); response.on("data", (chunk: Buffer | string) => { + if (bodySettled) return; const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; received += buffer.byteLength; if (maxBytes !== undefined && received > maxBytes) { - const error = new PinnedHttpError("output_byte_limit", `${context} exceeds ${maxBytes} byte cap`); - fail(error); - try { controller.error(error); } catch { /* closed */ } + failBody(new PinnedHttpError("output_byte_limit", `${context} exceeds ${maxBytes} byte cap`)); return; } try { controller.enqueue(buffer); } catch { /* closed */ } }); response.on("end", () => { + if (bodySettled) return; + bodySettled = true; try { controller.close(); } catch { /* closed */ } }); response.on("error", (error: Error) => { - fail(error); - try { controller.error(error); } catch { /* closed */ } + failBody(error); }); }, cancel() { @@ -255,4 +262,4 @@ export function pinnedHttpPost( options?: PinnedHttpRequestOptions, ): Promise { return pinnedHttpRequest(url, pinned, "POST", body, signal, options); -} +} \ No newline at end of file From 6e230610223905d9eec6f45857758b5e13eaf373 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:42:52 +0200 Subject: [PATCH 62/94] test(lab): cover compatibility follow-up regressions --- gui/tests/compatibility-lab-followup.test.tsx | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 gui/tests/compatibility-lab-followup.test.tsx diff --git a/gui/tests/compatibility-lab-followup.test.tsx b/gui/tests/compatibility-lab-followup.test.tsx new file mode 100644 index 000000000..50faadc42 --- /dev/null +++ b/gui/tests/compatibility-lab-followup.test.tsx @@ -0,0 +1,181 @@ +/** @jsxImportSource react */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import CompatibilityMatrix from "../src/pages/CompatibilityMatrix"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +const originalFetch = globalThis.fetch; +let restoreGlobals: (() => void) | undefined; +let previousLanguageDescriptor: PropertyDescriptor | undefined; +let testWindow: Window; + +const API_BASE = "http://127.0.0.1:4096"; + +const STATUS_AVAILABLE = { + projectionAvailable: true, + subjectCount: 1, + verdictCount: 1, + observationCount: 0, + eventCount: 2, + builtAtMs: 1_700_000_000_000, +}; + +const SUBJECTS = { + subjects: [{ subjectId: "subject-alpha", subjectKind: "protocol" }], + hasMore: false, +}; + +const SUBJECT_DETAIL = { + subject: { + subjectKind: "protocol", + subjectSchemaVersion: 1, + inboundProtocol: "openai-chat", + }, +}; + +const EVENT_DETAIL = { + event: { + eventKind: "observation", + eventId: "e1", + recordedAt: 1_700_000_000_040, + producer: "lab", + producerVersion: "1", + subjectId: "subject-alpha", + evidenceLayer: "protocol_conformance", + suiteId: "responses-core", + outcome: "pass", + excluded: false, + exclusionReason: null, + }, +}; + +function verdictPage(eventIds: string[]) { + return { + verdicts: [{ + projectionKey: "k1", + subjectId: "subject-alpha", + evidenceLayer: "protocol_conformance", + suiteId: "responses-core", + suiteVersion: "1", + suiteManifestDigest: "digest-a", + projectionSpecVersion: "cl-02.v1", + verdict: "VERIFIED", + asOf: 1_700_000_000_100, + scenarioManifestDigests: [], + claimSourceDigest: null, + contributingEventIds: eventIds, + contradictingEventIds: [], + notes: [], + }], + hasMore: true, + nextCursor: "cursor-2", + }; +} + +type FetchOptions = { + failLoadMore?: boolean; + partialEvents?: boolean; +}; + +function installLabFetch(opts: FetchOptions = {}) { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/lab/status")) return Response.json(STATUS_AVAILABLE); + if (url.includes("/api/lab/verdicts")) { + if (url.includes("cursor=cursor-2")) { + if (opts.failLoadMore) return new Response("unavailable", { status: 503 }); + return Response.json({ verdicts: [], hasMore: false }); + } + return Response.json(verdictPage(opts.partialEvents ? ["e1", "missing-event"] : ["e1"])); + } + if (url.includes("/api/lab/subjects/subject-alpha")) return Response.json(SUBJECT_DETAIL); + if (url.includes("/api/lab/subjects")) return Response.json(SUBJECTS); + if (url.includes("/api/lab/observations")) return Response.json({ observations: [], hasMore: false }); + if (url.includes("/api/lab/events/e1")) return Response.json(EVENT_DETAIL); + if (url.includes("/api/lab/events/missing-event")) return new Response("gone", { status: 404 }); + if (url.includes("/api/lab/artifacts/")) return new Response("gone", { status: 404 }); + if (url.includes("/api/lab/production-signals")) return new Response("gone", { status: 404 }); + return new Response("{}", { status: 404 }); + }) as typeof fetch; +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + testWindow = new Window({ url: "http://localhost/#models/compatibility" }); + previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language"); + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" }); + const keys = ["document", "window", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = Object.fromEntries( + keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as Record<(typeof keys)[number], PropertyDescriptor | undefined>; + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + restoreGlobals = () => { + for (const key of keys) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + if (previousLanguageDescriptor) { + Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor); + } + }; +}); + +afterEach(() => { + restoreGlobals?.(); + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } +} + +async function renderMatrix(): Promise<{ root: Root; container: HTMLDivElement }> { + const { createRoot } = await import("react-dom/client"); + const container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + return { root, container }; +} + +test("load-more failures stay visible while the loaded rows remain retryable", async () => { + installLabFetch({ failLoadMore: true }); + const { root, container } = await renderMatrix(); + await waitFor(() => container.querySelector(".lab-load-more button") !== null); + const button = container.querySelector(".lab-load-more button") as HTMLButtonElement; + await act(async () => { button.click(); }); + await waitFor(() => container.querySelector(".notice-err")?.textContent?.includes("HTTP 503") ?? false); + expect(container.textContent).toContain("Verified"); + expect(container.querySelector(".lab-load-more button")).not.toBeNull(); + await act(async () => root.unmount()); +}); + +test("verdict detail reports when referenced evidence events are only partially available", async () => { + installLabFetch({ partialEvents: true }); + const { root, container } = await renderMatrix(); + await waitFor(() => container.querySelector('button[data-verdict-detail="k1"]') !== null); + const button = container.querySelector('button[data-verdict-detail="k1"]') as HTMLButtonElement; + await act(async () => { button.click(); }); + await waitFor(() => container.querySelector(".lab-detail-pane")?.textContent?.includes("Evidence events (1/2)") ?? false); + expect(container.querySelector(".lab-detail-pane")?.textContent).toContain("Evidence events (1/2)"); + await act(async () => root.unmount()); +}); From 5f59416cf1a588c8440ee676d6af5a2e04784e54 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:46:03 +0200 Subject: [PATCH 63/94] fix(lab): surface partial compatibility reads --- gui/src/pages/CompatibilityMatrix.tsx | 39 +++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/CompatibilityMatrix.tsx b/gui/src/pages/CompatibilityMatrix.tsx index 9fcac9b12..e6084d808 100644 --- a/gui/src/pages/CompatibilityMatrix.tsx +++ b/gui/src/pages/CompatibilityMatrix.tsx @@ -62,6 +62,12 @@ type ExtraVerdictPage = { hasMore: boolean; }; +type LoadMoreFailure = { + baseData: LabPageData; + queryKey: string; + message: string; +}; + function localizedFetchError(e: unknown, fallback: string): string { if (!(e instanceof Error)) return fallback; const msg = e.message; @@ -171,6 +177,11 @@ function DetailPane({ locale: Parameters[0]; onClose: () => void; }) { + const expectedEventCount = new Set([ + ...verdict.contributingEventIds, + ...verdict.contradictingEventIds, + ]).size; + return (
+ {visibleLoadMoreError && {visibleLoadMoreError}} {pageHasMore && (
+ +
+ {t("models.customFieldReasoning")} +
+ +
+ {customFormReasoning && ( +
+ {REASONING_EFFORT_LEVELS.map(effort => ( + + ))} +
+ )} +
@@ -1611,19 +1656,24 @@ export default function Models({ apiBase }: { apiBase: string }) { const ctxVal = customFormContextWindow ? Number(customFormContextWindow.replace(/[_,\s]/g, "")) : undefined; const contextWindow = ctxVal && ctxVal > 0 ? Math.floor(ctxVal) : undefined; if (customModalMode === "add") { + const reasoningEfforts = customFormReasoning ? customFormReasoningEfforts : undefined; void addCustomModel( customModalProvider, modelId, displayName || undefined, contextWindow, customFormModalities.length > 0 ? customFormModalities : undefined, + reasoningEfforts, ); } else { + // `null` clears a stored override back to "inherit from the provider row"; + // an explicit empty ladder stays stored as "no reasoning". void updateCustomModel(customModalId, { modelId, displayName, contextWindow: contextWindow ?? null, inputModalities: customFormModalities, + reasoningEfforts: customFormReasoning ? customFormReasoningEfforts : null, }); } }} diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index ab845a13c..a71069403 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -38,8 +38,14 @@ export interface ModelRow { contextWindow?: number; contextCap?: number; contextCapped?: boolean; + /** Stored custom-row override (not the inherited ladder); only present on custom rows. */ + reasoningEfforts?: string[]; + defaultReasoningEffort?: string; } +/** Codex ladder labels offered in the custom-model dialog. */ +export const REASONING_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max", "ultra"] as const; + export interface ProviderContextCapsResponse { cap?: number; value?: number; diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index 73c4901a1..e2cc78310 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -367,6 +367,12 @@ gap: var(--space-4); } +.models-field-stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + .models-field { display: flex; flex-direction: column; @@ -382,6 +388,18 @@ cursor: pointer; } +/* Uniform checkbox rendering in the custom-model dialog: native checkbox size is + browser-dependent (Chrome ~13px, Safari ~16px) and can even differ between rows in + some renderers, so pin an explicit size for every checkbox in the modal. 13px is the + size Chrome renders natively here; pinning it keeps the effort-step checkboxes exactly + as large as the other dialog checkboxes. */ +.modal-card .models-modality-option input[type="checkbox"] { + width: 13px; + height: 13px; + margin: 0; + flex: none; +} + /* Beat .row { gap: 10px } (defined later in styles.css). */ .row.models-model-row, .row.models-cap-row, diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index e451bb100..c80e83be0 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -16,7 +16,9 @@ import { const USAGE = `Usage: ocx models live [--provider ] [--json] ocx models edit [--model-id ] [--display-name ] - [--context-window ] [--modalities ] [--json] + [--context-window ] [--modalities ] + [--reasoning-efforts ] + [--default-reasoning-effort ] [--json] ocx models [--native] [--json] ocx models provider [--json] ocx models selected [--set |--clear] [--json] @@ -57,6 +59,8 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const displayName = takeOption(args, "--display-name"); const contextRaw = takeOption(args, "--context-window"); const modalitiesRaw = takeOption(args, "--modalities"); + const reasoningEffortsRaw = takeOption(args, "--reasoning-efforts"); + const defaultEffortRaw = takeOption(args, "--default-reasoning-effort"); rejectArgs(args, USAGE); if (modelId !== undefined) patch.modelId = modelId; if (displayName !== undefined) patch.displayName = displayName === "-" ? "" : displayName; @@ -66,6 +70,12 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { patch.contextWindow = value === 0 ? null : value; } if (modalitiesRaw !== undefined) patch.inputModalities = modalitiesRaw === "-" ? [] : csv(modalitiesRaw); + // "-" restores inheritance by clearing the stored ladder (null). An explicit empty + // ladder ("no reasoning") has no CLI shorthand — use the dashboard for that state. + if (reasoningEffortsRaw !== undefined) { + patch.reasoningEfforts = reasoningEffortsRaw === "-" ? null : csv(reasoningEffortsRaw); + } + if (defaultEffortRaw !== undefined) patch.defaultReasoningEffort = defaultEffortRaw === "-" ? null : defaultEffortRaw; if (Object.keys(patch).length === 0) throw new CliUsageError("at least one edit option is required", USAGE); const result = await runtimeRequest(`/api/custom-models/${encodeURIComponent(id)}`, { method: "PUT", diff --git a/src/cli/models.ts b/src/cli/models.ts index 11787e9bc..67cbd73b2 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -5,11 +5,12 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; +import { isCodexReasoningEffort } from "../reasoning-effort"; import { routedSlug } from "../providers/slug-codec"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig, OcxCustomModel } from "../types"; -const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio]"; +const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio] [--reasoning-efforts ] [--default-reasoning-effort ]"; const REMOVE_USAGE = "Usage: ocx models remove [--yes]"; const LIST_CUSTOM_USAGE = "Usage: ocx models list-custom [--json]"; const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]); @@ -118,6 +119,8 @@ async function handleCustomAdd(args: string[]): Promise { const displayNameValue = consumeFlagValue(rest, "--display-name"); const contextWindowValue = consumeFlagValue(rest, "--context-window"); const modalitiesValue = consumeFlagValue(rest, "--modalities"); + const reasoningEffortsValue = consumeFlagValue(rest, "--reasoning-efforts"); + const defaultEffortValue = consumeFlagValue(rest, "--default-reasoning-effort"); rejectUnexpectedArgs(rest, ADD_USAGE); if (!provider || !modelId) fail("provider and modelId are required", ADD_USAGE); @@ -150,6 +153,27 @@ async function handleCustomAdd(args: string[]): Promise { inputModalities = [...new Set(inputModalities)]; } + let reasoningEfforts: string[] | undefined; + if (reasoningEffortsValue !== undefined) { + reasoningEfforts = reasoningEffortsValue.split(",").map(value => value.trim()).filter(Boolean); + const invalid = reasoningEfforts.filter(value => !isCodexReasoningEffort(value)); + if (invalid.length > 0) { + fail(`unsupported reasoning effort: ${invalid.join(", ")} (allowed: low, medium, high, xhigh, max, ultra)`); + } + reasoningEfforts = [...new Set(reasoningEfforts)]; + } + if (defaultEffortValue !== undefined) { + if (!isCodexReasoningEffort(defaultEffortValue)) { + fail(`unsupported reasoning effort: ${defaultEffortValue} (allowed: low, medium, high, xhigh, max, ultra)`); + } + if (!reasoningEfforts || reasoningEfforts.length === 0) { + fail("--default-reasoning-effort requires --reasoning-efforts"); + } + if (!reasoningEfforts.includes(defaultEffortValue)) { + fail(`--default-reasoning-effort "${defaultEffortValue}" is not in the declared reasoning efforts`); + } + } + const existing = config.customModels ?? []; const slug = routedSlug(provider, modelId); if (existing.some(model => routedSlug(model.provider, model.modelId) === slug)) { @@ -163,6 +187,8 @@ async function handleCustomAdd(args: string[]): Promise { ...(displayName ? { displayName } : {}), ...(contextWindow ? { contextWindow } : {}), ...(inputModalities ? { inputModalities } : {}), + ...(reasoningEfforts ? { reasoningEfforts } : {}), + ...(defaultEffortValue ? { defaultReasoningEffort: defaultEffortValue } : {}), addedAt: new Date().toISOString(), }; config.customModels = [...existing, entry]; @@ -218,12 +244,14 @@ function customModelCells(model: OcxCustomModel): string[] { model.displayName ?? "-", model.contextWindow ? `${Math.round(model.contextWindow / 1000)}k` : "-", model.inputModalities?.join(",") ?? "-", + model.reasoningEfforts?.join(",") ?? "-", + model.defaultReasoningEffort ?? "-", ]; } function printCustomModelGroup(provider: string, models: OcxCustomModel[]): void { const rows = models.map(customModelCells); - const headers = ["ID", "MODEL", "DISPLAY NAME", "CONTEXT", "MODALITIES"]; + const headers = ["ID", "MODEL", "DISPLAY NAME", "CONTEXT", "MODALITIES", "EFFORTS", "DEFAULT EFFORT"]; const widths = headers.map((header, column) => Math.max(header.length, ...rows.map(row => row[column].length))); const line = (cells: string[]) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" "); console.log(`${provider}:`); diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 44240c9ef..c45c84aab 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -664,6 +664,8 @@ export interface PiModelEntry { input: string[]; contextWindow?: number; maxTokens?: number; + /** Advertised when the catalog row carries a non-empty effort ladder. */ + reasoning?: true; } export interface PiProviderBlock { @@ -836,16 +838,21 @@ export interface DshGeneratedConfig { * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`), * unlike OpenCode's keyed object. * - * Two fields are deliberately absent. `cost` requires all four price fields and we have - * no price data at all, so emitting zeros would assert every routed model is free. - * `reasoning` is a boolean in Pi while our catalog carries an effort list — mapping one - * to the other would be a guess. + * Two fields were deliberately absent once. `cost` still is: it requires all four price + * fields and we have no price data at all, so emitting zeros would assert every routed + * model is free. `reasoning` used to be omitted because Pi's boolean and the catalog's + * effort ladder did not obviously map — but a NON-EMPTY ladder is the catalog's own + * statement that the model accepts reasoning parameters (adapters honor `reasoning_effort`), + * and an empty or absent ladder is the statement that it does not. Emitting `reasoning: + * true` exactly for rows with a ladder is therefore not a guess; it is what makes Pi's + * effort control appear for routed models at all. Users who need Pi-specific + * effort values (e.g. `xhigh`/`max` clamping) can still hand-tune `thinkingLevelMap`. * * Pi's input enum IS verified: its documented model configuration accepts only * `text` and `image`, and a validation failure yields an EMPTY model config * rather than dropping the offending entry — one bad value costs every routed - * model. The rest of this contract (omitting `cost` and `reasoning`) is still - * ours rather than a claim about Pi's acceptance. + * model. The rest of this contract (omitting `cost`) is still ours rather than + * a claim about Pi's acceptance. */ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { const models: PiModelEntry[] = []; @@ -862,6 +869,9 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { name: exportModelLabel(model), input, }; + if (Array.isArray(model.reasoningEfforts) && model.reasoningEfforts.length > 0) { + entry.reasoning = true; + } const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.contextWindow = context; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 137e8e441..a452c441d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1774,6 +1774,11 @@ async function gatherRoutedModelsUncached( ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}), + // Explicit custom-row ladder wins over the inherited provider row below: the merge only + // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept + // verbatim instead of being replaced by the replaced row's metadata. + ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), + ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(codexForwardNativeCapabilityAlias ? { diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 101602995..51194a374 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -29,6 +29,44 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string } return { values: raw as string[] }; } + +/** + * Custom-row reasoning ladder. Labels are validated against the Codex ladder (low..ultra) + * exactly like provider `modelReasoningEfforts` values; unknown labels would otherwise + * surface in a catalog the upstream never accepts. An empty array is meaningful (explicit + * "no reasoning" hides the effort control) and must be preserved, not cleared. + */ +function readReasoningEfforts(raw: unknown): { values?: string[]; error?: string } { + if (raw === undefined) return {}; + if (!Array.isArray(raw)) return { error: "reasoningEfforts must be an array" }; + const rejected: string[] = []; + const values: string[] = []; + for (const value of raw) { + if (typeof value !== "string") return { error: "reasoningEfforts must contain only strings" }; + if (!isCodexReasoningEffort(value)) { rejected.push(value); continue; } + if (!values.includes(value)) values.push(value); + } + if (rejected.length > 0) { + return { error: `unsupported reasoning effort: ${rejected.join(", ")} (allowed: low, medium, high, xhigh, max, ultra)` }; + } + return { values }; +} + +/** Default effort must be a ladder member that the declared ladder actually includes. */ +function readDefaultReasoningEffort(raw: unknown, efforts: string[] | undefined): { value?: string; error?: string } { + if (raw === undefined) return {}; + if (raw === null) return { value: undefined }; + if (typeof raw !== "string" || !isCodexReasoningEffort(raw)) { + return { error: "defaultReasoningEffort must be one of: low, medium, high, xhigh, max, ultra" }; + } + if (efforts === undefined || efforts.length === 0) { + return { error: "defaultReasoningEffort requires a non-empty reasoningEfforts ladder" }; + } + if (!efforts.includes(raw)) { + return { error: `defaultReasoningEffort "${raw}" is not in the declared reasoningEfforts ladder` }; + } + return { value: raw }; +} import type { CatalogModel } from "../../codex/catalog"; import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; @@ -71,6 +109,7 @@ import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summa import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; +import { isCodexReasoningEffort } from "../../reasoning-effort"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { clearDebugSettings, @@ -330,7 +369,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise routedSlug(cm.provider, cm.modelId) === newSlug)) { @@ -356,6 +399,8 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0 ? { inputModalities } : {}), + ...(reasoning.values !== undefined ? { reasoningEfforts: reasoning.values } : {}), + ...(defaultEffort.value ? { defaultReasoningEffort: defaultEffort.value } : {}), addedAt: new Date().toISOString(), }; config.customModels = [...existing, entry]; @@ -368,7 +413,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise cm.id === id); @@ -391,6 +436,23 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0 ? edited.values : undefined; } + // `null` clears the stored ladder back to "inherit from the provider row"; `[]` stays + // stored as an explicit "no reasoning" override. The default effort rides along and is + // validated against the ladder the row ends up with. + if (body.reasoningEfforts !== undefined) { + if (body.reasoningEfforts === null) { + cm.reasoningEfforts = undefined; + } else { + const edited = readReasoningEfforts(body.reasoningEfforts); + if (edited.error) return jsonResponse({ error: edited.error }, 400); + cm.reasoningEfforts = edited.values; + } + } + if (body.defaultReasoningEffort !== undefined) { + const edited = readDefaultReasoningEffort(body.defaultReasoningEffort, cm.reasoningEfforts); + if (edited.error) return jsonResponse({ error: edited.error }, 400); + cm.defaultReasoningEffort = edited.value; + } const updatedSlug = routedSlug(cm.provider, cm.modelId); if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) { return jsonResponse({ error: "duplicate model" }, 409); diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index fa83d10ad..5f73f7af1 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -91,6 +91,11 @@ export async function listManagementModelRows(config: OcxConfig): Promise { expect(persistCalls).toBe(1); }); }); + +/* + * The same closed-enum argument applies to the reasoning ladder: a label outside the Codex + * ladder (low..ultra) stored through /api/custom-models would surface in a catalog the + * upstream never accepts, and the GUI's effort checkboxes are only as honest as the API + * that validates them. Unlike modalities, an EMPTY ladder is meaningful here — it is the + * explicit "no reasoning" override that hides the effort control (#883) — so `[]` is + * stored, not cleared, and `null` is the only way a PUT restores inheritance. + */ +describe("custom-model API validates reasoning-effort ladders", () => { + let persistCalls = 0; + + async function callCustomModels( + method: "POST" | "PUT", + body: unknown, + pathname = "/api/custom-models", + ): Promise { + const { handleModelRoutes } = await import("../src/server/management/model-routes"); + const url = new URL(`http://127.0.0.1:10199${pathname}`); + const req = new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return handleModelRoutes({ + req, + url, + config: { + providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1" } }, + customModels: [ + // Seeded WITH a ladder on purpose: the null-clear test needs a stored value to + // remove, and the explicit-empty test needs to prove `[]` is NOT a clear. + { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4", reasoningEfforts: ["low", "high"] }, + ], + } as unknown as Parameters[0]["config"], + deps: { + saveConfigPreservingClaudeCode: () => { persistCalls++; }, + } as Parameters[0]["deps"], + convergeCodexCatalog: async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + } + + test("POST refuses an effort outside the Codex ladder, naming the offending value", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v5", + reasoningEfforts: ["low", "deep"], + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("deep"); + expect(persistCalls).toBe(0); + }); + + test("POST refuses a non-string member instead of filtering it away", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v5", + reasoningEfforts: ["low", 42], + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("strings"); + expect(persistCalls).toBe(0); + }); + + test("POST accepts a valid ladder with a member default and dedupes", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["low", "high", "high"], + defaultReasoningEffort: "high", + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual(["low", "high"]); + expect(payload.defaultReasoningEffort).toBe("high"); + expect(persistCalls).toBe(1); + }); + + test("POST stores an explicit empty ladder as the no-reasoning override", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: [], + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toEqual([]); + expect(persistCalls).toBe(1); + }); + + test("POST refuses a default effort outside the declared ladder", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "max", + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("max"); + expect(persistCalls).toBe(0); + }); + + test("POST refuses a default effort without any ladder", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + defaultReasoningEffort: "high", + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("reasoningEfforts"); + expect(persistCalls).toBe(0); + }); + + test("PUT stores an explicit empty ladder instead of clearing it", async () => { + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: [] }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toEqual([]); + expect(persistCalls).toBe(1); + }); + + test("PUT null restores inheritance by clearing the stored ladder", async () => { + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: null }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("PUT clears the default when the ladder is removed", async () => { + persistCalls = 0; + const res = await callCustomModels( + "PUT", + { reasoningEfforts: null, defaultReasoningEffort: null }, + "/api/custom-models/existing-uuid", + ); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toBeUndefined(); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); +}); diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index be8491924..72828bf58 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -182,10 +182,23 @@ describe("Pi serializer (accept criterion 2)", () => { expect(JSON.stringify(piConfig())).not.toContain("cost"); }); - test("reasoning is omitted — an effort list is not Pi's boolean", () => { + test("reasoning is emitted only for rows with a non-empty effort ladder", () => { + // The shared fixture carries no ladder anywhere: every entry stays reasoning-free. for (const model of piConfig().providers.opencodex!.models) { expect(model).not.toHaveProperty("reasoning"); } + const config = piConfig(ctx({ + models: [ + { namespaced: "a/reasoning", provider: "a", id: "reasoning", reasoningEfforts: ["low", "high"] }, + { namespaced: "b/none", provider: "b", id: "none", reasoningEfforts: [] }, + { namespaced: "c/plain", provider: "c", id: "plain" }, + ], + })); + const models = config.providers.opencodex!.models; + expect(models.find(model => model.id === "a/reasoning")!.reasoning).toBe(true); + // An explicit empty ladder is the catalog's "no reasoning" statement; no boolean. + expect(models.find(model => model.id === "b/none")).not.toHaveProperty("reasoning"); + expect(models.find(model => model.id === "c/plain")).not.toHaveProperty("reasoning"); }); test("contextWindow and maxTokens are omitted when the context window is unknown", () => { diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 3ebcfe85a..4e98dd2b1 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1877,6 +1877,105 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { clearModelCache("custom-provider"); } }); + + test("a customModel reasoning ladder overrides the inherited provider ladder end-to-end", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["baseline-model", "renamed-model"], + // The provider row for the same slug advertises low/high; the custom row must win. + modelReasoningEfforts: { "baseline-model": ["low", "high"], "renamed-model": ["low", "high"] }, + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + displayName: "Renamed Model", + reasoningEfforts: ["medium", "max"], + defaultReasoningEffort: "max", + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + expect(fetchCalls).toBe(0); + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + // The explicit ladder rides on the row itself, not on the replaced provider row. + expect(custom?.reasoningEfforts).toEqual(["medium", "max"]); + expect(custom?.defaultReasoningEffort).toBe("max"); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + // The sync appends the mock top rungs (max/ultra) for subagent spawn compatibility; + // the declared medium/max survive verbatim, the inherited low/high does not. + expect(levels).toEqual(["medium", "max", "ultra"]); + expect(row?.default_reasoning_level).toBe("max"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); + + test("an explicit empty customModel ladder hides the effort control despite an inherited one", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + modelReasoningEfforts: { "renamed-model": ["low", "high"] }, + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: [], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + expect(custom?.reasoningEfforts).toEqual([]); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + expect(row?.supported_reasoning_levels).toEqual([]); + expect(row).not.toHaveProperty("default_reasoning_level"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); }); describe("legacy custom-model catalog ownership", () => { From 64272d763d0b08eb730f7ac6455a7885cea0de65 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:15:00 +0200 Subject: [PATCH 77/94] docs: add custom-model reasoning steps dialog screenshot --- .../custom-model-reasoning-steps.png | Bin 0 -> 146783 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/custom-model-reasoning-steps.png diff --git a/docs/screenshots/custom-model-reasoning-steps.png b/docs/screenshots/custom-model-reasoning-steps.png new file mode 100644 index 0000000000000000000000000000000000000000..41a574f1442ac621ec191e051868e0a2d8f4bc34 GIT binary patch literal 146783 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 literal 0 HcmV?d00001 From 709b2ff319802404bcd0ebce2cfffa55612361a2 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:20:39 +0200 Subject: [PATCH 78/94] fix(models): enforce ladder invariants and constrain Pi effort levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the lens review of the custom-model reasoning feature: - PUT /api/custom-models now mirrors the POST invariant: a stored default effort is dropped whenever the final ladder no longer contains it, so a ladder shrink/clear (e.g. the GUI toggle-off path, which sends only reasoningEfforts) can no longer leave a stale default that re-applies itself onto the inherited ladder in the generated catalog (#962). - The Pi export constrains pi's own level scale with a thinkingLevelMap: ladder members map to themselves, everything else (incl. minimal) is hidden. Custom-row ladders are advertisement-only — the wire clamp in mapReasoningEffort reads provider config, not customModels — so without the map pi could offer (and send) levels the ladder does not contain. - Ladders are canonicalized into Codex order (low..ultra) at API and CLI ingress so a caller-chosen order cannot leak into supported_reasoning_levels. - GUI: enabling "Override reasoning effort" pre-checks the full ladder, so an accidental save with zero steps can no longer silently disable reasoning for the model (#883 semantics stays reachable deliberately). - CLI: ocx models add accepts "-" for both reasoning flags (inherit) and rejects an empty string; the parsing/validation is extracted into an exported parseReasoningArgs and covered by tests together with the edit flag mapping. - Removes the now-dead defaultReasoningEffort exposure from /api/models custom rows and the GUI ModelRow (CLI/API keep it on OcxCustomModel). --- docs-site/src/content/docs/guides/pi.md | 5 +- gui/src/i18n/de.ts | 4 +- gui/src/i18n/en.ts | 4 +- gui/src/i18n/ja.ts | 4 +- gui/src/i18n/ko.ts | 4 +- gui/src/i18n/ru.ts | 4 +- gui/src/i18n/tr.ts | 4 +- gui/src/i18n/zh-TW.ts | 4 +- gui/src/i18n/zh.ts | 4 +- gui/src/pages/Models.tsx | 8 +- gui/src/pages/models-shared.ts | 1 - src/cli/models.ts | 77 +++++++++++++------ src/clients/config-export.ts | 23 +++++- src/reasoning-effort.ts | 11 +++ src/server/management/model-routes.ts | 16 +++- src/server/management/model-rows.ts | 1 - tests/catalog-input-modality-enum.test.ts | 69 +++++++++++++++++ tests/cli-models-reasoning.test.ts | 90 +++++++++++++++++++++++ tests/client-config-export.test.ts | 10 +++ 19 files changed, 295 insertions(+), 48 deletions(-) create mode 100644 tests/cli-models-reasoning.test.ts diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index 52c83a0be..8805ea803 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -111,8 +111,9 @@ catalog's ladder is the proxy's own statement about whether a model accepts reas (adapters honor `reasoning_effort`), an export row with a **non-empty** ladder now emits `"reasoning": true`, and a row without one (or with an explicitly empty ladder) stays reasoning-free. Pi then offers its effort control for exactly the models opencodex will accept it -on. If you need Pi-specific effort values — for example clamping `xhigh`/`max` away — add a -hand-written `thinkingLevelMap` to the model entry as documented by Pi. +on. The export also emits a `thinkingLevelMap` that hides every pi level outside the declared +ladder (`null`), so pi never offers — and never sends — an effort the ladder does not contain. +If you need a different mapping, hand-edit `thinkingLevelMap` afterwards as documented by Pi. ## Schema status diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8a1882921..af4c87f64 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -539,8 +539,8 @@ export const de: Record = { "models.customFieldDisplayNamePlaceholder": "z. B. Qwen 4 Max Preview", "models.customFieldContext": "Kontextfenster", "models.customFieldModalities": "Eingabemodalitäten", - "models.customFieldReasoning": "Unterstützte Reasoning Stufen", - "models.customFieldReasoningOverride": "Reasoning-Stufen überschreiben", + "models.customFieldReasoning": "Reasoning-Aufwand", + "models.customFieldReasoningOverride": "Reasoning-Aufwand überschreiben", "models.tipProvider": "Anbieter", "models.tipContext": "Kontext", "models.tipModalities": "Modalitäten", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9ea0fe167..7fbe6941b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -564,8 +564,8 @@ export const en = { "models.customFieldDisplayNamePlaceholder": "e.g. Qwen 4 Max Preview", "models.customFieldContext": "Context window", "models.customFieldModalities": "Input modalities", - "models.customFieldReasoning": "Supported reasoning steps", - "models.customFieldReasoningOverride": "Override reasoning steps", + "models.customFieldReasoning": "Reasoning effort", + "models.customFieldReasoningOverride": "Override reasoning effort", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 751f59d87..7e5e70f1c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1957,8 +1957,8 @@ export const ja: Record = { "models.customFieldDisplayNamePlaceholder": "e.g. Qwen 4 Max Preview", "models.customFieldContext": "Context window", "models.customFieldModalities": "Input modalities", - "models.customFieldReasoning": "サポートされる推論ステップ", - "models.customFieldReasoningOverride": "推論ステップを上書き", + "models.customFieldReasoning": "推論努力", + "models.customFieldReasoningOverride": "推論努力を上書き", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a1415d9f6..dee156cf4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -550,8 +550,8 @@ export const ko: Record = { "models.customFieldDisplayNamePlaceholder": "예: Qwen 4 Max Preview", "models.customFieldContext": "컨텍스트 윈도우", "models.customFieldModalities": "입력 모달리티", - "models.customFieldReasoning": "지원 추론 단계", - "models.customFieldReasoningOverride": "추론 단계 재정의", + "models.customFieldReasoning": "추론 노력", + "models.customFieldReasoningOverride": "추론 노력 재정의", "models.tipProvider": "프로바이더", "models.tipContext": "컨텍스트", "models.tipModalities": "모달리티", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 69f8090ad..bb32993b8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -552,8 +552,8 @@ export const ru: Record = { "models.customFieldDisplayNamePlaceholder": "например, Qwen 4 Max Preview", "models.customFieldContext": "Контекстное окно", "models.customFieldModalities": "Входные модальности", - "models.customFieldReasoning": "Поддерживаемые ступени рассуждений", - "models.customFieldReasoningOverride": "Переопределить ступени рассуждений", + "models.customFieldReasoning": "Уровень рассуждений", + "models.customFieldReasoningOverride": "Переопределить уровень рассуждений", "models.tipProvider": "Провайдер", "models.tipContext": "Контекст", "models.tipModalities": "Модальности", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b3550788c..13ce134b2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -555,8 +555,8 @@ export const tr: Record = { "models.customFieldDisplayNamePlaceholder": "örn. Qwen 4 Max Preview", "models.customFieldContext": "Bağlam penceresi", "models.customFieldModalities": "Girdi türleri", - "models.customFieldReasoning": "Desteklenen akıl yürütme adımları", - "models.customFieldReasoningOverride": "Akıl yürütme adımlarını geçersiz kıl", + "models.customFieldReasoning": "Akıl yürütme çabası", + "models.customFieldReasoningOverride": "Akıl yürütme çabasını geçersiz kıl", "models.tipProvider": "Sağlayıcı", "models.tipContext": "Bağlam", "models.tipModalities": "Girdi Türleri", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 701bb778e..75e1c06ea 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -420,8 +420,8 @@ export const zhTW: Record = { "models.customFieldDisplayNamePlaceholder": "例如 Qwen 4 Max Preview", "models.customFieldContext": "上下文視窗", "models.customFieldModalities": "輸入模態", - "models.customFieldReasoning": "支援的推理步驟", - "models.customFieldReasoningOverride": "覆寫推理步驟", + "models.customFieldReasoning": "推理強度", + "models.customFieldReasoningOverride": "覆寫推理強度", "models.tipProvider": "供應商", "models.tipContext": "上下文", "models.tipModalities": "模態", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4e7ad1982..72c536202 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -547,8 +547,8 @@ export const zh: Record = { "models.customFieldDisplayNamePlaceholder": "例如 Qwen 4 Max Preview", "models.customFieldContext": "上下文窗口", "models.customFieldModalities": "输入模态", - "models.customFieldReasoning": "支持的推理步骤", - "models.customFieldReasoningOverride": "覆盖推理步骤", + "models.customFieldReasoning": "推理强度", + "models.customFieldReasoningOverride": "覆盖推理强度", "models.tipProvider": "提供方", "models.tipContext": "上下文", "models.tipModalities": "模态", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 3eaced421..428223d81 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1614,7 +1614,13 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomFormReasoning(e.target.checked)} + onChange={e => { + setCustomFormReasoning(e.target.checked); + // Default to the full ladder: the common intent is "allow every known + // step". An explicit no-reasoning override (empty ladder) then requires + // deliberately unchecking all of them instead of being an accident. + if (e.target.checked) setCustomFormReasoningEfforts([...REASONING_EFFORT_LEVELS]); + }} disabled={customSaving} /> {t("models.customFieldReasoningOverride")} diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index a71069403..8d46302b4 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -40,7 +40,6 @@ export interface ModelRow { contextCapped?: boolean; /** Stored custom-row override (not the inherited ladder); only present on custom rows. */ reasoningEfforts?: string[]; - defaultReasoningEffort?: string; } /** Codex ladder labels offered in the custom-model dialog. */ diff --git a/src/cli/models.ts b/src/cli/models.ts index 67cbd73b2..cbfcd2fe9 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; -import { isCodexReasoningEffort } from "../reasoning-effort"; +import { canonicalizeReasoningEfforts, isCodexReasoningEffort } from "../reasoning-effort"; import { routedSlug } from "../providers/slug-codec"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig, OcxCustomModel } from "../types"; @@ -15,6 +15,55 @@ const REMOVE_USAGE = "Usage: ocx models remove [--ye const LIST_CUSTOM_USAGE = "Usage: ocx models list-custom [--json]"; const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]); +/** + * Parse and validate the reasoning flags shared by `ocx models add` (offline path). + * "-" means "inherit" and omits the field entirely; an empty string is rejected instead of + * silently meaning something (edit's "-" is the documented clear idiom). Values are + * canonicalized into Codex ladder order so the stored config matches what the API stores. + */ +export function parseReasoningArgs( + reasoningEffortsValue: string | undefined, + defaultEffortValue: string | undefined, +): { reasoningEfforts?: string[]; defaultReasoningEffort?: string; error?: string } { + if (reasoningEffortsValue === undefined && defaultEffortValue === undefined) return {}; + let reasoningEfforts: string[] | undefined; + if (reasoningEffortsValue !== undefined) { + const trimmed = reasoningEffortsValue.trim(); + if (trimmed === "-") { + reasoningEfforts = undefined; + } else { + const parts = trimmed.split(",").map(value => value.trim()); + if (parts.length === 0 || parts.some(part => part === "")) { + return { error: "--reasoning-efforts must be comma-separated values from low, medium, high, xhigh, max, ultra (or \"-\" to inherit)" }; + } + const invalid = parts.filter(value => !isCodexReasoningEffort(value)); + if (invalid.length > 0) { + return { error: `unsupported reasoning effort: ${invalid.join(", ")} (allowed: low, medium, high, xhigh, max, ultra)` }; + } + reasoningEfforts = canonicalizeReasoningEfforts(parts); + } + } + let defaultReasoningEffort: string | undefined; + if (defaultEffortValue !== undefined) { + const trimmed = defaultEffortValue.trim(); + if (trimmed === "-") { + defaultReasoningEffort = undefined; + } else { + if (!isCodexReasoningEffort(trimmed)) { + return { error: `unsupported reasoning effort: ${trimmed} (allowed: low, medium, high, xhigh, max, ultra)` }; + } + if (!reasoningEfforts || reasoningEfforts.length === 0) { + return { error: "--default-reasoning-effort requires --reasoning-efforts" }; + } + if (!reasoningEfforts.includes(trimmed)) { + return { error: `--default-reasoning-effort "${trimmed}" is not in the declared reasoning efforts` }; + } + defaultReasoningEffort = trimmed; + } + } + return { reasoningEfforts, defaultReasoningEffort }; +} + interface ModelEntry { provider: string; model: string; @@ -153,26 +202,8 @@ async function handleCustomAdd(args: string[]): Promise { inputModalities = [...new Set(inputModalities)]; } - let reasoningEfforts: string[] | undefined; - if (reasoningEffortsValue !== undefined) { - reasoningEfforts = reasoningEffortsValue.split(",").map(value => value.trim()).filter(Boolean); - const invalid = reasoningEfforts.filter(value => !isCodexReasoningEffort(value)); - if (invalid.length > 0) { - fail(`unsupported reasoning effort: ${invalid.join(", ")} (allowed: low, medium, high, xhigh, max, ultra)`); - } - reasoningEfforts = [...new Set(reasoningEfforts)]; - } - if (defaultEffortValue !== undefined) { - if (!isCodexReasoningEffort(defaultEffortValue)) { - fail(`unsupported reasoning effort: ${defaultEffortValue} (allowed: low, medium, high, xhigh, max, ultra)`); - } - if (!reasoningEfforts || reasoningEfforts.length === 0) { - fail("--default-reasoning-effort requires --reasoning-efforts"); - } - if (!reasoningEfforts.includes(defaultEffortValue)) { - fail(`--default-reasoning-effort "${defaultEffortValue}" is not in the declared reasoning efforts`); - } - } + const parsed = parseReasoningArgs(reasoningEffortsValue, defaultEffortValue); + if (parsed.error) fail(parsed.error); const existing = config.customModels ?? []; const slug = routedSlug(provider, modelId); @@ -187,8 +218,8 @@ async function handleCustomAdd(args: string[]): Promise { ...(displayName ? { displayName } : {}), ...(contextWindow ? { contextWindow } : {}), ...(inputModalities ? { inputModalities } : {}), - ...(reasoningEfforts ? { reasoningEfforts } : {}), - ...(defaultEffortValue ? { defaultReasoningEffort: defaultEffortValue } : {}), + ...(parsed.reasoningEfforts ? { reasoningEfforts: parsed.reasoningEfforts } : {}), + ...(parsed.defaultReasoningEffort ? { defaultReasoningEffort: parsed.defaultReasoningEffort } : {}), addedAt: new Date().toISOString(), }; config.customModels = [...existing, entry]; diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index c45c84aab..43cd180fd 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -666,6 +666,13 @@ export interface PiModelEntry { maxTokens?: number; /** Advertised when the catalog row carries a non-empty effort ladder. */ reasoning?: true; + /** + * Constrains pi's own level scale (minimal..max) to the declared ladder: members map to + * themselves, everything else is hidden (`null`). Without it pi would offer levels the + * ladder does not contain — harmless for provider-config ladders (the proxy clamps those + * at the wire) but a real 400 risk for custom-row ladders, which are advertisement-only. + */ + thinkingLevelMap?: Record; } export interface PiProviderBlock { @@ -845,8 +852,11 @@ export interface DshGeneratedConfig { * statement that the model accepts reasoning parameters (adapters honor `reasoning_effort`), * and an empty or absent ladder is the statement that it does not. Emitting `reasoning: * true` exactly for rows with a ladder is therefore not a guess; it is what makes Pi's - * effort control appear for routed models at all. Users who need Pi-specific - * effort values (e.g. `xhigh`/`max` clamping) can still hand-tune `thinkingLevelMap`. + * effort control appear for routed models at all. The export also emits a `thinkingLevelMap` + * that hides every pi level outside the declared ladder, so pi never offers (and sends) an + * effort the ladder does not contain — custom-row ladders are catalog advertisement only + * and get no wire clamp, so this map is what keeps pi honest for those. Users who need a + * different mapping can still hand-tune `thinkingLevelMap` afterwards. * * Pi's input enum IS verified: its documented model configuration accepts only * `text` and `image`, and a validation failure yields an EMPTY model config @@ -871,6 +881,15 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { }; if (Array.isArray(model.reasoningEfforts) && model.reasoningEfforts.length > 0) { entry.reasoning = true; + const efforts = model.reasoningEfforts; + entry.thinkingLevelMap = { + minimal: efforts.includes("minimal") ? "minimal" : null, + low: efforts.includes("low") ? "low" : null, + medium: efforts.includes("medium") ? "medium" : null, + high: efforts.includes("high") ? "high" : null, + xhigh: efforts.includes("xhigh") ? "xhigh" : null, + max: efforts.includes("max") ? "max" : null, + }; } const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index 5e97994e1..c89db76d8 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -19,6 +19,17 @@ export function isCodexReasoningEffort(effort: string): boolean { return CODEX_REASONING_SET.has(effort); } +/** + * Reorder any subset of the Codex ladder into canonical low..ultra order and drop + * duplicates. Catalog `supported_reasoning_levels` follow the input order and the + * fallback default picks the first entry, so non-canonical input ("high,low") would + * otherwise leak a caller-chosen order into the catalog. + */ +export function canonicalizeReasoningEfforts(values: readonly string[]): string[] { + const seen = new Set(values); + return CODEX_REASONING_ORDER.filter(effort => seen.has(effort)); +} + /** * Reasoning ladder accepted for the OpenAI vision sidecar. `ultra` is deliberately excluded: * the vision describer is a single helper call, and `ultra` would be collapsed to `max` by the diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 51194a374..3736c9c5b 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -49,7 +49,9 @@ function readReasoningEfforts(raw: unknown): { values?: string[]; error?: string if (rejected.length > 0) { return { error: `unsupported reasoning effort: ${rejected.join(", ")} (allowed: low, medium, high, xhigh, max, ultra)` }; } - return { values }; + // Canonical order: the catalog writes supported_reasoning_levels in input order and the + // fallback default picks the first entry, so a caller-chosen order must not leak through. + return { values: canonicalizeReasoningEfforts(values) }; } /** Default effort must be a ladder member that the declared ladder actually includes. */ @@ -109,7 +111,7 @@ import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summa import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { isCodexReasoningEffort } from "../../reasoning-effort"; +import { canonicalizeReasoningEfforts, isCodexReasoningEffort } from "../../reasoning-effort"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { clearDebugSettings, @@ -453,6 +455,16 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) { return jsonResponse({ error: "duplicate model" }, 409); diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 5f73f7af1..b10fea4cd 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -95,7 +95,6 @@ export async function listManagementModelRows(config: OcxConfig): Promise { expect(payload.defaultReasoningEffort).toBeUndefined(); expect(persistCalls).toBe(1); }); + + // POST rejects a default outside the ladder; PUT must not be able to produce that state + // on its own. A ladder shrink/clear on a row that was created with a default (CLI) must + // drop the stale default — otherwise it re-applies itself onto the inherited ladder in + // the generated catalog (GUI toggle-off path sends only reasoningEfforts). + test("PUT ladder shrink drops a stored default that is no longer a member", async () => { + persistCalls = 0; + const seeded = await callCustomModels("PUT", { + reasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "max", + }, "/api/custom-models/existing-uuid"); + expect(seeded?.status).toBe(200); + expect((await seeded!.json() as { defaultReasoningEffort?: string }).defaultReasoningEffort).toBe("max"); + + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: ["low"] }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual(["low"]); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("PUT null-clear drops a stored default even when the body does not mention it", async () => { + persistCalls = 0; + const seeded = await callCustomModels("PUT", { + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }, "/api/custom-models/existing-uuid"); + expect(seeded?.status).toBe(200); + + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: null }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toBeUndefined(); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("PUT explicit empty ladder also drops a stored default", async () => { + persistCalls = 0; + const seeded = await callCustomModels("PUT", { + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }, "/api/custom-models/existing-uuid"); + expect(seeded?.status).toBe(200); + + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: [] }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual([]); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("POST and PUT canonicalize the ladder into Codex order", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["max", "low", "high", "low"], + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toEqual(["low", "high", "max"]); + expect(persistCalls).toBe(1); + }); }); diff --git a/tests/cli-models-reasoning.test.ts b/tests/cli-models-reasoning.test.ts new file mode 100644 index 000000000..ac10b83cd --- /dev/null +++ b/tests/cli-models-reasoning.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { parseReasoningArgs } from "../src/cli/models"; +import { handleModelsRuntimeCommand } from "../src/cli/models-runtime"; + +/** + * The API validates reasoning ladders (9 tests in catalog-input-modality-enum.test.ts), + * but the CLI paths carry their own parsing and validation copies: `ocx models add` + * validates offline before writing config.json, and `ocx models edit` maps flags onto + * the PUT body ("-" -> null). These tests pin that mapping so CLI and API cannot drift. + */ +describe("ocx models add --reasoning-efforts parsing", () => { + test("a valid ladder is canonicalized into Codex order and deduped", () => { + expect(parseReasoningArgs("max,low,high,low", undefined)).toEqual({ + reasoningEfforts: ["low", "high", "max"], + }); + }); + + test("an unknown effort is rejected and names the offending value", () => { + const parsed = parseReasoningArgs("low,deep", undefined); + expect(parsed.error).toContain("deep"); + expect(parsed.reasoningEfforts).toBeUndefined(); + }); + + test("an empty string is rejected instead of silently meaning something", () => { + expect(parseReasoningArgs("", undefined)?.error).toContain("comma-separated"); + expect(parseReasoningArgs("low,,high", undefined)?.error).toContain("comma-separated"); + }); + + test('"-" omits the field (inherit) exactly like the API null-clear', () => { + expect(parseReasoningArgs("-", undefined)).toEqual({}); + expect(parseReasoningArgs(undefined, "-")).toEqual({}); + }); + + test("a default must be a ladder member", () => { + const parsed = parseReasoningArgs("low,high", "max"); + expect(parsed.error).toContain("max"); + expect(parsed.error).toContain("not in the declared reasoning efforts"); + }); + + test("a default requires a ladder", () => { + expect(parseReasoningArgs(undefined, "high")?.error).toContain("requires --reasoning-efforts"); + }); + + test("a member default is accepted", () => { + expect(parseReasoningArgs("low,high", "high")).toEqual({ + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }); + }); +}); + +describe("ocx models edit reasoning flag mapping onto the PUT body", () => { + async function editWith(patchArgs: string[]): Promise> { + let capturedBody: Record | null = null; + const fetchImpl = async (url: string, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ id: "cm-1", ...capturedBody }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + const code = await handleModelsRuntimeCommand("edit", ["cm-1", ...patchArgs], { + baseUrl: "http://127.0.0.1:1", + fetchImpl, + }); + expect(code).toBe(0); + return capturedBody ?? {}; + } + + test('"--reasoning-efforts -" maps to null (restore inheritance)', async () => { + const body = await editWith(["--reasoning-efforts", "-"]); + expect(body.reasoningEfforts).toBeNull(); + }); + + test("a csv ladder maps to an array", async () => { + const body = await editWith(["--reasoning-efforts", "low,high"]); + expect(body.reasoningEfforts).toEqual(["low", "high"]); + }); + + test('"--default-reasoning-effort -" maps to null', async () => { + const body = await editWith(["--default-reasoning-effort", "-"]); + expect(body.defaultReasoningEffort).toBeNull(); + }); + + test("a member default maps to its string", async () => { + const body = await editWith(["--reasoning-efforts", "low,high", "--default-reasoning-effort", "high"]); + expect(body.reasoningEfforts).toEqual(["low", "high"]); + expect(body.defaultReasoningEffort).toBe("high"); + }); +}); diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index 72828bf58..861daf3dc 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -196,6 +196,16 @@ describe("Pi serializer (accept criterion 2)", () => { })); const models = config.providers.opencodex!.models; expect(models.find(model => model.id === "a/reasoning")!.reasoning).toBe(true); + // Pi's level scale is constrained to the ladder: members map to themselves, everything + // else (incl. minimal, which the Codex ladder has no equivalent for) is hidden. + expect(models.find(model => model.id === "a/reasoning")!.thinkingLevelMap).toEqual({ + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: null, + }); // An explicit empty ladder is the catalog's "no reasoning" statement; no boolean. expect(models.find(model => model.id === "b/none")).not.toHaveProperty("reasoning"); expect(models.find(model => model.id === "c/plain")).not.toHaveProperty("reasoning"); From 4467a4be316d2db771ca5af02350b353e6cdf93b Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:34:48 +0200 Subject: [PATCH 79/94] docs: refresh custom-model dialog screenshot to final reasoning-effort state --- .../custom-model-reasoning-steps.png | Bin 146783 -> 30054 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/screenshots/custom-model-reasoning-steps.png b/docs/screenshots/custom-model-reasoning-steps.png index 41a574f1442ac621ec191e051868e0a2d8f4bc34..bbc0956c35802465ced68968157bc41fe9b02bb7 100644 GIT binary patch literal 30054 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 literal 146783 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 From 925fa936fb67ec790552559aeedfc970a6d2fa95 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:36:05 +0200 Subject: [PATCH 80/94] docs: rename screenshot for a cache-free URL (old filename was CDN-cached) --- ...-steps.png => custom-model-reasoning-effort.png} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/screenshots/{custom-model-reasoning-steps.png => custom-model-reasoning-effort.png} (100%) diff --git a/docs/screenshots/custom-model-reasoning-steps.png b/docs/screenshots/custom-model-reasoning-effort.png similarity index 100% rename from docs/screenshots/custom-model-reasoning-steps.png rename to docs/screenshots/custom-model-reasoning-effort.png From f88d69c08c56a52f970878ec4b3758fb28d8c0cc Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:39:17 +0200 Subject: [PATCH 81/94] docs: hi-res screenshot of the reasoning-effort dialog (none..max) --- .../custom-model-reasoning-effort-dialog.png | Bin 0 -> 145519 bytes .../custom-model-reasoning-effort.png | Bin 30054 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/custom-model-reasoning-effort-dialog.png delete mode 100644 docs/screenshots/custom-model-reasoning-effort.png diff --git a/docs/screenshots/custom-model-reasoning-effort-dialog.png b/docs/screenshots/custom-model-reasoning-effort-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..2ba25f5f8ec9e17723f03971abbe2d9564f932f6 GIT binary patch literal 145519 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 From 74a1a7b2bcc260c59383236f4a5e34bcd7a439aa Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:46:09 +0200 Subject: [PATCH 82/94] docs: use the user's browser screenshot of the reasoning-effort dialog --- .../custom-model-reasoning-dialog.png | Bin 0 -> 145222 bytes .../custom-model-reasoning-effort-dialog.png | Bin 145519 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/custom-model-reasoning-dialog.png delete mode 100644 docs/screenshots/custom-model-reasoning-effort-dialog.png diff --git a/docs/screenshots/custom-model-reasoning-dialog.png b/docs/screenshots/custom-model-reasoning-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..633c2159ae44a0df79c1acadaa1bff108c3984c3 GIT binary patch literal 145222 zcma&O1z1#D_c)A*N?bxx1?leYPU-HFuAy^C5fA|Zm1d-*ySsE~knS3!bLh_RaPRg0 z-v9evpXZzB8PDdNv)10L*Is*#VJb>8=%_DH5fBj2WnW3EAs{^BMnFJu`nziJxD5E46u9}W)tW)@VLxrco zxMX2pxn1hpCA+&C_?`;fy-A7pz`sRM#X^&p7sJ|N?9f7f`_7eH6-UG=ej0o_pJI|2 zQu3x)qX#pVp+u>JK%Ak_E(1n$i5&jS9O_|9pR7Q=(r$I_a3bJK4O2?iYu&r6zBOoB zd*zE$OO$Q6FBKdkP{Q6sBbb#cJsQu0R&Lld3KtC{#`uiOv;Clc02z>8h{<=Q={0`v>@E11l z^-4$l_4Xs~^oPI3NHE|Tf|$C5tSoRMDaB#JDbXy6NZ40s=m;LVT(R zvTBt30RAZ(4IMWfMFoB{M|)-yb4ODPW-oiEdprn&Ui`q&-onj<)XN^^;L7hMME>U+ z{J{ABHVZlFpRc&t36bk4s*p-Jx>%5MGqW4*VrVZtdpg z#LvRw>FLSr$-(UCV#UJ7$H&LQ%Fe>h&IG){vcuhTzlx2Q z1xQ!Y#vb4qFo!T3J14i`p8)^!=>Mkt4^W-|0OjD|{7=yTc=X?(nywZu5{~x3q;A6h zC$PVP|MTJBfPyUdwEqV${$li>cL7cdqYAS8BQ;^vl0xk7fFOx%B$YLQE1+igA4Ess zAKJgJ!1%#Syq;GffQJx8kd+kE@OrS5@+gr=bK;%jE9zYo6xpG4G5oiUe|~)4{4^== zm`8DlXk$LC#1hnbAA@yOm6Ij!&|F*4uw6(InXIAN@A?dh z6d~|gpmeznH5o!6HubkhhR_u4KNQ!I=-(Noqn}XyBmuh#UmZ+wtN(~%(&UWUOoqTU zA-06mCB~er*2n<(17r=xKxt$M-s~9>FVYD1&nNbJg1*RsBjf-wy&Fxp;#DO22IdSg zchZk?P&Dqi4)X6KQhZyO88zC)#)Bjp_LFDZY{3YBqQXr^fLdmRRf-_TB0umSihm_D ztblAT|0aT8UyU(=^qXvq==WD)IQtbT12!V5D<4OEW6d`!Ab7;W+5yw6HPnd2?dsO~<= zd>c1DH0*gGihzif_Y4uBXLOv17RgrT4M|n?a>oc3EefK+6xFa|$LK4i#HCF%JvC26 zqk=wDv}zsBh~l^%awveIVqT_=?4Bh=4}$x{w_PxvAx=L9IO6d|2NjV@=1oN7N<-1& ziyu8BPCL`C4#;C_`9-Ts^)_Y)_K!>|&o6`V#en(Wh}r@R$e-RvL84Dah_1w&zajFi z$N7oKaz;EbeBk_b1(_0JgN5j>LbqKe7ew?zAmlIhiIR#Tm~aHXeTpnfO8IRW^eV_1 zIrjMj=6&|Wr6G;%H774za)dD(KheI01dNIMUK$?ib|e;{FTb8wiVAZt)QAhu$t znC07$BmX94B-?D*XExqL78e?m75`JG$r^b~EW4EX%*8XQwkMji;C1j0z&kehk$VJrGpDFCvIevhz7}4 z_D$D}9-*;>pNj^# z%#I~G^#kF3G?_|1(zTgHHnB(-`UB)=4;+y2JYM9rW$z9h;7uGz`PXGEZ2$}4;hU3+ z;^|+zx3GJ%yIBBcT_0L(W6|mY%!&t14m%;Qq$2PH&v7vIGK~yNO*7%m=ONNXJ(^LJ z@}zo#`8OO604Eq|QHe$CjwlF=_r@IYM_vg;;CUw~%9Nux^1Wk>uqykFt=R)lsaElO z?E@t9Q6J!O{c6tysR}oeR}&MYWJUD;<{XhrUu9m6^g6z#r-qvuY7r;CmnYtQHId+- z<||6t1#sB)kJx^(bOYplFMVdA9%oTfOr$!ol?S$LpwCfE_ihC}ot^s|^QP4=&bUYb zTFXN;jf&J8g%Dk9F_}$-h2$dt1|sPI#_yQe82h^MF5E%#A?*kBvcB+LQw-w2S7`>U zlJ9Zv+PxfrRVJja@REL%L7byKM6M;{Rt)7vazEWf;rs}l5^X$@>Bst|?UzqQr;12K zCfz?4LQR%5Tu?`BNr89y9nXNTt)`i{a2;5&hh__alJd zPbgGUq0}NOwyAi6rDH4Kagj!_e!9p$42=fN_5!I+_lMQ@XGmLOuLINIXg~70I(6|7 zv1n}&eaGkn}x@0O@YU(^A8@s8BU(QwNNcNdOc?} z2py8*W~}{eP2{a!t;5T(B$Fqs82^eEn8BQyG|;s-md;@$OV(jzrAJ9Tk0ml$` zb@V|6l%!Eb)%fkAPPTtz$USe;J&-6JxE&{cRF~mKzc`~-9w;~KY7puAAnWCfACs5w zfgf6yBsrw9+~B4=b=ta$>B>#L0`Do97xB6gW8tcZeq-R57t)gxWJ4^`C3k|;G^pwf7|eX zVESuF%lIH9uhxE)dhrdf^O7WwZ#y!LjHIpv>K7>rkjK{8FjLKxsVa1&UhCNEtEOqP zsZsKM_VYh@?$b(5mEP3Yo zU^}zjBKEk?UGf~x%&dt2x-f*muN0(v{TT3B8F(_thVls}@s6eLu%j+nM?Vw|GXB*P zFrzs&d8XB5{Vy#ADbdyKnZ5a8zXI<6P}`xHZZn!wq8^H&dUE_+i^OVZuT=ouA_SWU zjRB+;k<6sW=_$|BTvb?55RM%gRqo|0J{WOjp6&k2tNoWzBBRi9HoJC3sjeRDR$A5@ z2i$O}A~j(xSuO_me^Yl&vWO3pRMZq8;qU-;1G&k6LH|;b^k8JS-Lam0?}qk%V}&`! z*mX;jV8t}`%7QTc2}#9}Gz#1Jp=nw5zdhuCujS*rHv~LXwzr3UERTBXlLAaC^~T&B zQe$#+P#^Vw;Tw&tDUkiG=v9c38r*h?Lb40fKML|RuQD(;i$d1~Yo|;j>IXcW=y5r> z21R=EH+|yCexp6TZ{D~#co=#u=A{okbG0Sn2kE$JWYdA90i7|RiKsO8J?O}M_$g2J z5AF{i`ug1IlWE=eT1e+%mvaB~et&EECWkuT~GaH#9( z)N`euw2Ax%k7)Wea+Sk)ueoX3`De7ZVH z#Ak~O=CX5R!TAjuAky^KP8Zv0=Pv&I9yDKP8`@ zpc4wu-RQoYHZ*`*e$=njE;TBt&o4LYjC38%RW2Q3UM~BMz9R(UGwIb0Xqo%IlXAZU z#EU|Dg2&^!Uaku-p02UiuRR`-m)XrO2#)%#ABa;F#T$We+AR6soM`jd9IhZsJ0n8^ zYu5mYF7hWM3OJ8sNm4L0&hmQyh7t|#z#99RI*&Chz5(*4Cz#JazfmP|=HMZQjKh)# z3LY2NY!z@@udEd3XQlt%_@v^e^1p)D($44~z0jIEJ}lTQieRcq+y-ezAxmi2zW!gh zzvy~H^RsQZ>%;TqcNm0gR+GAOMFF%8m=d))=e8S%6S0%jkAJ5RqN0DT-9S@jFsUq` z_d-)kmnuAlgxeIU`O$BI(jNy(HF`#G9r+%?Yr`%D-O=x%rzZ+tFf|{KH~^WALsfl@ z>F<_{fFzD+$QWr+Y1uzl+IqW1t(a=C&gy1g=@00Taix3$bBRnevBM#d$Uxh}Onjb) z0@3Z4G5^2ApELRg2i(Q_jf?mPLf*$4OIX|l#=g#z`RX)0{!kJkYjy)J?ECQ!Q8hF14#T_i6gT^Y4<3?Zp)-R{cjpCcyN4Fr7;3F}U4COPqNGfL9p1<9 zh3^gvOTU@~p+XJ^4PKgX*oPkcF0hdPG!UP7y8+jgsUkw* ziMRhQZ_(_huNXjjY#AV67XZ|Dp8U>2#vr14Caz)x1F=^UQwm}4Fn^=I2!RGT0LG>A zB)d5f=I`{h`w_4iHc&Aq{4LdDK=BA4mq*~Ydg>yUz8KOk$>d(}b^WWv^yA;CBz>T0 z8!7}vRXy)1H^XA^^0R+)-lA+c)+2T0b~9D^_ZikL=ls;|3#O`%TcGtHlo9<9fyw?P zj;{WiRv?*@3J3svGiOTW%eaGg*w8g?V<{Qk%z#59L1dH456Vm!=>JA^?BhTnU8o0Q zl}vzQGN;YHG~h{R71P)#E+KrbmO-_-I)p%Bqew`i@!uJHDE*r3=o@IhAkJ>QA{(va z2jH8jnrNt4do$Gnl1p0+bSaK#$I_a_PI9@uSXBGZa<~;zxce28_{aOXFqJ{V0K+dB zpZz;KXZs>5EG88lR56Q>VB$bmW$9rOE8hg}pNS&l5 zzer3KJ^uc``@nlIlHP>!Y6vu0W4}U|PZ9T?ufK^bf^SZL$x{A^K5mLGW^$ulHikTq zBU$zP-LHDpzu2`33KH(xe7WCM_d~TV1~J!y5D$VNPs^HXDo{kXzj5xM8>Zc@S^#Bvvs^Sg2PusSi;U-Tfy$a~4qslU~*PWY!)R zM|#2Lkv~i-N4&o*|0)FjKRm({6*Wo*qF4S#l#5L~gzhW{Nhygwx6h~YGXVD48)Ehw zuypmVsFG|jN3VeE+tm!lY=h)#IPvNn$nipAwFBU=T2~aI_N-#;&LJ{{YSx{T=wE3d zRr=;*JCGv4gUw~tpNwamwUYyAJGrwf;Cc&a2Ni?+D+J~O9q_6r0V(sK6vSH-n_u2*T(*0SjV#-ra*z)3+2g5tHE|@ zOrdKh9+#Ewc1{YQENg?!yE|nzU(&eq+4?$?XJH5CwH(>*)*@}>vx=AN^dt#}x?6n) zyO_b7{V$Gwj1`TcRy3$BG0Yt7tyWPPd8o|J!AQ&LGDgeebGkz*AJ@eg+>dygS)jq? zFjqI=qv7ygHimi}?AN+9sw}h`ukWENvgt8rL&M`XRn}f5O&JBMJedds-xzy+Z$4Oq zgxN>~rJ5xQr-5m!#+$QQmf2mPQhp(@Q3(&b|0reGkZ|WpI*BnA2OND$ArQTBtP>y8G6((`O z4qL5hsgXNuKYbyT=k#yWfU!R9L6)&^$ixd3W=R9Kbgb`BlIg zwi_fcgr|zjJ6|Z7kmiGqH->lJkMf$&TCTnZujR*^-TERDIyRMWd-gFMS!F$g@H&4c zWHSwxqWIwSu;q3}=;V0xeF~ip8{t118v8KtKHIt9`S7`})d4jU>A0c3#kwumKYA;t z^VM^^zu)If_qo%_w83ymM49fl60s>pQjo`^Kvc>%Wz%ITuyLpnbL+8MZ3zmbx;!7) zK>63suMr1aAIubKed@Dh0Cg^Q%b(spA$C$0JP3x^5U2b{?kjqMrJ`4~8^MT;_an&~ z5%%FZNALR2Eih$2lWkE7N(+4!=TfxD7xKWqek^XZsmxV+(7THhT*Nx{kISWRyyU&L zCyzImy-REKmYyl`!8y;CqeZB5o(KLN{A1HBVqwc^)Jh(kBUGChw`C+x8$_6aBFHdi zi8-3ATVsvmLdA9YryZ|t_R}0HwO-ez18?5;lw4v{cqwD6cELHc zaeKG1t)G<`>pdEzt$65XoVIa_jyM!`?a>Z{`!!k=hm%N>)C~|VE2iuc_EcqY0oP!Of1mPbz z<^=YdV%mhvYx6WnyFU1FseR}%*~K*2?YA<>g${Nx@fRCetB8|i#@ujjdbT9bVlkfBL0~*L@7NIp z9J4t(8ei`P?Cw)U?Z^D_5V0DMyL#*KcIlqO-MUCnF>h^`kx!hIrgw{gtsBEX8+;AY zKnMK%(Ja}Ryl1%u0cJfYd3wHAr|`^#D+-xNxfYeM0jVj**IjsWQyMN)**>RkeTlxJCpj>E56tfh-d+d$465$u_OG_4 z-J_z~@6WEb9)>d6r1A>Ec?ovsrh(u%A6XG`bVcItRKQ8ZW7w|O1&JSY8&#L$l7Dx0 zM(^=Q0eSquihK4oID)CcdgWynybtfTcER7Z_4;xfaTm5lNb=5mG?J%b+~-UQbbF4Y zq(iFsoe~I|Vf9CNUm_D(QhPG7y(S}CkdA2)0 zkIGPl^>P_-p4h91DPNW%l{Gar$CUeK>hpLc`3XAA-ls&=vmW5cI3D+pa!$`4mH5Ax=wp{HtepnA*2;d!SaYwhkbNzv)e3NdbO}K3v zNyy_NpNN$MrytV-6S~`DmYaK4xTiW)^-w2i z(Ln~6&rSj#K51w^o3GXC@*Mjl(|`j{##Edgsu7p>Y}xj2%A5Cbb8xFwA<{%5;j~t9 z!vL~HfjNbV^H2#(LmN6fMnp$l_53aO18a>=oHiff&;I9M{LT697 z_jA)mYD$@*Io#(9(IO?zrM&BNu9@M-HlA8H86^UK3mcb1ez)3?CN1p_xnmyIuY#d z=JwgI=&w?bbSzL9apN(}(U-_(Mo%CaFH^DrzO)yZ(XkkjsF*WXWqb!XaEJFI2I$D+xKHS_Oq-TK!jNf_ zXv=R-XKCzXw6KG;js3>w%~EwygJSc&51FfJt741NzM@Zygdx%7p|1(m>_gG6ro%Fb zIRn_gr|+Tj)B&2u&L%S5-M3~#?W~nk<4`s%{fTFK*h2|qb@NR4+vUlE|J6%R*rRnC zhp276RLwB1UW&Y0tBm|;quRM3p2Wnf^|XL@B;($`p)Wn0w2i?Qrp+yr#(Tu&EtBA{ zH>G9QhiC?9*d~Mg>qKY9+r~-3;XxRJL-3Ya2PLTG+?Z$WoOhv5$0ZBl?D(ZdJr0$N z&%r`zt{I8tQe*+gw%(5kN|>?J?S(@dI*%K6#BvS+s$}S4r zw_%&-?_0*Ac(0v2e?EBfBJ=_pY^?NlDqx4d;X0gQF3rZDR->RSpC~F?=&4QROZo<7z%$i`xwn04K!qsln&hm){eQxZ&*qjLBEZgu0oQFQ;I*V- zzxLMP!!(<`ru@?8Q$3Ag;d#t9gMho0!$t`f=s=CxoJ${5OsdGJ!jS*kv{k0rm>+f0 zW_C9uUVdV>aj$t@sT$EMIUpx{ZhFkf&C9=mWV0pspQ1*f0oD-OA&|=m+*r!4f?^_W zvS!}-w{F+(oh<|?S9c>Gt#YO>bfB9QWPeRqiGlNp#4Kw`RF#%oDKLYy1oAa8E2cd% zsWmk>jW=m#!gu+7(KRQ!?9 zwcbYEz7Z%qE@4Xxk{j}yLy8B~orUa>nj4?+IqT~;KKqNI^JLU|MYi=1Ig(tE=@VXa z8|CHK;1PJkIGvT(!xOVOP&}|B@pa9g)|R3$7bBJqYHX*xhMvo;oy7H0DjAxRbz>V< zu-VIFo+q^%qKK`hx;m&WvW@zWpg^#}C1p3Erpg8Ip5;q+fi4*L-j2CeK6K3_lz2-? zXf+lI8^ut6HX;+$WPrn<89n#<>vcRet@qVpD9L=w)!sTyrpdMM-O8vk1*QNPYQYXB zPP1F8Yw}S~s&P#Pvjf?yRzAM$k4ZMzO=$ohN^nmU4>L&kdY{69qt7l$fl(1uXSO%D>bhn5J|HfJ-8WL;exbP;c3@U47&WTbR{(w5=hZDc*^PoKdvxgY|# z$0^J0;>_E49zW`sM8DJ&kwcA0eRh*0T^xzhiUcK-Tb<(2vgf&7oWCj(CuBd{h6uk} zt58JjHqEH16`Lsuxv;Cby`~=BTNL)Ytdluba2U`E;l3FdcN7uAOi<_L2Qr4iEMF8z z0))4`Z$N;>i4wcyoj&hZNy2)fTx_8T!>O@0kGu6$W%`|0UNJOD&>sRbkG7)Av7nZA zJ{-q8qslT%I>^Rk=?&#IthYpL!99Fl)k6CHwa#g9E2ub$&ED!5f&zTN=^BVI%JWi3;s%qgw+icx1MVTd zg>sFp1eb6d+6N9I+Y(F!JSmxS>R(L-U}KM)72^|}X|6C-!(-0*7zejQcS5A+mY|l` zuJ*GFgiA*AdZ&*XSqwmDp>^Y$Q`hZ+(QR&&`Sh6)vk%V}_Ew6YC+||p(Xo%0Y-aE1 zO?cW`^QYo4CW{geI5S9_xaPj}{9yDx#9lKX?^(?5BYw;kzjTrvm|NNmY+`ghqa#Ey zo5|W{p=Ytd9~`Wx{Pv$B;|bMJdlOY-Qf7GS;OFej!oAZ<-MhTZD3@^1nB%?~oR4%` z{&nk1J{v=;0WNRmmbugeqZ-<&cdSAT8=lE(V*LZ;a2<)N@|r8jsUoIwZ^krCll#Mq zU1zjrbP`frQ=b&@-3FN!rarr&pbHoluy8XMC9tbPAy~%@wM-0UTj1j^ZCra%tN5{2 zy(^3LFkweJ{fR@s2UJ4iX~B~k4osfa5P_+t{HE2*?`pF`ukUW=r>#;}Jr{5HB3S0K zPf1IfK4i!u8FY*wZAj-hsoa5SF(#;#lMln1_OFjrX`2=n?rs+D>d{ylXd7fNz^LjX zx97{5`-3VwymwHvd4|%aB?9?BmZ3TAeDiL`l`0ZKQ+9gmv*8RS&%oDoGDgfPX`6?@OPX1rKdxRAp*K}2F%Lt)6xt`#gdqb?c9rsJ%$(e_UZ zLPlY)5y2Z|xg;ps$z9{gc14Q>t|b1)p9F^6A$6JsWf#eYBE5Xu#lq{AO_W!i;)7=} ztF#wv|aK0lyECltLtcXC-8CGD<1`h=K=dioC2Y8Q4PU!D=$i}Wg$T?NRh9z!O62A4ZiuuaGpm-J9k2F(2vJQ!s}-4+xd z;=*69JlA3#ocCCm=b>RMiYTjZ!NdG))y!iU(>POe^746Yb8+6s5n(U&_ZQ=*FSC5o zXSVI*G_}Hv_=CmEjPl^+>#1m7WowsLxh)GXFK_*a#i7l)If4$W-{}Kq7PQ^I6qdGl z$8Je%YBkNf9rf=9J|Q`d8D?b$U1FYK@4!7_VTbPYU9}_R-GtipW&d|huVReo{b{D> zOZxGZRI4mu?@BQ^&j#}n?)pJ$gkBjuH&I#T!DA2*WT!Sxd3d;!Ms29$J8z)8&67@~ z)2SyEWpk2u_i@`}+{yU%rNCK|e%F!c;lqboMu>aQ#4h_eLqCw0z)cu)>yX27_pWw#tC zw56?=dgzXuK^Vebf!i!iPc+mH((iQm16d=*8ul#*ImcDVM{d4XXRSt+`G4BqW%6$k zu`sH|ZctAeU<=DOd4JU&7E|>)Bz(`95LjIo~eR zE9R31=6i=vz|DcTt%{1W>TsH~hrP-bwJ^A}fO5z>$AG1GbLpQ)@UVb?@944Jt^VC@ ztpJA?-D*OdRjm>AxgZpq9kXog#ev-@3rj|AYcnoN2i!~8mI)HQN3!s)!PnEkoTVC? zz@6Xj05{{3XE;p1d3=!LDk1K%aY9JIeR8;Md1UE7e8`pCXnbMM{&$NjP~~(d8yYa0 zsHuJ>&@4E<7yYhEU0G=S)Df#^?hYWO`Q*)6@AtcDs`#n2L5IkYZHIP!5_Sh@DXNk=JpP_}evm&$|n+ zGT$NM6-^?UoY1{Up82W)lnA>kr-J=!9))LT;=#dtFPUBjLVv0!HF=~}Lae(-31(3y z!_Xk~#IajnK5J))3pV!&ijlQEPL8X|!k|ct>b$A0y`)U*lO$ zg}cI!RspP|)xdntM;RT1&Ui7y;M9R7@dN32TZsj``}hc!B__aa;2;>HJqaGj4M@~p zZCN|niuh(Da9N(8K-c3ESuhkS*a%RSa|%$khg0o|)0cvaT5)lHdtjdj_Y4h&)$ULw zMjt+NxcK48?YK8YIPN%$Ny2Kvdz>S*xe_z~WQ}fzi5Y}J#5+U>NBzH}ibDkCUY4$eRrxhC{npWCg4XN%Ygj-s!h(mj-Qr$u(pR~~JZ6m&3=Rihv zo|R7y!3zd&pK@5-F~(m0>p7NfFVh0lyYxg?Z7 zDRga#40Z)s7#x3Ij}^?@GOhOGjbpe<5#!A-_|Qi|ACr$fR4+y>5^}`gCR8-U|GKMaav>L@q8$-|niQ<4DX0CuzSbOCa;MN%XQggX6^n2Y zmhHd)^;6U9)$$kNFBVU)&nxh~8A=r$Q5`3}VM~Z*g0ZV~Nj>Z+kb_gXF_nQPw;a@? z-sLSty2k^k17!V!A!Yf9I=xquD)Eu;@oE%}{790|iIPjG4AtG)!W#Cvf?Ll|ZGBp< zJlfSU+lAywDu$L=&HeNR2;y96;yR)YGejUWU~D}@V09DmuhtYAIolQnrApK5L zWsAFY%hB?5D_wj$rjI^AYvDbo8Ft2-I#9uuh8#D0a9`J1^n!<9tAN@n`^oGitG{vm zj9^RJAG^lL5HhCDUj;Z?+(0Qt?JPCc%?|g1_us{$sTdnm5 zM6r2Dls+f4e1soeVRP`wg-w%847jk~*XT?Qreali`k-+3=?Q%PWX3NHyM``-LK;>v zZ)!Avl?UzO1nb_nGN|gjrZ(yk2IQ7~$VG3y0J%IOmPbz~a;a7{GMEh=_3|Iohv$Gx z!Q(|s1E+b0L1+l_lAS7qa#DE`a243Qc!sP7b)CoWyq*FiDcYrQJzB0?XmAxgU6)ted_hV<9TrrRN2urS-FtV8+vz95$_LqcOk>n7pyHKBWvQ?)wZ1mI?DjcPjAA z%m{y>*UoXV^u{4L=rN~v8x}!ki{puH`YJ9l^+lN?rYk30FTXC12al{_P7t%Q+>>$8 zmwH;xhzx zrJb(StyYLIx^eM1aW7bn>o8;&#n}8nsN(x$Lf)`Om%17{MX!? zDCG-Kp`g6xbH7x$xek^^?4aM8&nz{!O!qUUzBA)w($}Pa<@VHFOx9pN1e{{?Bt2ni z{aSLEpE-kVUz=R2Xhui#S1j)J(H=@rL0xqCwO60ko3X>gw_^#Zwfj<`1?+|Vm9_Fk z$-dliNB5rXt)%}|_46&6He0O?>M8?d$l|G-#kD1VQ*6sHrLp0AE?z}fm53(3V{hbK3g@X51i0CIxter8_%;Su14m0vT^%eZtOqEmnWJl~Ihg5KKaL_Vw@JU~swh>>i7+^`8 z3NSBkmH46OwSbujTS~cT={a|_TUO99W`?YKl-#1);~Y(Wd2OTz!{QbS`@zS(-0^;? z9&O#x=6ku9SQN*A;ONb)%Sn$z%Yk%l5+3OR{vdpFy(@HGZ{yHkY&gn`_L5HPczNGj zBjfB!zxq-bp4;a0rfHorUi)o_E4x1^q$rEcO~U@!<}21ZF5k%{U8E-Wmoubf{l zEqf~p6J}FZ2M6^|)E?t{B5l%Lvsb_DWT@lGD$B3wc!JAmLHZI7iO{6WR}U&x89lvJ(IP z{l*(nVH8|QS^WnaMr^J)Q#mDFPrb2u&G=I*`3Mhn&X4Vic=+^>f!-Jx8ZxA(ek>7U zXL5wjP*wIy#o8pm#~L_A*t6Z?n3l}<0?DO+Qdx#`3BX)~OlAH3x!Zh&buMqtShdfY zir&0C)G09ad-ioJ0o+Od*4TM9&YzvF2;R1N;&;_h{MP9;9)?;=y8=E7jKg_}5>2X< zPMi!hkek?JrjC~|h$hY+SE$6dT~SVOb>=D0ST1QgEL8k#YEa~kNYU70y*o=Hd>DTOJ%#4)XWWd6v|s z!KJ!W11C$?CN-yG6PZ+J^`9lbEbS~gMskLbxRkymHm!3l>>LTU6Lv6y{LO8Q)nct%TgS;+`B;`80@d6(z9T`!t<7X0Z|r~0 z)o3XLO;M$cd&zC~D#lD+0zI$MI_5B@@r!P}X>eq-)56%7u;VMWrJV4WTv<=UQidaSV#*e>w++a#*`;) z@3r0>T{VgaEdJ8bNY&eQSrI0_?O2xq5%z0H!$SvBb?=cd8vd6twfc;~C&GZs?ODmE zh<@p4>~|6;K)Lokv}|+J{B-I=TIL;Fbn|k>{?uqfSojn0u+(};cu@DUEPli%gE)vooGOK~YsB6wce)MnV!F4(Nzr>61s! zZU>I#<67>A?|RpREWSAMGJRhHAC1JGeXK2e^&w6BKAW<(p;uOE^%I%<-qcfqA&8pB z6HOh$5c;l+FHL$5hk+Qo#NY2`TpNd>JHp1Q&nmGew&^HZMGjHaLgZevSefOw|_b3J01|&<6S)~j_%lFc=!av&F2Xn+g7Fh z#IFE=fS8ZWXga8Yjh>B@RbcVScHW?W(or-lxqzK{J$9qWyl3MwyAUP2g}#< zP&rV`s}hvNlsdDG?wPS&wV|@a1zl7j87D7mWwt-ijP6hSbWS;r47H?Z|9%Zbh9|lk z^@5E}bb@XHIFjs|NI>4CZe&mIhND&6yfPwhct>EgANu7)Me4oAhi*s{;p%GQK$Rm3 zLB(?pOG#q)&3NUL=~Zf?-HM}uu;^%^UEVX}qmQV?HJG{1gYsZ<*=G?xZ{J-Fc{OdQ zRLtc&e=>WBBdNj0ALnNhLb-B6HR_@#9M>VSxct_r;6$xIS|m%vv|i@Q$oL3dx6Z-f z^-zT#O`HWb_esq*&e6#6dR~c;VbFmJ{QPuTDs*Y-tzh};C%EGJ0*1iu#@OH{V@z4T zom@S*ZEKlEe=+8)SkA+*NG(K>O<-J*6BA9aXVtH26CGG1^Mp1R_9rh>expI2H?kEMv2baBG0=)%hw$A4|yD zWRZXE%}r8@80J{+k2h855Lo@gxnUgF205m4xWrI^XFk!wy9MB&c{a8Tdbne$WIBoh0$K)z_93a9;7oVq=loPxU!I0>hG1m?D?m%FL`9X@y&* z1$W+k8g3xcz$vCqyX4C=#A!@^5juI0#g zbi@q3kkr9%`Db_&O~Gc<0X5{P$mhId$k-s_lbvA{d0*@MU?B@ zFCNM27H~J}K|`=QbL;hCScjW%9@gO9Th!&-wgzN~b0FLFMg4sf#}g8-Z+p~-;UaSd zIk|=8sXDwJuY6l`UqpK@_^@=01PuC*9@2)cH-*#m6!KO@D!zehr&PW=iqVw9Oqi^u zT+|WaZEgla^zIm?2qn+l>xXB9vkpfQ(_Cyl@Uqb;E>vDB;UePtS=dl%<>^e=^yDn$ zJd~+a&H69r89L*h8G-#yy>kU>)dNJI%@T463h58sRbEEIlSg^v5Nh2|BVpwWGvH~H2i)Oh zx(MC=1kNVvQs%<9=Vo`#T5WZt9x+0abb}BcA*5b3ct?WKVI{VNkQa#+Gtk?%c9g%_ zcBv&5uvFeetyugoB>f_8vx=hY(C30Vd>gF~=1y?rm`%l_)wS?vifn4oZu&MZW;gO}QD45#Cf-sD@1%Z7!9(m!}4s#K%87flg?6xlWD~V_G zoNNYBTP~Q0_x?!YxY@DJ+Xg!}mc)&6?JRp~x-k_QyuC>aXp!zN#@1c11S4w32~F!u z`#sohP(ckQVBFDKx3Z371ckT7>w^{@-h)xMQ`5=Wy~D5vGcvC}7;zeyoYA6WX3#BU z91;U%+Oqtsu#sp%pk)osOOv5Va$Q;HxxxBJ@x1pf4Zr>`fMCB*1x_Rf^h=k_pRBIT zPVxEuTWo~pM<*(LR+K!Htf#kkx(uv4$Uy~)^cx;NMTb|a@%pD6eQboabt<&ZXQmOj zDpY+#Zu7>LM{VQ|tvcUn4kFiS`hlW{qr72@0Og_F&dGxO%mfX-lv82z>#NRVP(1x@&4%o!hJ6)FMl=BNnw%vnqY3a|o@C#aI04E}fQr4(J*9ToB^JR-|nNO@j zYM5K+T*5w1J567rLK0@%=9#rVl5v95HqnELf8zPiO}2kgfXOdjE}oav*b9kvD#9Sx z=58NS>`8$0VSTq@+QE16ac?$`wDMk-CQcC&Jk+<~l_?O(Ex~tVm<2gb5 z!6;?Ko|{1JIQ;t)MT&Ad165)LREwAFZh70b#3l?Sbzk-82MAf-*iM@p>H8BRLsBKz zmc;yJvtOw61`gd`9}Uv3zQLB1e->K@bT!)Xh7kLVUgUrIV)W+hh-)k%vT38r$T~o0 z75-qNHAvA~)gLz`{U3+sXb;jJgO|=Nsg1A!_;mG#1N;~ zb9)q>@<^}P%|v}CikqbmCrZR96D`rG)AwrLbNI$OJ5DQn7cJGQu1wPGMEANQ*q9nP zD!I@z5|43d+6tZjdfJQyL%VUwmvr+h5%&LXM80YCJXumcrOi8^y92i6QN7xr@1VQ- zRRhCBx5A*g1K12lIP$HEbZzxA zz}WZcKzQeUQNUAeaxdzLvBAt(;IE>m)@a;ioSbWw=ksl|VV>eHL3r}aHlH^Iut%{OM5XVo2d4DN3YlQmZpmN87I zE(d;WCKH#xhe|&|qc0LymIRu1N)<#V;fi(#zuhv52Tnm41O2Ido~seYOy-48hG>DH zIZ^OUnzSP4w5Hys%~kk|S{SunFNB*B8P}NlOMG4ta^Rhw(V9m5_8v)VT7$pmh6QXf z^yOL<-{nSopJ;2pn?zXCtPX$N5CftK$4fThU#Y@0$plwWY3ya2x#G(}Y#e)5Sr}?`WD-%)WbKGstz;S{%}FgmH% zV6Aye%)3%Oe+h8aPV=PEa83*g1fp|WD`f9OsAE*aS|tLC=vk7noKK;bs%y>l5b)BwM?PyTYyk=)w79qbAA9c|)#TE>fhvj$s3-_1 zDA;JBiFBlibO<$}7wIkZE=`c4R1vA62uMljy@T}Ldl!%{ARR)x!}-2*^nAy2*Si1S z_4^~Ntc1MtzI$fxnLYD7&vr-7*})!UN`d34Fk5x)wK3&Ze?1-aMQCzcNu#EVcY|l| zs_4R4g6~~|O0m{{w+U0q$SR$I`dQkme$e55=B8{zC#iL#$-&SLLN@c09qMkUUSnBZ4!*U$lrt&Ls1#Rdp?Z7v$leU^-o(N-?;Sv3I8zAu zFYX>!{8J@wS3zz}2%0RUV?9@}DD(DSvY3|eoK#WtIl^T*-Gt$wYiIqMH>hgLaO0`w z*aY2OuGkheQ{gR)S|hpQEuRcueeW*X3MtLEPv_D<-k67KQf-)wjsl*8Ge&s4OYu2J z-0DpzkiJ`V7fxt~0$@H*Se19?>+4 zznc@=Y0E76QN31hh{zyyhPIU}wOeRTEOhw30?k6+K%BJ=^u8mL@SKr%EFFDrzV|Y1 zlW$6@bkwp>@ZFm@(0H|hAC4YzwyS+_QhF~ntI5=Q)xN3De=?|j#|z&HvF!LQ>NCGL zto50aF^*f_$>BsTEdORN4EFF=H;nmN91A0&n&MVGDJ%DH%jw+2+9B%jwW@1$15 zB)a7sFWr?4&wqk6JE`-lSe*0dWfV&rJm722TBV?0^}48_@dkT6kC05aZ2lWs>|H?a#~ly%r_a8me0dG6mMrw20+wB1V68FX+<@y zrS9g@PP7Ga)&W@Y+vEo!dxb)rmHpj2*@y45oOh6GcTU`hrxa6+lexNi>*EQ_D9{t5 zS*M46oi(tOTQ~&c6|TBire$-5G1l7bcoiCU}=`D2~M%r(E4?=7gK5{ zy35>}5Q;t&+( zw%<# z+lG!r`#tN7WKN1xqmLhe8^MHG7l3w#i1LGR>#aeU%mYMt*FkH@AHhK24xw_rTSjKd zjNiLQcL6wCLErpu!V6*2pP(y)S(P}B==Xf~--pGT(`~5;UOls0z6BSIf9bjYjaH$R z0H@YK#h&i+Z`rR9UxA3u*jTi?41jJ!A$9LpMtAkp@OmtI5`|$5ZrhQ}g&LK5;iALz zQCW!zzxL#xDgo!PTCT7f_tx93jIv>j^5#Ng*v(Y)8X!8FZQ%?WWNptNWLzd6k-X0K zBjg;l1|#+imw%qP{z7&GcS1+F&27JUDR@G5}Nkd3R&=ifE| zDFbI8vIL2IWMwm$p`!0vF~I@9pk9R0YLf_@D! z&DG#<11gM*OSVEYe=ae}?y8o){2?K2U$<<*>Uyrz%TBe+woa7k%Ly%kSG^Ns&uTXd?`nmWIjAyQToLQ$jc@;+bFmJm&&M&;1rlo zC)=j|x8}pbDPWcn^-iVxs7B>s6Aix^A*P%`#jIG$TKNeji+LAHRoXJ|q`DLi8_rl8 z8_hX8>_G40;F@GGcNtsnOjafxU%$s!!G36{@>|;i@tDUs-GX>rI5Dkq2gMajF@pV+ z=F8c)A!(BJA3ve^DXbYMq__krjm|i6vsQ|=XL?rMZFe923>UwUW&yLBfKyG)sMy*P z0-HecJUrvj9n-d}C8nOR@<(|4eq%%P<3wN-$k1qXBLBJ^njLiw?a0?4xNwuzmFbul z!17w&(Ej#TJ#Zi0?`kGi{3z<4&^#i0`>}y^F_tKXZvM%E&&=$e|F6fUe;*iWj#$uk zXZeJ$lg{#en`sC=@;E+QUXu5>RQb9DTj#EEkDXLRqzO*51?p41&WVb;Dfw%M{_R@A zA|Plrb9>GI>wiQ6POgDLJ;Cp~^XyUrA!lUfyz2>E|N0*Wz{XK`axebY#K79JdKdkK zhGF+;(l6?}r|+%Z3&g+2$3wdLuT0|C0x=*$$6WaP;e{p$#B3m@2TDAuqY)#^wuj6 z^7*$GtU(Kk=2JG`oTy~s@Hyhup1O{xXG7QSJbm2dth;rinZbh{2*;c$=>Q?4GXB#e zmSQqP(|-g_kGp63iRbGjMudj4G$z6#v~J#8Ylu+)E^@LVDt+A*0N`z(trz5zJ+*yX zKGRBPlh>g$+{^}O4y#2IgA>*(nQMT+!Q?}wKj0I{G1`33_Um(?>?Xbzz|z%ja*r5I z;h|G;X|&j4J{}8kvt62DS9U+He;DtwDc&j6P%n~yTSey`<+%Uxp{V3q*_gRGP}?%h zRh$Rcm`8j*v!ULlNYp1^I@cV4%v%T^9$*j^{)xCW}na3%8fiadx8*7V6 zK@#-Vy|k$%_)<^k3W$<&N;ewG%H4{`2@-~lLZRb;Givg(w8}!O7=Fphe5PJ}tu4FC zmSfU3l#IvGGH_?*X)f4qD0QE(Q|b=~)HNp$_5ppQw|Z)%cL&>$df$YpAW|wZy?)3# zM~8^LVHZ|Db=Q6|VXd!=&qj0%@5`f_?W<6rN0rfai#(x>H5%#e5hgm$chc{bmcw|V84y~1KQA0Ot` z_uP5fMn7v_vLUk9U58J-s{(n{K#w@l4THzjn?zMOQ%gm@SIF=kV=DzjuhL+?li|vv z%{HzH`?5fy{G2n1raf?kEA(I~Rn5c`a8jR5B>cGZkFfRW<=Jg}jp8A3JlEvwKoD=5G8QLl__|&Wxs$nC! z2AWtK&+oE#4%g3|1n3T1?Gqm9B@NShP z9~|tr>Z-@h*7@Q)l&@Afi^mP&32Zb~n!^h)~=GA={M3(FSDi1yZ`_5M&9 zd*3LMs7=wemx5_mwQbx|0qU=RkXY5|fdj@kRi!89LQ-J7W3TvHT`*sCbrO9eDVxc+ zNyk;T)Q6I|re^@*JmvI#;^)Ic0PYZ$*`L3p%dJy+|`Xw5{_jbxi7PWflBjyqSI zLLIW8O@naTpZ1k*(62f?aW1YV`X*+Gdmdi0Ely>*o4-7ohwy@C=GShvm5WdfAA{|A z^GKrBsAmr5Wg3)x3Y>+T#*F#fz0x`p-3c#}1fN56K0-I{XXXzl`j_c5d@c?#0}kq5 z=(q4<0^~XbhgU?GXEVd;z{zchQQv9l!o$fB!)T(O5MQ&R6hXSZ31^Ff{awW+jj?9E z*k^WroOa*4Yp#*9HXj?fvlz6Cl|>8A7ak#IIf3?s(H)#Te^OJTR1^JybkV5GKRBk` zknr+C-pu09$Iz&!l*Rntar5CVR6;Fu6t&O(@#2nE8@ID%57SG0;EU zc(4Ga)~orDUcV^~OSnt)PX!ER7Zoc7Jd)Au}@rr^zjSFSrWNQ4uWc-uRSZ=l|q|qov`RM*aB(r!# zPwB(!{!B%YrhO^UesVJwH!ay`p*uAl;i}sC9qcw`?y*LDkN)Y)-Q9oq@`Z#Pry6XM zk_D(h=xRxyw4B|VUWanSCJvvDraQ#QrXEn8-K%#L3zWIFZKw(RJ9%cWQYSoOeJATM z?v(00Y%SL4eYHB~V#_hT#dOPj=Hiw#rt2}WD}p>g&7#tdl@1QyzQ!^9MMIBV!#{FJ z$A~}=qizG0m&vwGlr9O>n=QS?UU+;q(P#j%wt{rs4An#Gc(-UovG{_<8Qpcga@QKr zj*`Zj1bh3U>WkVKOY1X>Morj=6{gaHF%$Mkh0d;S_Ay%phkPebh-=Mru=DE_v8Fv^ znjX$j*ATP$PV?1>zFb^0DFsZX`A~Wi<*Y20i{W84Q=3VFbWo@JWgR87Azx-aF~g}5 zCAac2y)#yg?$FZ5ns(&=8Tj4ox_KZ``;6>e(X06#9OvcJ_64 zwfz+G^`iu-L%C%0k<+T9-g}0nn=_M!s;61c)@)6)kN2!M?;b6s#>emslsT*r6T#%( z7IMsu^gT_8)zMUkR6RTdF(G#Jes&@JbR*d7wZrKJ{1Rk(dgP&9-#c!%DA*a?VdA^8 zR&kE|$Ux(k_)01Qx?+6nyk6clF`BK_I`CLl(_ytR3*|(3{prmOT{;*`hnKDsuenJ) zimB>kp3LDbJn_1)5fh?y3%)g{a=2nqEiCx$TT5t?o2v6_VbTs9W|*pBuOi?ge@LFQ z%aGUnFk~okcdL9Sse-|IdvRJ()0mQaT7fEC6h}G1Nn63}Jq-rAs?~J=CTwMoJ)x-3 z3EIlYY*ko|<71^D-A4w!Z3dQ71;I=wd)3Wt3TCQJqf9qt;_?VMrhu*uX}aMf#PYcL zWQ7y#+;W7Lo7J0+H?Rs=w{e4JW#VcBoy%U~iE{mL^Bedog>DmzPW{{Zs`aPqP@-F{ zjfxyy8gO$Zeu<+#yC+~>(22+NotRC8z{i2(Id_E{7$iGH?8SM@8&zLFcy$GzZc$Kk zIqMSo43JD-*y~s(p+3%39^tUptcMDF@j0$nrcHG{eK5d8p%wJc4K0BL03arQYYUL{ zsbk{Qaf@zw-D77W8Xk&bteXd?jCBM@0meRvpIWX1823t}Vnn#2vQzO{nWW?8+Y?Pj-9oj&<`#dH3!Ee>~(Sqw9Xn z<)p4}*GNcsA3I9y!`^NNDZ-k3DJq3uEWlnw$CaAYEzlo;%5M1f2bf4Ga zqs%?;ccrA709m;fWf%`F^AmdEbXlSz)NmfkF{S=UNNf7L1M+RkalGmA22eYpE}Hd* zzOyCpor6=KXDqNAQfZpdk1Ne1{C?!;Z{-Tq2+RQ8IAmzSX)xlHMUI)Za6Kl!)4Dv8 zCR%zJ=IwMZwc^4is4rvA*)zh*FQS0K@$@@nVmW%ZjHBe zhT+sK074FKa#e$uK72h0Zy)kHNstp@AcwNv?Jtzlkbf)QNUjv(Y|q+q94^?;k))%7XHw1>PCGHEDC9V?D-i4GJty$g*Pa?^B_3-SS>&HmMK)a zUwkmgQZvgNYNkd{lF#EsfK7SbK$jqWGK2U*AC3^Z%B}nPWf3QGe4fQmq=_k0UCC5Z z>qRSBAx#;WNhAq7C118amI1_hOvudp72WpfJL-5T`uQ%}4%yB@e0n9Fr;yL%ENMYP>sOh&WgK<_T^KP=A>XN@} zina9AN^X>;J*Sy-6qPrzs{eYk+Gk$41p^a4-9uWGd3QY9im~ak%WmS4u5^~!iS1G|w$V*xMY%hiA8E#zl; z%T4nVVW zg{baF*W+DxOP?)ACK397CbT2KOTOLWn{)8qyDhtH8NKF9;bwcd7SE@YVi8`uN`c;0 z4!fMiV--4L6aL7`7>_H0I_=`5-uw$+qP5BtZ-Yb2ZG<KogdZ$W!<;|?e_yFnCiFD2`|->)ikvUruAopUEH zz3jRjuTMChcHhE^)ZT~ty?Dfc%pn$M9EjXnlUbCByA<~nR1eVz9q-@r z%+cB)-g*M^6KFfx?mY)E!|jj!g&Y34M{rFLB*p^6;8^-HvMCNXfF{O)Bwc6~IvHpY zH55^?Ry3yVOEaX!9Snsl^PiN=CRR9JCU?o1lIl&s%@(^`HdWsXW|xe^B1$@9Li5LA zIj9)VJ+(Mlr>RVfhL;sLNV3wcD1x$Po=t+Z=T z+HOLE#hgT1YiA=~Q$1h^bBfz|zwsW5o_W5+!;S}F(!pv@gAmG^7xo>L>0adI&Sj>b z0Q`9|p{&DxOqljc?Xk%b`4$>}eey@#4Xs9*bjSC8S6p=#Hkj?ZYW{W%@yD*swX;Jj zf$d7i+piF4Wk1|bn)vz2`B*N?uV2#G2<|*)Ps?e0J|L*it-WQE9$rzmJ0^N?J1l}Z z_CS9sSKTQ!N;IZjDKxx0T>3T?5c&7!8x(Og9>O3Na;u^VEf{}a`BXx?0!{8VV4YlV zBRZZ_E4(*~RXqsA!CbhF4BXe1E?bAkA-O1bf5yK&f__|yN_u15zvuF<4Av*>N3H#* z-1vH6wsO7gM0K8yq;}p-G2u|1?nzRW<`^VnMVX}d$4LaekVd$~Q6OJ|`*dEyWd!XN z9uEFv{OwLgrNvc@10(hby#Q$59p)Ox7aq2rVCGBmzR zQM=Z{$7jn`ZX#<5!{VsaRgvAz8U40(^ikR`(qVt%`yK#aCVuPoR9m6kt(%8hDPN)& zmqTf|^!r1?tB)BqOsD69RkZO{fayBNEF4xci#2blX5HAbO~HB&xphm?dlwr>SZF5` zum_(>t!#=@3m-f@-JcD%Ef3~Hnm&}s1zqCU>7J1m_#H!L{ucL0i15w{Ek6n((l#UP zo*1>ubZhP;=f(<`q8M#xtUfKynP`zwE`V!Q{gA94Gq>4`^fiM3?ony_oMmPECR?ks z!!^QXwySkJFJ$`lR^M~(XAzx-+%s8-z};vT^l4bmMcEf2MtW&u+^Dytu47B%yp@R( zwbUZK4URpMg&(y=5_L8XF3 zn~^N}rY!gQ-{cJM&vFJK0G}z1cQT1Hof-H6@Mb!Y);9-1MDE68MVVuxo3qDU22%aC zwzVl0lyhuR(4Y#WRL0o&rV*cRDS7r28JQ^SI}%<84c1I+Ayj)h0JlSwk-gMMjo!?V z-G;@z?dkk*BuTyeSf!#dL){tSQyXRd0wStcw7cYfx&qF>$l>(WQ=7Jp4BzXH@j1V^ z0`asB;a=$>rD5343X!6+wudLrM(n)cFi&rVqD)?`iU z9s|A0q>?-S;n`kD+VsGw?F1rkqp^n#-EA4w6-x#kuC^T#aOGhyboHK^GOSJMgwC01 z^z(?%5oMsi6Le5oyKgR~R?!;!QdI1@eN5UjDW8nbSc@;9`|+1N#LvZd@Y&+~3dguD zcvdIYFc}5Gy7S>S3Z5yrk@a}ry%LAT!>fP1!RHf0oOHjqZj~DQY8k7Pn-K5fBN?@l zZEIrod#@g0WUX=Tp<`lN<#CWzn|Xs{gMu@2HxKaVtCEhJSQ~FPsbjAl;?DbKDgN!Hgmqok-_yputnx`;L41BsB0)cgT=T zrEvaC8&bKQq`l?wym7KD>)D29QY{l3d$@KjUOT~C4mNG`l(~oiBnkhN%L;=-_}r(A zK!CT}7EXCQYHlHnlAn8#nVmx$qB+nt7n_!|k}f5?vs(i9R5B{)G>e@@eYeKb9kTlr z6YPuJOD)a`mT1uJE7qv1?0AIn-0BMMREW)C z-V~U$lqy=s84$~v9v>S!t>0pV1oPos22-*Wf+04C_QBE}JUk7c$)-SjL;hxnDT9kG z8J}}SW1pUz<0G$+E$&5_)7)GtT-K4b+8D`R;JwM_ z>gjDFX(XXXnnFH4K0BJT~fmU z^|Vvw1`enT@Zg0{Nra}`Naf3hSo!1+KL+~x3LhZ|Xa92Pzn0-H;Ha{lV|sZtm5?1C zyMK@FZIq!jFK@#AU_?N^(r0Y#we_ekOD5ewtSRHNb-Kfce+ zcCx=#0O?3T_2xpz+a3pv8$B4QR@*UPn95Y+DDE6JEuRg3)_GQJMAd+!ZHe}LH_~sR z0+IMuaA}T>c6y~c+(w>@m?Uqum;DuE;m5H~!2~v$vAuBV{*AWJq(Da#67e-w(Rfl# z0S`|x>p2{vbd$(?q3zPu+g}+Ao><}V+T}VkL`rMn@k0P8EQ00p13(5c8qCG3I;fSmnXR85$962^j0#9tbHl$ zBTWZ0qR2SRW5y8v3`c882_)=uJE-x1_2|?!8al7X%u)-Blk)?qI`Z5R@yyv!r>e%) z2V<$YIO4LQB#E1mt!?myAZxiehqcm>FBx0=;FuvJ_f?g5hr;)=zKwLI>-n?$R$m3E znU)pj!!owmLzlQqT6yHcg1&h6x@6^cO7=z0BeOP$hsQupA+)8$HMQ40JHK_M|j z>O~QtqGfWj8m?q>fsk5%K@GmGr0GOSx_1iLuBzq{u?|6AgiiNbA9pr?3!x1qnG>c8 z5VMprpuX$wBKWa)fk1wn`hiG_%R5swZ_{^VfL9RT*0%NFA>Au*!9^8|me`$MC3jHBA#!M~PLdfQ*u)opqUYOylsbLXmggtdRb9La`~nDIWEF?VZlR4i-?r7IxTL+ z{O@Y5v~B3NN_hL~r=#qdN_NXu#=+LqF7mC#PDT*5jgV!@UGYQg2DA?-8#t zRda^CTu`?`QZhqOw(Mtm@yFZuh9F8hUHZSCR-}1AeD=>f7x};z=hF zs!oG0@jN7yz2#(nODC_z_#iHHoI~b>CA6Z9%uqoiYG`?&%`R|#va&qtrJ=pj{$?G1 zQaho{b^E<e9diYG#gZRJi%tnc%}1$a4kldp`?LWEwb|a z`}1vMt}CPRc3D?1jl5I}dpD$RR~W_as#@qLu9b^=sk-3K@=W?$UXiuzKzQ{i?sPn2 zXp;n?KUEHPP722$!L)Cunxz$Y@SQvC3WuUK4;_j=?8Bc$DROctmV7|Fb#ZUe?{(u) z$13YvQ)@V=?N0S}ZXbu0PAbxivUzV0X>2J8&j?4T-&Ki9f32BcmhR_(qgIMu231ps z_N6pKcTo}&8hx&ZyLRx5RkQnC9hZu&2b|#ES(6dkl6&N`g~_#DZuZDmT4;$~a7!HP zu0N`rIu#}MG))<7wqGZ8yv-JQ40*1UPk4#fi0%mydd>Z-hxw&#%lK(m>`$b@ie?sM z&AvnvZ99w8p748HYrD}sPALywpToLx|K}gsD_9qi-j>9iJ|75V^(SVFmSwa*8eCLL zHbt>gAW}$m^I@8%xz*S0P)b9Xs3PR4PZ3!KnrAjT`;wv^ib}t?`%!;SRgYlt{ocuJX0x?~yE46V0FjtY+<^WBu^ zx@#s!QrGUhA7`ChnzPqohI>*e-7&4`C;U#ovh3#`eUE|_&KX^yaUyc={KXprKmXhx z0uND2Y;br@@nyMLv0WY3)m$Rv(3Is1an2hE;x6O<{U}`eVs)PLAAO3u^~9pvjY2Zp z5xUf@y+Uv|!6(<0Hr(wOFY#aXX(SuK5UxI!$7N#+hg|!6E9X1}3GNTmf6lMTy)XL} zw21BI`Qf_Etf@KB{a%|IwV$^Q^j=1m#hMz)e>2Dp zn)BzO0e$__X)G;@8(|WA7y?d)J3yau)4H!y(IkB2i$Vg`bAv`(`nP|-gzW3Hn{brp zeEc_S{ACw027#QYQD4KGznJQ;F9klG!@k0f_v5pr*stT^=g4@k`uZhHA%54xpCA45 zPT)K)uG!{O#LuhWFFaRcU_ucm{PUw<-g#i%0+##|TKnLC$K!vR5HLCK+0oC( z^S?Ix@AG5R2;``|ruKdz_{%kac9us!unWr7k@)9FzrMqjef={3oD8n7*Wb=&d1%lj`Qm^e;Pp^?*<}ecUtB5T|y%O9t?|z_AbHiul||% z{;*4;c*Okx1rwuFpwW?3yIR6drN-kWxv>+pTO%>J+xk|@zw6e* z8_ESnA*hTD)-4kHC!ks!i_MP3+PEqLWvlpLnEsQEiur~1a0b;#P*@S!7)VOg9b!fE zy9;?!$(C9sL;={;=t^fIb7@dQWKQgwu;%UH1)a?v2mR^t?= z3UJ21@rC=+0xw9=mvc@~C>rzLkF;5SEatKDuVP;2lXn@6v71SOW7LV8G|m0{SJ0iG ze;Wthqh6yq*<}1Zr3;r>3?cy<$)N0252I*~icD(Oi^JU%r+fqRbzIhku6QjO(OUE0 z^_#@=Pl<`O*fFmdK*b_c8f!ygW@+V-S~(m^xNZmmdpMO5#{j*g7t7IAOx`f%ogeSwS`XZ+!1!#9{8CbcR zqNZGK+Gx@exmd==(7oegne|EY%qfvz>0 zn6dNC;5*NN@)1dmL6A%`!A3H-JuB&FJAlsNnj}Mt{%)XtDiZ)+H}QFJmRgD?ioD?> za-hp5man%APS=-WXPW=AYSo7?*nH8>udVTSf#WHG(P+KBXf_Q8z-X}arO0#{#R_V@ zeaX^fn5OG{JtZpERYKP^y!UGcmx1oq?;OsK<`XmfK)&N{43`0@aA8+6hZ(yC9$=M? ztc>xl!HX_t*5BUxTX4*hl>S3HKPf`+(Qx~Q%J;!!f3j+USyPi3DCuu{?HtwtF%2CZ z3#p;PLI1jL<2UC$t`UkF18L>c$+XLHpzOvJzBT@DocWvw4_*OK_+Q;9?n&CJSQ!7_ zxnN%BbB!aV{a7pK(V>7(W=2I~;{4p(pTT-Hum3d;J4?j}<*UEB;(clmZF@4ni?NZq z=^~O@faf>uuahrtvf0uq|9on1?LT>>Fc;VF;1mNL6`qMXlvGq44 zu$mb~f|vJ8mPT*i--U`zI)R)!t5EO~%(*jLTldS)|8l$oqWi-EyHvvg_78>wtfsYp zGZ%Oqfu)g%Ga*8x{05@237=ejJ=?PBo&&3CMy^j3tMP|YKmlGkcH}(w>qFA0d-xC`4BW^;1kEUO zSY3|mA(<642O{-VBZ&Ntc2M-rS1e`(`lw^CmCULPm|V)7)>0E5_cW?BZYZ~AtP0Z%2?6wuNfqX+621H`Ie$>0RQa~-aLIO^GG zc^dTdLw!ot_-dgAeQ;80s{+HCI&+~OR!F&PtNfK#|!AC#l%XckbbAj;&b#f|I z6PVZ?(9lZwAwid^7Y*t`k`L)(n4t?j#iN&y;5<5tYqg;7$e`T}RP3p>;Qe%e2f!k2 z4&}b0TA}V&N!e#3b%U%bZxPTjRV7X0;dUyiTQxvrZXy(?zXFcne{&p92WmkK)j4ssh_ zKI2D+4xmUkKfo1--@zO##3M^>7O2=q^7JKbzIt4acHJFm;!Vjq#ML2s?E{_dfy5kx z>#6T;#oCXyU?*NgCJ^lXj7ffQ5UnunH~$PFzdpnjb~*@T+{=J8-KrDJ7w;&;n^lx- z0|}jhP{T8!K#FH(qXpD+?G~(qs7BRTZaegfJ)gy7MSUx2stsm)>0Ncwn!0rhrNNtP?3OZ1 z%CLbuMWEJgltuONpoYaOb=`iG^8ZhFKcJzkONEx$0tZu10EkMd)3bwjcew7Z!?@LT zs|B!Kd9C>hd%C7!blCAn1!*h%NvDO_EY-Ya0{7G8I}2yf?Ibn08M$4*oI8J14KLIu z*+dHNxU6`XZmfVk-$EV#U9lY0SM6BIu8y-NyojWAawPa`{`u7WhqD5IDv+$Xh{O=2 zsb~Q3nEP!wADhMGIMg#C%h958CmiYWeAX`+2`{!=85EgdV`HudfMU?u(Rywt2Zmc< z?;UvxA{sNUJAcO7TU9L$$--;!Q6txm$aI{0tlFZmJ`%%wTfot0cat_oSrgu9`d2NY zSI!F2=uEn7L4jafeH!#Jd0!QLwGW&+_f@$%?~%gvKM(x?Pw==*gi7(s+9F$aekb?Z zaD=J}Ez7HIqv9zU{^R~55HV^#^f;ITfI$ul!tSav^o@F%?*L$>g(w6V$l=8@(3HeG zK@sZK;z?Fptm!#|DFBwulBCjO>O5Ij2PX;(5)KWexz$sMRtdMeYel5WtyCsr3tb%T z<^tHtopD`K895@Y(N1ve^<6OB(!FJmO>(zr%zUM+bUC`Oecb)zAoba$j*pquv#MsD z&Lx?n7<~f7GTYL=-o|ozwoiG-eih{24u24a$8n>9L?H`Qj}H}6df5TV+H+Iy+eyCD zI&e#9XKi8(emG#KWNVjX4vu)s<94`EDJ}Z22k7~1iapu=r9~JqRPq{()lRg*<4=o79^SMf$6{0Qq@M>y#ycuYx#RtYljjvay6ysh7=wH{p z5!6?%EY&`Xy?zB5wrAgS-W)0nbpiz6v4XKhFdxRq;ds%V4k%slCv~kw7g;u+=kIex zn}HO*cbe;GP|+bg3o5#2K?VCJ!ld@1>&5`0Rt~BgWBd@?n+)X%_4YVDTHx0>ccbv0 z9c=}u4o9FZu=y1Cb0_EQRT2{_{?zJf%radPQ}eVJfg2Y^cg60gX_kDd`{@Z|{Z`lE z7?_;P%+tYvrc0q!-?qM1s1!|e&v6<%VmL>LQf9xRQLRsp;PO3NS)G{bA5Pc>(F}WF z`pPQOs#Z5U?!`vFVjv~4ObTST*4o@@sHS@E!C&=F&8`x*6D=5DlJZ5=BVJWqAf&@- z$ewMZ87Q|Iu}`_!9IIr~s#ko^YYXVqu-@_rHpo}B%(`DMLr%b$&b({8c1yVfR2y<> zolq*)D}@LInQ))tnjATLOg}B;U3ViJ*+19|^o#V9tCkeTs7D^a9t~1D4Sz_Q|6}J{CVj(m2sVvPGNmv3slhv z9Y8ZA*g*Ny9G8 zX(>5k-1U?KdON{V0?M+4=2if`>sF}N>X@YzyK91C*r!@DsQe$b ziUhq%%p*tklkwOG!W2AWvADdMP>`>+Lm!u`1S#PesHbw!njp^?*wnZ@c2a0D1^s9X zxf+9_1&sy8ri2x#1&#B5Se6q?*h_KFYfGK+S!S^u_Y8+!zRptgZIM2c(OrfsDwb^( zgMIdrt!({%O8E4v;ce1wa`}P$EG~wmz2E@biR~PsgtIjUQU2CK7rv5RO|0jOLX69l z5H^zA{Z%Gz->tke&ILTElFB`!3bWiPwzBA&wRLNVu?iGHa|+=>+HG#~(Zk=O?n?}W zy16u#U1w)Y2tYf*wNcA@Y~H~qF3b7S@z1N0aoOQ;bQdXHdWA)hzi5$r&@dB=x6-{I z_71(=k~6T&0y*Fwa)TFUuDH~C;jHl{b{T85nP9$e5gvOTW_P%{x?#3r*J3cMZHoXT zVCTNv^yGN`IhEOvhJziywUG+^hsbeci1{%~3#h$Ssrke7s99*h{n7~$&O;$HO6LVr$#+6g!Zu+b6= zGUQ#0wti-1vHHacN5X|@qb3J+C0(X2Zqc@}gpVzWq$HwR7%PD>c;q$+=A{&z zw=7K@U#HEdlh2{fneP9XHqTYZMxNZZ=50*LQgW?Aai6%K9>#O+4(lFgO_-VSCmq>k zbx|CrCE+%wEo-~3ol&>ej=zI@Ik62yaolYTPD_&ho`5OE>cdeTrtoeQbvwH8*iq@&$y~c;?5q=%AZw`Ov zjtz_;t8~3|mg+4R>hw7r^e3RWMH2MX6&6u*NuJQt81znmhwOQ2YlY;`RUcRU%WK)N zg{x4cTc1Ovt+fhH*H2Hn0#QrxcoqY(st*#YVoYP7Jl;GX*mzEApo7JwW4Up;+LF#u z9y_(_uHn;^XnscT@KGN@i1!m*-NfiJSG1qx>Y$p1-2~;a^$4Z`8;P4ibSWKIvBpYS zZhlV#saC0o-Nv1|1c^Q>9dP^b2HX)=_-Kp+ybFb;cwP%V5?ZK5@fr0B9u`t{@I_RY z3}P8%`lB z3>2!zM=eF;mlvwT3QhYlq&fmwKtO&X9feUm?C++Po3`k;2PqrO@#nt8C*`wzyzB1S zy0G#R@}w<`DzH1FcKH?AkjoXqod#u(v_@8iAG3AkX{%`%VIpSCOqvP*6s#j{gCevE zbvA=rrl{V_E<41`Ux>D?N%53)HHOn{f|W&iE_LBGI~%1TdGFV+c&o@hPq~eo-Bw1= z$Y?OkMjK!T^n`F_;cRniMQ(C6DdY;A$xJJ+G(d9WJ*6?DIPh{A9V`9}q)>zd?@8al zbQ91hyd0{}Au2rKea|@DUq+fOGwW!Q zcCnIp$~H4?C8KNyQqfkR?>w2m(`SX*3&dx)J6_pI<5qh?Y_#b44Ojmf-A$6O)YZFB zUiPw~C^)vs1Mo&IDu8D5`nNr?5)F6qTE@kb1Mq++msLIBRI9NLlUDrJUTqjt>EJmN zMO%t$Fj_T&F}*?e0UL=%gW>N z*WRM6*V#4_bs*T-N!EM=bsg!CkhYtJg~8CoBnT4`E!Pd0g1!fvdW$daJ@mV=Qz?Vc-KgqSk7Dxt57*`0i7^w|E5ovC1#-(hlulb8=>Hpi|d0 zJQ6!~H*+pbuYk3Tf%BwY2Si;SI|af#8x6@j+JBEbN%ZA#{zANzXNRTW%|PVx8$x~` zR1pfT0nev$c(-@BRSW_w=&0QNvyQ6t^)cx_WJ9C#ifU4JQ*md`G=n=ZnJY91?H{O? zPl-MaSEekqNo=cQ<*%@_`Q$(%2Wnp`xDU;<%t%dk8zBqtAR-~j(0OgYUs(;sEfH5k(wXXey)S%Z=5ZR{Qp=O zks?gO4dL7R1eY2?NTEGKw4%rT)1&*^l)^q&@t{$g;$evxd6b`^Ysg)Gw+iQWh8r6c z@WDmAjA-;g|FuyLif+A4RhPLmK{30XZRmq+NV~0+2`Qc2YzFETpzTft#>A?G1(1e9 zA7~9Xj99Q#z#Y>!B_Vcb6l|9Esa2`_Y#Re8lrktgd%K^bOGjtNl8U@Ez#$Tq@z#&!{rCtA}+aQ#rWG zW4)2Zq^*KOnH1G*Y;vm-+1faTZfDgu(1!opfCCR!WMA$~Cf(tR+k@zN881@{9FsSclBx z~@s=Wo<0k2u1Rk*`gBL}j$Hg?P z>Qb26aXHxH;?W~63K0lzHa25iOL!?dJTt!N<|QOrq+dA%*N;slsH`a!oz{b_5Q2y#2 z_2HSaMBldlZMWOdOlgk|2Bt+0*hU(cBEn$);R( z!GjTU-VpQ^`L7Af9a7PrX~umil*Bt{RtFU(<5#gdzU2}SlI1&`(bUTxXMxP0@kdBR zG{mEyQUz{ivfP^WTF{-k>*aUixBJ22$0MDi>HqD4=$GOTkhLmorFy+#y$mta?njXy z@;Dbiy^+r%B0&r`IPHb*rsX|}5uh44zZBh5IE%!0d5Xh6aIF43HyUkAL_o=b{cyPz zoqN)+ul77HI?K+ka#y22pLgAEI(2q)oeZ1h6-BFDsG#w@HQY?(n)auiJmZt&REQef z7>zQKBP?CKd=YL6skn-Sq#pgT2@eWY5-l zQ1+4fRky}~J8PE-y{9lWFHZY;M}wc=B|>0kJGWUR+74GL_&lN5$E0oYgqixAx^IIx z?%E5%7fELxWVN*7n|_jzgbxo1Z@thkQd^>u2U}fuRz<%|3N(KVeb4_4edxWQ?Dx1$ zE&*IppjYg{_HiI>+RoFZJd#4!Ktq&y3a%pToeC1n+fpEp=P=DGH5hI2JF+t30WF&4 z)LdFiu)MVP#8QqZ#q(WsLD>&5nAm7PtVg4C#YwLAe{(F%FvwAQiOLZhK;tIOsH*3n z=78%O?28%Zg10bb705#OxuCZSS7A zCcPASA>l>j+%mCS=%T?2_d$EYp|g(Yv6C+z7c}58jRXDr(Jjz~)Y`7mqHoLHqvq6O zFIh4$1Nai8>l_E%5^RkI<5sDdfL`ts?(E=~3Wq}(^XVCReuvBwgcixsY0_=J@no2d z$62POZkFSD8Ar=RVg*j7ZuggvxAiXFT-wX;%lPCaZ z51gSLl;J46>a>>%WAJn7=agpSO!6rCsq}Of3M&ZRrj9BAmW zqOIkh`(POazEke|k2U$(SK{4Ij9GPUsh~stK9?8AOKrk%Y?fy#3|CTT_twcM5X-ri zPd`7YAF{FQ(Mvo=BeN&{G3~NSI>#j;JoR2vTBwW2=_j{wTV3ah++)i!p*g6X8NQ_L znCi|Iy5a40_&xtgQ2@;ISZieGuR%^mpuYs-(ZMIst0-EYomuNf^O8$4`xKT1>-X%3 zA29zv?7e4HlWEuYJr+Pguz?B)s3V9$Km~$yu%Sq3NkBS+1PGxw=>no7RYh7tQ!qg2 z(t8t-5_%wX>Am;<9C7BF;&|Pk-u0|^J?s9$Vl5>(&!g>q?ET-rt^T;3>wk7EOAf+b z;rVh1y?&-vOgN1_5FD`9wh(&w8=(y?3e_bCD-Fk*L}X#19L;fbP$BA%I=)!Xi?G}8 zOxiG=geA-BgFv6^MQ#VyDUWA0~zP=B{-yU~00^-f1uDjZht zQFIUs;bPydFbU7vmxwL^c;KJnybIMmN-#BQl2{j<>ef9A+YgN3y4Gqm4pz?NQ4ODN zNswHi4g5ryjgKw7__(9$!*)wd_bu%F3(caVBx&oc;v<>U#dt zBD!40-zFX<-F}<*3#`vZNan$Vf<-+pXILM9Q(Gly7^2Z4v{ z_;&DPPMWkr=A|S#@pTy(*bfT*{5zix2Sreo>@-!n4c~_LMAo_l@+r2DIr97pY z-1UX@jt4qDRJ6TFyNcz@`{wyRSg&A?vB30<-GIxt*cvA8s}~-ci#Ag75=i0cqA41W zg&mWncM0qMIk9#^)ro6t_V+q%zI!jr^SJBe_Kd6PYQ`f(MxS^>14&fKq_5DyVcs$2 z*GI*r{m+K!Z2%0S{iy&cWL2ue*iRv{sldF88YkIIvYM2Q0lleHx-U-IJPiJd9z2KZ za=E`bEk3m@CjQ)#`yi4tNi;uIgop0hG#=)8F`DK4BHDHr1VK zir@{h`j{Od=?+NZaN+t*06bV#%EogfuDH#2rhyuaRG(W~Z}kM)ze)a?D3#6td8Yf( zG9c|6>O>Euhs+58oTC#xhiVala!S}xUUkI@g{?P|PB zXlWb(1tr#M=4qT>mbYPuka0Iw8K4B-BbeNv#A#S)9asmQ(rq z3FlVFk;s}mFOOLb&ra@4KF;#vT2?;s(dO*SbjgL5j0vp!Sh!K|YTDu!qxAHKkUhau zIGbBaJE;JbWl=1J$WJ~Y7gRV=9Qz9#S>zPWsdmrErr%DyYin~IW}TjBHVtUvNGXhQ z>sjyh+F==Vem7LtY`jxZO=vFkk)7s5Io|D9bKpTI{wbT=b1WsdJqr5aECQ;-9aE0k z*U1fI0(H9MR?!3ff}!B{`waPJTH+JFGey1C&g`mu`+moS!0qTzT7lKrdmGHNsTJJYL-WM4kzGa zl#tI@Ucq@GD!(D1z+RQv>Z$e5X>5pMv}J8u79X8@}Pv>GBR|11g+(t z-wkp=rM2kX0u(6)ISr1=D2LJ%3?(K8K4(p+B@oi_9lzp0yFX3vu4GJLK`?m=u~lE( ze7s|)lfZ-%?XNs8)thkY1|bpqxHE_V1@}JzTobo@Wc=1n$DTDa2EDx%(Qe7cl6fxfbRTNx%v^rx)}jo#IDds~lNv-A!5k zG<}=&W=VB4JQ=x~WT1TH*%GLcA{s-t@?1OCinfQ_rrYe|hu;(i+`CwC(6P%M{PWx4 zyIUY2hCy>vQ}JP4HRlg_W1M&k=4`bm-4O7+&vOOSSbE{!X73_e5vDh$kEv3UV&pvR z397hdcJs6xw*qt9UE{EWyLu-`D>^D=iTI-x6+WE_qXMq-!7F1B4lF?U;R{&o%E;#;M>OI%SY4;>s*J<%Mv6q(aTNw|K!z|_> zxrU*ydj58R|8mg#5+&?Oafh^Nfm}wU=%eGUW6)L3pqJ8I2cM+p&zEeYKGW7Sra!-} zLGSwEx{?gj4|?ku6vd0zp_)p_7gd-O-R$sJgwJ+qVjzmks$d2S;x)aTTD`+%gSZ$t`E(lVJvZH>=p z>XTkxZcfvc67G5f!Rvx*xR)!fjwye|gMa@G0Wylo_lMZQ&C&jY{{O!eNkG}7_3YfA z`~5G?+=WugjP(C!Mt&~X(xnKtEdC!BY{}>HY0Imthf|cc>RHCh4Jtn!i09IChu{*Lbg$>`aX~}bY=xLa2W?8t|pC^9EBQu6Lpw+T326B1db80!kf+@Wa!C{ ztNZ(6wZ77vp!{naMbxENeo$a7P|7+tR@^)s2o1?>Vz$?bA&)B>BE>qjZ;AA?{Zua~ z9fN&NI}fBcXjD_U0hfHl75=PT!t$US)OPnnSF;e0Q=*_JQ9QEN-mC`;blG{S6I1UL{+r5n$}=@}LIBk$ z7!)sGyE?a2;0pfMiid6f^Gd7vn+|{j4BuQHA2(^5q5jGH#$2RDAP`|dcarxorN)od z=uGzw=%d7wn6X6+dM%M*wmVQ;^bg5( z>aoX}acZinWB=``-%}wH6V;oU(2ZD$9{C8t#HYG^h*Ri;(squSwqv?|I@mPpfpe20gQ(aaPh%iVu06v2^%_6F!~V)g!T9>P zt8h8%k1+{B_tTj_y&JE2Pslat>(0WoT*@%g{w?@-4ZT`bV(Dt&&vees3x@?!_`R#L z=-z}36RrMR%G_7{nSuT?u?;Vj&|e11c2*>nDpD=s$O!-0dvZTtJqj__gSXWp6P4h_ z&sjBoC0}b6NbO3(c4wRvl)UXtFOJkmMnwIWuh*K_5tFTVVbw!Nh2(DuJD68`9nrZ_ zj(T3M{nMR!f|~&>kD*(N=Q;nqk|;^;!{F#YK<)5@0sTXE7jp}^gucV#R`35kvJ~5S z;>ZbLEPMZ^n*7VC{NgbLvI^G%mcJ<4 z@zEB|sMd8@8IUY?E+p)mM;&Xm;~4U%r+JZ^v?y4 zfm8OSRg`1!k@iIC>5dfGtF>8TgL-De#^Ug_Z4V}S?l4fJ8-nH+dZ>m76G~UIf}ZP| ze@U}{pQe~!z*EqDyUe(KF#;f>@Z)k4L{^KEC+t84tnV&z-ks%ux1ksNv$AV##f2g} zUwA!}^f1=1K;E0k?H+Nd|A%zdV57fBRm2QFNU( zbH}RkAcQl#at#RxGxdGxP(JMj^TN5Qy`u4cXNoVo3tzQ% zUe|UAFELFsV|SUjXhR~Yb=KcVZ`(OS@!@)%mWin1)eeD9E$0+Rz2=ymnY@7Y&(nN` zVX4}M*tC+p?jpc0vMXBM>eA}nww#r8ZP0e;H>vLN`3^vq54)A&#_*>2$7xWi0W)BC zj3qZ8s6T5W?8d^a6boy{8^R3$ZI;HS-Yhp92tT>)yGmTQv4v(zA9AWfG_kK&&m+E2 zK{EKCU-41>78V-_l|6n=Pjc5bVYBprEYs}tLg&nBFB+ULe}0^?uI#gKdqVei%Pc`B zU~DCWMEkML6}ZiN3HN`wy>c?Zy;d}8p}_%PYAm}`S8*oWB!~+&*%%4yww$kfoVnQWb1U(;4){P@_;VLPe$wk zixZ`g6^zvEg2-o6B=Xu01I7J?SXp+=f*wJd^RZvyJwD4>QfJuSZmwndj!As9&YQF` zOR<2F*HJ^8zBr0gzQ*!L-m1L<&v zNdiymbs&n)R_?SzMw!gO2HKhOK+XPK6Ce@c5{|wK0V?avh{*JW<<>_ykkSpe0@ZMC zJBzXc4s%+i%nq8Ws;Cg4-*#{Z+Cy`95RVj~`Z&D2Zx=^ zC|5ta8GBdZ{rmnb;pRCSs;9-X)3xpP(}O?%TCzpJBJR>shY(i1&hA-Fi zt=aW~%BoZA*@3A!Jh@nRZKveINcEy|IUbQ$7MS1pU7I5!u4+JPQK$ ziYOf)h>TrOht)K;*_4!>pH=Lpwo@aeb#_YEZL?~}IQOKGdPw7|RJP(KM)RpvcAJ@u z*Te0iP8=I}19+LR&6qr;_GjNRTN4%%xBfQZ2|;^*d%je0lEW@4Te-N#))ln0u7wT7 z`m_QB6S7BlLmEHNT&ln|UpYN{oD0{(q0sNX?`ji~g8B60RY$1-92JhD&O-E1rsqXV zt&i@@)JEEOEaEKkIj!PY3~ZW#=&w|`_t`L{T%J--Qwu=cT~@EydJA3$&#+P5%H188 zGGDXa&#Q<#Zua$q{fk*b!E%5Y78G?Pb{Al;jR7v!&C~$9A!b?}T{W%hx#b z&#!ypb?=vd1N7A=a|PSx`%1x@R4(yRE6xF-tCiR6Z{q09HgFRnFHQ{DeHN2K?MjTY@xPNa{dm~v zr5T>BZjeii2Xsrtwye2qcjk{{C1xG{Oq(RPlSSq|=}svQpav8vAxdX{EyJ46F!Qa& zZ?{^Dd&ZV=tw%3n^N7R79N$aL;WtoO4@&dhbL zNDK?VnR1;7?W!#)vD?hH|l2$&a)Ere@ZDLwYh zmGf=aL0a1zcMu1%+5_H_gm-vB?5Km&OpV5`=qzD;y~XG zfq5I(^JV%0mVY1}%j&u$bV7O5h^#^d!QRs}cnM59f6mZsnvV?93@GSD?~{Vi@nq>~ zf4!SQnzN?l=9dbfS}#MSJK*R!663ex)oNBgTa^pjKrC{PS9SIVNN#6C&cSb8jgOw4 zjs(r=C-n~{*v({IeN2jz7n}Y3D0o3OuynmqY|Q;vSr%j-VW zF(wh)84MPOZmy13s;;Z*gaBv6*9QJd-XHJ5RUo1r9xs5z=>c}*eELOAspUv|gIc;Is@4M+6z=uKCbDCFLW0A+I<^+@E&PzQ<0@ z#v?@%v5_6A&4uQ8O;7FM0@8N6?v?ao_gLR>s$Eab1@#j4nq36@8LJv(QiHEEjBpMy z8iOs6aR*ZFxstupqEaSF9P`-+o<3t|43k{@`YYdgq|ks+PW{3o{?eHW)zXtx6&7Ul z{m7bx3^j|K%;y$GE%(PVo1}!VL|gmN3GWt&IyiL6DcZSm?2H7Z4mC@K&gEDPl#H2c zej?;#+NtR=lMo*^U!=sfFDBM)bo7`fsUY+H=xft*NLUH8bzASxv__R+KmHkP!|c7gebC63{Al=!~hYsCG*x#o6>Kd*EhFpS$1Y z5bP(KKkQ9H#y7)_qJ+HIozm;BpVCP8Vxt{QEcepRo+}0=k*SqmsAnScrg=!t_ow5X zI9~R+kEi7NK?>8dgX`D%Q+F7m$Nx$<{}DO(+XU|{HwqNa=irhgHJengZ!Pd0yIz?( z;YXvz%oOxUtKTd2dud#qOlDQTWNn**v;_-t_U3^q6E5$^(lW8l0!Tdm`~ ziWEMBFKA4vH@AjA=xnlIF0Q0D&m~`nwt-PV4y|S$KxsL*sfwYmDcJEGFRZKKo#$|0 zpUu_Q`=qzzA>*}Ei9E>fokwdEgyX1DDIXKf5fvcxJ>f;nZiUdt@0nCS8i0btx=n24 zQ@N1Bp65tpbgdZ;>o2}^d$ zw%6UY%)H!?my#g19Aea|^Jh3pqK6tYa|SEnNK%wjw~1S(iN$cgfX0os^}*QVQu;}` zYETOPEWfpc;a`R6Rgmu+Yy8k_b?7q4G>r z!HbzVr4Vu|H~RW-51B9!C6!JKq?3t!1r3K2cSf!Jq2MpHzVjIUn((5@YSunoT4pBb zmVL!y4enP4sb1t$bBh|PmGZYyY7WC{WY*m}f@H+KNXUtxLv^;>j^u*m zoGF6&%oo-(rNBEEt)bG9j6f{p<tWc5O6)Kh7-VrM{rkShXwp#-Ro`KFF1*-zPim6^O zyR^>DmbmM{#}dvsP1X0buqK`sy6@Qfa7>ByJQoeljeKxc0DYTfUZCB6UNzL5%7^xh zfXS8au1uzLX!({sO*5u^pXw*YoeIWr1buKyP6?)N&YJ9W9`d?)z z>1hv0O0>G;S`Q_!1h7E9ZCwak>sil*r3{`BYxhc3^gp{bIP`?3VjZsaJjJXXN|s-Z zs$FqSsPz(j-F#(jgNA&O=U+UO@5-X6MlCZ>7_mR)!rV58+jZw|<`Bw}zgl!#%3GIK zs~tgFdmA}3YA1G}r7%bInusN>R~XP$MSE)*$fA!u9B8G25R$dJ0+Pt}jhFi?12Xfn z*v8=ou4omOSzTTca=QV`xXQ3LI%LMOBDTg%4dvhVtc5c)tUTxQXRee~PZ*M$(6a=| zu~tmGld0p7GyE8R?NM>R3k{CN86MQ1pdpNWlTwRmZ>5U)kXW51-e(`$VZYsNzVKu< zSy$l>m+{04#GujA=US$iaB3V+_uP!v?6Y)q?cfuz%BJm)97Y~C?XdB`hb3$p3e|5{ z+PfgK7k1yfUDx7PF!raK!d2(&R0f!K)l%blvBFKx>6_t^?GMMD zmPTstSF**KdSOC0WzW2_nhaSD5-|~pd?jTzin3nrBat{YwKL(_W;y-cTAJb?XomB+z4jn!X^S73t6`?5J%wb7?$-{*LTl@WYC9z+9SOi~d`*i}+Bhb5AGLH@4XirT)hWZd(s{|;e6v}% z8tT)WhO8J#i!^0of7HW!)7xtr=6wa(B9#FaTRz1KB(O=%fLO6#z-s=EgI>9Nzw@{@ z=tE;_lNfnyCZj4it-S4D*@PJ5@5dVf4MeI#Dl&nDE{5irhW%~bp$7u=(9=9*F->yD zk+e(!yCf5+8Ih3k``Y7(>ciAj6)mS0_HJz{YQA z*}V_#^@A&CZegbaN<$;t?*%(eZMhb)tuGd@53J+rHlR4YOs_~z?bFPVfNtquMP25IoJ zI<0bL>LO6!^()^wZ=B$j#}#yZrkMe~O^yL8qQq*w@*gAPyPx^ACSZ(S?3y>=7_b=v z4s5{&v-!W4mgM)Pl?h}JAPi!8dOSfz8R=ov9geM3#p%Hf))KceV(Fnn)raMQRh4gl zDmmrmS`gVjffU2XT3C71x#lD#Re$iqC5i>eMn&$o`N_P{{2@;ociO z+%kAnLgXTAbq4S|*39M>!sd#bB*%K@OCI&R6azE!(7U>8stQnev%4P?NDNsok8hM& z{5HGf*8cnesYA7SJt{ImuCq%W+U!=_sKG(9-4$PMho?f8073M6ZZ|}4037%H>E*NL zYeIV)AheL%=dhks*n{x`2v@%)njS)XwA)_Sj5&`@WwT)KTdskY=3}(8LhvF`Uj)vE zOvk1##m6g$I{-3vQyWw~algvzJeykS`mYEO1;3zZ#MP#k8O>(1umjqz+2;1;3FVCN z*{g(rV3nFkUH4%e?fd?$U=8fb*fUM4&Hf>D=69z=2Ym(J_2dd&L~Yvp}~D=r2LOC^|LLw-}k@*rc>#u8c@*Z!H)`GZK^#1%Rb)ouQHyqejmqP_riCWD7Dj4;rf&LIrE&}@Q6RDI3-hN~M@)`*5Oc^C z#zWB~Yy4tv)0Fbv)J|=)Zwbx(`I~yM>G`bXM)O`osXQn;u$FKNyJ9BvDRul2rgOa} z`dLNVocZouSr%uc0KNtmXNyY_?N|*JZ}2*}Lvam==Q)Hulx)OaPMr>vFfXv1o~rI> zKZh&vo{#TFU{K3ZuYr*;XiIVDuZ6k&D-P83U|95XQigUg?>Ic;7%9& z&^#yiz$m4GF{<*y{XP?m+@KhjCszzw?n)3!+N>fidl${MQ(~r+k#Aq}@+pbbDO|Um zDf38$$PaHO*1uyQqCw`t=ABHQ~_W zlFr%9wl5E?3k*i{whMhf#)M-iZLA_7yDBlqiPM`QxYU9*&nRIx03 zX8B-{qJ)Vc^IP$e+{6?=@yZehTYpN|0Mv1@1{v$BC+9-;d#E#-x`vg{r7}+=2PZW_ zk;1rQR^|DLTiLpivczY6cOv(NI<(rj%Sg`X2-m$y^}2^ z6zQcl6PE6z%b~BUd+Rk>_tApxkK4!R5oNB&>uGS|Gjk?As~UZ&yN9J}r@v6)jJo-T zwr2}1^sT7OOGFiqLx7C$N*XV_fnuPyjNjfGjL8Co#O;Djo#aWeENbvfsVi!rWEG;Z ze^PTWuY#5*kBHtc-PuZ_3uoL*^xYcEAt>-7(-U96Z}%@T>&dzA(WR5a`pird3cS|I z!VBBGrq>=)=|y6y;yf#&G0*UG>#HX*1fRvPAc#R*!#K7X;=-0NrOFhXY1Fjl`_wim zLqTXP@t_USg5wdslKuDP z36vLus6>La8Q=}4<$%TuRAUZ3BrT6HNFCxe9!*4;fvy^v0Fdd=S&@oB3!06?1n*aL*F`?uTH9mwj|wKreX7}j3&HbdzKTF^ zOf?=^WmsC`3e(iQM_3QKZEM%EK6_a+I3EN{Wpy&$!X?+l=g~=;`%iSe`fUJD|8x0P z?ilEonaT7i@E-deu5tCAfhL7pSyd=p&-0Z>v9);>s=vFH1q)KEBSCz|9pn$S9+*7$ zxgX(YO4mS)yUH4GZ5#0p^kEY`cDD#GJHZ2$%Di*&yw%8CA??UfgTshy*J)6jf8(BzdJvb{$A1OXX~CC$ zvOJUdjNTi7Gn`-o)}|n?(NbEb8r(XiCXEcHy(k58YRr}mEpLN;vqf%u61x*V>5l(a zd0hAkc@gw#L$=08x;bTE{H4{xgY#d0j6CpTD#ZC6#R#{LK}r741jLl(1;p?l3YrOY zSzH!f7W=!z$6}I~Q}(D7L^hwcb=t3%G}+I0#3|Xe9Dz>X&X|H0^bB(F<(_tcMF`e< zV{OJ8Qwn}@|JRFr0WWgTWoc@tnNEn@-q2p+x;Yv;c8ayp#k6KDE;toLh$o{ob(nt) z66OOf3lOxOG=rZ$ys2LA4zLbi?%rM9Jw?grc6UA017Tl9;Xx%=j^o=+#bxj8Kl8xW zV!OKScw2vbS?OOq5a1~iZJ+(6z5esE@I6tb0`hYg6zOPw0+XcVZxew(44+&$|Kl_M z9`9z11MeUcb%ufVpV_wme1!^Nss4k4^N;_={Q%5_r#SC<#-D$B96%+@f2y?kF}D7D zURmi4crhwRja%G5+K%4`;Q=Q&)Z|``|9eO%4-s<#zd zf0uno%h0yN($!I*Lk;8%X@iNYV`DmCuP5HYA~zi1CQ}#39v}F}tNtmBV0kIMR*nky zx!eHjiz@Y>Ap?)=;d$aKA9;tTy`2ugTYqb-mHGXRe~_F26#<$9v&V*(^U=$E zqQze%_tSx(y&Yp;jv^_+`CovUR=7g*SB>M}K2W;yM1p#{x zUz+F%bH7D8q~Hm&_;+~w_puz&iWw_M0RVSI+8$Z{7&iDL>MC;f!C_qGD;)YHN_U2^^0J}KBTT52Aa0Q)amhwX?;5CbpM!Th(U4>pz6=?$^%X*3_y8X)5=c$ zZ0XahQWS#%rXAM=494U$%c!I1Q%+*elrdX8z0&|q(JWMI;|O>(cou=J^A1LRD1NY- zyZ5g!1m&q%qMkb~O+{PB0p2jKdKBrr4(W{ma^L#5EItfC$r){eG|I8)fwmB}CJXiw zv%-F+LVPYA@%$wp;<(-}9Io686qQ;`M!lk+R&Ie0h$7~gb%&|)Y8L#e^d@ofBqH*^ zaBATPkXGoZGgOiPQFjt;S3hb801B~!HjvK1mQlpsekS9_XsObufEPa#h#q|1nV+1_+;#`-V7j2SxK^HeU0*9uCqLsf{TTCIuM269 zQxOQ;q+$ z?0NO|^{{b;$lfSa4|C!fKCQ4<#h>YZayogu{-@Ofz?|GgD-<99C5l6k2o)h_gw(ki?HeSDg znDGzYFsM;=Oby!yCU&t4eyIde6L8-vs}U>2Qv({;|7=GbL7X-2FUrTYJMFwU%GnkR zN=UXGQ`=-Ua?ys}S>|SE8gl!Z$6M%&6|B0>{lt<<-#-Y$k>@6uMp08)CaCU}N#+#z z1RW4yZycjEh!)JuR5Za5Rahc~YA_h~e=i^D7zQfAYP{2#rYM0Lie!l)SIZrDiQJG; zjJnh5U5S|xd5QYzqh}m+vj^j3V!xtdd>AJp>DyOeYc+h<*ahaF`Cm56z(GSp!~4S+ zNc5I#Lzv6FuQ;RQRB|G%-l~8&NoIL;0Vf-*aOP9sPzb3-)65Fd0`NL z&G9VsD={lLSPju$bpFO)8adYxmh0sxeCVnApMBoQZD4;jf_3oRb7-AvMA=E%^G_%Wp=AI7!{!p?{+NfO6?*kJY1OUsYKpj={emqA#6E(;3_38HDO;DV0lkUv5BOKQ2 zt0Fl7M~Gzyu_?|_t=3}M8b4+OiewDPsd~}%H4-uDPB^n1tMTusM@_$)f9TOY-hC%= zq)-Z!0Ju-bzRQrJF!U7>9ll*S;J)AI$1c(B0=|9>6m`GW&S8vx?6x`T4;k#xai0Ae z8ZWKLx8CoZMrVELETM+RhRNP6e>7w)!luzGO+J^ybw#;4P32-3xH}1YU zpke3yPGOub{^RvyE*=$((-UY@f{z2V{g}2AHz-WL#KAJDgk+1Ki=d#X>1gb`O|HaF z-4miZ3+SS;Nzbj1zyIdGa=!%7J9^Lg05mm}_IZ#ObMeevUm*_khp$7Lp3xUF<*TFs zTIw-ib*19cD&*`=HpcaVsY;z%%I?&$NexfV1ehNzP!5uittOiV$DnrRy5KppUVTWv zhM~{bw+S~4xdD*3uawJD^fU(bmsU%`cw;GqXF-$JFWRguL5ND>jSj#M&Mny&BhB7B zt36Vo61jee;Z_+{sD@6oqQih|?)J4-OFAb>41kv4>2zJo5@(Bm*kth19GU6Ol&mix zR_E;|h>4Y>n7H=-yvI}=DnEE}$xqLF`nxcScUlv9W=DVlzPV7ZNrfs%ilSE+<70K{e-uD_mt47$BGJ6*s` zcfY(NDXj$C@xb7zz7Dgx$)Xp3GwShrF!ot8c!zG4Z zgba2M53JN}SF0>xN>f)R91}+vH%%+N9Qz7#El=+Mkbyys5eYcG%r*XUwqMW z_ckpz69R8qB9>n*(F-2&RIo#0N87DeK#r74Cm^-)?NBZ|07++PMNL~;5L77KZS7n$ z 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

wmRf}baotA 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

O_r+6!@aA6_)EWZ)4hw&X0(QNC{prYj`xE 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 XbE~CYG<$-NfghC{8VcERX1@Oi_V>a} literal 0 HcmV?d00001 diff --git a/docs/screenshots/custom-model-reasoning-effort-dialog.png b/docs/screenshots/custom-model-reasoning-effort-dialog.png deleted file mode 100644 index 2ba25f5f8ec9e17723f03971abbe2d9564f932f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 145519 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 Z2xgHGxOVus#u2m Date: Sat, 15 Aug 2026 02:24:23 +0200 Subject: [PATCH 83/94] feat(models): add none/minimal as declared reasoning efforts; fix review findings - none and minimal are valid declared reasoning_effort values: accepted and canonicalized by the API and CLI, kept by catalog sanitize, never implicit defaults, and excluded from the mock max/ultra repair for sentinel-only ladders - pi export maps the off level to none when declared; minimal maps to itself - GUI dialog offers the full real-effort set: none, minimal, low, medium, high, xhigh, max (ultra stays a catalog-only label) - CodeRabbit: inherited default gap-fill guarded by effectiveLadder membership, native-alias explicit-override ordering, CLI usage errors, single JSON parse per response, pi.md adapter-dependent reasoning semantics - CLI regression suite: parseReasoningArgs, edit '-'->null, config persistence, list-custom output --- docs-site/src/content/docs/guides/pi.md | 7 + gui/src/pages/models-shared.ts | 10 +- src/cli/models-runtime.ts | 12 +- src/cli/models.ts | 12 +- src/clients/config-export.ts | 3 + src/codex/catalog/effort.ts | 9 +- src/codex/catalog/provider-fetch.ts | 29 +++- src/reasoning-effort.ts | 27 +++- src/server/management/model-routes.ts | 10 +- tests/catalog-input-modality-enum.test.ts | 30 +++- tests/cli-models-reasoning.test.ts | 78 +++++++++- tests/client-config-export.test.ts | 13 ++ tests/codex-catalog.test.ts | 179 +++++++++++++++++++++- 13 files changed, 378 insertions(+), 41 deletions(-) diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index 8805ea803..cba4a1619 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -115,6 +115,13 @@ on. The export also emits a `thinkingLevelMap` that hides every pi level outside ladder (`null`), so pi never offers — and never sends — an effort the ladder does not contain. If you need a different mapping, hand-edit `thinkingLevelMap` afterwards as documented by Pi. +Treat `reasoning` as Pi-UI metadata: it is derived from the catalog ladder, not proof that the +upstream natively supports a reasoning parameter. What the proxy actually sends for a given +`reasoning_effort` value depends on the provider's adapter and model — it may pass the value +through, translate it (wire aliases), clamp it to the configured ladder, emulate it, or omit it +entirely (e.g. `noReasoningModels`). The boolean only controls whether Pi offers the control at +all. + ## Schema status :::note[Unverified against a real install] diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 8d46302b4..a8205616c 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -42,8 +42,14 @@ export interface ModelRow { reasoningEfforts?: string[]; } -/** Codex ladder labels offered in the custom-model dialog. */ -export const REASONING_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max", "ultra"] as const; +/** + * Reasoning-effort labels offered in the custom-model dialog. The full set of real + * `reasoning_effort` values (none, minimal, low, medium, high, xhigh, max). Deliberately + * excludes `ultra`: that is a Codex catalog label for the multi-agent collab surface, not a + * real `reasoning_effort` value — codex-rs converts it to `max` before any provider + * request, and the catalog writer appends it to every non-empty ladder anyway. + */ +export const REASONING_EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const; export interface ProviderContextCapsResponse { cap?: number; diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index c80e83be0..eadb3a59e 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -73,7 +73,17 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { // "-" restores inheritance by clearing the stored ladder (null). An explicit empty // ladder ("no reasoning") has no CLI shorthand — use the dashboard for that state. if (reasoningEffortsRaw !== undefined) { - patch.reasoningEfforts = reasoningEffortsRaw === "-" ? null : csv(reasoningEffortsRaw); + if (reasoningEffortsRaw === "-") { + patch.reasoningEfforts = null; + } else { + const values = csv(reasoningEffortsRaw); + // csv() silently drops empty members; a value that normalizes to an empty list would + // otherwise store an explicit "no reasoning" ladder the user never asked for. + if (!values || values.length === 0) { + throw new CliUsageError("--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (or \"-\" to inherit)", USAGE); + } + patch.reasoningEfforts = values; + } } if (defaultEffortRaw !== undefined) patch.defaultReasoningEffort = defaultEffortRaw === "-" ? null : defaultEffortRaw; if (Object.keys(patch).length === 0) throw new CliUsageError("at least one edit option is required", USAGE); diff --git a/src/cli/models.ts b/src/cli/models.ts index cbfcd2fe9..95ec29396 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; -import { canonicalizeReasoningEfforts, isCodexReasoningEffort } from "../reasoning-effort"; +import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort"; import { routedSlug } from "../providers/slug-codec"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig, OcxCustomModel } from "../types"; @@ -34,11 +34,11 @@ export function parseReasoningArgs( } else { const parts = trimmed.split(",").map(value => value.trim()); if (parts.length === 0 || parts.some(part => part === "")) { - return { error: "--reasoning-efforts must be comma-separated values from low, medium, high, xhigh, max, ultra (or \"-\" to inherit)" }; + return { error: "--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (or \"-\" to inherit)" }; } - const invalid = parts.filter(value => !isCodexReasoningEffort(value)); + const invalid = parts.filter(value => !isDeclaredReasoningEffort(value)); if (invalid.length > 0) { - return { error: `unsupported reasoning effort: ${invalid.join(", ")} (allowed: low, medium, high, xhigh, max, ultra)` }; + return { error: `unsupported reasoning effort: ${invalid.join(", ")} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` }; } reasoningEfforts = canonicalizeReasoningEfforts(parts); } @@ -49,8 +49,8 @@ export function parseReasoningArgs( if (trimmed === "-") { defaultReasoningEffort = undefined; } else { - if (!isCodexReasoningEffort(trimmed)) { - return { error: `unsupported reasoning effort: ${trimmed} (allowed: low, medium, high, xhigh, max, ultra)` }; + if (!isDeclaredReasoningEffort(trimmed)) { + return { error: `unsupported reasoning effort: ${trimmed} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` }; } if (!reasoningEfforts || reasoningEfforts.length === 0) { return { error: "--default-reasoning-effort requires --reasoning-efforts" }; diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 43cd180fd..11d6dd689 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -883,6 +883,9 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { entry.reasoning = true; const efforts = model.reasoningEfforts; entry.thinkingLevelMap = { + // pi's off level maps to the declared `none` sentinel (the proxy omits the + // reasoning parameter for it); hidden when the ladder does not declare none. + off: efforts.includes("none") ? "none" : null, minimal: efforts.includes("minimal") ? "minimal" : null, low: efforts.includes("low") ? "low" : null, medium: efforts.includes("medium") ? "medium" : null, diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index b1ac6b540..4e1c9fa7f 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -153,8 +153,9 @@ export function applyReasoningLevels( // (no ultra->max client conversion) and codex-rs validates it by catalog membership, // so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest: // routed adapters clamp via clampToSupportedCodexEffort and natives via - // nativeEffortClamp (max -> the model's real top rung). - if (!preserveExact && efforts.length > 0) { + // nativeEffortClamp (max -> the model's real top rung). A `none`-only ladder is NOT + // reasoning-capable, so it must not grow synthetic top rungs. + if (!preserveExact && efforts.length > 0 && efforts.some(effort => effort !== "none" && effort !== "minimal")) { const additions: string[] = []; if (!efforts.includes("max")) additions.push("max"); if (!efforts.includes("ultra")) additions.push("ultra"); @@ -177,7 +178,9 @@ export function applyReasoningLevels( } entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride) ? defaultOverride - : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0]; + : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" + // Sentinels never become the implicit default when real rungs are declared. + : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0]; } export function isGpt56NativeSlug(slug: string): boolean { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index a452c441d..f617f2302 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1774,20 +1774,27 @@ async function gatherRoutedModelsUncached( ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}), - // Explicit custom-row ladder wins over the inherited provider row below: the merge only - // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept - // verbatim instead of being replaced by the replaced row's metadata. - ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), - ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + // Native-alias defaults apply only where the custom row declares nothing: the explicit + // spreads below must win (later in object order), so a stored `[]` stays empty and a + // declared ladder is never replaced by the alias's native ladder. ...(codexForwardNativeCapabilityAlias ? { codexForwardNativeCapabilityAlias: true, - reasoningEfforts: nativeReasoningEfforts(cm.modelId), parallelToolCalls: nativeParallelToolCalls(cm.modelId), - ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}), + ...(Array.isArray(cm.reasoningEfforts) + ? {} + : { + reasoningEfforts: nativeReasoningEfforts(cm.modelId), + ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}), + }), } : {}), + // Explicit custom-row ladder wins over the inherited provider row below: the merge only + // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept + // verbatim instead of being replaced by the replaced row's metadata. + ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), + ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, @@ -1796,13 +1803,19 @@ async function gatherRoutedModelsUncached( // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one, // which Codex then rejects for spawn_agent with effort "none". const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId)); + // The final ladder is what the catalog will advertise; the inherited default only rides + // along when it is actually a member — otherwise a provider default like "xhigh" would + // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels. + const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts; const merged: CatalogModel = replaced ? { ...base, ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), ...(base.maxInputTokens === undefined && replaced.maxInputTokens !== undefined ? { maxInputTokens: replaced.maxInputTokens } : {}), ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}), ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}), - ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}), + ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined + && Array.isArray(effectiveLadder) && effectiveLadder.includes(replaced.defaultReasoningEffort) + ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}), ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}), ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index c89db76d8..ff168f4b7 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -20,14 +20,27 @@ export function isCodexReasoningEffort(effort: string): boolean { } /** - * Reorder any subset of the Codex ladder into canonical low..ultra order and drop - * duplicates. Catalog `supported_reasoning_levels` follow the input order and the - * fallback default picks the first entry, so non-canonical input ("high,low") would - * otherwise leak a caller-chosen order into the catalog. + * True for ladder members plus the `none`/`minimal` sentinels. Both are valid declared + * efforts (OpenAI accepts `minimal`; Codex validates `none` against + * `supported_reasoning_levels` for no-reasoning subagent spawns, #883/#962) but are NOT + * part of the low..ultra ladder: they never appear in default ladders, ranks, or clamps + * (`minimal` is mapped to `low` on the wire by requestToCodexEffort). + */ +export function isDeclaredReasoningEffort(effort: string): boolean { + return effort === "none" || effort === "minimal" || CODEX_REASONING_SET.has(effort); +} + +/** + * Reorder any declared subset (low..ultra, plus the optional `none`/`minimal` sentinels + * first, in that order) into canonical order and drop duplicates. Catalog + * `supported_reasoning_levels` follow the input order and the fallback default picks the + * first entry, so a caller-chosen order would otherwise leak into the catalog. */ export function canonicalizeReasoningEfforts(values: readonly string[]): string[] { const seen = new Set(values); - return CODEX_REASONING_ORDER.filter(effort => seen.has(effort)); + const ordered = CODEX_REASONING_ORDER.filter(effort => seen.has(effort)); + const sentinels = ["none", "minimal"].filter(effort => seen.has(effort)); + return [...sentinels, ...ordered]; } /** @@ -77,7 +90,9 @@ export function sanitizeCodexReasoningEfforts(efforts: readonly string[] | undef const seen = new Set(); const out: string[] = []; for (const effort of efforts) { - if (!CODEX_REASONING_SET.has(effort) || seen.has(effort)) continue; + // `none`/`minimal` are valid declared sentinels, kept and sorted first (rank -1); they + // never appear in the default ladder. + if ((effort !== "none" && effort !== "minimal" && !CODEX_REASONING_SET.has(effort)) || seen.has(effort)) continue; seen.add(effort); out.push(effort); } diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 3736c9c5b..0cf22043f 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -43,11 +43,11 @@ function readReasoningEfforts(raw: unknown): { values?: string[]; error?: string const values: string[] = []; for (const value of raw) { if (typeof value !== "string") return { error: "reasoningEfforts must contain only strings" }; - if (!isCodexReasoningEffort(value)) { rejected.push(value); continue; } + if (!isDeclaredReasoningEffort(value)) { rejected.push(value); continue; } if (!values.includes(value)) values.push(value); } if (rejected.length > 0) { - return { error: `unsupported reasoning effort: ${rejected.join(", ")} (allowed: low, medium, high, xhigh, max, ultra)` }; + return { error: `unsupported reasoning effort: ${rejected.join(", ")} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` }; } // Canonical order: the catalog writes supported_reasoning_levels in input order and the // fallback default picks the first entry, so a caller-chosen order must not leak through. @@ -58,8 +58,8 @@ function readReasoningEfforts(raw: unknown): { values?: string[]; error?: string function readDefaultReasoningEffort(raw: unknown, efforts: string[] | undefined): { value?: string; error?: string } { if (raw === undefined) return {}; if (raw === null) return { value: undefined }; - if (typeof raw !== "string" || !isCodexReasoningEffort(raw)) { - return { error: "defaultReasoningEffort must be one of: low, medium, high, xhigh, max, ultra" }; + if (typeof raw !== "string" || !isDeclaredReasoningEffort(raw)) { + return { error: "defaultReasoningEffort must be one of: none, minimal, low, medium, high, xhigh, max, ultra" }; } if (efforts === undefined || efforts.length === 0) { return { error: "defaultReasoningEffort requires a non-empty reasoningEfforts ladder" }; @@ -111,7 +111,7 @@ import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summa import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { canonicalizeReasoningEfforts, isCodexReasoningEffort } from "../../reasoning-effort"; +import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../../reasoning-effort"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { clearDebugSettings, diff --git a/tests/catalog-input-modality-enum.test.ts b/tests/catalog-input-modality-enum.test.ts index a236072ea..2696ac43c 100644 --- a/tests/catalog-input-modality-enum.test.ts +++ b/tests/catalog-input-modality-enum.test.ts @@ -263,6 +263,21 @@ describe("custom-model API validates reasoning-effort ladders", () => { expect(persistCalls).toBe(1); }); + test("POST accepts the none/minimal sentinels, canonicalized first", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["max", "none", "low", "minimal"], + defaultReasoningEffort: "none", + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual(["none", "minimal", "low", "max"]); + expect(payload.defaultReasoningEffort).toBe("none"); + expect(persistCalls).toBe(1); + }); + test("POST refuses a default effort outside the declared ladder", async () => { persistCalls = 0; const res = await callCustomModels("POST", { @@ -326,12 +341,13 @@ describe("custom-model API validates reasoning-effort ladders", () => { // the generated catalog (GUI toggle-off path sends only reasoningEfforts). test("PUT ladder shrink drops a stored default that is no longer a member", async () => { persistCalls = 0; - const seeded = await callCustomModels("PUT", { + const seededRes = await callCustomModels("PUT", { reasoningEfforts: ["low", "high", "max"], defaultReasoningEffort: "max", }, "/api/custom-models/existing-uuid"); - expect(seeded?.status).toBe(200); - expect((await seeded!.json() as { defaultReasoningEffort?: string }).defaultReasoningEffort).toBe("max"); + expect(seededRes?.status).toBe(200); + const seeded = await seededRes!.json() as { defaultReasoningEffort?: string }; + expect(seeded.defaultReasoningEffort).toBe("max"); persistCalls = 0; const res = await callCustomModels("PUT", { reasoningEfforts: ["low"] }, "/api/custom-models/existing-uuid"); @@ -344,11 +360,11 @@ describe("custom-model API validates reasoning-effort ladders", () => { test("PUT null-clear drops a stored default even when the body does not mention it", async () => { persistCalls = 0; - const seeded = await callCustomModels("PUT", { + const seededRes = await callCustomModels("PUT", { reasoningEfforts: ["low", "high"], defaultReasoningEffort: "high", }, "/api/custom-models/existing-uuid"); - expect(seeded?.status).toBe(200); + expect(seededRes?.status).toBe(200); persistCalls = 0; const res = await callCustomModels("PUT", { reasoningEfforts: null }, "/api/custom-models/existing-uuid"); @@ -361,11 +377,11 @@ describe("custom-model API validates reasoning-effort ladders", () => { test("PUT explicit empty ladder also drops a stored default", async () => { persistCalls = 0; - const seeded = await callCustomModels("PUT", { + const seededRes = await callCustomModels("PUT", { reasoningEfforts: ["low", "high"], defaultReasoningEffort: "high", }, "/api/custom-models/existing-uuid"); - expect(seeded?.status).toBe(200); + expect(seededRes?.status).toBe(200); persistCalls = 0; const res = await callCustomModels("PUT", { reasoningEfforts: [] }, "/api/custom-models/existing-uuid"); diff --git a/tests/cli-models-reasoning.test.ts b/tests/cli-models-reasoning.test.ts index ac10b83cd..ffa1cc427 100644 --- a/tests/cli-models-reasoning.test.ts +++ b/tests/cli-models-reasoning.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, test } from "bun:test"; -import { parseReasoningArgs } from "../src/cli/models"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseReasoningArgs, handleModels } from "../src/cli/models"; import { handleModelsRuntimeCommand } from "../src/cli/models-runtime"; /** @@ -15,6 +18,12 @@ describe("ocx models add --reasoning-efforts parsing", () => { }); }); + test("the none sentinel is accepted and canonicalized first", () => { + expect(parseReasoningArgs("low,none,max", undefined)).toEqual({ + reasoningEfforts: ["none", "low", "max"], + }); + }); + test("an unknown effort is rejected and names the offending value", () => { const parsed = parseReasoningArgs("low,deep", undefined); expect(parsed.error).toContain("deep"); @@ -72,6 +81,18 @@ describe("ocx models edit reasoning flag mapping onto the PUT body", () => { expect(body.reasoningEfforts).toBeNull(); }); + test('"--reasoning-efforts \"\"" is rejected instead of storing an empty ladder', async () => { + let fetchCalled = false; + const fetchImpl = async () => { fetchCalled = true; return new Response("{}", { status: 200 }); }; + const code = await handleModelsRuntimeCommand("edit", ["cm-1", "--reasoning-efforts", ""], { + baseUrl: "http://127.0.0.1:1", + fetchImpl, + }); + // runCliAction turns CliUsageError into exit code 2 without touching the API. + expect(code).toBe(2); + expect(fetchCalled).toBe(false); + }); + test("a csv ladder maps to an array", async () => { const body = await editWith(["--reasoning-efforts", "low,high"]); expect(body.reasoningEfforts).toEqual(["low", "high"]); @@ -88,3 +109,56 @@ describe("ocx models edit reasoning flag mapping onto the PUT body", () => { expect(body.defaultReasoningEffort).toBe("high"); }); }); + +describe("ocx models add persists reasoning metadata into config.json", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-cli-test-")); + const previousHome = process.env.OPENCODEX_HOME; + + beforeAll(() => { + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "config.json"), JSON.stringify({ + providers: { + deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1", authMode: "key" }, + }, + })); + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + }); + + function readConfig(): { customModels?: Array> } { + return JSON.parse(readFileSync(join(home, "config.json"), "utf8")); + } + + test("a ladder with a member default is stored canonicalized", async () => { + await handleModels(["add", "deepseek", "m1", "--reasoning-efforts", "max,low,high", "--default-reasoning-effort", "high"]); + const entry = readConfig().customModels!.find(model => model.modelId === "m1")!; + expect(entry.reasoningEfforts).toEqual(["low", "high", "max"]); + expect(entry.defaultReasoningEffort).toBe("high"); + }); + + test('"-" omits the reasoning fields entirely (inherit)', async () => { + await handleModels(["add", "deepseek", "m2", "--reasoning-efforts", "-"]); + const entry = readConfig().customModels!.find(model => model.modelId === "m2")!; + expect(entry.reasoningEfforts).toBeUndefined(); + expect(entry.defaultReasoningEffort).toBeUndefined(); + }); + + test("list-custom renders the stored ladder columns", async () => { + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + try { + await handleModels(["list-custom"]); + } finally { + console.log = originalLog; + } + const table = lines.join("\n"); + expect(table).toContain("EFFORTS"); + expect(table).toContain("low,high,max"); + expect(table).toContain("-"); // m2 has no ladder -> dash cell + }); +}); diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index 861daf3dc..73c586da8 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -192,6 +192,7 @@ describe("Pi serializer (accept criterion 2)", () => { { namespaced: "a/reasoning", provider: "a", id: "reasoning", reasoningEfforts: ["low", "high"] }, { namespaced: "b/none", provider: "b", id: "none", reasoningEfforts: [] }, { namespaced: "c/plain", provider: "c", id: "plain" }, + { namespaced: "d/off", provider: "d", id: "off", reasoningEfforts: ["none", "minimal", "low"] }, ], })); const models = config.providers.opencodex!.models; @@ -199,6 +200,7 @@ describe("Pi serializer (accept criterion 2)", () => { // Pi's level scale is constrained to the ladder: members map to themselves, everything // else (incl. minimal, which the Codex ladder has no equivalent for) is hidden. expect(models.find(model => model.id === "a/reasoning")!.thinkingLevelMap).toEqual({ + off: null, minimal: null, low: "low", medium: null, @@ -206,6 +208,17 @@ describe("Pi serializer (accept criterion 2)", () => { xhigh: null, max: null, }); + // The none sentinel maps pi's off level to "none" (the proxy omits the parameter); + // minimal maps to itself. + expect(models.find(model => model.id === "d/off")!.thinkingLevelMap).toEqual({ + off: "none", + minimal: "minimal", + low: "low", + medium: null, + high: null, + xhigh: null, + max: null, + }); // An explicit empty ladder is the catalog's "no reasoning" statement; no boolean. expect(models.find(model => model.id === "b/none")).not.toHaveProperty("reasoning"); expect(models.find(model => model.id === "c/plain")).not.toHaveProperty("reasoning"); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 4e98dd2b1..8f98d0411 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1704,8 +1704,10 @@ describe("Google Gemini catalog metadata", () => { const entry = buildCatalogEntries(nativeTemplate(), [], models) .find(row => row.slug === "google/gemini-3.6-flash"); + // The registry ladder declares minimal for Gemini 3.6 Flash; it now flows through + // (previously sanitize silently dropped it) plus the mock top rungs for subagent spawns. expect((entry?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) - .toEqual(["low", "medium", "high", "max", "ultra"]); + .toEqual(["minimal", "low", "medium", "high", "max", "ultra"]); expect(entry?.input_modalities).toEqual(["text", "image"]); expect(entry?.context_window).toBe(1_048_576); }); @@ -1976,6 +1978,144 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { clearModelCache("custom-provider"); } }); + + test("a none-only custom ladder advertises no synthetic top rungs", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: ["none"], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + expect(custom?.reasoningEfforts).toEqual(["none"]); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + // No reasoning-capable rung -> the mock max/ultra repair must not fire. + expect(levels).toEqual(["none"]); + expect(row?.default_reasoning_level).toBe("none"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); + + test("a mixed none+low custom ladder keeps none first and gets the mock top rungs", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: ["none", "low"], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + expect(levels).toEqual(["none", "low", "max", "ultra"]); + // `none` is declared but real rungs exist: the implicit default must be low, not none. + expect(row?.default_reasoning_level).toBe("low"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); + + test("an inherited provider default does not ride onto a custom ladder that excludes it", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + // The provider row advertises low/high with a high default; the custom ladder + // drops high, so the merged row must not keep advertising high as default. + modelReasoningEfforts: { "renamed-model": ["low", "high"] }, + modelDefaultReasoningEfforts: { "renamed-model": "high" }, + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: ["low"], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + expect(custom?.reasoningEfforts).toEqual(["low"]); + expect(custom?.defaultReasoningEffort).toBeUndefined(); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + expect(levels).toEqual(["low", "max", "ultra"]); + // No high in the ladder, so the fallback default is medium? low is the first rung — + // applyReasoningLevels picks medium when present, else high, else the first entry. + expect(row?.default_reasoning_level).toBe("low"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); }); describe("legacy custom-model catalog ownership", () => { @@ -2775,6 +2915,43 @@ describe("Codex catalog routed normalization", () => { expect(row?.multi_agent_version).toBeUndefined(); }); + test("an explicit empty custom ladder beats the native-alias ladder on a forward row", async () => { + globalThis.fetch = (() => { throw new Error("forward providers must not fetch /models"); }) as typeof fetch; + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "pool", + }, + }, + codexAccountPickerEnabled: false, + codexAccountNamespaces: { main: "@main" }, + customModels: [{ + id: "daybreak-no-reasoning", + provider: "openai", + modelId: NATIVE_DAYBREAK_BLUE_MODEL, + // Explicit "no reasoning": the alias's native ladder (low..ultra, default low) must + // not overwrite it — otherwise the catalog would advertise reasoning the user + // explicitly disabled for this row. + reasoningEfforts: [], + }], + }); + const model = models.find(row => row.provider === "openai" && row.id === NATIVE_DAYBREAK_BLUE_MODEL); + expect(model).toMatchObject({ + codexForwardNativeCapabilityAlias: true, + reasoningEfforts: [], + }); + expect(model?.defaultReasoningEffort).toBeUndefined(); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const daybreak = entries.find(entry => entry.slug === `openai/${NATIVE_DAYBREAK_BLUE_MODEL}`); + expect(daybreak?.supported_reasoning_levels).toEqual([]); + expect(daybreak).not.toHaveProperty("default_reasoning_level"); + }); + test("catalog sync upgrades fallback-quality gpt-5.6 entries but preserves genuine ones", () => { // Fallback-quality: display_name stamped with the bare slug (ocx synthesis signature), // wrong ladder (ultra on luna) left by an older ocx version. From 8ccca16310aa5bed9abaab026a738662204a06ce Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:50:48 +0200 Subject: [PATCH 84/94] fix(models): address CodeRabbit round 3 + carry-over findings - CLI: empty-string --reasoning-efforts stores an explicit no-reasoning ladder; embedded blank CSV members (low,,high, ,,) are rejected in add and edit; usage text lists none and minimal - pi export: max maps to ultra when ultra is the only declared tier (Pi would otherwise have no selectable level) - GUI: re-enabling the reasoning override restores the loaded ladder instead of re-pre-checking every level - management rows carry the stored defaultReasoningEffort so clients can restore the full edit state - catalog test restores globalThis.fetch in a finally block - management-API tests share one config fixture (seed + follow-up state) and reset it per test - docs: US English afterward --- docs-site/src/content/docs/guides/pi.md | 2 +- gui/src/pages/Models.tsx | 19 ++++++++++++---- src/cli/models-runtime.ts | 17 +++++++------- src/cli/models.ts | 18 ++++++++++----- src/clients/config-export.ts | 2 +- src/server/management/model-rows.ts | 4 ++++ tests/catalog-input-modality-enum.test.ts | 27 +++++++++++++++-------- tests/cli-models-reasoning.test.ts | 18 +++++++++++---- tests/client-config-export.test.ts | 10 +++++++++ tests/codex-catalog.test.ts | 15 ++++++++----- 10 files changed, 94 insertions(+), 38 deletions(-) diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index cba4a1619..3e7520d3b 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -113,7 +113,7 @@ catalog's ladder is the proxy's own statement about whether a model accepts reas reasoning-free. Pi then offers its effort control for exactly the models opencodex will accept it on. The export also emits a `thinkingLevelMap` that hides every pi level outside the declared ladder (`null`), so pi never offers — and never sends — an effort the ladder does not contain. -If you need a different mapping, hand-edit `thinkingLevelMap` afterwards as documented by Pi. +If you need a different mapping, hand-edit `thinkingLevelMap` afterward as documented by Pi. Treat `reasoning` as Pi-UI metadata: it is derived from the catalog ladder, not proof that the upstream natively supports a reasoning parameter. What the proxy actually sends for a given diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 428223d81..ac19a8704 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -196,6 +196,9 @@ export default function Models({ apiBase }: { apiBase: string }) { const [customFormModalities, setCustomFormModalities] = useState(["text"]); const [customFormReasoning, setCustomFormReasoning] = useState(false); const [customFormReasoningEfforts, setCustomFormReasoningEfforts] = useState([]); + // The ladder loaded from the row being edited. Re-enabling the override must restore this + // instead of re-pre-checking every level, which would silently discard a stored ladder. + const customFormReasoningLoadedRef = useRef([]); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(""); const [contextModalProvider, setContextModalProvider] = useState(null); @@ -1006,6 +1009,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomFormModalities(["text"]); setCustomFormReasoning(false); setCustomFormReasoningEfforts([]); + customFormReasoningLoadedRef.current = []; setCustomError(""); setCustomModalOpen(true); }} @@ -1157,6 +1161,7 @@ export default function Models({ apiBase }: { apiBase: string }) { // provider row's current metadata. setCustomFormReasoning(Array.isArray(m.reasoningEfforts)); setCustomFormReasoningEfforts(m.reasoningEfforts ?? []); + customFormReasoningLoadedRef.current = m.reasoningEfforts ?? []; setCustomError(""); setCustomModalOpen(true); setHoveredModel(null); @@ -1616,10 +1621,16 @@ export default function Models({ apiBase }: { apiBase: string }) { checked={customFormReasoning} onChange={e => { setCustomFormReasoning(e.target.checked); - // Default to the full ladder: the common intent is "allow every known - // step". An explicit no-reasoning override (empty ladder) then requires - // deliberately unchecking all of them instead of being an accident. - if (e.target.checked) setCustomFormReasoningEfforts([...REASONING_EFFORT_LEVELS]); + if (e.target.checked) { + setCustomFormReasoningEfforts(prev => { + // First enable (nothing picked yet): seed from the stored ladder, + // or pre-check the full set for a new row / an explicit + // no-reasoning row. Re-enable keeps whatever the user had. + if (prev.length > 0) return prev; + const loaded = customFormReasoningLoadedRef.current; + return loaded.length > 0 ? loaded : [...REASONING_EFFORT_LEVELS]; + }); + } }} disabled={customSaving} /> diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index eadb3a59e..c309274c8 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -17,7 +17,7 @@ const USAGE = `Usage: ocx models live [--provider ] [--json] ocx models edit [--model-id ] [--display-name ] [--context-window ] [--modalities ] - [--reasoning-efforts ] + [--reasoning-efforts ] [--default-reasoning-effort ] [--json] ocx models [--native] [--json] ocx models provider [--json] @@ -70,17 +70,18 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { patch.contextWindow = value === 0 ? null : value; } if (modalitiesRaw !== undefined) patch.inputModalities = modalitiesRaw === "-" ? [] : csv(modalitiesRaw); - // "-" restores inheritance by clearing the stored ladder (null). An explicit empty - // ladder ("no reasoning") has no CLI shorthand — use the dashboard for that state. + // "-" restores inheritance by clearing the stored ladder (null); "" stores an explicit + // empty ladder (the "no reasoning" override, same as the dashboard's uncheck-all). + // Embedded blank CSV members (`low,,high`, `,,`) are malformed and must be rejected, not + // silently normalized by csv(). if (reasoningEffortsRaw !== undefined) { if (reasoningEffortsRaw === "-") { patch.reasoningEfforts = null; } else { - const values = csv(reasoningEffortsRaw); - // csv() silently drops empty members; a value that normalizes to an empty list would - // otherwise store an explicit "no reasoning" ladder the user never asked for. - if (!values || values.length === 0) { - throw new CliUsageError("--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (or \"-\" to inherit)", USAGE); + const trimmed = reasoningEffortsRaw.trim(); + const values = trimmed === "" ? [] : trimmed.split(",").map(value => value.trim()); + if (values.some(value => value === "")) { + throw new CliUsageError("--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (\"\" for no reasoning, \"-\" to inherit)", USAGE); } patch.reasoningEfforts = values; } diff --git a/src/cli/models.ts b/src/cli/models.ts index 95ec29396..83403c6a0 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -10,16 +10,18 @@ import { routedSlug } from "../providers/slug-codec"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig, OcxCustomModel } from "../types"; -const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio] [--reasoning-efforts ] [--default-reasoning-effort ]"; +const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio] [--reasoning-efforts ] [--default-reasoning-effort ]"; const REMOVE_USAGE = "Usage: ocx models remove [--yes]"; const LIST_CUSTOM_USAGE = "Usage: ocx models list-custom [--json]"; const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]); /** * Parse and validate the reasoning flags shared by `ocx models add` (offline path). - * "-" means "inherit" and omits the field entirely; an empty string is rejected instead of - * silently meaning something (edit's "-" is the documented clear idiom). Values are - * canonicalized into Codex ladder order so the stored config matches what the API stores. + * "-" means "inherit" and omits the field entirely; "" means an explicit empty ladder + * ("no reasoning" override, the same state the dashboard stores for the toggle-off + * checkbox set). Malformed CSV like `low,,high` or `,,` is rejected instead of being + * silently normalized. Values are canonicalized into Codex ladder order so the stored + * config matches what the API stores. */ export function parseReasoningArgs( reasoningEffortsValue: string | undefined, @@ -31,10 +33,14 @@ export function parseReasoningArgs( const trimmed = reasoningEffortsValue.trim(); if (trimmed === "-") { reasoningEfforts = undefined; + } else if (trimmed === "") { + // Explicit no-reasoning override, exactly like the API's [] / the dashboard's + // uncheck-all state. + reasoningEfforts = []; } else { const parts = trimmed.split(",").map(value => value.trim()); - if (parts.length === 0 || parts.some(part => part === "")) { - return { error: "--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (or \"-\" to inherit)" }; + if (parts.some(part => part === "")) { + return { error: "--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (\"\" for no reasoning, \"-\" to inherit)" }; } const invalid = parts.filter(value => !isDeclaredReasoningEffort(value)); if (invalid.length > 0) { diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 11d6dd689..4c903fa0e 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -891,7 +891,7 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { medium: efforts.includes("medium") ? "medium" : null, high: efforts.includes("high") ? "high" : null, xhigh: efforts.includes("xhigh") ? "xhigh" : null, - max: efforts.includes("max") ? "max" : null, + max: efforts.includes("max") ? "max" : efforts.includes("ultra") ? "ultra" : null, }; } const context = authoritativeContextWindow(model.contextWindow); diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index b10fea4cd..957aa6890 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -95,6 +95,10 @@ export async function listManagementModelRows(config: OcxConfig): Promise { */ describe("custom-model API rejects out-of-enum input modalities", () => { let persistCalls = 0; + // Shared fixture: the PUT/POST handlers mutate and persist the config object they + // receive, so seed requests and their follow-ups must see the SAME object (a fresh + // object per call would discard the seeded default before the follow-up asserts on it). + const fixtureConfig = { + providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1" } }, + customModels: [] as Array<{ id: string; provider: string; modelId: string; inputModalities?: string[] }>, + } as unknown as OcxConfig; + + beforeEach(() => { + // Seeded WITH modalities on purpose: a fixture without them would let the + // clear-path test pass against a PUT that ignored the field entirely. + fixtureConfig.customModels = [ + { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4", inputModalities: ["text", "image"] }, + ]; + }); async function callCustomModels( method: "POST" | "PUT", @@ -73,14 +89,7 @@ describe("custom-model API rejects out-of-enum input modalities", () => { return handleModelRoutes({ req, url, - config: { - providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1" } }, - customModels: [ - // Seeded WITH modalities on purpose: a fixture without them would let the - // clear-path test pass against a PUT that ignored the field entirely. - { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4", inputModalities: ["text", "image"] }, - ], - } as unknown as Parameters[0]["config"], + config: fixtureConfig, // This handler mutates and persists the config object it receives. The // fixture must NEVER reach the process-global OPENCODEX_HOME; that exact bug // replaced a real 41KB provider config with this `existing-uuid` fixture. diff --git a/tests/cli-models-reasoning.test.ts b/tests/cli-models-reasoning.test.ts index ffa1cc427..4c7e13152 100644 --- a/tests/cli-models-reasoning.test.ts +++ b/tests/cli-models-reasoning.test.ts @@ -30,9 +30,14 @@ describe("ocx models add --reasoning-efforts parsing", () => { expect(parsed.reasoningEfforts).toBeUndefined(); }); - test("an empty string is rejected instead of silently meaning something", () => { - expect(parseReasoningArgs("", undefined)?.error).toContain("comma-separated"); + test('an empty string is the explicit no-reasoning ladder; malformed CSV is rejected', () => { + expect(parseReasoningArgs("", undefined)).toEqual({ reasoningEfforts: [] }); expect(parseReasoningArgs("low,,high", undefined)?.error).toContain("comma-separated"); + expect(parseReasoningArgs(",,", undefined)?.error).toContain("comma-separated"); + }); + + test("a default still cannot ride on an explicit empty ladder", () => { + expect(parseReasoningArgs("", "low")?.error).toContain("requires --reasoning-efforts"); }); test('"-" omits the field (inherit) exactly like the API null-clear', () => { @@ -81,10 +86,15 @@ describe("ocx models edit reasoning flag mapping onto the PUT body", () => { expect(body.reasoningEfforts).toBeNull(); }); - test('"--reasoning-efforts \"\"" is rejected instead of storing an empty ladder', async () => { + test('"--reasoning-efforts \"\"" stores an explicit empty ladder (no-reasoning override)', async () => { + const body = await editWith(["--reasoning-efforts", ""]); + expect(body.reasoningEfforts).toEqual([]); + }); + + test("embedded blank CSV members are rejected without touching the API", async () => { let fetchCalled = false; const fetchImpl = async () => { fetchCalled = true; return new Response("{}", { status: 200 }); }; - const code = await handleModelsRuntimeCommand("edit", ["cm-1", "--reasoning-efforts", ""], { + const code = await handleModelsRuntimeCommand("edit", ["cm-1", "--reasoning-efforts", "low,,high"], { baseUrl: "http://127.0.0.1:1", fetchImpl, }); diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index 73c586da8..a9eaaff32 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -219,6 +219,16 @@ describe("Pi serializer (accept criterion 2)", () => { xhigh: null, max: null, }); + // An ultra-only ladder has no exact pi level: pi's max maps to the only declared tier + // so the model's sole reasoning level is actually selectable. + const ultraOnly = piConfig(ctx({ + models: [{ namespaced: "e/ultra", provider: "e", id: "ultra", reasoningEfforts: ["ultra"] }], + })); + expect(ultraOnly.providers.opencodex!.models[0]!.thinkingLevelMap).toMatchObject({ + max: "ultra", + off: null, + minimal: null, + }); // An explicit empty ladder is the catalog's "no reasoning" statement; no boolean. expect(models.find(model => model.id === "b/none")).not.toHaveProperty("reasoning"); expect(models.find(model => model.id === "c/plain")).not.toHaveProperty("reasoning"); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 8f98d0411..60021106f 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2916,8 +2916,10 @@ describe("Codex catalog routed normalization", () => { }); test("an explicit empty custom ladder beats the native-alias ladder on a forward row", async () => { + const originalFetch = globalThis.fetch; globalThis.fetch = (() => { throw new Error("forward providers must not fetch /models"); }) as typeof fetch; - const models = await gatherRoutedModels({ + try { + const models = await gatherRoutedModels({ port: 10100, defaultProvider: "openai", providers: { @@ -2946,10 +2948,13 @@ describe("Codex catalog routed normalization", () => { }); expect(model?.defaultReasoningEffort).toBeUndefined(); - const entries = buildCatalogEntries(nativeTemplate(), [], models); - const daybreak = entries.find(entry => entry.slug === `openai/${NATIVE_DAYBREAK_BLUE_MODEL}`); - expect(daybreak?.supported_reasoning_levels).toEqual([]); - expect(daybreak).not.toHaveProperty("default_reasoning_level"); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const daybreak = entries.find(entry => entry.slug === `openai/${NATIVE_DAYBREAK_BLUE_MODEL}`); + expect(daybreak?.supported_reasoning_levels).toEqual([]); + expect(daybreak).not.toHaveProperty("default_reasoning_level"); + } finally { + globalThis.fetch = originalFetch; + } }); test("catalog sync upgrades fallback-quality gpt-5.6 entries but preserves genuine ones", () => { From a42dfe5e9950460a6df835939f91bf9146cf2e15 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:14:26 +0200 Subject: [PATCH 85/94] =?UTF-8?q?fix(models):=20CodeRabbit=20round=204=20?= =?UTF-8?q?=E2=80=94=20init-ref=20seeding,=20capability-aware=20preselect,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GUI: initialization tracked by ref, not by array contents, so re-enabling the override preserves an intentionally empty ladder (explicit no-reasoning) and never resurrects previously cleared levels - GUI: first enable seeds from the model row's advertised ladder when known (providers may support a subset); unknown ids fall back to the full set, the wire clamp still bounds what is sent - pi.md documents the max->ultra thinkingLevelMap fallback --- docs-site/src/content/docs/guides/pi.md | 6 +++-- gui/src/pages/Models.tsx | 36 +++++++++++++++---------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index 3e7520d3b..c44b97f12 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -111,8 +111,10 @@ catalog's ladder is the proxy's own statement about whether a model accepts reas (adapters honor `reasoning_effort`), an export row with a **non-empty** ladder now emits `"reasoning": true`, and a row without one (or with an explicitly empty ladder) stays reasoning-free. Pi then offers its effort control for exactly the models opencodex will accept it -on. The export also emits a `thinkingLevelMap` that hides every pi level outside the declared -ladder (`null`), so pi never offers — and never sends — an effort the ladder does not contain. +on. The export also emits a `thinkingLevelMap` that hides every pi level with no declared target +(`null`), so pi never offers — and never sends — an effort the ladder does not contain. One +fallback keeps the model usable: when `ultra` is declared without `max`, pi's `max` level maps +to `ultra` (still a ladder member). If you need a different mapping, hand-edit `thinkingLevelMap` afterward as documented by Pi. Treat `reasoning` as Pi-UI metadata: it is derived from the catalog ladder, not proof that the diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index ac19a8704..f8322dd59 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -196,9 +196,11 @@ export default function Models({ apiBase }: { apiBase: string }) { const [customFormModalities, setCustomFormModalities] = useState(["text"]); const [customFormReasoning, setCustomFormReasoning] = useState(false); const [customFormReasoningEfforts, setCustomFormReasoningEfforts] = useState([]); - // The ladder loaded from the row being edited. Re-enabling the override must restore this - // instead of re-pre-checking every level, which would silently discard a stored ladder. - const customFormReasoningLoadedRef = useRef([]); + // Whether the ladder has been seeded at least once. `[]` is a MEANINGFUL explicit + // no-reasoning override, so initialization is tracked separately from the array contents: + // once seeded (an edit's stored ladder — including an explicit empty one — or a new form's + // first enable), re-enabling the override preserves the current array even when empty. + const customFormReasoningInitializedRef = useRef(false); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(""); const [contextModalProvider, setContextModalProvider] = useState(null); @@ -1009,7 +1011,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomFormModalities(["text"]); setCustomFormReasoning(false); setCustomFormReasoningEfforts([]); - customFormReasoningLoadedRef.current = []; + customFormReasoningInitializedRef.current = false; setCustomError(""); setCustomModalOpen(true); }} @@ -1161,7 +1163,9 @@ export default function Models({ apiBase }: { apiBase: string }) { // provider row's current metadata. setCustomFormReasoning(Array.isArray(m.reasoningEfforts)); setCustomFormReasoningEfforts(m.reasoningEfforts ?? []); - customFormReasoningLoadedRef.current = m.reasoningEfforts ?? []; + // A stored ladder — even an explicit empty one — is a real + // configuration: re-enabling must preserve it, not reseed. + customFormReasoningInitializedRef.current = Array.isArray(m.reasoningEfforts); setCustomError(""); setCustomModalOpen(true); setHoveredModel(null); @@ -1621,15 +1625,19 @@ export default function Models({ apiBase }: { apiBase: string }) { checked={customFormReasoning} onChange={e => { setCustomFormReasoning(e.target.checked); - if (e.target.checked) { - setCustomFormReasoningEfforts(prev => { - // First enable (nothing picked yet): seed from the stored ladder, - // or pre-check the full set for a new row / an explicit - // no-reasoning row. Re-enable keeps whatever the user had. - if (prev.length > 0) return prev; - const loaded = customFormReasoningLoadedRef.current; - return loaded.length > 0 ? loaded : [...REASONING_EFFORT_LEVELS]; - }); + if (e.target.checked && !customFormReasoningInitializedRef.current) { + customFormReasoningInitializedRef.current = true; + // First enable: seed from the model's advertised ladder when the + // row is known (a provider may support only a subset of levels — + // preselecting the full shared list would persist levels the model + // does not accept). Unknown model ids fall back to the full set: + // the common intent of enabling the override is "allow every known + // step", and the wire clamp still bounds what is actually sent. + const row = models.find(m => m.provider === customModalProvider && m.id === customFormModelId); + const advertised = Array.isArray(row?.reasoningEfforts) + ? row.reasoningEfforts + : undefined; + setCustomFormReasoningEfforts(advertised ?? [...REASONING_EFFORT_LEVELS]); } }} disabled={customSaving} From a087d2a6f440027e424c2536bf39f6fb0a57ab9b Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:25:44 +0200 Subject: [PATCH 86/94] fix(gui): localize reasoning-effort labels in the custom-model dialog The effort checkboxes rendered canonical identifiers as user-visible text. Add models.reasoningEffort.* keys to all 8 locales and render translated labels; state and payloads keep the canonical identifiers. --- gui/src/i18n/de.ts | 7 +++++++ gui/src/i18n/en.ts | 7 +++++++ gui/src/i18n/ja.ts | 7 +++++++ gui/src/i18n/ko.ts | 7 +++++++ gui/src/i18n/ru.ts | 7 +++++++ gui/src/i18n/tr.ts | 7 +++++++ gui/src/i18n/zh-TW.ts | 7 +++++++ gui/src/i18n/zh.ts | 7 +++++++ gui/src/pages/Models.tsx | 2 +- 9 files changed, 57 insertions(+), 1 deletion(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index af4c87f64..a1af60c71 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -541,6 +541,13 @@ export const de: Record = { "models.customFieldModalities": "Eingabemodalitäten", "models.customFieldReasoning": "Reasoning-Aufwand", "models.customFieldReasoningOverride": "Reasoning-Aufwand überschreiben", + "models.reasoningEffort.none": "Keine", + "models.reasoningEffort.minimal": "Minimal", + "models.reasoningEffort.low": "Niedrig", + "models.reasoningEffort.medium": "Mittel", + "models.reasoningEffort.high": "Hoch", + "models.reasoningEffort.xhigh": "Sehr hoch", + "models.reasoningEffort.max": "Maximal", "models.tipProvider": "Anbieter", "models.tipContext": "Kontext", "models.tipModalities": "Modalitäten", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7fbe6941b..48b5e4e7d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -566,6 +566,13 @@ export const en = { "models.customFieldModalities": "Input modalities", "models.customFieldReasoning": "Reasoning effort", "models.customFieldReasoningOverride": "Override reasoning effort", + "models.reasoningEffort.none": "None", + "models.reasoningEffort.minimal": "Minimal", + "models.reasoningEffort.low": "Low", + "models.reasoningEffort.medium": "Medium", + "models.reasoningEffort.high": "High", + "models.reasoningEffort.xhigh": "Extra high", + "models.reasoningEffort.max": "Maximum", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7e5e70f1c..791f1cb22 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1959,6 +1959,13 @@ export const ja: Record = { "models.customFieldModalities": "Input modalities", "models.customFieldReasoning": "推論努力", "models.customFieldReasoningOverride": "推論努力を上書き", + "models.reasoningEffort.none": "なし", + "models.reasoningEffort.minimal": "最小", + "models.reasoningEffort.low": "低", + "models.reasoningEffort.medium": "中", + "models.reasoningEffort.high": "高", + "models.reasoningEffort.xhigh": "非常に高", + "models.reasoningEffort.max": "最大", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index dee156cf4..ed0637bf6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -552,6 +552,13 @@ export const ko: Record = { "models.customFieldModalities": "입력 모달리티", "models.customFieldReasoning": "추론 노력", "models.customFieldReasoningOverride": "추론 노력 재정의", + "models.reasoningEffort.none": "없음", + "models.reasoningEffort.minimal": "최소", + "models.reasoningEffort.low": "낮음", + "models.reasoningEffort.medium": "중간", + "models.reasoningEffort.high": "높음", + "models.reasoningEffort.xhigh": "매우 높음", + "models.reasoningEffort.max": "최대", "models.tipProvider": "프로바이더", "models.tipContext": "컨텍스트", "models.tipModalities": "모달리티", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bb32993b8..4880fec78 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -554,6 +554,13 @@ export const ru: Record = { "models.customFieldModalities": "Входные модальности", "models.customFieldReasoning": "Уровень рассуждений", "models.customFieldReasoningOverride": "Переопределить уровень рассуждений", + "models.reasoningEffort.none": "Нет", + "models.reasoningEffort.minimal": "Минимальный", + "models.reasoningEffort.low": "Низкий", + "models.reasoningEffort.medium": "Средний", + "models.reasoningEffort.high": "Высокий", + "models.reasoningEffort.xhigh": "Очень высокий", + "models.reasoningEffort.max": "Максимальный", "models.tipProvider": "Провайдер", "models.tipContext": "Контекст", "models.tipModalities": "Модальности", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 13ce134b2..f74c53f0d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -557,6 +557,13 @@ export const tr: Record = { "models.customFieldModalities": "Girdi türleri", "models.customFieldReasoning": "Akıl yürütme çabası", "models.customFieldReasoningOverride": "Akıl yürütme çabasını geçersiz kıl", + "models.reasoningEffort.none": "Yok", + "models.reasoningEffort.minimal": "Minimal", + "models.reasoningEffort.low": "Düşük", + "models.reasoningEffort.medium": "Orta", + "models.reasoningEffort.high": "Yüksek", + "models.reasoningEffort.xhigh": "Çok yüksek", + "models.reasoningEffort.max": "Maksimum", "models.tipProvider": "Sağlayıcı", "models.tipContext": "Bağlam", "models.tipModalities": "Girdi Türleri", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 75e1c06ea..8d008686d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -422,6 +422,13 @@ export const zhTW: Record = { "models.customFieldModalities": "輸入模態", "models.customFieldReasoning": "推理強度", "models.customFieldReasoningOverride": "覆寫推理強度", + "models.reasoningEffort.none": "無", + "models.reasoningEffort.minimal": "最低", + "models.reasoningEffort.low": "低", + "models.reasoningEffort.medium": "中", + "models.reasoningEffort.high": "高", + "models.reasoningEffort.xhigh": "極高", + "models.reasoningEffort.max": "最高", "models.tipProvider": "供應商", "models.tipContext": "上下文", "models.tipModalities": "模態", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 72c536202..1b169d645 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -549,6 +549,13 @@ export const zh: Record = { "models.customFieldModalities": "输入模态", "models.customFieldReasoning": "推理强度", "models.customFieldReasoningOverride": "覆盖推理强度", + "models.reasoningEffort.none": "无", + "models.reasoningEffort.minimal": "最低", + "models.reasoningEffort.low": "低", + "models.reasoningEffort.medium": "中", + "models.reasoningEffort.high": "高", + "models.reasoningEffort.xhigh": "极高", + "models.reasoningEffort.max": "最高", "models.tipProvider": "提供方", "models.tipContext": "上下文", "models.tipModalities": "模态", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index f8322dd59..3a9567d00 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1659,7 +1659,7 @@ export default function Models({ apiBase }: { apiBase: string }) { }} disabled={customSaving} /> - {effort} + {t(`models.reasoningEffort.${effort}` as TKey)} ))}

From ccdc7bcd9dd6ee0a5538a56a758a95bf3fb721dd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:28:26 +0200 Subject: [PATCH 87/94] refactor(adapters): centralize runtime adapter authority --- src/adapters/registry.ts | 144 +++++++++++++++++++++++ src/server/adapter-resolve.ts | 34 +----- structure/10_adapter-registry.md | 36 ++++++ tests/adapter-registry-authority.test.ts | 98 +++++++++++++++ 4 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 src/adapters/registry.ts create mode 100644 structure/10_adapter-registry.md create mode 100644 tests/adapter-registry-authority.test.ts diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts new file mode 100644 index 000000000..2b88338b3 --- /dev/null +++ b/src/adapters/registry.ts @@ -0,0 +1,144 @@ +import { createAnthropicAdapter } from "./anthropic"; +import { createAzureAdapter } from "./azure"; +import type { ProviderAdapter } from "./base"; +import { createCommandCodeAdapter } from "./command-code"; +import { createCursorAdapter } from "./cursor"; +import { createGoogleAdapter } from "./google"; +import { createKiroAdapter } from "./kiro"; +import { createMimoFreeAdapter } from "./mimo-free"; +import { createOpenAIChatAdapter } from "./openai-chat"; +import { createResponsesPassthroughAdapter } from "./openai-responses"; +import type { OcxProviderConfig } from "../types"; + +export type AdapterCacheRetention = "none" | "short" | "long"; + +export interface AdapterFactoryContext { + cacheRetention?: AdapterCacheRetention; +} + +export type AdapterWire = + | "command-code" + | "openai-chat" + | "anthropic" + | "openai-responses" + | "google" + | "kiro" + | "cursor"; + +export type AdapterMutationContract = + | "codex-owned" + | "codex-owned-with-gated-native-fallback"; + +type AdapterFactory = ( + provider: OcxProviderConfig, + context: AdapterFactoryContext, +) => ProviderAdapter; + +type DirectAdapterDefinition = { + wire: AdapterWire; + mutation: AdapterMutationContract; + create: AdapterFactory; +}; + +type InheritedAdapterDefinition = { + /** Semantic contract inheritance only. Runtime construction remains independent. */ + contractParent: string; + create: AdapterFactory; +}; + +type AdapterDefinition = DirectAdapterDefinition | InheritedAdapterDefinition; + +export const ADAPTER_REGISTRY = { + "command-code": { + wire: "command-code", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCommandCodeAdapter(provider), + }, + "openai-chat": { + wire: "openai-chat", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createOpenAIChatAdapter(provider), + }, + anthropic: { + wire: "anthropic", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, context: AdapterFactoryContext) => + createAnthropicAdapter(provider, context.cacheRetention), + }, + "openai-responses": { + wire: "openai-responses", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => + createResponsesPassthroughAdapter(provider), + }, + google: { + wire: "google", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createGoogleAdapter(provider), + }, + kiro: { + wire: "kiro", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createKiroAdapter(provider), + }, + azure: { + contractParent: "openai-responses", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createAzureAdapter(provider), + }, + "azure-openai": { + contractParent: "openai-responses", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createAzureAdapter(provider), + }, + cursor: { + wire: "cursor", + mutation: "codex-owned-with-gated-native-fallback", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCursorAdapter(provider), + }, + "mimo-free": { + contractParent: "openai-chat", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), + }, +} as const satisfies Record; + +export type AdapterId = keyof typeof ADAPTER_REGISTRY; +export type RegisteredAdapterDefinition = typeof ADAPTER_REGISTRY[AdapterId]; + +export function adapterDefinitions(): Array<[AdapterId, RegisteredAdapterDefinition]> { + return Object.entries(ADAPTER_REGISTRY) as Array<[AdapterId, RegisteredAdapterDefinition]>; +} + +export function getAdapterDefinition(adapterId: unknown): RegisteredAdapterDefinition | undefined { + if (typeof adapterId !== "string" || !Object.hasOwn(ADAPTER_REGISTRY, adapterId)) return undefined; + return ADAPTER_REGISTRY[adapterId as AdapterId]; +} + +export function effectiveAdapterContract(adapterId: string): Readonly<{ + wire: AdapterWire; + mutation: AdapterMutationContract; +}> { + const visited = new Set(); + let current = adapterId; + + while (true) { + if (visited.has(current)) { + throw new Error(`Adapter contract cycle detected at ${current}`); + } + visited.add(current); + + const definition = getAdapterDefinition(current); + if (!definition) throw new Error(`Unknown adapter: ${current}`); + if ("wire" in definition) { + return { wire: definition.wire, mutation: definition.mutation }; + } + current = definition.contractParent; + } +} + +export function createRegisteredAdapter( + provider: OcxProviderConfig, + context: AdapterFactoryContext = {}, +): ProviderAdapter { + const definition = getAdapterDefinition(provider.adapter); + if (!definition) throw new Error(`Unknown adapter: ${provider.adapter}`); + return definition.create(provider, context); +} diff --git a/src/server/adapter-resolve.ts b/src/server/adapter-resolve.ts index 2edf7e3ee..8587b3191 100644 --- a/src/server/adapter-resolve.ts +++ b/src/server/adapter-resolve.ts @@ -1,12 +1,4 @@ -import { createAnthropicAdapter } from "../adapters/anthropic"; -import { createAzureAdapter } from "../adapters/azure"; -import { createCursorAdapter } from "../adapters/cursor"; -import { createGoogleAdapter } from "../adapters/google"; -import { createKiroAdapter } from "../adapters/kiro"; -import { createMimoFreeAdapter } from "../adapters/mimo-free"; -import { createOpenAIChatAdapter } from "../adapters/openai-chat"; -import { createCommandCodeAdapter } from "../adapters/command-code"; -import { createResponsesPassthroughAdapter } from "../adapters/openai-responses"; +import { createRegisteredAdapter } from "../adapters/registry"; import type { OcxProviderConfig } from "../types"; import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; @@ -57,27 +49,5 @@ export function resolveWireProtocolOverride( /** Build the provider adapter for a resolved provider config. */ export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { - switch (providerConfig.adapter) { - case "command-code": - return createCommandCodeAdapter(providerConfig); - case "openai-chat": - return createOpenAIChatAdapter(providerConfig); - case "anthropic": - return createAnthropicAdapter(providerConfig, cacheRetention); - case "openai-responses": - return createResponsesPassthroughAdapter(providerConfig); - case "google": - return createGoogleAdapter(providerConfig); - case "kiro": - return createKiroAdapter(providerConfig); - case "azure": - case "azure-openai": - return createAzureAdapter(providerConfig); - case "cursor": - return createCursorAdapter(providerConfig); - case "mimo-free": - return createMimoFreeAdapter(providerConfig); - default: - throw new Error(`Unknown adapter: ${providerConfig.adapter}`); - } + return createRegisteredAdapter(providerConfig, { cacheRetention }); } diff --git a/structure/10_adapter-registry.md b/structure/10_adapter-registry.md new file mode 100644 index 000000000..41bbc20d6 --- /dev/null +++ b/structure/10_adapter-registry.md @@ -0,0 +1,36 @@ +# Adapter registry authority + +## Decision + +Runtime adapter construction has one authority: `src/adapters/registry.ts`. + +`src/server/adapter-resolve.ts` may resolve a provider/model onto an adapter id, but it does not maintain a second adapter factory inventory. The selected persisted/configured adapter id remains an untrusted string until the registry lookup succeeds. Unknown ids fail with the existing `Unknown adapter: ` error instead of widening configuration types around a closed compile-time union. + +## Semantic inheritance is not constructor inheritance + +Some adapters share another adapter's routed-tool semantics while retaining independent runtime construction: + +- `azure` and `azure-openai` inherit the `openai-responses` contract. +- `mimo-free` inherits the `openai-chat` contract. +- `cursor` stays direct because its `runTurn` transport and gated native-file fallback are distinct. + +The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. + +## Wrapper-cycle and runtime validation policy + +`effectiveAdapterContract()` follows `contractParent` links at runtime with a visited set. Unknown parents and cycles fail closed. This is intentionally runtime validation: registry/config values can originate in persisted files written by older or hand-edited installations, so compile-time typing alone is not an adequate boundary. + +## Extension policy + +Adding a production adapter requires: + +1. one `ADAPTER_REGISTRY` entry with its factory; +2. either a direct `wire` + mutation contract or an explicit `contractParent`; +3. provider/model adapter ids that point only at registered ids; +4. registry-derived conformance coverage in the follow-up conformance layer. + +Do not add a second switch/list of adapter factories in request routing. Focused tests may construct a concrete adapter directly when they are testing that adapter itself; cross-adapter production routing should use registry authority. + +## Scope boundary + +This decision does not change routed `apply_patch` behavior, Cursor structured-edit conversion, Azure/MiMo request construction, or provider wire selection. Those behaviors remain owned by their existing modules and focused tests. The registry exposes the universe and semantic relationships; the next stack layer consumes that metadata for generic conformance. diff --git a/tests/adapter-registry-authority.test.ts b/tests/adapter-registry-authority.test.ts new file mode 100644 index 000000000..0412bd60f --- /dev/null +++ b/tests/adapter-registry-authority.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { + adapterDefinitions, + createRegisteredAdapter, + effectiveAdapterContract, + getAdapterDefinition, +} from "../src/adapters/registry"; +import { resolveAdapter } from "../src/server/adapter-resolve"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const EXPECTED_ADAPTER_NAMES = { + "command-code": "command-code", + "openai-chat": "openai-chat", + anthropic: "anthropic", + "openai-responses": "openai-responses", + google: "google", + kiro: "kiro", + azure: "azure-openai", + "azure-openai": "azure-openai", + cursor: "cursor", + "mimo-free": "mimo-free", +} as const; + +function provider(adapter: string): OcxProviderConfig { + return { + adapter, + baseUrl: "https://example.invalid/v1", + authMode: "key", + apiKey: "test-key", + defaultMaxOutputTokens: 4096, + } as OcxProviderConfig; +} + +const ANTHROPIC_CACHE_REQUEST: OcxParsedRequest = { + modelId: "claude-haiku-4-5", + stream: true, + options: {}, + context: { + messages: [{ role: "user", content: "cache me", timestamp: 0 }], + }, +}; + +async function expectLongCacheRetention(adapter: ReturnType): Promise { + const request = await withTestTranslatorBudget(adapter).buildRequest(ANTHROPIC_CACHE_REQUEST); + const body = JSON.parse(request.body) as { + messages?: Array<{ content?: string | Array<{ cache_control?: { type?: string; ttl?: string } }> }>; + }; + const content = body.messages?.[0]?.content; + if (!Array.isArray(content)) throw new Error("expected Anthropic cache retention to annotate user content"); + expect(content.at(-1)?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" }); +} + +describe("adapter registry authority", () => { + test("enumerates every production adapter exactly once", () => { + expect(adapterDefinitions().map(([id]) => id)).toEqual(Object.keys(EXPECTED_ADAPTER_NAMES)); + }); + + test("records semantic inheritance without forcing constructor wrapping", () => { + expect(getAdapterDefinition("azure")?.contractParent).toBe("openai-responses"); + expect(getAdapterDefinition("azure-openai")?.contractParent).toBe("openai-responses"); + expect(getAdapterDefinition("mimo-free")?.contractParent).toBe("openai-chat"); + + expect(effectiveAdapterContract("azure").wire).toBe("openai-responses"); + expect(effectiveAdapterContract("azure-openai").wire).toBe("openai-responses"); + expect(effectiveAdapterContract("mimo-free").wire).toBe("openai-chat"); + expect(effectiveAdapterContract("cursor").mutation).toBe("codex-owned-with-gated-native-fallback"); + }); + + test("constructs every current adapter with its existing observable identity", () => { + for (const [adapterId, expectedName] of Object.entries(EXPECTED_ADAPTER_NAMES)) { + expect(createRegisteredAdapter(provider(adapterId)).name, adapterId).toBe(expectedName); + expect(resolveAdapter(provider(adapterId)).name, adapterId).toBe(expectedName); + } + }); + + test("forwards Anthropic cache retention through registry and server resolution", async () => { + await expectLongCacheRetention(createRegisteredAdapter(provider("anthropic"), { cacheRetention: "long" })); + await expectLongCacheRetention(resolveAdapter(provider("anthropic"), "long")); + }); + + test("rejects unknown persisted adapter ids at the runtime boundary", () => { + for (const adapterId of ["not-a-real-adapter", "__proto__", "constructor"]) { + expect(() => createRegisteredAdapter(provider(adapterId))) + .toThrow(`Unknown adapter: ${adapterId}`); + expect(() => effectiveAdapterContract(adapterId)) + .toThrow(`Unknown adapter: ${adapterId}`); + } + }); + + test("rejects non-string persisted adapter ids before registry lookup", () => { + for (const adapterId of [null, 42, ["azure"]]) { + expect(getAdapterDefinition(adapterId)).toBeUndefined(); + const malformed = { ...provider("anthropic"), adapter: adapterId } as unknown as OcxProviderConfig; + expect(() => createRegisteredAdapter(malformed)).toThrow("Unknown adapter:"); + } + }); +}); From ca0a51246bc0d8a5ac2278d041cf729cff334ce0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:13:12 +0200 Subject: [PATCH 88/94] test(adapters): derive routed tool conformance from registry --- tests/adapter-tool-conformance.test.ts | 441 ++++++++++++++++++ .../adapter-conformance/wire-drivers.ts | 282 +++++++++++ 2 files changed, 723 insertions(+) create mode 100644 tests/adapter-tool-conformance.test.ts create mode 100644 tests/helpers/adapter-conformance/wire-drivers.ts diff --git a/tests/adapter-tool-conformance.test.ts b/tests/adapter-tool-conformance.test.ts new file mode 100644 index 000000000..54483543f --- /dev/null +++ b/tests/adapter-tool-conformance.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, test } from "bun:test"; +import { + adapterDefinitions, + createRegisteredAdapter, + effectiveAdapterContract, + getAdapterDefinition, + type AdapterWire, +} from "../src/adapters/registry"; +import { resetMimoJwtCache } from "../src/adapters/mimo-free"; +import { bridgeToResponsesSSE } from "../src/bridge"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { parseRequest } from "../src/responses/parser"; +import { buildToolBridgeMaps } from "../src/server/responses"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, type OcxParsedRequest, type OcxProviderConfig } from "../src/types"; +import { TOOL_WIRE_DRIVERS } from "./helpers/adapter-conformance/wire-drivers"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +const PATCH = `*** Begin Patch +*** Add File: conformance-안녕.txt ++quote: "double" ++slash: \\ path ++unicode: 世界 +*** End Patch`; + +const EXEC_DESCRIPTION = + "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + +const WIRE_MODELS: Record = { + "openai-chat": "grok-4.6", + anthropic: "claude-haiku-4-5", + google: "gemini-3.5-flash", + "command-code": "deepseek/deepseek-v4-flash", + kiro: "claude-sonnet-4.5", + "openai-responses": "deepseek-v4-flash", + cursor: "cursor/auto", +}; + +function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { + const baseUrls: Record = { + "openai-chat": "https://api.x.ai/v1", + anthropic: "https://api.anthropic.com", + google: "https://generativelanguage.googleapis.com", + "command-code": "https://api.commandcode.ai", + kiro: "https://runtime.us-east-1.kiro.dev", + "openai-responses": "https://api.deepseek.com", + cursor: "https://api2.cursor.sh", + }; + // Semantic wrappers with provider-specific URL shapes must override the wire-family default here. + const baseUrl = adapterId === "mimo-free" + ? "https://api.xiaomimimo.com/api/free-ai/openai" + : adapterId === "azure" || adapterId === "azure-openai" + ? "https://example.openai.azure.com/openai/v1" + : baseUrls[wire]; + return { + adapter: adapterId, + baseUrl, + authMode: wire === "anthropic" || wire === "command-code" ? "oauth" : "key", + apiKey: wire === "kiro" ? "ksk_test" : "test-key", + defaultMaxOutputTokens: 64_000, + googleMode: "ai-studio", + ...(wire === "openai-responses" ? { responsesPath: "/responses" } : {}), + } satisfies OcxProviderConfig; +} + +function prepareForWire(parsed: OcxParsedRequest, wire: AdapterWire): OcxParsedRequest { + if (wire !== "kiro") return parsed; + return { ...parsed, _kiroAuthContext: { apiRegion: "us-east-1" } }; +} + +function codeModeParsed(wire: AdapterWire): OcxParsedRequest { + const model = WIRE_MODELS[wire]; + return prepareForWire(parseRequest({ + model, + instructions: "Use apply_patch for local file edits.", + input: "Patch the requested file.", + stream: true, + tools: [ + { + type: "custom", + name: "exec", + description: EXEC_DESCRIPTION, + format: { type: "grammar", syntax: "lark" }, + }, + { + type: "function", + name: "wait", + description: "Wait for work.", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + }), wire); +} + +function freeformParsed(wire: AdapterWire): OcxParsedRequest { + return prepareForWire(parseRequest({ + model: WIRE_MODELS[wire], + input: "Apply the exact patch.", + stream: true, + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch" }], + }), wire); +} + +function toolChoiceParsed(wire: AdapterWire, toolChoice?: "none"): OcxParsedRequest { + return prepareForWire(parseRequest({ + model: WIRE_MODELS[wire], + input: "Do not call a tool.", + stream: true, + ...(toolChoice ? { tool_choice: toolChoice } : {}), + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch" }, + { + type: "function", + name: "noop", + description: "No operation", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + }), wire); +} + +function continuationParsed(wire: AdapterWire): OcxParsedRequest { + return prepareForWire(parseRequest({ + model: WIRE_MODELS[wire], + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Apply the patch exactly." }], + }, + { + type: "custom_tool_call", + id: "ctc_patch", + call_id: "call_continue_patch", + name: "apply_patch", + input: PATCH, + }, + { + type: "custom_tool_call_output", + call_id: "call_continue_patch", + output: "Done!", + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Continue after patch." }], + }, + ], + stream: true, + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch" }], + }), wire); +} + +async function withMimoBootstrap(adapterId: string, run: () => Promise): Promise { + if (adapterId !== "mimo-free") return await run(); + const originalFetch = globalThis.fetch; + resetMimoJwtCache(); + let bootstrapCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (url !== "https://api.xiaomimimo.com/api/free-ai/bootstrap") { + throw new Error(`mimo-free conformance made an unexpected request: ${url}`); + } + bootstrapCalls++; + return new Response(JSON.stringify({ + jwt: "e30.eyJleHAiOjQxMDI0NDQ4MDB9.x", + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + const result = await run(); + if (bootstrapCalls !== 1) { + throw new Error(`expected one MiMo bootstrap request, got ${bootstrapCalls}`); + } + return result; + } finally { + globalThis.fetch = originalFetch; + resetMimoJwtCache(); + } +} + +async function outbound(adapterId: string, parsed: OcxParsedRequest): Promise { + const contract = effectiveAdapterContract(adapterId); + const adapter = createRegisteredAdapter(providerFixture(adapterId, contract.wire)); + return await withMimoBootstrap(adapterId, () => TOOL_WIRE_DRIVERS[contract.wire].observeOutbound(adapter, parsed)); +} + +function advertisedToolNames(wire: AdapterWire, body: string): string[] { + const parsed = JSON.parse(body) as Record; + if (wire === "openai-chat") { + const tools = parsed.tools as Array<{ function?: { name?: string } }> | undefined; + return (tools ?? []).flatMap(tool => typeof tool.function?.name === "string" ? [tool.function.name] : []); + } + if (wire === "anthropic" || wire === "openai-responses" || wire === "cursor") { + const tools = parsed.tools as Array<{ name?: string }> | undefined; + return (tools ?? []).flatMap(tool => typeof tool.name === "string" ? [tool.name] : []); + } + if (wire === "google") { + const tools = parsed.tools as Array<{ functionDeclarations?: Array<{ name?: string }> }> | undefined; + return (tools ?? []).flatMap(group => + (group.functionDeclarations ?? []).flatMap(tool => typeof tool.name === "string" ? [tool.name] : [])); + } + if (wire === "command-code") { + const params = parsed.params as { tools?: Array<{ name?: string }> } | undefined; + return (params?.tools ?? []).flatMap(tool => typeof tool.name === "string" ? [tool.name] : []); + } + const state = parsed.conversationState as { + currentMessage?: { + userInputMessage?: { + userInputMessageContext?: { + tools?: Array<{ toolSpecification?: { name?: string } }>; + }; + }; + }; + } | undefined; + const tools = state?.currentMessage?.userInputMessage?.userInputMessageContext?.tools ?? []; + return tools.flatMap(tool => typeof tool.toolSpecification?.name === "string" ? [tool.toolSpecification.name] : []); +} + +function toolCallsDisabled(wire: AdapterWire, body: string): boolean { + if (advertisedToolNames(wire, body).length === 0) return true; + const parsed = JSON.parse(body) as Record; + if (wire === "openai-chat" || wire === "openai-responses") return parsed.tool_choice === "none"; + if (wire === "anthropic") { + const choice = parsed.tool_choice as { type?: unknown } | undefined; + return choice?.type === "none"; + } + if (wire === "google") { + const config = parsed.toolConfig as { functionCallingConfig?: { mode?: unknown } } | undefined; + return config?.functionCallingConfig?.mode === "NONE"; + } + return false; +} + +function inputFromValue(value: unknown): string | undefined { + if (typeof value === "string") { + try { + const row = JSON.parse(value) as { input?: unknown }; + return typeof row.input === "string" ? row.input : value; + } catch { + return value; + } + } + if (value && typeof value === "object" && !Array.isArray(value)) { + const input = (value as Record).input; + if (typeof input === "string") return input; + } + return undefined; +} + +function continuationInput(wire: AdapterWire, body: string): string | undefined { + const parsed = JSON.parse(body) as Record; + if (wire === "openai-chat") { + const messages = parsed.messages as Array<{ tool_calls?: Array<{ function?: { name?: string; arguments?: unknown } }> }> | undefined; + for (const message of messages ?? []) { + for (const call of message.tool_calls ?? []) { + if (call.function?.name?.includes("apply_patch")) return inputFromValue(call.function.arguments); + } + } + return undefined; + } + if (wire === "anthropic") { + const messages = parsed.messages as Array<{ content?: unknown }> | undefined; + for (const message of messages ?? []) { + if (!Array.isArray(message.content)) continue; + for (const block of message.content) { + if (!block || typeof block !== "object" || Array.isArray(block)) continue; + const row = block as Record; + if (row.type === "tool_use" && typeof row.name === "string" && row.name.includes("apply_patch")) { + return inputFromValue(row.input); + } + } + } + return undefined; + } + if (wire === "google") { + const contents = parsed.contents as Array<{ parts?: Array<{ functionCall?: { name?: string; args?: unknown } }> }> | undefined; + for (const content of contents ?? []) { + for (const part of content.parts ?? []) { + if (part.functionCall?.name?.includes("apply_patch")) return inputFromValue(part.functionCall.args); + } + } + return undefined; + } + if (wire === "command-code") { + const params = parsed.params as { messages?: Array<{ content?: Array> }> } | undefined; + for (const message of params?.messages ?? []) { + for (const part of message.content ?? []) { + if (part.type === "tool-call" && typeof part.toolName === "string" && part.toolName.includes("apply_patch")) { + return inputFromValue(part.input); + } + } + } + return undefined; + } + if (wire === "kiro") { + const state = parsed.conversationState as { + history?: Array<{ assistantResponseMessage?: { toolUses?: Array<{ name?: string; input?: unknown }> } }>; + currentMessage?: { assistantResponseMessage?: { toolUses?: Array<{ name?: string; input?: unknown }> } }; + } | undefined; + const entries = [...(state?.history ?? []), ...(state?.currentMessage ? [state.currentMessage] : [])]; + for (const entry of entries) { + for (const use of entry.assistantResponseMessage?.toolUses ?? []) { + if (use.name?.includes("apply_patch")) return inputFromValue(use.input); + } + } + return undefined; + } + if (wire === "openai-responses") { + const input = parsed.input as Array> | undefined; + for (const item of input ?? []) { + if (typeof item.name !== "string" || !item.name.includes("apply_patch")) continue; + if (item.type === "custom_tool_call") return inputFromValue(item.input); + if (item.type === "function_call") return inputFromValue(item.arguments); + } + return undefined; + } + const visit = (value: unknown): string | undefined => { + if (!value || typeof value !== "object") return undefined; + if (Array.isArray(value)) { + for (const item of value) { + const found = visit(item); + if (found !== undefined) return found; + } + return undefined; + } + const row = value as Record; + if (typeof row.name === "string" && row.name.includes("apply_patch")) { + const found = inputFromValue(row.input ?? row.arguments); + if (found !== undefined) return found; + } + for (const nested of Object.values(row)) { + const found = visit(nested); + if (found !== undefined) return found; + } + return undefined; + }; + return visit(parsed); +} + +function parseResponsesFrames(text: string): Array<{ event?: string; data: Record }> { + return text.split("\n\n") + .map(frame => frame.trim()) + .filter(frame => frame.length > 0 && frame !== "data: [DONE]") + .map(frame => { + const lines = frame.split("\n"); + const event = lines.find(line => line.startsWith("event: "))?.slice(7); + const data = lines.find(line => line.startsWith("data: "))?.slice(6) ?? "{}"; + return { event, data: JSON.parse(data) as Record }; + }); +} + +async function restoredStreamInput(adapterId: string, wire: AdapterWire): Promise { + const driver = TOOL_WIRE_DRIVERS[wire]; + if (!driver.streamingToolCall) return undefined; + const parsed = freeformParsed(wire); + const adapter = createRegisteredAdapter(providerFixture(adapterId, wire)); + const body = await withMimoBootstrap(adapterId, () => driver.observeOutbound(adapter, parsed)); + const wireName = driver.extractWireToolName?.(body, "apply_patch") ?? "apply_patch"; + const maps = buildToolBridgeMaps(parsed); + const bridged = bridgeToResponsesSSE( + adapter.parseStream( + driver.streamingToolCall(wireName, JSON.stringify({ input: PATCH })), + createTestTranslatorBudget(), + ), + parsed.modelId, + maps.toolNsMap, + maps.freeformToolNames, + maps.toolSearchToolNames, + undefined, + 2_000, + { declaredToolNames: maps.declaredToolNames }, + ); + const frames = parseResponsesFrames(await new Response(bridged).text()); + return frames.find(frame => frame.event === "response.custom_tool_call_input.done")?.data.input as string | undefined; +} + +describe("registry-derived routed tool conformance", () => { + test("provider and model-wire configuration ids are registry members", () => { + for (const provider of PROVIDER_REGISTRY) { + expect(getAdapterDefinition(provider.adapter), provider.id).toBeDefined(); + for (const value of Object.values(provider.modelWireDefaults ?? {})) { + const adapterId = typeof value === "string" ? value : value.wire; + expect(getAdapterDefinition(adapterId), `${provider.id}:${adapterId}`).toBeDefined(); + } + } + for (const adapterId of MODEL_ADAPTER_OVERRIDE_ALLOWED) { + expect(getAdapterDefinition(adapterId), adapterId).toBeDefined(); + } + }); + + test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + const body = await outbound(adapterId, codeModeParsed(contract.wire)); + const advertised = advertisedToolNames(contract.wire, body); + expect(advertised.some(name => name === "exec" || name.endsWith("_exec")), adapterId).toBe(true); + const normalized = body.replace(/\\n/g, " ").replace(/\s+/g, " "); + expect(normalized, adapterId).toContain("apply_patch(input: string)"); + expect(normalized, adapterId).not.toMatch(/(?:do not|don't|never|must not|cannot|can't)[^.]{0,260}\bapply_patch\b/i); + expect(normalized, adapterId).not.toMatch(/\bapply_patch\b[^.]{0,180}\b(?:forbidden|unavailable|off-limits)\b/i); + } + }); + + test("tool_choice none disables every registered adapter's callable tool surface", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + const enabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire)); + expect(advertisedToolNames(contract.wire, enabledBody).length, `${adapterId}:enabled`).toBeGreaterThan(0); + const disabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire, "none")); + expect(toolCallsDisabled(contract.wire, disabledBody), adapterId).toBe(true); + } + }); + + test("every parsed streaming wire restores hostile freeform input exactly", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + const driver = TOOL_WIRE_DRIVERS[contract.wire]; + if (!driver.streamingToolCall) { + // OpenAI Responses is a normal passthrough here and only parses routed compaction; + // Cursor's proprietary runTurn stream has focused parser coverage elsewhere. + expect(["openai-responses", "cursor"]).toContain(contract.wire); + continue; + } + expect(await restoredStreamInput(adapterId, contract.wire), adapterId).toBe(PATCH); + } + }); + + test("every registered adapter replays the exact apply_patch input on continuation", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + const body = await outbound(adapterId, continuationParsed(contract.wire)); + expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); + } + }); +}); \ No newline at end of file diff --git a/tests/helpers/adapter-conformance/wire-drivers.ts b/tests/helpers/adapter-conformance/wire-drivers.ts new file mode 100644 index 000000000..3abc7855e --- /dev/null +++ b/tests/helpers/adapter-conformance/wire-drivers.ts @@ -0,0 +1,282 @@ +import { create, fromBinary } from "@bufbuild/protobuf"; +import type { ProviderAdapter } from "../../../src/adapters/base"; +import type { AdapterWire } from "../../../src/adapters/registry"; +import { decodeCursorArgsMap } from "../../../src/adapters/cursor/arg-codec"; +import { + AgentClientMessageSchema, + ConversationStepSchema, + ConversationTurnStructureSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; +import { + handleCursorNativeKv, + releaseCursorBlobRequestScope, + type CursorBlobRequestScopeToken, +} from "../../../src/adapters/cursor/native-exec"; +import { prepareCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { createCursorRequest } from "../../../src/adapters/cursor/request-builder"; +import { encodeMessage } from "../../../src/lib/eventstream-decoder"; +import type { OcxParsedRequest } from "../../../src/types"; +import { withTestTranslatorBudget } from "../translator-budget"; + +export interface ToolWireDriver { + observeOutbound(adapter: ProviderAdapter, parsed: OcxParsedRequest): Promise; + extractWireToolName?(body: string, canonicalName: string): string; + streamingToolCall?(wireName: string, wrappedArguments: string): Response; +} + +async function observeHttpOutbound(adapter: ProviderAdapter, parsed: OcxParsedRequest): Promise { + const testAdapter = withTestTranslatorBudget(adapter); + const request = await testAdapter.buildRequest(parsed); + try { + return request.body; + } finally { + request.releaseBodyObservation?.(); + } +} + +function cursorBlobData(blobId: Uint8Array, scope: CursorBlobRequestScopeToken): Uint8Array { + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, + }), scope)); + if (reply.message.case !== "kvClientMessage") { + throw new Error(`Cursor conformance expected kvClientMessage, got ${reply.message.case || "empty"}`); + } + const kv = reply.message.value; + if (kv.message.case !== "getBlobResult" || !kv.message.value.blobData) { + throw new Error(`Cursor conformance could not hydrate blob ${Buffer.from(blobId).toString("hex")}`); + } + return kv.message.value.blobData; +} + +function splitInTwo(input: string): [string, string] { + const split = Math.max(1, Math.floor(input.length / 2)); + return [input.slice(0, split), input.slice(split)]; +} + +function openAiChatToolCall(wireName: string, wrappedArguments: string): Response { + const fragments = splitInTwo(wrappedArguments); + const frames = fragments.map((argumentsFragment, index) => ({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + ...(index === 0 ? { id: "call_patch", type: "function" } : {}), + function: { + ...(index === 0 ? { name: wireName } : {}), + arguments: argumentsFragment, + }, + }], + }, + finish_reason: index === fragments.length - 1 ? "tool_calls" : null, + }], + })); + return new Response(`${frames.map(frame => `data: ${JSON.stringify(frame)}`).join("\n\n")}\n\ndata: [DONE]\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); +} + +function anthropicToolCall(wireName: string, wrappedArguments: string): Response { + const fragments = splitInTwo(wrappedArguments); + const frame = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + return new Response([ + frame("content_block_start", { + type: "content_block_start", + content_block: { type: "tool_use", id: "toolu_patch", name: wireName }, + }), + ...fragments.map(partialJson => frame("content_block_delta", { + type: "content_block_delta", + delta: { type: "input_json_delta", partial_json: partialJson }, + })), + frame("content_block_stop", { type: "content_block_stop" }), + frame("message_stop", { type: "message_stop" }), + ].join(""), { headers: { "content-type": "text/event-stream" } }); +} + +function googleToolCall(wireName: string, wrappedArguments: string): Response { + return new Response( + `data: ${JSON.stringify({ + candidates: [{ + content: { parts: [{ functionCall: { name: wireName, args: JSON.parse(wrappedArguments) } }] }, + finishReason: "STOP", + }], + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); +} + +function commandCodeToolCall(wireName: string, wrappedArguments: string): Response { + return new Response([ + JSON.stringify({ + type: "tool-call", + toolCallId: "call_patch", + toolName: wireName, + input: JSON.parse(wrappedArguments), + }), + JSON.stringify({ type: "finish", rawFinishReason: "tool_use" }), + ].join("\n")); +} + +const kiroEncoder = new TextEncoder(); +function kiroFrame(payload: unknown): Uint8Array { + return encodeMessage( + { ":message-type": "event", ":event-type": "toolUseEvent" }, + kiroEncoder.encode(JSON.stringify(payload)), + ); +} + +function kiroToolCall(wireName: string, wrappedArguments: string): Response { + const fragments = splitInTwo(wrappedArguments); + const frames = [ + kiroFrame({ name: wireName, toolUseId: "call_patch" }), + ...fragments.map(input => kiroFrame({ input, name: wireName, toolUseId: "call_patch" })), + kiroFrame({ name: wireName, stop: true, toolUseId: "call_patch" }), + ]; + let index = 0; + return new Response(new ReadableStream({ + pull(controller) { + if (index < frames.length) controller.enqueue(frames[index++]!); + else controller.close(); + }, + })); +} + +function requireWireToolName( + match: string | undefined, + canonicalName: string, + wire: AdapterWire, +): string { + if (!match) throw new Error(`${wire} outbound body advertised no tool matching "${canonicalName}"`); + return match; +} + +const openAiChatDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { tools?: Array<{ function?: { name?: string } }> }; + const match = parsed.tools?.find(tool => tool.function?.name?.includes(canonicalName))?.function?.name; + return requireWireToolName(match, canonicalName, "openai-chat"); + }, + streamingToolCall: openAiChatToolCall, +}; + +const anthropicDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { tools?: Array<{ name?: string }> }; + const match = parsed.tools?.find(tool => tool.name?.includes(canonicalName))?.name; + return requireWireToolName(match, canonicalName, "anthropic"); + }, + streamingToolCall: anthropicToolCall, +}; + +const googleDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { + tools?: Array<{ functionDeclarations?: Array<{ name?: string }> }>; + }; + for (const toolGroup of parsed.tools ?? []) { + const match = toolGroup.functionDeclarations?.find(tool => tool.name?.includes(canonicalName))?.name; + if (match) return match; + } + return requireWireToolName(undefined, canonicalName, "google"); + }, + streamingToolCall: googleToolCall, +}; + +const commandCodeDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { params?: { tools?: Array<{ name?: string }> } }; + const match = parsed.params?.tools?.find(tool => tool.name?.includes(canonicalName))?.name; + return requireWireToolName(match, canonicalName, "command-code"); + }, + streamingToolCall: commandCodeToolCall, +}; + +const kiroDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { + conversationState?: { + currentMessage?: { + userInputMessage?: { + userInputMessageContext?: { + tools?: Array<{ toolSpecification?: { name?: string } }>; + }; + }; + }; + }; + }; + const tools = parsed.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext?.tools ?? []; + const match = tools.find(tool => tool.toolSpecification?.name?.includes(canonicalName))?.toolSpecification?.name; + return requireWireToolName(match, canonicalName, "kiro"); + }, + streamingToolCall: kiroToolCall, +}; + +const responsesDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { tools?: Array<{ name?: string }> }; + const match = parsed.tools?.find(tool => tool.name?.includes(canonicalName))?.name; + return requireWireToolName(match, canonicalName, "openai-responses"); + }, +}; + +export const TOOL_WIRE_DRIVERS = { + "openai-chat": openAiChatDriver, + anthropic: anthropicDriver, + google: googleDriver, + "command-code": commandCodeDriver, + kiro: kiroDriver, + "openai-responses": responsesDriver, + cursor: { + async observeOutbound(_adapter, parsed) { + const request = createCursorRequest(parsed); + const prepared = prepareCursorRunRequest(request); + try { + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + if (message.message.case !== "runRequest") { + throw new Error(`Cursor conformance expected runRequest, got ${message.message.case || "empty"}`); + } + const runRequest = message.message.value; + const tools = runRequest.mcpTools?.mcpTools ?? []; + const continuationToolCalls: Array<{ name: string; arguments: Record }> = []; + for (const turnId of runRequest.conversationState?.turns ?? []) { + const turn = fromBinary( + ConversationTurnStructureSchema, + cursorBlobData(turnId, prepared.blobRequestScope), + ); + if (turn.turn.case !== "agentConversationTurn") continue; + for (const stepId of turn.turn.value.steps) { + const step = fromBinary( + ConversationStepSchema, + cursorBlobData(stepId, prepared.blobRequestScope), + ); + if (step.message.case !== "toolCall") continue; + const tool = step.message.value.tool; + if (tool.case !== "mcpToolCall") continue; + const args = tool.value.args; + continuationToolCalls.push({ + name: args?.toolName || args?.name || "", + arguments: decodeCursorArgsMap(args?.args), + }); + } + } + return JSON.stringify({ + tools: tools.map(tool => ({ + name: tool.toolName || tool.name, + description: tool.description, + })), + continuationToolCalls, + }); + } finally { + releaseCursorBlobRequestScope(prepared.blobRequestScope); + } + }, + }, +} satisfies Record; From 3b05a27afe6071307eea0330e8a4ec41879efe5c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:14:03 +0200 Subject: [PATCH 89/94] test(adapters): cover buffered freeform restoration --- .../adapter-buffered-tool-conformance.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/adapter-buffered-tool-conformance.test.ts diff --git a/tests/adapter-buffered-tool-conformance.test.ts b/tests/adapter-buffered-tool-conformance.test.ts new file mode 100644 index 000000000..45741755a --- /dev/null +++ b/tests/adapter-buffered-tool-conformance.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { adapterDefinitions, createRegisteredAdapter, effectiveAdapterContract, type AdapterWire } from "../src/adapters/registry"; +import { buildResponseJSON } from "../src/bridge"; +import { encodeMessage } from "../src/lib/eventstream-decoder"; +import { parseRequest } from "../src/responses/parser"; +import { buildToolBridgeMaps } from "../src/server/responses"; +import type { OcxProviderConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +const PATCH = `*** Begin Patch +*** Add File: buffered-안녕.txt ++quote: "double" ++slash: \\ path ++unicode: 世界 +*** End Patch`; + +const WIRE_MODELS: Record = { + "openai-chat": "grok-4.6", + anthropic: "claude-haiku-4-5", + google: "gemini-3.5-flash", + "command-code": "deepseek/deepseek-v4-flash", + kiro: "claude-sonnet-4.5", + "openai-responses": "deepseek-v4-flash", + cursor: "cursor/auto", +}; + +function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { + const baseUrls: Record = { + "openai-chat": "https://api.x.ai/v1", + anthropic: "https://api.anthropic.com", + google: "https://generativelanguage.googleapis.com", + "command-code": "https://api.commandcode.ai", + kiro: "https://runtime.us-east-1.kiro.dev", + "openai-responses": "https://api.deepseek.com", + cursor: "https://api2.cursor.sh", + }; + const baseUrl = adapterId === "mimo-free" + ? "https://api.xiaomimimo.com/api/free-ai/openai" + : adapterId === "azure" || adapterId === "azure-openai" + ? "https://example.openai.azure.com/openai/v1" + : baseUrls[wire]; + return { + adapter: adapterId, + baseUrl, + authMode: wire === "anthropic" || wire === "command-code" ? "oauth" : "key", + apiKey: wire === "kiro" ? "ksk_test" : "test-key", + defaultMaxOutputTokens: 64_000, + googleMode: "ai-studio", + ...(wire === "openai-responses" ? { responsesPath: "/responses" } : {}), + } as OcxProviderConfig; +} + +function parsed(wire: AdapterWire) { + const value = parseRequest({ + model: WIRE_MODELS[wire], + input: "Apply the exact patch.", + stream: false, + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch" }], + }); + if (wire === "kiro") value._kiroAuthContext = { apiRegion: "us-east-1" }; + return value; +} + +const kiroEncoder = new TextEncoder(); +function kiroFrame(payload: unknown): Uint8Array { + return encodeMessage( + { ":message-type": "event", ":event-type": "toolUseEvent" }, + kiroEncoder.encode(JSON.stringify(payload)), + ); +} + +function bufferedResponse(wire: AdapterWire, wireName = "apply_patch"): Response | undefined { + const args = { input: PATCH }; + if (wire === "openai-chat") { + return new Response(JSON.stringify({ + choices: [{ + message: { + role: "assistant", + tool_calls: [{ + id: "call_buffered_patch", + type: "function", + function: { name: wireName, arguments: JSON.stringify(args) }, + }], + }, + finish_reason: "tool_calls", + }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })); + } + if (wire === "anthropic") { + return new Response(JSON.stringify({ + content: [{ type: "tool_use", id: "call_buffered_patch", name: wireName, input: args }], + stop_reason: "tool_use", + usage: { input_tokens: 1, output_tokens: 1 }, + })); + } + if (wire === "google") { + return new Response(JSON.stringify({ + candidates: [{ + content: { parts: [{ functionCall: { name: wireName, args } }] }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + })); + } + if (wire === "command-code") { + return new Response([ + JSON.stringify({ + type: "tool-call", + toolCallId: "call_buffered_patch", + toolName: wireName, + input: args, + }), + JSON.stringify({ type: "finish", rawFinishReason: "tool_use" }), + ].join("\n")); + } + if (wire === "kiro") { + const frames = [ + kiroFrame({ name: wireName, toolUseId: "call_buffered_patch" }), + kiroFrame({ input: JSON.stringify(args), name: wireName, toolUseId: "call_buffered_patch" }), + kiroFrame({ name: wireName, stop: true, toolUseId: "call_buffered_patch" }), + ]; + let index = 0; + return new Response(new ReadableStream({ + pull(controller) { + if (index < frames.length) controller.enqueue(frames[index++]!); + else controller.close(); + }, + })); + } + return undefined; +} + +function restoredInput(output: unknown): string | undefined { + if (!Array.isArray(output)) return undefined; + const call = output.find(item => + item && typeof item === "object" + && (item as Record).type === "custom_tool_call" + && (item as Record).name === "apply_patch" + ) as Record | undefined; + return typeof call?.input === "string" ? call.input : undefined; +} + +describe("registry-derived buffered tool conformance", () => { + test("every buffered parser restores hostile freeform input exactly", async () => { + let covered = 0; + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + const adapter = createRegisteredAdapter(providerFixture(adapterId, contract.wire)); + if (!adapter.parseResponse) continue; + if (contract.wire === "openai-responses") { + // Responses passthrough only invokes parseResponse for routed compaction, where tool calls + // are not part of the contract. Azure inherits that same compaction-only parser. + expect(["openai-responses", "azure", "azure-openai"]).toContain(adapterId); + continue; + } + const response = bufferedResponse(contract.wire); + expect(response, `${adapterId}:${contract.wire}`).toBeDefined(); + if (!response) continue; + covered += 1; + + const request = parsed(contract.wire); + const events = await adapter.parseResponse(response, createTestTranslatorBudget()); + const maps = buildToolBridgeMaps(request); + const built = buildResponseJSON(events, request.modelId, { + toolNsMap: maps.toolNsMap, + declaredToolNames: maps.declaredToolNames, + freeformToolNames: maps.freeformToolNames, + toolSearchToolNames: maps.toolSearchToolNames, + }); + expect(restoredInput(built.output), adapterId).toBe(PATCH); + } + expect(covered).toBeGreaterThan(0); + }); +}); From 13429bedc63fa3f801c42a1a07dbeed733e286c5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 14:04:29 +0900 Subject: [PATCH 90/94] test(adapters): use canonical mimo-free endpoint in registry authority fixture (#1714 guard) --- tests/adapter-registry-authority.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/adapter-registry-authority.test.ts b/tests/adapter-registry-authority.test.ts index 0412bd60f..fa10f2798 100644 --- a/tests/adapter-registry-authority.test.ts +++ b/tests/adapter-registry-authority.test.ts @@ -25,7 +25,11 @@ const EXPECTED_ADAPTER_NAMES = { function provider(adapter: string): OcxProviderConfig { return { adapter, - baseUrl: "https://example.invalid/v1", + // mimo-free throws for non-canonical endpoints since #1714; every other + // adapter accepts the placeholder URL. + baseUrl: adapter === "mimo-free" + ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" + : "https://example.invalid/v1", authMode: "key", apiKey: "test-key", defaultMaxOutputTokens: 4096, From ca5c10fb93e660574bbf93165c0ec8aa44a327b2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 14:04:50 +0900 Subject: [PATCH 91/94] test(adapters): canonical mimo-free chat endpoint in tool conformance fixture (#1714 guard) --- tests/adapter-tool-conformance.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/adapter-tool-conformance.test.ts b/tests/adapter-tool-conformance.test.ts index 54483543f..3e23c13f2 100644 --- a/tests/adapter-tool-conformance.test.ts +++ b/tests/adapter-tool-conformance.test.ts @@ -47,7 +47,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi }; // Semantic wrappers with provider-specific URL shapes must override the wire-family default here. const baseUrl = adapterId === "mimo-free" - ? "https://api.xiaomimimo.com/api/free-ai/openai" + ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" : adapterId === "azure" || adapterId === "azure-openai" ? "https://example.openai.azure.com/openai/v1" : baseUrls[wire]; @@ -438,4 +438,4 @@ describe("registry-derived routed tool conformance", () => { expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); } }); -}); \ No newline at end of file +}); From d1491ab611a31796ba86f7a0a4cc58ee3da65059 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 14:05:15 +0900 Subject: [PATCH 92/94] test(adapters): canonical mimo-free chat endpoint in buffered conformance fixture (#1714 guard) --- tests/adapter-buffered-tool-conformance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/adapter-buffered-tool-conformance.test.ts b/tests/adapter-buffered-tool-conformance.test.ts index 45741755a..6c2f11b92 100644 --- a/tests/adapter-buffered-tool-conformance.test.ts +++ b/tests/adapter-buffered-tool-conformance.test.ts @@ -35,7 +35,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi cursor: "https://api2.cursor.sh", }; const baseUrl = adapterId === "mimo-free" - ? "https://api.xiaomimimo.com/api/free-ai/openai" + ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" : adapterId === "azure" || adapterId === "azure-openai" ? "https://example.openai.azure.com/openai/v1" : baseUrls[wire]; From 57097a1eaf64cf7b80f4ab2e011de8d3a13b5309 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 14:06:11 +0900 Subject: [PATCH 93/94] docs(devlog): 260815 open-PR triage plan, matrix, and execution record --- .../_plan/260815_open_pr_triage/000_plan.md | 45 +++++++++++++ .../010_triage_matrix.md | 51 +++++++++++++++ .../020_merge_execution.md | 65 +++++++++++++++++++ .../030_final_verification.md | 4 ++ 4 files changed, 165 insertions(+) create mode 100644 devlog/_plan/260815_open_pr_triage/000_plan.md create mode 100644 devlog/_plan/260815_open_pr_triage/010_triage_matrix.md create mode 100644 devlog/_plan/260815_open_pr_triage/020_merge_execution.md create mode 100644 devlog/_plan/260815_open_pr_triage/030_final_verification.md diff --git a/devlog/_plan/260815_open_pr_triage/000_plan.md b/devlog/_plan/260815_open_pr_triage/000_plan.md new file mode 100644 index 000000000..751cbe7fd --- /dev/null +++ b/devlog/_plan/260815_open_pr_triage/000_plan.md @@ -0,0 +1,45 @@ +# 000 — 260815_open_pr_triage: Plan + +## Objective + +Triage every open PR shown in the owner's 2026-08-15 list (22 PRs, #1704-#1732) in lidge-jun/opencodex. Each PR gets exactly one executed disposition: MERGE (squash via gh, or cherry-pick when unmergeable), CLOSE with evidence-led comment, or KEEP-DRAFT with named gaps. Owner directives: suites run only on ssh lidge; pushing to dev is authorized (--no-verify allowed); unlimited subagents; multi-cycle PABCD. + +## Loop-spec + +- Loop archetype: verifier-defined (gh PR state + lidge suite green on dev). +- Write scope: GitHub PR state (merge/close/comment), devlog unit docs. Out-of-scope: main/preview branches, npm release, issues, PRs #1703 and older (not in the owner's list). +- Budget / bounds: wall-clock one session; BLOCKED if lidge or gh auth fails. + +## Evidence base + +- 5 explorer subagent verdicts (diff-level per-PR review), 2026-08-15. +- GraphQL reviewThreads (authority for unresolved blocking threads). +- Exact-head check-run rollup per PR (gh pr view --json statusCheckRollup). + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp1 | 010 | Triage matrix (this unit's docs) | - | +| wp2 | 020 | Lab CL merges: #1708 #1709 #1710 #1712 #1715 #1717 #1719 #1720, then #1705 -> #1706 stack | wp1 | +| wp3 | 020 | Adapters: #1714 merge, then #1721 rebase + fixture fix + merge; #1722/#1723 stay draft (Major thread) | wp2 | +| wp4 | 020 | Contributor #1716 merge; KEEP-DRAFT comments #1718 #1725 #1728; #1704 stays draft | wp2 | +| wp5 | 020 | Storage/log-guard #1727 #1729 #1732 stay draft (named gaps) | wp1 | +| wp6 | 030 | Final: lidge full suite on dev, verify all dispositions, report | wp2-wp5 | + +## Merge mechanics (B-phase execution) + +1. Independent lab fixes first (disjoint files; overlaps #1709/#1717 and #1712/#1720 verified clean by merge-tree): gh pr merge --squash (--admin only if the review-requirement blocks; owner-directed triage). +2. #1705 merges; GitHub auto-retargets #1706 to dev; merge #1706. +3. #1714 merges. Then #1721: rebase onto dev, switch mimo-free fixture URL to canonical MIMO_CHAT_URL (semantic conflict with #1714's endpoint guard), push, merge. +4. #1716 merges (external contributor - use gh merge so it records as merged). +5. KEEP-DRAFT set: no state change; gaps recorded in 010 matrix; brief maintainer comment on external drafts (#1718 #1725 #1728). + +## Accept criteria + +- c1: 010 matrix written with per-PR evidence (this unit). +- c2: every listed PR shows merged/closed/draft disposition via gh pr view. +- c3: full suite green on lidge against final dev head. +- c4: no security notes in tracked files (log-guard findings stay in matrix form - all referenced fixes already public in PR diffs). +- c5: final per-PR outcome report. + diff --git a/devlog/_plan/260815_open_pr_triage/010_triage_matrix.md b/devlog/_plan/260815_open_pr_triage/010_triage_matrix.md new file mode 100644 index 000000000..8d6289e3a --- /dev/null +++ b/devlog/_plan/260815_open_pr_triage/010_triage_matrix.md @@ -0,0 +1,51 @@ +# 010 - Triage matrix (wp1 deliverable) + +Evidence gathered 2026-08-15. "checks" = exact-head check-runs; "threads" = fresh unresolved reviewThreads (GraphQL). Verdicts from 5 independent explorer subagents, spot-verified by maintainer agent. + +A-audit amendment (GO-WITH-FIXES, 2 blockers folded): +1. #1706 will be retargeted to dev explicitly ('gh pr edit 1706 --base dev') before merging - the repo does not auto-delete branches, so merge does not auto-retarget stacked children. +2. #1722/#1723 upgraded KEEP-DRAFT -> MERGE: the Cursor false-pass Major was fixed at head ca0a5124 (force-push 02:13Z, after the 01:56Z CodeRabbit comment); same fixed-in-branch standard the plan already applied to #1706. Residual: stale-thread hygiene only. + +## MERGE (15) + +| PR | What | Gate state | Notes | +|----|------|-----------|-------| +| #1708 | fix(lab) CL-01: negative control asserted its own repair | 35 green, 0 threads | harness integrity | +| #1709 | fix(lab) CL-02: transactional ledger mutations | 26 green, 0 threads | TOCTOU races closed | +| #1710 | fix(lab) CL-03: distinct transport failure classes | 35 green, 1 outdated | backward compatible | +| #1712 | fix(lab) CL-04: 400 on invalid read filters | 35 green, 1 minor | empty-artifactClass follow-up optional | +| #1715 | fix(lab) CL-05: GUI partial-read failures | 29 green, 0 threads | gui-screenshot-waived label present | +| #1717 | fix(lab) CL-07: fail-closed outcome validation | 29 green, 0 threads | clean vs #1709 | +| #1719 | fix(lab) CL-08: rebind runtime ownership on server replace | 32 green, 0 threads | lifecycle defect | +| #1720 | fix(lab) CL-09: passive read surface alignment | 32 green, 0 threads | clean vs #1712 | +| #1705 | feat(lab) CL-10 trust core | 31 green (react-doctor cancelled = superseded dup), 2 trivial + 1 minor threads | stack base | +| #1706 | feat(lab) CL-10 operator/community | 26 green, Major fixed in-branch (7a1e066ae) | explicit retarget to dev before merge; core-lab boundary test green | +| #1714 | fix(providers) static model discovery | 32 green, 0 threads | land BEFORE adapter stack (endpoint-guard fixture conflict) | +| #1721 | refactor(adapters) registry authority Part 1 | 26 green, 0 threads | rebase + mimo fixture fix (authority test only) after #1714 | +| #1722 | test(adapters) conformance Part 2 | 23 green; Major fixed at head ca0a5124 | stack order after #1721; A-audit upgrade | +| #1723 | test(adapters) buffered freeform Part 3 | 23 green, 0 threads | stack order after #1722; A-audit upgrade | +| #1716 | feat(models) per-custom-model reasoning effort | 27 green (+1 cancelled react-doctor dup; win shard skip systemic), screenshot present | "Critical duplicate payload" verified false positive; 1 minor trim nit | + +## KEEP-DRAFT (7) + +| PR | Why not now | Gap to merge | +|----|-------------|--------------| +| #1704 | own GUI quota PR: hygiene + enforce-target FAIL (no screenshot, no regression test), 5 fresh threads | screenshot, unit test, address threads | +| #1718 | external draft, light gates only, no full CI | author checklist + maintainer-triggered CI | +| #1725 | external draft, no full CI | author readiness + maintainer CI | +| #1727 | enforce-target (screenshot), hygiene empty_catch, unresolved Major: sqliteHome/databasePath leak in API/CLI/UI | redact path fields, fixed-message errors, screenshot | +| #1728 | author mid-flight: checklist 0/4, manual verification pending | author completes checklist | +| #1729 | 5 real macOS test failures in protection/lock core, 10 unresolved threads | fix lock/trigger semantics failures | +| #1732 | failing reclaim/gates checks, plausible TOCTOU + stopReason Majors, CI still running | post-open path re-validation, stopReason fix, drop duplicate workflow | + +## Stacks and order constraints + +- #1705 (base dev) -> #1706 (base cl10-public-core): merge #1705, then 'gh pr edit 1706 --base dev', then merge #1706. +- #1721 -> #1722 -> #1723 stack: merge in order; use --delete-branch on each merge so the next child retargets to dev automatically (or retarget explicitly). +- #1727 -> #1729 -> #1732 stack: all stay draft. +- Semantic conflict: #1714's canonical-endpoint guard breaks the mimo-free fixture in #1721's authority test (example.invalid/v1). #1714 lands first; #1721 rebases with MIMO_CHAT_URL fixture before merge. #1722/#1723 fixtures re-verified against the combined tree before their merges. + +## Out of scope (open but not in owner's list) + +#1703 #1669 #1664 #1660 #1655 #1652 #1645 #1644 #1624 #1584 #1569 #1557 #1552 #1526 #1521 #1498 #1367 #1165 - untouched this round. + diff --git a/devlog/_plan/260815_open_pr_triage/020_merge_execution.md b/devlog/_plan/260815_open_pr_triage/020_merge_execution.md new file mode 100644 index 000000000..4ac3dd12c --- /dev/null +++ b/devlog/_plan/260815_open_pr_triage/020_merge_execution.md @@ -0,0 +1,65 @@ +# 020 - Merge execution record (wp2-wp3) + +## Plan (written at P, verified: zero head drift on all 15 PRs, 2026-08-15) + +Mechanism: local integration branch with --no-ff merges of pull//head refs, +so each PR head becomes an ancestor of dev and GitHub auto-marks the PR Merged +on push (preserves contributor attribution, e.g. external #1716). One dev push, +one dev CI run, one lidge validation before the push. + +### Step 1 - retarget stacked children to dev (gh pr edit --base dev) + +- #1706 (was cl10-public-core), #1722 (was refactor/adapter-registry-authority), + #1723 (was test/adapter-registry-conformance). + +### Step 2 - integration branch + +git fetch origin +git switch -c int/260815-pr-landings origin/dev + +Merge order (dependency-safe): +1. #1708 #1709 #1710 #1712 #1715 #1717 #1719 #1720 (independent lab fixes) +2. #1705 then #1706 (stack; #1706 branch contains #1705) +3. #1714 (endpoint guard) +4. #1721 (authority test fixture fix required: tests/adapter-registry-authority.test.ts + mimo-free provider baseUrl example.invalid/v1 -> canonical MIMO_CHAT_URL + https://api.xiaomimimo.com/api/free-ai/openai/chat; separate fix commit) +5. #1722 then #1723 (stack; contains #1721) +6. #1716 (external feature, disjoint files) + +Each: git merge --no-ff FETCH_HEAD -m 'Merge PR #: ' using +git fetch origin pull/<n>/head. + +### Step 3 - devlog unit onto int + +Cherry-pick 1628d06c2 (triage docs) onto int. + +### Step 4 - validate + +git push origin int/260815-pr-landings +ssh lidge: clone/fetch, checkout int branch, bun install, bun run typecheck + +bun run test (+ privacy:scan). Suite runs ONLY on lidge per owner directive. + +### Step 5 - land + +git push origin int/260815-pr-landings:dev --no-verify +(owner-authorized; enforce_admins=false so admin bypass works on protected dev) +Then verify all 15 PRs auto-marked Merged; stragglers get an evidence comment +and manual close. + +### Step 6 (wp3) - KEEP-DRAFT comments + +Brief maintainer comment on #1704 #1718 #1725 #1727 #1728 #1729 #1732 naming +the recorded gaps (010 matrix). + +## A-audit amendments (GO-WITH-FIXES, 3 blockers folded) + +1. #1709 -> #1706 semantic conflict in src/lab/ledger/purge.ts: #1706's export-purge steps + deferred-error vars must be re-expressed inside #1709's withLedgerMutation wrapper. Pre-staged resolution; purge tests re-run. +2. #1715 -> #1714 trivial conflict in gui/.eslint/i18n-allowlist.ts: take #1714's /^HTTP$/i version (superset). +3. Fixture fix covers THREE files (mimo-free canonical /chat under #1714's guard): tests/adapter-registry-authority.test.ts (#1721), tests/adapter-tool-conformance.test.ts (#1722), tests/adapter-buffered-tool-conformance.test.ts (#1723). Separate commits, never amend PR heads (auto-merge detection is exact-SHA). +4. Advisory: retargets before push; re-verify all 15 head SHAs at push time. + +## Execution log + +(pending) + diff --git a/devlog/_plan/260815_open_pr_triage/030_final_verification.md b/devlog/_plan/260815_open_pr_triage/030_final_verification.md new file mode 100644 index 000000000..44659ec5c --- /dev/null +++ b/devlog/_plan/260815_open_pr_triage/030_final_verification.md @@ -0,0 +1,4 @@ +# 030 - Final verification (wp6) + +(pending: lidge suite output on final dev head, gh disposition verification, per-PR outcome report) + From ba20ce17f26e06c35ac8b6e0e0401a9dbb9646e7 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch <bitkyc08@gmail.com> Date: Sat, 15 Aug 2026 14:08:56 +0900 Subject: [PATCH 94/94] fix(lab): type-safe deferred export error in merged purge flow --- src/lab/ledger/purge.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/lab/ledger/purge.ts b/src/lab/ledger/purge.ts index d20e33099..4ec55709a 100644 --- a/src/lab/ledger/purge.ts +++ b/src/lab/ledger/purge.ts @@ -187,12 +187,15 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto const purgeActions = [...(req.purgeActions ?? PURGE_ACTIONS)].sort(); const explicitSensitive = new Set(targetArtifactDigests); const completed: string[] = []; - let deferredExportError: PurgeError | null = null; + // Cell object: the mutation callback assigns through the property, which keeps + // the post-mutation read at the declared type (a closure-captured let would + // narrow to null and break the combined-error report below). + const deferredExport: { error: PurgeError | null } = { error: null }; let operationError: PurgeError | null = null; let tombstone: PurgeTombstoneEvent | null = null; try { - withLedgerMutation(paths.ledgerPath, (ledger) => { + tombstone = withLedgerMutation(paths.ledgerPath, (ledger) => { // Replay and plan under the same lock as every append. Otherwise an event // appended after this snapshot can be lost by the atomic rename or can // start referencing an artifact after the deletion plan was calculated. @@ -227,7 +230,7 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto } catch (err) { // Export deletion is independent from artifact/ledger/sqlite deletion. Keep // deleting every other requested sensitive copy, then report this failure. - deferredExportError = normalizePurgeError(err, completed); + deferredExport.error = normalizePurgeError(err, completed); } } @@ -243,7 +246,7 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto // Never persist a tombstone claiming that export completed when the export // purge failed. Other independent actions remain recordable and continue. - const tombstoneActions = deferredExportError + const tombstoneActions = deferredExport.error ? purgeActions.filter((action) => action !== "export") : purgeActions; const hasTombstoneTarget = removeIds.size > 0 @@ -251,21 +254,23 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto || tombstoneActions.includes("scratch") || tombstoneActions.includes("export"); - if (tombstoneActions.length > 0 && (hasTombstoneTarget || !deferredExportError)) { - tombstone = buildPurgeTombstone(req, removeIds, targetArtifactDigests, tombstoneActions); + if (tombstoneActions.length > 0 && (hasTombstoneTarget || !deferredExport.error)) { + const mutationTombstone = buildPurgeTombstone(req, removeIds, targetArtifactDigests, tombstoneActions); if (purgeActions.includes("ledger")) { const kept: LabEvent[] = []; for (const event of replay.events) { if (removeIds.has(event.eventId)) continue; kept.push(event); } - kept.push(tombstone); + kept.push(mutationTombstone); atomicRewriteLedger(paths.ledgerPath, kept); completed.push("ledger"); } else { - ledger.append(tombstone); + ledger.append(mutationTombstone); } + return mutationTombstone; } + return null; } finally { if (dir) closeTrustedArtifactDir(dir); } @@ -281,6 +286,7 @@ export function purgeSensitiveEvidence(req: SensitivePurgeRequest): PurgeTombsto operationError = normalizePurgeError(err, completed); } + const deferredExportError = deferredExport.error; if (operationError && deferredExportError) { throw new PurgeError( "purge_failed",