From 36df2d316255838adce228d9b8c6fd121ccd3d6c Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 21:09:55 +0800 Subject: [PATCH 01/13] feat(sdk): add wire dispatch classifier (P-1 S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify decoded NDJSON frames into the 12-class frozen disposition table (T-C through T-L). T-A/T-B are handled by the frame decoder. Closed envelope validation (response candidates): - jsonrpc "2.0" required, closed outer key sets enforced - Error body requires code (number) + message (string) - Mutual exclusivity: exactly one of result/error Method direction & readiness gates: - Notification-only methods (row.isNotification) with id → T-F - RESERVED rows (input type `never`) → T-G: no legal params exist in v0, disposition table has no accept class for Requests (ACCEPT_CLASSES = {T-J, T-L} only — Fable ruling) - Unknown methods → T-F MethodNotFound Authorization boundary: - Consumes contract validateEffectiveGrants() — unknown capabilities and duplicates rejected (T-K for notifications, T-G for requests) - All CLOSED-row bounds use contract constants (no hardcoded literals) Cross-frame oracle: - Ping nonce byte-equality (row 11) - Delivery ID byte-equality (row 9) Test-only relative import for contract disposition fixtures (not part of published beta.4 surface — Fable F1 ruling: no contract barrel modification; register as delta carry-forward item). 44 tests (33 fixture-driven + 5 structural + 6 anti-example refutation vectors from sol cross-family review). Monorepo gate green. Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/package.json | 6 +- packages/plugin-sdk/src/index.ts | 16 +- packages/plugin-sdk/src/wire-dispatch.test.ts | 296 +++++++++++ packages/plugin-sdk/src/wire-dispatch.ts | 482 ++++++++++++++++++ 4 files changed, 795 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-sdk/src/wire-dispatch.test.ts create mode 100644 packages/plugin-sdk/src/wire-dispatch.ts diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index a7e3896..9f54863 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -20,9 +20,9 @@ }, "scripts": { "build": "pnpm clean && tsc -p tsconfig.build.json", - "typecheck": "tsc --noEmit", - "test": "pnpm --filter @clowder-ai/plugin-contract build && pnpm build && node --import tsx --test src/stdio-runtime.test.ts", - "lint": "tsc --noEmit", + "typecheck": "tsc -p tsconfig.build.json --noEmit", + "test": "pnpm --filter @clowder-ai/plugin-contract build && pnpm build && node --import tsx --test src/stdio-runtime.test.ts src/wire-dispatch.test.ts", + "lint": "tsc -p tsconfig.build.json --noEmit", "prepack": "pnpm build", "clean": "rm -rf dist" }, diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 259402d..3218672 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,9 +1,14 @@ /** * Public SDK entrypoint. * - * This package intentionally exposes schema-neutral runtime primitives only. + * This package exposes: + * 1. Schema-neutral NDJSON transport (stdio-runtime, S0/#12) + * 2. Wire dispatch classifier (wire-dispatch, S1) + * * Production RPC methods remain reserved in plugin-contract until their rows - * become executable. + * become executable. The dispatch classifier gates all methods: CLOSED rows + * validate input shapes, RESERVED rows fail-closed with T-G (input type is + * `never` in v0 — no legal params exist). */ export { NdjsonFrameError, @@ -20,3 +25,10 @@ export { type StdioRuntimeFatalErrorOptions, type StdioRuntimeOptions, } from './stdio-runtime.js'; + +export { + classifyFrame, + type DispatchResult, + type InFlightEntry, + type RequestSnapshot, +} from './wire-dispatch.js'; diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts new file mode 100644 index 0000000..f41294a --- /dev/null +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -0,0 +1,296 @@ +/** + * Wire dispatch classifier tests — fixture-driven from the contract's + * frozen disposition table (§3.8-1), plus structural edge cases. + * + * S1 scope: classify decoded NDJSON frames into disposition classes + * T-C through T-L. T-A (transport) and T-B (JSON parse) are handled + * by the NDJSON decoder layer, not the dispatch classifier. + * + * All fixture vectors consumed from @clowder-ai/plugin-contract — no + * fixture data is redefined here (Fable's S1 boundary). + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +// Test-only relative import: disposition fixtures are not part of the +// published beta.4 public surface. SDK tests consume them directly from +// contract source (tsx resolves .ts at runtime; test files are excluded +// from the SDK dist artifact). See Fable ruling on F1/beta.4 immutability. +import { + DISPOSITION_FIXTURE_VECTORS, + type DispositionFixtureVector, +} from '../../plugin-contract/src/wire/disposition-fixtures.js'; + +import type { + DecodedNdjsonFrame, + JsonObject, +} from '@clowder-ai/plugin-contract/conformance'; + +import { + classifyFrame, + type DispatchResult, + type InFlightEntry, +} from './wire-dispatch.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a DecodedNdjsonFrame from fixture rawFrame, or null for pre-decode vectors. */ +function frameFromFixture(v: DispositionFixtureVector): DecodedNdjsonFrame | null { + if (v.rawFrameEncoding === 'hex') return null; // T-A: byte-level failure + try { + const raw = Buffer.from(v.rawFrame, 'utf8'); + const value = JSON.parse(v.rawFrame) as JsonObject; + return { raw, value }; + } catch { + return null; // T-B: JSON parse failure + } +} + +/** Build InFlightEntry map from fixture preState. */ +function inFlightFromFixture( + v: DispositionFixtureVector, +): ReadonlyMap { + const map = new Map(); + for (const rec of v.preState.inFlightRequests) { + map.set(rec.id, { + method: rec.method, + requestSnapshot: rec.requestSnapshot, + }); + } + return map; +} + +const NO_IN_FLIGHT: ReadonlyMap = new Map(); + +// --------------------------------------------------------------------------- +// Fixture-driven sweep: all T-C through T-L vectors +// --------------------------------------------------------------------------- + +const classifiableVectors = DISPOSITION_FIXTURE_VECTORS.filter( + v => v.expectedClass !== 'T-A' && v.expectedClass !== 'T-B', +); + +for (const v of classifiableVectors) { + test(`disposition ${v.id}: ${v.description}`, () => { + const frame = frameFromFixture(v); + assert.ok( + frame !== null, + `fixture ${v.id} must be parseable for post-decode classification`, + ); + + const result = classifyFrame(frame, inFlightFromFixture(v)); + + assert.equal( + result.disposition, + v.expectedClass, + `expected disposition ${v.expectedClass}, got ${result.disposition}`, + ); + assert.equal( + result.outcome, + v.expectedOutcome, + `expected outcome ${v.expectedOutcome}, got ${result.outcome}`, + ); + + if (v.expectedResponseFrame !== null) { + assert.equal(result.outcome, 'respond'); + assert.ok( + result.response !== undefined, + 'respond outcome must carry a response envelope', + ); + assert.equal( + JSON.stringify(result.response), + v.expectedResponseFrame, + 'response frame must match the contract-defined expected envelope', + ); + } else if (result.outcome !== 'accept') { + assert.equal( + result.response, + undefined, + 'non-respond outcomes must not carry a response', + ); + } + }); +} + +// --------------------------------------------------------------------------- +// Fixture coverage sanity +// --------------------------------------------------------------------------- + +test('fixture sweep covers all post-decode vectors from the contract', () => { + const preDecodeCount = DISPOSITION_FIXTURE_VECTORS.filter( + v => v.expectedClass === 'T-A' || v.expectedClass === 'T-B', + ).length; + assert.equal( + classifiableVectors.length, + DISPOSITION_FIXTURE_VECTORS.length - preDecodeCount, + ); + assert.ok( + classifiableVectors.length >= 30, + `expected ≥30 post-decode vectors, got ${classifiableVectors.length}`, + ); +}); + +// --------------------------------------------------------------------------- +// Structural edge cases — behaviors the fixtures reference but worth +// testing with explicit intent +// --------------------------------------------------------------------------- + +test('valid request that passes all checks returns accept with null disposition', () => { + const frame: DecodedNdjsonFrame = { + raw: Buffer.from( + '{"jsonrpc":"2.0","id":"req-1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"hello"}}}', + 'utf8', + ), + value: { + jsonrpc: '2.0', + id: 'req-1', + method: 'host.lifecycle.ping', + params: { + meta: { deadlineUnixMs: 1 }, + input: { nonce: 'hello' }, + }, + }, + }; + + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.outcome, 'accept'); + assert.equal(result.disposition, null); +}); + +test('reserved method with valid envelope is T-G (input type never → value violation)', () => { + const frame: DecodedNdjsonFrame = { + raw: Buffer.from( + '{"jsonrpc":"2.0","id":"r1","method":"messaging.send","params":{"meta":{"deadlineUnixMs":1},"input":{}}}', + 'utf8', + ), + value: { + jsonrpc: '2.0', + id: 'r1', + method: 'messaging.send', + params: { meta: { deadlineUnixMs: 1 }, input: {} }, + }, + }; + + const result = classifyFrame(frame, NO_IN_FLIGHT); + // RESERVED rows have input type `never` — no legal params value exists + // in v0. The disposition table has no accept class for Requests + // (ACCEPT_CLASSES = {T-J, T-L} only). Fable ruling: T-G respond error. + assert.equal(result.disposition, 'T-G'); + assert.equal(result.outcome, 'respond'); + assert.ok(result.response !== undefined); +}); + +// --------------------------------------------------------------------------- +// Fail-closed anti-examples — each proves a specific fail-open gap is sealed. +// These are the independent refutation vectors from the R1 review. +// --------------------------------------------------------------------------- + +test('response candidate missing jsonrpc is T-H, not T-L (closed envelope)', () => { + const rawFrame = '{"id":"r1","result":{"nonce":"x"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'missing jsonrpc must not settle as T-L'); + assert.equal(result.outcome, 'close'); +}); + +test('response candidate with extra outer member is T-H (closed envelope)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"x"},"extra":1}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra outer key must not settle as T-L'); + assert.equal(result.outcome, 'close'); +}); + +test('error response with empty error body {} is T-H (missing code/message)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'error without code/message must not settle as T-L'); + assert.equal(result.outcome, 'close'); +}); + +test('grants.changed notification with unknown capability is T-K (authorization boundary)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":["UNKNOWN_CAP"]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'unknown capability must not be accepted as T-J'); + assert.equal(result.outcome, 'close'); +}); + +test('grants.changed notification with duplicate capability is T-K (authorization boundary)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":["messaging.send","messaging.send"]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'duplicate capability must not be accepted as T-J'); + assert.equal(result.outcome, 'close'); +}); + +test('notification-only method (host.grants.changed) with id is T-F (direction gate)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":[]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-F', 'notification-only method as request must be rejected'); + assert.equal(result.outcome, 'respond'); +}); + +// --------------------------------------------------------------------------- +// Mutual-exclusivity proof pair (pre-existing) +// --------------------------------------------------------------------------- + +test('the same raw frame classified as T-H without in-flight and T-L with in-flight', () => { + const rawFrame = + '{"jsonrpc":"2.0","id":"corr-test","result":{"nonce":"x"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + + // Without in-flight → T-H (uncorrelated) + const withoutInFlight = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(withoutInFlight.disposition, 'T-H'); + assert.equal(withoutInFlight.outcome, 'close'); + + // With in-flight → T-L (correlated, nonce matches) + const inFlight = new Map([ + [ + 'corr-test', + { + method: 'host.lifecycle.ping', + requestSnapshot: { nonce: 'x' }, + }, + ], + ]); + const withInFlight = classifyFrame(frame, inFlight); + assert.equal(withInFlight.disposition, 'T-L'); + assert.equal(withInFlight.outcome, 'accept'); +}); diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts new file mode 100644 index 0000000..1aff2c9 --- /dev/null +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -0,0 +1,482 @@ +/** + * Wire dispatch classifier — pre-dispatch frame classification per the + * frozen disposition table (T-A through T-L, §3.8-1 of #1165). + * + * This module classifies decoded NDJSON frames into one of the 12 + * disposition classes. T-A (transport failure) and T-B (JSON parse error) + * are handled by the NDJSON frame decoder layer — this classifier covers + * T-C through T-L. + * + * A frame that passes all rejection checks returns disposition=null with + * outcome='accept', indicating a valid request that should be dispatched + * to a method handler. + * + * This module defines nothing new — every classification rule traces to + * the frozen disposition table in @clowder-ai/plugin-contract. + */ + +import type { DecodedNdjsonFrame, JsonObject } from '@clowder-ai/plugin-contract/conformance'; + +import { + type DispositionClass, + type WireMethodName, + validateRequestId, + validateEffectiveGrants, + isWireMethod, + isWireUInt53, + NOTIFICATION_METHODS, + WIRE_METHOD_REGISTRY, + INVALID_REQUEST_CODE, + INVALID_REQUEST_MESSAGE, + METHOD_NOT_FOUND_CODE, + METHOD_NOT_FOUND_MESSAGE, + INVALID_PARAMS_CODE, + INVALID_PARAMS_MESSAGE, + PING_NONCE_MIN_LENGTH, + PING_NONCE_MAX_LENGTH, + SUBSCRIBE_HANDLE_MIN_LENGTH, + SUBSCRIBE_HANDLE_MAX_LENGTH, + ACK_SUBSCRIPTION_ID_MIN_LENGTH, + ACK_SUBSCRIPTION_ID_MAX_LENGTH, + ACK_TOKEN_MIN_LENGTH, + ACK_TOKEN_MAX_LENGTH, +} from '@clowder-ai/plugin-contract'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Cross-frame oracle snapshot — original request fields needed to + * verify byte-equality invariants on correlated responses. + * + * Structurally identical to contract's internal RequestSnapshot; + * defined locally because that type is not part of the published + * beta.4 public surface (Fable ruling on F1/immutability). + */ +export interface RequestSnapshot { + /** Row 11 ping: nonce that must be echoed byte-equal in the result. */ + readonly nonce?: string; + /** Row 9 deliver: deliveryId that must match byte-equal in the ack. */ + readonly deliveryId?: string; +} + +export interface InFlightEntry { + readonly method: WireMethodName; + readonly requestSnapshot?: RequestSnapshot; +} + +export interface DispatchResult { + /** T-class from the disposition table, or null for valid requests. */ + readonly disposition: DispositionClass | null; + /** Required transport action: close, respond (with error), or accept. */ + readonly outcome: 'close' | 'respond' | 'accept'; + /** For 'respond': the error envelope to write back as NDJSON. */ + readonly response?: JsonObject; +} + +// --------------------------------------------------------------------------- +// Error response builders +// --------------------------------------------------------------------------- + +function respondInvalidRequestNull(): DispatchResult { + return { + disposition: 'T-D', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id: null, + error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE }, + }, + }; +} + +function respondInvalidRequestId(id: string): DispatchResult { + return { + disposition: 'T-F', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE }, + }, + }; +} + +function respondMethodNotFound(id: string): DispatchResult { + return { + disposition: 'T-F', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: METHOD_NOT_FOUND_CODE, message: METHOD_NOT_FOUND_MESSAGE }, + }, + }; +} + +function respondInvalidParams(id: string): DispatchResult { + return { + disposition: 'T-F', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE }, + }, + }; +} + +function respondInvalidParamsValue(id: string): DispatchResult { + return { + disposition: 'T-G', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE }, + }, + }; +} + +function close(disposition: DispositionClass): DispatchResult { + return { disposition, outcome: 'close' }; +} + +function accept(disposition: DispositionClass): DispatchResult { + return { disposition, outcome: 'accept' }; +} + +// --------------------------------------------------------------------------- +// Response candidate sub-classifier (T-H / T-L) +// --------------------------------------------------------------------------- + +/** Closed outer key sets for WireSuccessResponse / WireErrorResponse. */ +const RESPONSE_SUCCESS_KEYS = new Set(['jsonrpc', 'id', 'result']); +const RESPONSE_ERROR_KEYS = new Set(['jsonrpc', 'id', 'error']); + +function classifyResponseCandidate( + value: JsonObject, + inFlight: ReadonlyMap, +): DispatchResult { + // Response candidate: no method key, has result or error. + const hasResult = 'result' in value; + const hasError = 'error' in value; + + // ── Closed envelope structure ────────────────────────────────────── + // jsonrpc must be exactly "2.0" + if (value.jsonrpc !== '2.0') return close('T-H'); + + // Mutual exclusivity: exactly one of result/error + if (hasResult && hasError) return close('T-H'); + + // Closed outer keys: no additional members allowed + const allowedKeys = hasResult ? RESPONSE_SUCCESS_KEYS : RESPONSE_ERROR_KEYS; + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) return close('T-H'); + } + + // ── id validation ────────────────────────────────────────────────── + if (!('id' in value)) return close('T-H'); + const id = value.id; + if (typeof id !== 'string') return close('T-H'); + if (validateRequestId(id) === null) return close('T-H'); + + // ── In-flight correlation ────────────────────────────────────────── + const inFlightEntry = inFlight.get(id); + if (inFlightEntry === undefined) return close('T-H'); + + // ── Error body structure (code: number, message: string) ─────────── + if (hasError) { + const error = value.error; + if (error === null || typeof error !== 'object' || Array.isArray(error)) { + return close('T-H'); + } + const errObj = error as Record; + if (typeof errObj.code !== 'number') return close('T-H'); + if (typeof errObj.message !== 'string') return close('T-H'); + } + + // ── Cross-frame oracle ───────────────────────────────────────────── + if (hasResult && inFlightEntry.requestSnapshot !== undefined) { + const result = value.result; + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return close('T-H'); + } + const resultObj = result as Record; + + // Ping nonce byte-equality (row 11) + if (inFlightEntry.requestSnapshot.nonce !== undefined) { + if (resultObj.nonce !== inFlightEntry.requestSnapshot.nonce) { + return close('T-H'); + } + } + + // Delivery ID byte-equality (row 9) + if (inFlightEntry.requestSnapshot.deliveryId !== undefined) { + if (resultObj.deliveryId !== inFlightEntry.requestSnapshot.deliveryId) { + return close('T-H'); + } + } + } + + return accept('T-L'); +} + +// --------------------------------------------------------------------------- +// Notification sub-classifier (T-J / T-K) +// --------------------------------------------------------------------------- + +const NOTIFICATION_ALLOWED_KEYS = new Set(['jsonrpc', 'method', 'params']); + +function classifyNotification(value: JsonObject): DispatchResult { + const method = value.method as string; + + // Extra keys beyond jsonrpc/method/params → T-K + for (const key of Object.keys(value)) { + if (!NOTIFICATION_ALLOWED_KEYS.has(key)) return close('T-K'); + } + + // Missing params → T-K + if (!('params' in value)) return close('T-K'); + + // Only row 10 (host.grants.changed) is a legal notification in v0 + const isLegalNotification = (NOTIFICATION_METHODS as readonly string[]).includes(method); + if (!isLegalNotification) return close('T-K'); + + // Validate params structure + const params = value.params; + if (params === null || typeof params !== 'object' || Array.isArray(params)) { + return close('T-K'); + } + const paramsObj = params as Record; + + // meta.deadlineUnixMs + if (!('meta' in paramsObj)) return close('T-K'); + const meta = paramsObj.meta; + if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { + return close('T-K'); + } + const metaObj = meta as Record; + const notifDeadline = metaObj.deadlineUnixMs; + if (typeof notifDeadline !== 'number' || !isWireUInt53(notifDeadline)) return close('T-K'); + if (notifDeadline === 0) return close('T-K'); + + // input validation for row 10 (grants.changed) + if (!('input' in paramsObj)) return close('T-K'); + const input = paramsObj.input; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + return close('T-K'); + } + const inputObj = input as Record; + + // grantRevision must be WireUInt53 (≥0) + const grantRev = inputObj.grantRevision; + if (typeof grantRev !== 'number' || !isWireUInt53(grantRev)) return close('T-K'); + + // effectiveGrants must pass authorization boundary validation + if (!Array.isArray(inputObj.effectiveGrants)) return close('T-K'); + if (!validateEffectiveGrants(inputObj.effectiveGrants as string[])) return close('T-K'); + + return accept('T-J'); +} + +// --------------------------------------------------------------------------- +// Request sub-classifier (T-E / T-F / T-G / T-I / accept) +// --------------------------------------------------------------------------- + +const REQUEST_ALLOWED_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']); + +function classifyRequest( + value: JsonObject, + id: string, + method: string, + inFlight: ReadonlyMap, +): DispatchResult { + // Extra keys (result/error alongside method+id) → T-F + for (const key of Object.keys(value)) { + if (!REQUEST_ALLOWED_KEYS.has(key)) return respondInvalidRequestId(id); + } + + // Missing params → T-F InvalidRequest + if (!('params' in value)) return respondInvalidRequestId(id); + + // params must be an object + const params = value.params; + if (params === null || typeof params !== 'object') { + return respondInvalidParams(id); + } + if (Array.isArray(params)) return respondInvalidParams(id); + + // Method gate: unknown method → T-F MethodNotFound + if (!isWireMethod(method)) return respondMethodNotFound(id); + + // Direction gate: notification-only methods must not appear as requests + const wireMethod = method as WireMethodName; + const row = WIRE_METHOD_REGISTRY[wireMethod]; + if (row.isNotification) return respondInvalidRequestId(id); + + // In-flight collision → T-I + if (inFlight.has(id)) return close('T-I'); + + // Validate params.meta structure + const paramsObj = params as Record; + if (!('meta' in paramsObj)) return respondInvalidParams(id); + const meta = paramsObj.meta; + if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { + return respondInvalidParams(id); + } + const metaObj = meta as Record; + + // deadlineUnixMs must be positive WireUInt53 + const reqDeadline = metaObj.deadlineUnixMs; + if (typeof reqDeadline !== 'number' || !isWireUInt53(reqDeadline)) return respondInvalidParamsValue(id); + if (reqDeadline === 0) return respondInvalidParamsValue(id); + + // Validate params.input exists and is an object + if (!('input' in paramsObj)) return respondInvalidParams(id); + const input = paramsObj.input; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + return respondInvalidParams(id); + } + + // Row-specific value validation + if (row.leafClosure === 'CLOSED') { + const valueResult = validateClosedRowInput(wireMethod, input as Record, id); + if (valueResult !== null) return valueResult; + } else { + // RESERVED rows: input type is `never` → no legal params value exists + // in v0 → any invocation is a value violation (T-G). + // Fable ruling: disposition table has no accept class for Requests; + // ACCEPT_CLASSES = {T-J (notification), T-L (response)} only. + return respondInvalidParamsValue(id); + } + + // All checks passed — valid CLOSED-row request for dispatch + return { disposition: null, outcome: 'accept' }; +} + +// --------------------------------------------------------------------------- +// CLOSED row input validation (T-G detection) +// --------------------------------------------------------------------------- + +function validateClosedRowInput( + method: WireMethodName, + input: Record, + id: string, +): DispatchResult | null { + switch (method) { + case 'host.lifecycle.ping': { + // nonce must be string, 1..512 code points + if (typeof input.nonce !== 'string') return respondInvalidParamsValue(id); + const cpLen = [...input.nonce].length; + if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + return null; + } + case 'host.lifecycle.drain': { + // input.deadlineUnixMs must be positive WireUInt53 + const drainDeadline = input.deadlineUnixMs; + if (typeof drainDeadline !== 'number' || !isWireUInt53(drainDeadline)) return respondInvalidParamsValue(id); + if (drainDeadline === 0) return respondInvalidParamsValue(id); + return null; + } + case 'messaging.subscribe': { + // handle must be string, bounds from contract + if (typeof input.handle !== 'string') return respondInvalidParamsValue(id); + const cpLen = [...input.handle].length; + if (cpLen < SUBSCRIBE_HANDLE_MIN_LENGTH || cpLen > SUBSCRIBE_HANDLE_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + return null; + } + case 'messaging.ack': { + // subscriptionId + ackToken: string, bounds from contract + if (typeof input.subscriptionId !== 'string') return respondInvalidParamsValue(id); + if (typeof input.ackToken !== 'string') return respondInvalidParamsValue(id); + const subLen = [...input.subscriptionId].length; + const tokenLen = [...input.ackToken].length; + if (subLen < ACK_SUBSCRIPTION_ID_MIN_LENGTH || subLen > ACK_SUBSCRIPTION_ID_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + if (tokenLen < ACK_TOKEN_MIN_LENGTH || tokenLen > ACK_TOKEN_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + return null; + } + // host.grants.changed (row 10) is notification-only — direction gate + // in classifyRequest rejects it before reaching this function. + default: + // RESERVED rows: skip input validation (shapes are `never`) + return null; + } +} + +// --------------------------------------------------------------------------- +// Main classifier entry point +// --------------------------------------------------------------------------- + +/** + * Classify a decoded NDJSON frame into a disposition class. + * + * Covers T-C through T-L of the frozen disposition table. T-A and T-B + * are handled by the NDJSON frame decoder layer. + * + * @param frame Decoded frame with raw bytes and parsed value. + * @param inFlight Map of in-flight request IDs to their entry metadata. + * @returns The disposition result with class, outcome, and optional error response. + */ +export function classifyFrame( + frame: DecodedNdjsonFrame, + inFlight: ReadonlyMap, +): DispatchResult { + const { raw, value } = frame; + + // ── T-C: canonicality check ────────────────────────────────────────── + // The raw bytes must be compact-canonical JSON (no whitespace, no + // duplicate keys visible at the byte level). + const rawStr = new TextDecoder('utf-8', { fatal: true }).decode(raw); + const canonical = JSON.stringify(value); + if (rawStr !== canonical) return close('T-C'); + + // ── Response candidate detection ───────────────────────────────────── + // An object with no `method` key and at least one of `result`/`error` + // enters the response lane before any Request/Notification routing. + const hasMethod = 'method' in value; + const hasResult = 'result' in value; + const hasError = 'error' in value; + + if (!hasMethod && (hasResult || hasError)) { + return classifyResponseCandidate(value, inFlight); + } + + // ── Structural validity ────────────────────────────────────────────── + // Must be JSON-RPC 2.0 with a string method. + if (value.jsonrpc !== '2.0' || typeof value.method !== 'string') { + // T-D: canonical idless non-response-candidate, not a valid Request + // (If id exists but is profile-invalid, it's still T-D for idless path) + if ('id' in value) { + const id = validateRequestId(value.id); + if (id !== null) { + // Valid id with structural problem → T-F + return respondInvalidRequestId(id); + } + } + return respondInvalidRequestNull(); + } + + const method = value.method as string; + + // ── Notification vs Request fork ───────────────────────────────────── + if (!('id' in value)) { + return classifyNotification(value); + } + + // ── T-E: profile-invalid id ────────────────────────────────────────── + const id = validateRequestId(value.id); + if (id === null) return close('T-E'); + + // ── Request path ───────────────────────────────────────────────────── + return classifyRequest(value, id, method, inFlight); +} From 0a7792a53f97dfa81b4b2f9ded2a3b42824b1afe Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 21:10:50 +0800 Subject: [PATCH 02/13] docs: add P-1 remaining slices plan with Fable rulings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the 5-slice DAG (S1-S5), non-claims, and two design rulings from Fable's review arbitration: - F1: beta.4 immutability — test-only relative import path - F2: RESERVED ready gate belongs to S1 classifier (T-G) Also records delta carry-forward items for next contract revision. Co-Authored-By: Claude Opus 4.6 --- .../2026-07-27-p1-remaining-slices-plan.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/plans/2026-07-27-p1-remaining-slices-plan.md diff --git a/docs/plans/2026-07-27-p1-remaining-slices-plan.md b/docs/plans/2026-07-27-p1-remaining-slices-plan.md new file mode 100644 index 0000000..2cb9da1 --- /dev/null +++ b/docs/plans/2026-07-27-p1-remaining-slices-plan.md @@ -0,0 +1,79 @@ +--- +feature_ids: [P-1] +topics: [sdk, wire-dispatch, standalone-io, slice-plan] +doc_kind: plan +created: 2026-07-27 +--- + +# P-1 Remaining Slices Plan (M0 PR-4 Completion) + +**Baseline**: `main@ae9dc86` (PR #12 merged) + `feat/p1-sdk-dispatch` +**Truth source**: `docs/proposals/v0-implementation-roadmap.md` PR-4 + M0 gate +**Contract surface**: beta.4 frozen wire shapes (12 rows, all `ready: false`) + +## Slice DAG + +``` +S1 dispatch → S2 shell → S3 handshake → S4 loopback plugin → S5 adversarial matrix (SDK half) + ↓ (blocked seam) +K-1/K-2 merged (maintainer) ───────────→ host half co-run + M0 gate acceptance +``` + +| Slice | Content | Package boundary | RED input | +|---|---|---|---| +| **S1** wire dispatch | Classify decoded frames per frozen disposition table T-C..T-L; method gate; direction gate; RESERVED→T-G; CLOSED-row input validation; cross-frame oracle | `plugin-sdk/src/wire-dispatch.ts` + test | Contract disposition-fixtures (test-only relative import) | +| **S2** shell completion | Manifest load+validate → fail-closed startup; `startStdioRuntime` ← S1 dispatch; `ping/drain` plugin-side response logic | `plugin-sdk/src/standalone-host.ts` (new) or runtime extension | Illegal/unauthorized manifest fixtures | +| **S3** handshake client | CandidateHello construction (digest/nonce); SessionBinding validation; zero production frames before `broker.ready` | `plugin-sdk/src/handshake-client.ts` + PassThrough fake host test | Bad digest / illegal binding / out-of-order ready | +| **S4** loopback fixture plugin | Independent private workspace package; legal manifest + stdio startup + schema-neutral echo | `packages/loopback-fixture-plugin/` (`private: true`) | End-to-end: spawn real child process | +| **S5** adversarial matrix SDK half | Framing/envelope/method-gate/handshake/lifecycle/oversize violations; fixture-driven for host CI reuse | `plugin-sdk` or conformance directory | Contract fixtures + S4 | + +## Non-claims (every PR body) + +1. 12 rows remain `ready: false` — no flip, no flip request +2. No host Broker/Adapter implementation (K-2 = maintainer) +3. No M0 gate claim (precondition: K-1/K-2 merged) +4. No new wire shapes beyond beta.4 +5. Loopback echo ≠ `messaging.send` production semantics +6. No changes to clowder-ai core worktree + +## Rulings (Fable, 2026-07-27) + +### F1: beta.4 artifact immutability + +S1 must not modify the published contract barrel (`wire/index.ts`). +Disposition fixtures are consumed via **test-only relative import** +(`../../plugin-contract/src/wire/disposition-fixtures.js`) — test files +are excluded from the SDK dist artifact, no P14 violation. + +**Delta carry-forward items** (next contract revision, not standalone): +- (a) Promote disposition fixtures to public conformance export +- (b) Add reserved-method fixture vectors to the disposition fixture set + +### F2: RESERVED method ready gate belongs to S1 + +Contract evidence chain: +1. Disposition table is frozen; ACCEPT_CLASSES = {T-J, T-L} only — no + accept class exists for Requests +2. RESERVED rows have input type `never` — no legal params value exists +3. Therefore: any RESERVED-method Request → T-G (value violation, + respond error, id echoed) + +S2 handler shell may keep a defensive assertion but is not the primary gate. + +### Consumption path (F1 method — applies to S1 and future slices) + +SDK production code imports only from `@clowder-ai/plugin-contract` +public barrel. Test files may use relative imports to contract source +when the needed symbol is not part of the published surface. Typecheck +uses `tsconfig.build.json` (excludes test files); test types validated +by tsx at runtime. + +## Branch / Gate / Handoff + +- **Branch**: `feat/p1-sdk-dispatch` from main; single clean commit per slice +- **Gate (per slice)**: TDD → `pnpm --filter @clowder-ai/plugin-sdk test` + + monorepo typecheck/lint/build/conformance all green → quality-gate → + cross-individual review (sol/kimi) → PR + `register_pr_tracking` → + codex → maintainer +- **Shape gap / contract contradiction**: stop → `@Fable` for delta + co-sign, no workaround From 7cbdb2ad0e415111212245e60c3d2239156a0df7 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 21:25:09 +0800 Subject: [PATCH 03/13] fix(sdk): seal R2 fail-open gaps in wire dispatch classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol R2 review identified 3 gaps in the S1 classifier: 1. Response validation (P1): error body now validates closed error union (known codes only, canonical code→message mapping, standard errors forbid data, application errors require data). Success responses validated per-method: ping→{nonce}, subscribe→{subscriptionId}, ack→null, drain→null. RESERVED rows use oracle-only validation. 2. Nested closed shapes (P2): params/meta/input objects now enforce additionalProperties:false via key-set checks. Per-method CLOSED-row input key sets verified (ping/drain/subscribe/ack/grants.changed). 3. Test typecheck (P2): tsconfig.test.json added for static type checking of test files (separate from build tsconfig due to rootDir constraint from cross-package relative imports). typecheck/lint scripts now run both build and test typechecks. 84/84 tests pass (21 new anti-example tests for R2 findings). Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/package.json | 4 +- packages/plugin-sdk/src/wire-dispatch.test.ts | 284 ++++++++++++++++++ packages/plugin-sdk/src/wire-dispatch.ts | 282 ++++++++++++++--- packages/plugin-sdk/tsconfig.test.json | 15 + 4 files changed, 548 insertions(+), 37 deletions(-) create mode 100644 packages/plugin-sdk/tsconfig.test.json diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 9f54863..70678b5 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -20,9 +20,9 @@ }, "scripts": { "build": "pnpm clean && tsc -p tsconfig.build.json", - "typecheck": "tsc -p tsconfig.build.json --noEmit", + "typecheck": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.test.json", "test": "pnpm --filter @clowder-ai/plugin-contract build && pnpm build && node --import tsx --test src/stdio-runtime.test.ts src/wire-dispatch.test.ts", - "lint": "tsc -p tsconfig.build.json --noEmit", + "lint": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.test.json", "prepack": "pnpm build", "clean": "rm -rf dist" }, diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index f41294a..e770506 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -263,6 +263,290 @@ test('notification-only method (host.grants.changed) with id is T-F (direction g assert.equal(result.outcome, 'respond'); }); +// --------------------------------------------------------------------------- +// R2 Finding 1: Response result shape validation (fail-closed) +// --------------------------------------------------------------------------- + +test('ping result:null without snapshot is T-H (null is not {nonce:string})', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'null result for ping must not accept'); + assert.equal(result.outcome, 'close'); +}); + +test('ping result with extra field is T-H (closed shape)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"x","extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra field in ping result must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('ack result:{} is T-H (ack result must be null)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.ack' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-null result for ack must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('subscribe result:null is T-H (must be {subscriptionId:string})', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'null result for subscribe must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('drain result:{} is T-H (drain result must be null)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-null result for drain must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('error with unknown code 123 is T-H (closed error code set)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":123,"message":"whatever"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'unknown error code must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('standard error with data field is T-H (standard errors forbid data)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'standard error with data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('application error without data field is T-H (application errors require data)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'application error without data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('error with wrong code→message mapping is T-H (canonical mapping)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"wrong message"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'wrong code→message mapping must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('valid standard error (Internal error) with in-flight is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid standard error with in-flight must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid application error (deadline_expired) with in-flight is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid application error with in-flight must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid subscribe result {subscriptionId:"sub-1"} is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"subscriptionId":"sub-1"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid subscribe result must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid ack result null is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.ack' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid ack null result must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid drain result null is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid drain null result must accept'); + assert.equal(result.outcome, 'accept'); +}); + +// --------------------------------------------------------------------------- +// R2 Finding 2: Nested closed-shape key enforcement +// --------------------------------------------------------------------------- + +test('request params with extra key beyond meta+input is T-F', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"x"},"extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-F', 'extra params key must reject'); + assert.equal(result.outcome, 'respond'); +}); + +test('request meta with extra key beyond deadlineUnixMs is T-G', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1,"extra":true},"input":{"nonce":"x"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-G', 'extra meta key must reject'); + assert.equal(result.outcome, 'respond'); +}); + +test('ping input with extra key beyond nonce is T-G', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"x","extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-G', 'extra input key must reject'); + assert.equal(result.outcome, 'respond'); +}); + +test('notification params with extra key is T-K', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":[]},"extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'extra notification params key must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('notification meta with extra key is T-K', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1,"extra":true},"input":{"grantRevision":0,"effectiveGrants":[]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'extra notification meta key must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('notification input with extra key is T-K (grants.changed input closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":[],"extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'extra notification input key must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('error body with extra field beyond code/message is T-H (standard error)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error","extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra error body key must reject'); + assert.equal(result.outcome, 'close'); +}); + // --------------------------------------------------------------------------- // Mutual-exclusivity proof pair (pre-existing) // --------------------------------------------------------------------------- diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 1aff2c9..8bdffa8 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -36,10 +36,15 @@ import { PING_NONCE_MAX_LENGTH, SUBSCRIBE_HANDLE_MIN_LENGTH, SUBSCRIBE_HANDLE_MAX_LENGTH, + SUBSCRIBE_SUBSCRIPTION_ID_MIN_LENGTH, + SUBSCRIBE_SUBSCRIPTION_ID_MAX_LENGTH, ACK_SUBSCRIPTION_ID_MIN_LENGTH, ACK_SUBSCRIPTION_ID_MAX_LENGTH, ACK_TOKEN_MIN_LENGTH, ACK_TOKEN_MAX_LENGTH, + ALL_ERROR_CODES, + APPLICATION_ERROR_CODES, + ERROR_CODE_TO_MESSAGE, } from '@clowder-ai/plugin-contract'; // --------------------------------------------------------------------------- @@ -148,29 +153,60 @@ function accept(disposition: DispositionClass): DispatchResult { } // --------------------------------------------------------------------------- -// Response candidate sub-classifier (T-H / T-L) +// Closed key-set constants (additionalProperties: false enforcement) // --------------------------------------------------------------------------- -/** Closed outer key sets for WireSuccessResponse / WireErrorResponse. */ +/** Outer envelope: WireSuccessResponse = {jsonrpc, id, result}. */ const RESPONSE_SUCCESS_KEYS = new Set(['jsonrpc', 'id', 'result']); +/** Outer envelope: WireErrorResponse = {jsonrpc, id, error}. */ const RESPONSE_ERROR_KEYS = new Set(['jsonrpc', 'id', 'error']); +/** Outer envelope: WireNotification = {jsonrpc, method, params}. */ +const NOTIFICATION_ALLOWED_KEYS = new Set(['jsonrpc', 'method', 'params']); +/** Outer envelope: WireRequest = {jsonrpc, id, method, params}. */ +const REQUEST_ALLOWED_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']); + +/** params object: {meta, input} — closed per WireRequest/WireNotification. */ +const PARAMS_ALLOWED_KEYS = new Set(['meta', 'input']); +/** meta object: CallMeta = {deadlineUnixMs} — closed per envelope.ts. */ +const META_ALLOWED_KEYS = new Set(['deadlineUnixMs']); + +// Per-method CLOSED-row input key sets (additionalProperties: false). +const PING_INPUT_KEYS = new Set(['nonce']); +const DRAIN_INPUT_KEYS = new Set(['deadlineUnixMs']); +const SUBSCRIBE_INPUT_KEYS = new Set(['handle']); +const ACK_INPUT_KEYS = new Set(['subscriptionId', 'ackToken']); +const GRANTS_CHANGED_INPUT_KEYS = new Set(['grantRevision', 'effectiveGrants']); + +// Per-method CLOSED-row result key sets (additionalProperties: false). +const PING_RESULT_KEYS = new Set(['nonce']); +const SUBSCRIBE_RESULT_KEYS = new Set(['subscriptionId']); + +// Error body closed key sets. +const ERROR_BODY_STANDARD_KEYS = new Set(['code', 'message']); +const ERROR_BODY_APPLICATION_KEYS = new Set(['code', 'message', 'data']); + +// Error code validation sets (built once from contract arrays). +const KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES); +const APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES); + +// --------------------------------------------------------------------------- +// Response candidate sub-classifier (T-H / T-L) +// --------------------------------------------------------------------------- function classifyResponseCandidate( value: JsonObject, inFlight: ReadonlyMap, ): DispatchResult { - // Response candidate: no method key, has result or error. const hasResult = 'result' in value; const hasError = 'error' in value; // ── Closed envelope structure ────────────────────────────────────── - // jsonrpc must be exactly "2.0" if (value.jsonrpc !== '2.0') return close('T-H'); // Mutual exclusivity: exactly one of result/error if (hasResult && hasError) return close('T-H'); - // Closed outer keys: no additional members allowed + // Closed outer keys: no additional members const allowedKeys = hasResult ? RESPONSE_SUCCESS_KEYS : RESPONSE_ERROR_KEYS; for (const key of Object.keys(value)) { if (!allowedKeys.has(key)) return close('T-H'); @@ -186,53 +222,196 @@ function classifyResponseCandidate( const inFlightEntry = inFlight.get(id); if (inFlightEntry === undefined) return close('T-H'); - // ── Error body structure (code: number, message: string) ─────────── + // ── Error body: closed union validation ──────────────────────────── if (hasError) { const error = value.error; if (error === null || typeof error !== 'object' || Array.isArray(error)) { return close('T-H'); } const errObj = error as Record; + + // code: number, message: string (structural) if (typeof errObj.code !== 'number') return close('T-H'); if (typeof errObj.message !== 'string') return close('T-H'); - } - // ── Cross-frame oracle ───────────────────────────────────────────── - if (hasResult && inFlightEntry.requestSnapshot !== undefined) { - const result = value.result; - if (result === null || typeof result !== 'object' || Array.isArray(result)) { - return close('T-H'); + // Error code must be from the closed set + if (!KNOWN_ERROR_CODES.has(errObj.code)) return close('T-H'); + + // Code → message canonical mapping + const expectedMessage = ERROR_CODE_TO_MESSAGE[errObj.code as keyof typeof ERROR_CODE_TO_MESSAGE]; + if (errObj.message !== expectedMessage) return close('T-H'); + + // Standard vs application error body structure + if (APPLICATION_CODES.has(errObj.code)) { + // Application errors (-32090..-32094) MUST have `data` (object) + if (!('data' in errObj)) return close('T-H'); + if (errObj.data === null || typeof errObj.data !== 'object' || Array.isArray(errObj.data)) { + return close('T-H'); + } + // Closed keys: {code, message, data} only + for (const key of Object.keys(errObj)) { + if (!ERROR_BODY_APPLICATION_KEYS.has(key)) return close('T-H'); + } + } else { + // Standard errors (-32700..-32603) MUST NOT have `data` + if ('data' in errObj) return close('T-H'); + // Closed keys: {code, message} only + for (const key of Object.keys(errObj)) { + if (!ERROR_BODY_STANDARD_KEYS.has(key)) return close('T-H'); + } } - const resultObj = result as Record; - // Ping nonce byte-equality (row 11) - if (inFlightEntry.requestSnapshot.nonce !== undefined) { - if (resultObj.nonce !== inFlightEntry.requestSnapshot.nonce) { + return accept('T-L'); + } + + // ── Success result: method-specific shape validation ─────────────── + const resultCheck = validateResponseResult(value.result, inFlightEntry); + if (resultCheck !== null) return resultCheck; + + return accept('T-L'); +} + +// --------------------------------------------------------------------------- +// Response result shape validation (per-method, CLOSED rows only) +// --------------------------------------------------------------------------- + +/** + * Validate the `result` value of a success response against the + * correlated in-flight method's expected result shape. + * + * CLOSED rows: full per-method result shape validation (additionalProperties: false). + * RESERVED rows: no shape contract to validate — only cross-frame oracle applies. + * + * Returns null if valid; DispatchResult (T-H) if invalid. + */ +function validateResponseResult( + result: unknown, + entry: InFlightEntry, +): DispatchResult | null { + const method = entry.method; + const row = WIRE_METHOD_REGISTRY[method]; + + // RESERVED rows: no shape contract to validate against. + // Only cross-frame oracle checks apply (nonce/deliveryId byte-equality). + if (row.leafClosure !== 'CLOSED') { + return validateReservedRowOracle(result, entry); + } + + switch (method) { + case 'host.lifecycle.ping': { + // PingResult: {nonce: string} — additionalProperties: false + if (result === null || typeof result !== 'object' || Array.isArray(result)) { return close('T-H'); } + const obj = result as Record; + // Closed keys: {nonce} only + for (const key of Object.keys(obj)) { + if (!PING_RESULT_KEYS.has(key)) return close('T-H'); + } + if (typeof obj.nonce !== 'string') return close('T-H'); + // Nonce bounds + const cpLen = [...(obj.nonce as string)].length; + if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) { + return close('T-H'); + } + // Cross-frame oracle: nonce byte-equality + if (entry.requestSnapshot?.nonce !== undefined) { + if (obj.nonce !== entry.requestSnapshot.nonce) return close('T-H'); + } + return null; } - // Delivery ID byte-equality (row 9) - if (inFlightEntry.requestSnapshot.deliveryId !== undefined) { - if (resultObj.deliveryId !== inFlightEntry.requestSnapshot.deliveryId) { + case 'host.lifecycle.drain': { + // DrainResult: null + if (result !== null) return close('T-H'); + return null; + } + + case 'messaging.subscribe': { + // SubscribeResult: {subscriptionId: string} — additionalProperties: false + if (result === null || typeof result !== 'object' || Array.isArray(result)) { return close('T-H'); } + const obj = result as Record; + // Closed keys: {subscriptionId} only + for (const key of Object.keys(obj)) { + if (!SUBSCRIBE_RESULT_KEYS.has(key)) return close('T-H'); + } + if (typeof obj.subscriptionId !== 'string') return close('T-H'); + const cpLen = [...(obj.subscriptionId as string)].length; + if (cpLen < SUBSCRIBE_SUBSCRIPTION_ID_MIN_LENGTH || cpLen > SUBSCRIBE_SUBSCRIPTION_ID_MAX_LENGTH) { + return close('T-H'); + } + return null; + } + + case 'messaging.ack': { + // MessagingAckResult: null + if (result !== null) return close('T-H'); + return null; + } + + case 'host.grants.changed': { + // Notification-only — should never be in in-flight. + // Direction gate prevents this; defense-in-depth. + return close('T-H'); + } + + default: { + // Unreachable for CLOSED rows (all covered above). + // Defense-in-depth: unknown method in in-flight → T-H. + return close('T-H'); } } +} - return accept('T-L'); +// --------------------------------------------------------------------------- +// Cross-frame oracle for RESERVED row responses +// --------------------------------------------------------------------------- + +/** + * For RESERVED rows (no closed result shape), the only validation + * available is the cross-frame oracle — byte-equality checks on + * nonce/deliveryId from the original request snapshot. + */ +function validateReservedRowOracle( + result: unknown, + entry: InFlightEntry, +): DispatchResult | null { + if (entry.requestSnapshot === undefined) return null; + + // Oracle fields require result to be an object + const hasOracleField = + entry.requestSnapshot.nonce !== undefined || + entry.requestSnapshot.deliveryId !== undefined; + if (!hasOracleField) return null; + + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return close('T-H'); + } + const resultObj = result as Record; + + // Nonce byte-equality (ping oracle — carried forward for RESERVED rows) + if (entry.requestSnapshot.nonce !== undefined) { + if (resultObj.nonce !== entry.requestSnapshot.nonce) return close('T-H'); + } + + // DeliveryId byte-equality (deliver oracle — row 9) + if (entry.requestSnapshot.deliveryId !== undefined) { + if (resultObj.deliveryId !== entry.requestSnapshot.deliveryId) return close('T-H'); + } + + return null; } // --------------------------------------------------------------------------- // Notification sub-classifier (T-J / T-K) // --------------------------------------------------------------------------- -const NOTIFICATION_ALLOWED_KEYS = new Set(['jsonrpc', 'method', 'params']); - function classifyNotification(value: JsonObject): DispatchResult { const method = value.method as string; - // Extra keys beyond jsonrpc/method/params → T-K + // Closed outer keys: {jsonrpc, method, params} only for (const key of Object.keys(value)) { if (!NOTIFICATION_ALLOWED_KEYS.has(key)) return close('T-K'); } @@ -251,6 +430,11 @@ function classifyNotification(value: JsonObject): DispatchResult { } const paramsObj = params as Record; + // Closed params keys: {meta, input} only + for (const key of Object.keys(paramsObj)) { + if (!PARAMS_ALLOWED_KEYS.has(key)) return close('T-K'); + } + // meta.deadlineUnixMs if (!('meta' in paramsObj)) return close('T-K'); const meta = paramsObj.meta; @@ -258,6 +442,12 @@ function classifyNotification(value: JsonObject): DispatchResult { return close('T-K'); } const metaObj = meta as Record; + + // Closed meta keys: {deadlineUnixMs} only + for (const key of Object.keys(metaObj)) { + if (!META_ALLOWED_KEYS.has(key)) return close('T-K'); + } + const notifDeadline = metaObj.deadlineUnixMs; if (typeof notifDeadline !== 'number' || !isWireUInt53(notifDeadline)) return close('T-K'); if (notifDeadline === 0) return close('T-K'); @@ -270,6 +460,11 @@ function classifyNotification(value: JsonObject): DispatchResult { } const inputObj = input as Record; + // Closed input keys: {grantRevision, effectiveGrants} only + for (const key of Object.keys(inputObj)) { + if (!GRANTS_CHANGED_INPUT_KEYS.has(key)) return close('T-K'); + } + // grantRevision must be WireUInt53 (≥0) const grantRev = inputObj.grantRevision; if (typeof grantRev !== 'number' || !isWireUInt53(grantRev)) return close('T-K'); @@ -285,15 +480,13 @@ function classifyNotification(value: JsonObject): DispatchResult { // Request sub-classifier (T-E / T-F / T-G / T-I / accept) // --------------------------------------------------------------------------- -const REQUEST_ALLOWED_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']); - function classifyRequest( value: JsonObject, id: string, method: string, inFlight: ReadonlyMap, ): DispatchResult { - // Extra keys (result/error alongside method+id) → T-F + // Closed outer keys: {jsonrpc, id, method, params} only for (const key of Object.keys(value)) { if (!REQUEST_ALLOWED_KEYS.has(key)) return respondInvalidRequestId(id); } @@ -319,8 +512,14 @@ function classifyRequest( // In-flight collision → T-I if (inFlight.has(id)) return close('T-I'); - // Validate params.meta structure const paramsObj = params as Record; + + // Closed params keys: {meta, input} only + for (const key of Object.keys(paramsObj)) { + if (!PARAMS_ALLOWED_KEYS.has(key)) return respondInvalidParams(id); + } + + // Validate params.meta structure if (!('meta' in paramsObj)) return respondInvalidParams(id); const meta = paramsObj.meta; if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { @@ -328,6 +527,11 @@ function classifyRequest( } const metaObj = meta as Record; + // Closed meta keys: {deadlineUnixMs} only + for (const key of Object.keys(metaObj)) { + if (!META_ALLOWED_KEYS.has(key)) return respondInvalidParamsValue(id); + } + // deadlineUnixMs must be positive WireUInt53 const reqDeadline = metaObj.deadlineUnixMs; if (typeof reqDeadline !== 'number' || !isWireUInt53(reqDeadline)) return respondInvalidParamsValue(id); @@ -367,6 +571,10 @@ function validateClosedRowInput( ): DispatchResult | null { switch (method) { case 'host.lifecycle.ping': { + // Closed input keys: {nonce} only + for (const key of Object.keys(input)) { + if (!PING_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } // nonce must be string, 1..512 code points if (typeof input.nonce !== 'string') return respondInvalidParamsValue(id); const cpLen = [...input.nonce].length; @@ -376,6 +584,10 @@ function validateClosedRowInput( return null; } case 'host.lifecycle.drain': { + // Closed input keys: {deadlineUnixMs} only + for (const key of Object.keys(input)) { + if (!DRAIN_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } // input.deadlineUnixMs must be positive WireUInt53 const drainDeadline = input.deadlineUnixMs; if (typeof drainDeadline !== 'number' || !isWireUInt53(drainDeadline)) return respondInvalidParamsValue(id); @@ -383,6 +595,10 @@ function validateClosedRowInput( return null; } case 'messaging.subscribe': { + // Closed input keys: {handle} only + for (const key of Object.keys(input)) { + if (!SUBSCRIBE_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } // handle must be string, bounds from contract if (typeof input.handle !== 'string') return respondInvalidParamsValue(id); const cpLen = [...input.handle].length; @@ -392,6 +608,10 @@ function validateClosedRowInput( return null; } case 'messaging.ack': { + // Closed input keys: {subscriptionId, ackToken} only + for (const key of Object.keys(input)) { + if (!ACK_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } // subscriptionId + ackToken: string, bounds from contract if (typeof input.subscriptionId !== 'string') return respondInvalidParamsValue(id); if (typeof input.ackToken !== 'string') return respondInvalidParamsValue(id); @@ -434,15 +654,11 @@ export function classifyFrame( const { raw, value } = frame; // ── T-C: canonicality check ────────────────────────────────────────── - // The raw bytes must be compact-canonical JSON (no whitespace, no - // duplicate keys visible at the byte level). const rawStr = new TextDecoder('utf-8', { fatal: true }).decode(raw); const canonical = JSON.stringify(value); if (rawStr !== canonical) return close('T-C'); // ── Response candidate detection ───────────────────────────────────── - // An object with no `method` key and at least one of `result`/`error` - // enters the response lane before any Request/Notification routing. const hasMethod = 'method' in value; const hasResult = 'result' in value; const hasError = 'error' in value; @@ -452,14 +668,10 @@ export function classifyFrame( } // ── Structural validity ────────────────────────────────────────────── - // Must be JSON-RPC 2.0 with a string method. if (value.jsonrpc !== '2.0' || typeof value.method !== 'string') { - // T-D: canonical idless non-response-candidate, not a valid Request - // (If id exists but is profile-invalid, it's still T-D for idless path) if ('id' in value) { const id = validateRequestId(value.id); if (id !== null) { - // Valid id with structural problem → T-F return respondInvalidRequestId(id); } } diff --git a/packages/plugin-sdk/tsconfig.test.json b/packages/plugin-sdk/tsconfig.test.json new file mode 100644 index 0000000..4c4a6bb --- /dev/null +++ b/packages/plugin-sdk/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*.test.ts"], + "exclude": ["node_modules", "dist"] +} From ae3d4faca7276512dd8969df61a2ff968c160697 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 21:35:58 +0800 Subject: [PATCH 04/13] fix(sdk): seal R3 per-arm error data, oracle fail-closed, ParseError null-id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol R3 review identified 3 gaps: 1. Per-arm application error data schema (P1): each of the 5 application error arms now validates its data field per the contract's closed schema: - HandshakeRejected: data.reason ∈ HANDSHAKE_REJECT_REASONS, closed keys - DeliveryRejected: data.reason ∈ DELIVERY_REJECT_REASONS, closed keys - DomainError: data has exactly {code: string} — enum validation deferred pending Fable ruling (no runtime MessagingErrorCode array in contract public surface; P15 forbids second truth source) - DeadlineExpired: data must be empty object (Record) - SnapshotUnavailable: data.reason ∈ SNAPSHOT_UNAVAILABLE_REASONS, closed keys Additionally: ParseError (-32700) with string id → T-H because ParseError mandates id: null per the contract envelope. 2. Oracle fail-closed (P1): ping success responses now REQUIRE the requestSnapshot.nonce field. Missing snapshot → T-H (fail-closed), not fail-open. This is a caller bug if the snapshot is missing — the classifier cannot verify the liveness proof without the oracle. 3. Plan doc text (P3): updated consumption path to reflect tsconfig.test.json (was incorrectly still referencing tsx). 95/95 tests pass (11 new anti-example tests for R3 findings). Co-Authored-By: Claude Opus 4.6 --- .../2026-07-27-p1-remaining-slices-plan.md | 5 +- packages/plugin-sdk/src/wire-dispatch.test.ts | 163 ++++++++++++++++++ packages/plugin-sdk/src/wire-dispatch.ts | 124 ++++++++++++- 3 files changed, 286 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-07-27-p1-remaining-slices-plan.md b/docs/plans/2026-07-27-p1-remaining-slices-plan.md index 2cb9da1..e1e6fa0 100644 --- a/docs/plans/2026-07-27-p1-remaining-slices-plan.md +++ b/docs/plans/2026-07-27-p1-remaining-slices-plan.md @@ -65,8 +65,9 @@ S2 handler shell may keep a defensive assertion but is not the primary gate. SDK production code imports only from `@clowder-ai/plugin-contract` public barrel. Test files may use relative imports to contract source when the needed symbol is not part of the published surface. Typecheck -uses `tsconfig.build.json` (excludes test files); test types validated -by tsx at runtime. +uses `tsconfig.build.json` (production code) + `tsconfig.test.json` +(test files, separate due to rootDir constraint from cross-package +relative imports). Both are run by `pnpm typecheck`. ## Branch / Gate / Handoff diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index e770506..f7c0caa 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -547,6 +547,169 @@ test('error body with extra field beyond code/message is T-H (standard error)', assert.equal(result.outcome, 'close'); }); +// --------------------------------------------------------------------------- +// R3 Finding 1: Per-arm application error data schema validation +// --------------------------------------------------------------------------- + +test('handshake rejection with missing reason is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'handshake rejection missing reason must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('handshake rejection with unknown reason is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"UNKNOWN_REASON"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'unknown handshake rejection reason must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('handshake rejection with extra data key is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"MALFORMED_HELLO","extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra key in handshake rejection data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deadline_expired with non-empty data is T-H (must be Record)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{"extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-empty deadline_expired data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('domain_error with extra data key is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION","extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra key in domain error data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('domain_error with non-string code is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":42}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-string domain error code must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('snapshot_unavailable with valid reason is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32094,"message":"snapshot unavailable","data":{"reason":"VIEW_EXPIRED"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid snapshot_unavailable must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid handshake_rejected with known reason is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"PACKAGE_MISMATCH"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid handshake_rejected must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('ParseError (-32700) with correlated string id is T-H (must have null id)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32700,"message":"Parse error"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'ParseError with string id violates null-id mandate'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// R3 Finding 2: Oracle fail-closed on missing snapshot +// --------------------------------------------------------------------------- + +test('ping success with valid shape but no oracle snapshot is T-H (fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"hello"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // In-flight entry for ping WITHOUT requestSnapshot — caller bug + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'missing ping oracle snapshot must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +test('ping success with snapshot nonce present is T-L (oracle pass)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"hello"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'hello' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'ping with matching nonce oracle must accept'); + assert.equal(result.outcome, 'accept'); +}); + // --------------------------------------------------------------------------- // Mutual-exclusivity proof pair (pre-existing) // --------------------------------------------------------------------------- diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 8bdffa8..7b4c092 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -45,6 +45,18 @@ import { ALL_ERROR_CODES, APPLICATION_ERROR_CODES, ERROR_CODE_TO_MESSAGE, + // Per-arm application error codes (for data schema dispatch) + HANDSHAKE_REJECTED_CODE, + DELIVERY_REJECTED_CODE, + DOMAIN_ERROR_CODE, + DEADLINE_EXPIRED_CODE, + SNAPSHOT_UNAVAILABLE_CODE, + // Standard error code (ParseError null-id arm validation) + PARSE_ERROR_CODE, + // Reject-reason closed enums (application error data validation) + HANDSHAKE_REJECT_REASONS, + DELIVERY_REJECT_REASONS, + SNAPSHOT_UNAVAILABLE_REASONS, } from '@clowder-ai/plugin-contract'; // --------------------------------------------------------------------------- @@ -189,6 +201,21 @@ const ERROR_BODY_APPLICATION_KEYS = new Set(['code', 'message', 'data']); const KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES); const APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES); +// Per-arm application error data key sets (additionalProperties: false). +const REASON_DATA_KEYS = new Set(['reason']); +const CODE_DATA_KEYS = new Set(['code']); + +// Per-arm reason enum sets (built once from contract arrays). +const HANDSHAKE_REASONS = new Set(HANDSHAKE_REJECT_REASONS); +const DELIVERY_REASONS = new Set(DELIVERY_REJECT_REASONS); +const SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS); + +// Standard error codes that mandate null id (not string RequestId). +// ParseError (-32700) ALWAYS has id: null per the contract envelope. +// If we reach the error validation path, id is already a valid string +// (verified upstream), so ParseError with string id is invalid. +const NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]); + // --------------------------------------------------------------------------- // Response candidate sub-classifier (T-H / T-L) // --------------------------------------------------------------------------- @@ -252,7 +279,18 @@ function classifyResponseCandidate( for (const key of Object.keys(errObj)) { if (!ERROR_BODY_APPLICATION_KEYS.has(key)) return close('T-H'); } + // Per-arm data schema validation + const dataCheck = validateApplicationErrorData( + errObj.code, + errObj.data as Record, + ); + if (dataCheck !== null) return dataCheck; } else { + // Standard errors: ParseError (-32700) mandates id: null. + // We already validated id is a string (not null) upstream. + // Therefore ParseError with string id is a protocol violation. + if (NULL_ID_ERROR_CODES.has(errObj.code)) return close('T-H'); + // Standard errors (-32700..-32603) MUST NOT have `data` if ('data' in errObj) return close('T-H'); // Closed keys: {code, message} only @@ -271,6 +309,82 @@ function classifyResponseCandidate( return accept('T-L'); } +// --------------------------------------------------------------------------- +// Application error data schema validation (per-arm) +// --------------------------------------------------------------------------- + +/** + * Validate the `data` field of an application error against the + * per-arm closed schema (additionalProperties: false). + * + * 5 arms: HandshakeRejected, DeliveryRejected, DomainError, + * DeadlineExpired, SnapshotUnavailable. + * + * Contract seam: DomainError.data.code (MessagingErrorCode) has no + * runtime enum in the contract public surface — only the TypeScript + * type union exists. We validate structure (key + string type) but + * skip enum validation to avoid a second truth source (P15). + * See Sol R3 F1 → Fable escalation. + */ +function validateApplicationErrorData( + code: number, + data: Record, +): DispatchResult | null { + switch (code) { + case HANDSHAKE_REJECTED_CODE: { + // data: { reason: HandshakeRejectReason } — closed + for (const key of Object.keys(data)) { + if (!REASON_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.reason !== 'string') return close('T-H'); + if (!HANDSHAKE_REASONS.has(data.reason)) return close('T-H'); + return null; + } + + case DELIVERY_REJECTED_CODE: { + // data: { reason: DeliveryRejectReason } — closed + for (const key of Object.keys(data)) { + if (!REASON_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.reason !== 'string') return close('T-H'); + if (!DELIVERY_REASONS.has(data.reason)) return close('T-H'); + return null; + } + + case DOMAIN_ERROR_CODE: { + // data: { code: MessagingErrorCode } — closed keys + // NOTE: MessagingErrorCode enum validation deferred — no runtime + // array in contract public surface. Validates structure only. + for (const key of Object.keys(data)) { + if (!CODE_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.code !== 'string') return close('T-H'); + return null; + } + + case DEADLINE_EXPIRED_CODE: { + // data: Record — must be empty object + if (Object.keys(data).length !== 0) return close('T-H'); + return null; + } + + case SNAPSHOT_UNAVAILABLE_CODE: { + // data: { reason: SnapshotUnavailableReason } — closed + for (const key of Object.keys(data)) { + if (!REASON_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.reason !== 'string') return close('T-H'); + if (!SNAPSHOT_REASONS.has(data.reason)) return close('T-H'); + return null; + } + + default: + // Unknown application code — should be unreachable since + // APPLICATION_CODES was already checked. Defense-in-depth. + return close('T-H'); + } +} + // --------------------------------------------------------------------------- // Response result shape validation (per-method, CLOSED rows only) // --------------------------------------------------------------------------- @@ -314,10 +428,12 @@ function validateResponseResult( if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) { return close('T-H'); } - // Cross-frame oracle: nonce byte-equality - if (entry.requestSnapshot?.nonce !== undefined) { - if (obj.nonce !== entry.requestSnapshot.nonce) return close('T-H'); - } + // Cross-frame oracle: nonce byte-equality (REQUIRED for ping). + // Ping's nonce echo is the fundamental liveness proof — accepting + // a response without verifying the oracle defeats the purpose. + // Missing snapshot is a caller bug; fail-closed, not fail-open. + if (entry.requestSnapshot?.nonce === undefined) return close('T-H'); + if (obj.nonce !== entry.requestSnapshot.nonce) return close('T-H'); return null; } From 93fb28dd9e5bae2ad9c8fc44864e40622e0ac161 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 21:44:59 +0800 Subject: [PATCH 05/13] feat(sdk): add contract-mirror module with drift-prevention tests (Fable F3 ruling) Execute Fable's R3 contract seam ruling: 1. contract-mirror.ts: centralized module for all contract type-level constraints that lack runtime exports (key sets from additionalProperties:false interfaces, MessagingErrorCode enum). Self-documenting as a deletion target for beta.5 delta. 2. contract-mirror.test.ts: 19 drift-prevention tests. MessagingErrorCode has automated exact-match test against messaging.schema.json enum (the sole truth source). Key sets verified by cardinality + member assertions anchored to contract source files. 3. DomainError.data.code now validates against MESSAGING_ERROR_CODE_SET (closing the last deferred validation from R3/R4). 4. Plan doc updated with F3 ruling: contract-mirror rules (centralize, drift-test, delta-list) and accumulated beta.5 delta scope. 5. wire-dispatch.ts refactored: key-set constants imported from contract-mirror.ts; derived constants (built from contract imports) remain local. 116/116 tests pass (19 drift-prevention + 97 classifier/runtime). Co-Authored-By: Claude Opus 4.6 --- .../2026-07-27-p1-remaining-slices-plan.md | 40 +++- packages/plugin-sdk/package.json | 2 +- .../plugin-sdk/src/contract-mirror.test.ts | 217 ++++++++++++++++++ packages/plugin-sdk/src/contract-mirror.ts | 165 +++++++++++++ packages/plugin-sdk/src/wire-dispatch.test.ts | 28 +++ packages/plugin-sdk/src/wire-dispatch.ts | 75 +++--- 6 files changed, 479 insertions(+), 48 deletions(-) create mode 100644 packages/plugin-sdk/src/contract-mirror.test.ts create mode 100644 packages/plugin-sdk/src/contract-mirror.ts diff --git a/docs/plans/2026-07-27-p1-remaining-slices-plan.md b/docs/plans/2026-07-27-p1-remaining-slices-plan.md index e1e6fa0..318145b 100644 --- a/docs/plans/2026-07-27-p1-remaining-slices-plan.md +++ b/docs/plans/2026-07-27-p1-remaining-slices-plan.md @@ -60,14 +60,38 @@ Contract evidence chain: S2 handler shell may keep a defensive assertion but is not the primary gate. -### Consumption path (F1 method — applies to S1 and future slices) - -SDK production code imports only from `@clowder-ai/plugin-contract` -public barrel. Test files may use relative imports to contract source -when the needed symbol is not part of the published surface. Typecheck -uses `tsconfig.build.json` (production code) + `tsconfig.test.json` -(test files, separate due to rootDir constraint from cross-package -relative imports). Both are run by `pnpm typecheck`. +### F3: Contract-mirror drift prevention (R3 seam ruling) + +When the contract expresses a closed constraint only at the type level +(additionalProperties: false, enum unions) with no runtime export, +the SDK may create a **mirror constant** under these rules: + +1. **Centralized**: all mirrors live in `contract-mirror.ts` (self-documenting + name: "this is a deletable mirror") +2. **Drift-tested**: each mirror has a test in `contract-mirror.test.ts` + anchored to the contract source (schema JSON enum where available, + structural assertions against contract types elsewhere). Drift = CI red. +3. **Delta-listed**: all mirrors are scheduled for deletion in the beta.5 + delta scope when the contract exports runtime equivalents. + +Pattern precedent: #10 MAX_FRAME_BYTES alias + drift test → F1 fixtures +test-only import → F3 systematic contract-mirror. + +**beta.5 delta scope** (accumulated): +- (a) from F1: Promote disposition fixtures to public conformance export +- (b) from F1: Add reserved-method fixture vectors +- (c) from F3: codegen "schema enum → runtime const array" for MessagingErrorCode +- (d) from F3: Delete `contract-mirror.ts` — replace with public contract imports + +### Consumption path (F1/F3 method — applies to S1 and future slices) + +SDK production code imports from `@clowder-ai/plugin-contract` public +barrel and `./contract-mirror.js` (for type-level-only constraints). +Test files may use relative imports to contract source when the needed +symbol is not part of the published surface. Typecheck uses +`tsconfig.build.json` (production code) + `tsconfig.test.json` (test +files, separate due to rootDir constraint from cross-package relative +imports). Both are run by `pnpm typecheck`. ## Branch / Gate / Handoff diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 70678b5..0965f4b 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -21,7 +21,7 @@ "scripts": { "build": "pnpm clean && tsc -p tsconfig.build.json", "typecheck": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.test.json", - "test": "pnpm --filter @clowder-ai/plugin-contract build && pnpm build && node --import tsx --test src/stdio-runtime.test.ts src/wire-dispatch.test.ts", + "test": "pnpm --filter @clowder-ai/plugin-contract build && pnpm build && node --import tsx --test src/stdio-runtime.test.ts src/wire-dispatch.test.ts src/contract-mirror.test.ts", "lint": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.test.json", "prepack": "pnpm build", "clean": "rm -rf dist" diff --git a/packages/plugin-sdk/src/contract-mirror.test.ts b/packages/plugin-sdk/src/contract-mirror.test.ts new file mode 100644 index 0000000..b8e93ce --- /dev/null +++ b/packages/plugin-sdk/src/contract-mirror.test.ts @@ -0,0 +1,217 @@ +/** + * Drift-prevention tests for contract-mirror.ts. + * + * Every constant in contract-mirror.ts mirrors a contract type-level + * constraint that lacks a runtime export. These tests anchor each + * mirror to its contract source and fail if they drift apart. + * + * Automated drift tests use test-only relative imports to read contract + * source data (schema JSON, TypeScript constants). These imports are + * excluded from the SDK dist artifact (test files not in tsconfig.build). + * + * Fable ruling (S1 R3 contract seam): systematic drift prevention for + * all contract mirrors. Pattern precedent: #10 MAX_FRAME_BYTES. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + MESSAGING_ERROR_CODES, + MESSAGING_ERROR_CODE_SET, + RESPONSE_SUCCESS_KEYS, + RESPONSE_ERROR_KEYS, + NOTIFICATION_ALLOWED_KEYS, + REQUEST_ALLOWED_KEYS, + PARAMS_ALLOWED_KEYS, + META_ALLOWED_KEYS, + PING_INPUT_KEYS, + DRAIN_INPUT_KEYS, + SUBSCRIBE_INPUT_KEYS, + ACK_INPUT_KEYS, + GRANTS_CHANGED_INPUT_KEYS, + PING_RESULT_KEYS, + SUBSCRIBE_RESULT_KEYS, + ERROR_BODY_STANDARD_KEYS, + ERROR_BODY_APPLICATION_KEYS, + REASON_DATA_KEYS, + CODE_DATA_KEYS, +} from './contract-mirror.js'; + +// --------------------------------------------------------------------------- +// Schema JSON source path (test-only) +// --------------------------------------------------------------------------- + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MESSAGING_SCHEMA_PATH = resolve( + __dirname, + '../../plugin-contract/src/schemas/messaging.schema.json', +); + +// --------------------------------------------------------------------------- +// Automated drift test: MessagingErrorCode enum +// --------------------------------------------------------------------------- + +test('MESSAGING_ERROR_CODES matches messaging.schema.json enum exactly', () => { + const schemaRaw = readFileSync(MESSAGING_SCHEMA_PATH, 'utf8'); + const schema = JSON.parse(schemaRaw) as { + $defs: { + MessagingErrorCode: { type: string; enum: string[] }; + }; + }; + + const schemaEnum = schema.$defs.MessagingErrorCode.enum; + + assert.ok( + Array.isArray(schemaEnum), + 'schema must define MessagingErrorCode.enum as array', + ); + + // Exact member match (same values, same order) + assert.deepEqual( + [...MESSAGING_ERROR_CODES], + schemaEnum, + 'MESSAGING_ERROR_CODES must match schema enum exactly (values + order). ' + + 'If this fails, update contract-mirror.ts to match the schema.', + ); +}); + +test('MESSAGING_ERROR_CODE_SET has same cardinality as array', () => { + assert.equal( + MESSAGING_ERROR_CODE_SET.size, + MESSAGING_ERROR_CODES.length, + 'Set and array must have same size (no duplicates in array)', + ); +}); + +// --------------------------------------------------------------------------- +// Structural drift tests: envelope key sets +// +// These verify the key sets have the expected cardinality and members. +// When the contract adds/removes interface fields, update both the +// mirror constant and this test. The contract source file and line +// are documented in contract-mirror.ts for each constant. +// --------------------------------------------------------------------------- + +test('RESPONSE_SUCCESS_KEYS matches WireSuccessResponse interface', () => { + // WireSuccessResponse: { jsonrpc, id, result } + assert.equal(RESPONSE_SUCCESS_KEYS.size, 3); + assert.ok(RESPONSE_SUCCESS_KEYS.has('jsonrpc')); + assert.ok(RESPONSE_SUCCESS_KEYS.has('id')); + assert.ok(RESPONSE_SUCCESS_KEYS.has('result')); +}); + +test('RESPONSE_ERROR_KEYS matches WireErrorResponse interface', () => { + // WireApplicationErrorResponse / WireStandardErrorResponse: { jsonrpc, id, error } + assert.equal(RESPONSE_ERROR_KEYS.size, 3); + assert.ok(RESPONSE_ERROR_KEYS.has('jsonrpc')); + assert.ok(RESPONSE_ERROR_KEYS.has('id')); + assert.ok(RESPONSE_ERROR_KEYS.has('error')); +}); + +test('NOTIFICATION_ALLOWED_KEYS matches WireNotification interface', () => { + // WireNotification: { jsonrpc, method, params } + assert.equal(NOTIFICATION_ALLOWED_KEYS.size, 3); + assert.ok(NOTIFICATION_ALLOWED_KEYS.has('jsonrpc')); + assert.ok(NOTIFICATION_ALLOWED_KEYS.has('method')); + assert.ok(NOTIFICATION_ALLOWED_KEYS.has('params')); +}); + +test('REQUEST_ALLOWED_KEYS matches WireRequest interface', () => { + // WireRequest: { jsonrpc, id, method, params } + assert.equal(REQUEST_ALLOWED_KEYS.size, 4); + assert.ok(REQUEST_ALLOWED_KEYS.has('jsonrpc')); + assert.ok(REQUEST_ALLOWED_KEYS.has('id')); + assert.ok(REQUEST_ALLOWED_KEYS.has('method')); + assert.ok(REQUEST_ALLOWED_KEYS.has('params')); +}); + +test('PARAMS_ALLOWED_KEYS matches WireRequest.params / WireNotification.params', () => { + // params: { meta, input } + assert.equal(PARAMS_ALLOWED_KEYS.size, 2); + assert.ok(PARAMS_ALLOWED_KEYS.has('meta')); + assert.ok(PARAMS_ALLOWED_KEYS.has('input')); +}); + +test('META_ALLOWED_KEYS matches CallMeta interface', () => { + // CallMeta: { deadlineUnixMs } + assert.equal(META_ALLOWED_KEYS.size, 1); + assert.ok(META_ALLOWED_KEYS.has('deadlineUnixMs')); +}); + +// --------------------------------------------------------------------------- +// Structural drift tests: per-method input key sets +// --------------------------------------------------------------------------- + +test('PING_INPUT_KEYS matches PingInput interface', () => { + assert.equal(PING_INPUT_KEYS.size, 1); + assert.ok(PING_INPUT_KEYS.has('nonce')); +}); + +test('DRAIN_INPUT_KEYS matches DrainInput interface', () => { + assert.equal(DRAIN_INPUT_KEYS.size, 1); + assert.ok(DRAIN_INPUT_KEYS.has('deadlineUnixMs')); +}); + +test('SUBSCRIBE_INPUT_KEYS matches SubscribeInput interface', () => { + assert.equal(SUBSCRIBE_INPUT_KEYS.size, 1); + assert.ok(SUBSCRIBE_INPUT_KEYS.has('handle')); +}); + +test('ACK_INPUT_KEYS matches MessagingAckRequest interface', () => { + assert.equal(ACK_INPUT_KEYS.size, 2); + assert.ok(ACK_INPUT_KEYS.has('subscriptionId')); + assert.ok(ACK_INPUT_KEYS.has('ackToken')); +}); + +test('GRANTS_CHANGED_INPUT_KEYS matches GrantSnapshot interface', () => { + assert.equal(GRANTS_CHANGED_INPUT_KEYS.size, 2); + assert.ok(GRANTS_CHANGED_INPUT_KEYS.has('grantRevision')); + assert.ok(GRANTS_CHANGED_INPUT_KEYS.has('effectiveGrants')); +}); + +// --------------------------------------------------------------------------- +// Structural drift tests: per-method result key sets +// --------------------------------------------------------------------------- + +test('PING_RESULT_KEYS matches PingResult interface', () => { + assert.equal(PING_RESULT_KEYS.size, 1); + assert.ok(PING_RESULT_KEYS.has('nonce')); +}); + +test('SUBSCRIBE_RESULT_KEYS matches SubscribeResult interface', () => { + assert.equal(SUBSCRIBE_RESULT_KEYS.size, 1); + assert.ok(SUBSCRIBE_RESULT_KEYS.has('subscriptionId')); +}); + +// --------------------------------------------------------------------------- +// Structural drift tests: error body key sets +// --------------------------------------------------------------------------- + +test('ERROR_BODY_STANDARD_KEYS matches StandardWireError body', () => { + // Standard: { code, message } — no data + assert.equal(ERROR_BODY_STANDARD_KEYS.size, 2); + assert.ok(ERROR_BODY_STANDARD_KEYS.has('code')); + assert.ok(ERROR_BODY_STANDARD_KEYS.has('message')); +}); + +test('ERROR_BODY_APPLICATION_KEYS matches ApplicationWireError body', () => { + // Application: { code, message, data } + assert.equal(ERROR_BODY_APPLICATION_KEYS.size, 3); + assert.ok(ERROR_BODY_APPLICATION_KEYS.has('code')); + assert.ok(ERROR_BODY_APPLICATION_KEYS.has('message')); + assert.ok(ERROR_BODY_APPLICATION_KEYS.has('data')); +}); + +test('REASON_DATA_KEYS matches per-arm data: {reason}', () => { + assert.equal(REASON_DATA_KEYS.size, 1); + assert.ok(REASON_DATA_KEYS.has('reason')); +}); + +test('CODE_DATA_KEYS matches DomainError data: {code}', () => { + assert.equal(CODE_DATA_KEYS.size, 1); + assert.ok(CODE_DATA_KEYS.has('code')); +}); diff --git a/packages/plugin-sdk/src/contract-mirror.ts b/packages/plugin-sdk/src/contract-mirror.ts new file mode 100644 index 0000000..63e652b --- /dev/null +++ b/packages/plugin-sdk/src/contract-mirror.ts @@ -0,0 +1,165 @@ +/** + * Contract-mirror constants — runtime key sets and enum values that + * mirror @clowder-ai/plugin-contract TypeScript interfaces and types. + * + * These exist because the contract expresses certain closed constraints + * (additionalProperties: false, enum unions) only at the type level, + * with no runtime array/set export. + * + * ## DELETION TARGET + * + * Every constant in this module is scheduled for deletion when the + * contract exports a runtime equivalent (beta.5 delta scope, Fable + * ruling on S1 R3 contract seam). Each constant documents which + * contract source it mirrors. + * + * ## Drift prevention + * + * contract-mirror.test.ts verifies each constant against the contract + * source (schema JSON enum where available, structural assertions + * against contract types elsewhere). Drift = CI red. + * + * Pattern precedent: #10 MAX_FRAME_BYTES alias + drift test → Fable + * ruling 1 (fixtures test-only import) → this ruling (systematic). + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// Enum mirrors — contract has type-only unions, no runtime arrays +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * MessagingErrorCode runtime values. + * + * Mirror of: messaging.schema.json → definitions.MessagingErrorCode.enum + * Contract type: MessagingErrorCode (contract.generated.ts:217) + * Drift test: automated, vs schema JSON enum (exact member + order match) + */ +export const MESSAGING_ERROR_CODES = [ + 'VALIDATION', + 'PERMISSION', + 'NOT_FOUND', + 'CONFLICT', + 'RETRYABLE_INFLIGHT', + 'STALE_CURSOR', +] as const; + +export const MESSAGING_ERROR_CODE_SET = new Set(MESSAGING_ERROR_CODES); + +// ═══════════════════════════════════════════════════════════════════════════ +// Envelope outer key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: WireSuccessResponse interface (envelope.ts:108-113) + * Keys: jsonrpc, id, result — additionalProperties: false + */ +export const RESPONSE_SUCCESS_KEYS = new Set(['jsonrpc', 'id', 'result']); + +/** + * Mirror of: WireApplicationErrorResponse / WireStandardErrorResponse + * (envelope.ts:129-166). Keys: jsonrpc, id, error — additionalProperties: false + */ +export const RESPONSE_ERROR_KEYS = new Set(['jsonrpc', 'id', 'error']); + +/** + * Mirror of: WireNotification interface (envelope.ts:89-96) + * Keys: jsonrpc, method, params — additionalProperties: false + */ +export const NOTIFICATION_ALLOWED_KEYS = new Set(['jsonrpc', 'method', 'params']); + +/** + * Mirror of: WireRequest interface (envelope.ts:66-75) + * Keys: jsonrpc, id, method, params — additionalProperties: false + */ +export const REQUEST_ALLOWED_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Params / meta key sets (closed nested objects) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: WireRequest.params / WireNotification.params (envelope.ts:71-74, 93-95) + * Keys: meta, input — additionalProperties: false + */ +export const PARAMS_ALLOWED_KEYS = new Set(['meta', 'input']); + +/** + * Mirror of: CallMeta interface (envelope.ts:43-52) + * Keys: deadlineUnixMs — additionalProperties: false, v0 single field + */ +export const META_ALLOWED_KEYS = new Set(['deadlineUnixMs']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Per-method CLOSED-row input key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** Mirror of: PingInput (row-shapes.ts:144-147). Keys: {nonce} */ +export const PING_INPUT_KEYS = new Set(['nonce']); + +/** Mirror of: DrainInput (row-shapes.ts:183-193). Keys: {deadlineUnixMs} */ +export const DRAIN_INPUT_KEYS = new Set(['deadlineUnixMs']); + +/** Mirror of: SubscribeInput (row-shapes.ts:27-30). Keys: {handle} */ +export const SUBSCRIBE_INPUT_KEYS = new Set(['handle']); + +/** Mirror of: MessagingAckRequest (row-shapes.ts:76-81). Keys: {subscriptionId, ackToken} */ +export const ACK_INPUT_KEYS = new Set(['subscriptionId', 'ackToken']); + +/** Mirror of: GrantsChangedInput = GrantSnapshot (row-shapes.ts:128, grants.ts). Keys: {grantRevision, effectiveGrants} */ +export const GRANTS_CHANGED_INPUT_KEYS = new Set(['grantRevision', 'effectiveGrants']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Per-method CLOSED-row result key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** Mirror of: PingResult (row-shapes.ts:152-158). Keys: {nonce} */ +export const PING_RESULT_KEYS = new Set(['nonce']); + +/** Mirror of: SubscribeResult (row-shapes.ts:35-39). Keys: {subscriptionId} */ +export const SUBSCRIBE_RESULT_KEYS = new Set(['subscriptionId']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Error body key sets (closed per error variant) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: StandardWireError body keys (errors.ts:240-299) + * Standard errors: {code, message} — no data field. + */ +export const ERROR_BODY_STANDARD_KEYS = new Set(['code', 'message']); + +/** + * Mirror of: ApplicationWireError body keys (errors.ts:182-234) + * Application errors: {code, message, data} — data required. + */ +export const ERROR_BODY_APPLICATION_KEYS = new Set(['code', 'message', 'data']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Per-arm application error data key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: HandshakeRejectedError.data / DeliveryRejectedError.data / + * SnapshotUnavailableError.data — all {reason: } + */ +export const REASON_DATA_KEYS = new Set(['reason']); + +/** + * Mirror of: DomainError.data (errors.ts:205-206) + * Keys: {code: MessagingErrorCode} — additionalProperties: false + */ +export const CODE_DATA_KEYS = new Set(['code']); + +// ═══════════════════════════════════════════════════════════════════════════ +// NOTE: Constants NOT in this module (derived from contract imports) +// ═══════════════════════════════════════════════════════════════════════════ +// +// The following are built from contract-imported arrays, not mirrors: +// KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES) — from contract +// APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES) — from contract +// HANDSHAKE_REASONS = new Set(HANDSHAKE_REJECT_REASONS) — from contract +// DELIVERY_REASONS = new Set(DELIVERY_REJECT_REASONS) — from contract +// SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS) — from contract +// NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]) — from contract +// +// These stay in wire-dispatch.ts as performance wrappers, not mirrors. diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index f7c0caa..aa5ac15 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -621,6 +621,34 @@ test('domain_error with extra data key is T-H', () => { assert.equal(result.outcome, 'close'); }); +test('domain_error with unknown MessagingErrorCode is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"UNKNOWN_CODE"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'unknown MessagingErrorCode must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('domain_error with valid MessagingErrorCode is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid MessagingErrorCode must accept'); + assert.equal(result.outcome, 'accept'); +}); + test('domain_error with non-string code is T-H', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":42}}}'; const frame: DecodedNdjsonFrame = { diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 7b4c092..785fb3b 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -165,47 +165,43 @@ function accept(disposition: DispositionClass): DispatchResult { } // --------------------------------------------------------------------------- -// Closed key-set constants (additionalProperties: false enforcement) +// Contract-mirror imports (key sets from contract-mirror.ts) +// +// These mirror contract type-level constraints (additionalProperties: false) +// that lack runtime exports. Each is drift-tested in contract-mirror.test.ts. +// See contract-mirror.ts for deletion schedule and anchoring. // --------------------------------------------------------------------------- -/** Outer envelope: WireSuccessResponse = {jsonrpc, id, result}. */ -const RESPONSE_SUCCESS_KEYS = new Set(['jsonrpc', 'id', 'result']); -/** Outer envelope: WireErrorResponse = {jsonrpc, id, error}. */ -const RESPONSE_ERROR_KEYS = new Set(['jsonrpc', 'id', 'error']); -/** Outer envelope: WireNotification = {jsonrpc, method, params}. */ -const NOTIFICATION_ALLOWED_KEYS = new Set(['jsonrpc', 'method', 'params']); -/** Outer envelope: WireRequest = {jsonrpc, id, method, params}. */ -const REQUEST_ALLOWED_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']); - -/** params object: {meta, input} — closed per WireRequest/WireNotification. */ -const PARAMS_ALLOWED_KEYS = new Set(['meta', 'input']); -/** meta object: CallMeta = {deadlineUnixMs} — closed per envelope.ts. */ -const META_ALLOWED_KEYS = new Set(['deadlineUnixMs']); - -// Per-method CLOSED-row input key sets (additionalProperties: false). -const PING_INPUT_KEYS = new Set(['nonce']); -const DRAIN_INPUT_KEYS = new Set(['deadlineUnixMs']); -const SUBSCRIBE_INPUT_KEYS = new Set(['handle']); -const ACK_INPUT_KEYS = new Set(['subscriptionId', 'ackToken']); -const GRANTS_CHANGED_INPUT_KEYS = new Set(['grantRevision', 'effectiveGrants']); - -// Per-method CLOSED-row result key sets (additionalProperties: false). -const PING_RESULT_KEYS = new Set(['nonce']); -const SUBSCRIBE_RESULT_KEYS = new Set(['subscriptionId']); - -// Error body closed key sets. -const ERROR_BODY_STANDARD_KEYS = new Set(['code', 'message']); -const ERROR_BODY_APPLICATION_KEYS = new Set(['code', 'message', 'data']); - -// Error code validation sets (built once from contract arrays). +import { + MESSAGING_ERROR_CODE_SET, + RESPONSE_SUCCESS_KEYS, + RESPONSE_ERROR_KEYS, + NOTIFICATION_ALLOWED_KEYS, + REQUEST_ALLOWED_KEYS, + PARAMS_ALLOWED_KEYS, + META_ALLOWED_KEYS, + PING_INPUT_KEYS, + DRAIN_INPUT_KEYS, + SUBSCRIBE_INPUT_KEYS, + ACK_INPUT_KEYS, + GRANTS_CHANGED_INPUT_KEYS, + PING_RESULT_KEYS, + SUBSCRIBE_RESULT_KEYS, + ERROR_BODY_STANDARD_KEYS, + ERROR_BODY_APPLICATION_KEYS, + REASON_DATA_KEYS, + CODE_DATA_KEYS, +} from './contract-mirror.js'; + +// --------------------------------------------------------------------------- +// Derived constants (built from contract runtime imports, NOT mirrors) +// --------------------------------------------------------------------------- + +// Error code validation sets (built from contract arrays). const KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES); const APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES); -// Per-arm application error data key sets (additionalProperties: false). -const REASON_DATA_KEYS = new Set(['reason']); -const CODE_DATA_KEYS = new Set(['code']); - -// Per-arm reason enum sets (built once from contract arrays). +// Reason enum sets (built from contract arrays). const HANDSHAKE_REASONS = new Set(HANDSHAKE_REJECT_REASONS); const DELIVERY_REASONS = new Set(DELIVERY_REJECT_REASONS); const SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS); @@ -352,13 +348,14 @@ function validateApplicationErrorData( } case DOMAIN_ERROR_CODE: { - // data: { code: MessagingErrorCode } — closed keys - // NOTE: MessagingErrorCode enum validation deferred — no runtime - // array in contract public surface. Validates structure only. + // data: { code: MessagingErrorCode } — closed keys + enum + // MESSAGING_ERROR_CODE_SET from contract-mirror.ts (drift-tested + // against messaging.schema.json enum, Fable ruling on R3 seam). for (const key of Object.keys(data)) { if (!CODE_DATA_KEYS.has(key)) return close('T-H'); } if (typeof data.code !== 'string') return close('T-H'); + if (!MESSAGING_ERROR_CODE_SET.has(data.code)) return close('T-H'); return null; } From fb94bc0f5f2db4ae0d0e3a01042ab4d5bf3013fe Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 22:00:07 +0800 Subject: [PATCH 06/13] fix(plugin-sdk): R4 deliver oracle fail-closed + compile-time drift anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol R4 findings: 1. RESERVED row 9 (host.messaging.deliver) oracle was fail-open: when requestSnapshot was missing or had no deliveryId, validateReservedRowOracle returned null (accept) — any deliveryId in a deliver response got T-L. Fix: require requestSnapshot.deliveryId for deliver method — missing → T-H. Mirrors the ping oracle fail-closed pattern from R3. 2. Key-set drift tests only re-asserted mirror's own hardcoded literals. Contract interface changes (e.g., adding a field to CallMeta) passed green. Fix: add compile-time ExactKeys type assertions anchored to 17 contract interfaces via public barrel + test-only relative imports. If contract adds/removes a field, tsc -p tsconfig.test.json fails before runtime tests even run. Together with runtime cardinality/member checks, any single-party change (contract OR mirror) is caught. Tests: 118/118 pass. Contract diff: 0. Typecheck: green. Co-Authored-By: Claude Opus 4.6 --- .../plugin-sdk/src/contract-mirror.test.ts | 110 ++++++++++++++++-- packages/plugin-sdk/src/wire-dispatch.test.ts | 34 ++++++ packages/plugin-sdk/src/wire-dispatch.ts | 19 ++- 3 files changed, 153 insertions(+), 10 deletions(-) diff --git a/packages/plugin-sdk/src/contract-mirror.test.ts b/packages/plugin-sdk/src/contract-mirror.test.ts index b8e93ce..a89e4b9 100644 --- a/packages/plugin-sdk/src/contract-mirror.test.ts +++ b/packages/plugin-sdk/src/contract-mirror.test.ts @@ -5,9 +5,22 @@ * constraint that lacks a runtime export. These tests anchor each * mirror to its contract source and fail if they drift apart. * + * TWO-LAYER DRIFT DETECTION: + * + * 1. **Compile-time**: ExactKeys type assertions + * fail at `tsc -p tsconfig.test.json` time when the contract interface + * adds or removes a field. No runtime cost. + * + * 2. **Runtime**: each test verifies the mirror Set has the expected + * cardinality and members. Catches mirror literal drift. + * + * Together they triangulate: any single-party change (contract OR mirror) + * is caught. Only coordinated updates to both pass green — which is the + * intended outcome (mirror updated to match the contract). + * * Automated drift tests use test-only relative imports to read contract - * source data (schema JSON, TypeScript constants). These imports are - * excluded from the SDK dist artifact (test files not in tsconfig.build). + * source data (schema JSON, TypeScript types). These imports are excluded + * from the SDK dist artifact (test files not in tsconfig.build). * * Fable ruling (S1 R3 contract seam): systematic drift prevention for * all contract mirrors. Pattern precedent: #10 MAX_FRAME_BYTES. @@ -41,6 +54,88 @@ import { CODE_DATA_KEYS, } from './contract-mirror.js'; +// --------------------------------------------------------------------------- +// Contract type imports — compile-time drift anchors +// --------------------------------------------------------------------------- + +// Public barrel: types available in published @clowder-ai/plugin-contract +import type { + CallMeta, + WireSuccessResponse, + ParseErrorEnvelope, + PingInput, + PingResult, + DrainInput, + SubscribeInput, + SubscribeResult, + MessagingAckRequest, + GrantsChangedInput, + ParseError, + HandshakeRejectedError, + DomainError, +} from '@clowder-ai/plugin-contract'; + +// Test-only relative import: generic envelopes NOT in public barrel (Q1 ruling). +// These types are the sole source for REQUEST_ALLOWED_KEYS, NOTIFICATION_ALLOWED_KEYS, +// and PARAMS_ALLOWED_KEYS. Safe: test files excluded from SDK dist artifact. +import type { + WireRequest, + WireNotification, +} from '../../plugin-contract/src/wire/envelope.js'; + +// --------------------------------------------------------------------------- +// Compile-time drift detection: ExactKeys type utility +// --------------------------------------------------------------------------- + +/** + * Evaluates to `true` iff `keyof T` is exactly the string literal union K. + * Bidirectional: catches both additions and removals. + * + * If the contract interface changes, the const assignment `const _: ExactKeys<...> = true` + * fails at tsc time with "Type 'false' is not assignable to type 'true'". + * + * Tuple wrapping [A] extends [B] prevents union distribution. + */ +type ExactKeys = + [keyof T & string] extends [K] ? [K] extends [keyof T & string] ? true : false : false; + +// Envelope outer key sets +const _driftSuccessResp: ExactKeys, 'jsonrpc' | 'id' | 'result'> = true; +const _driftErrorResp: ExactKeys = true; +const _driftRequest: ExactKeys = true; +const _driftNotification: ExactKeys = true; + +// Nested params / meta +const _driftParams: ExactKeys = true; +const _driftMeta: ExactKeys = true; + +// Per-method CLOSED-row input shapes +const _driftPingIn: ExactKeys = true; +const _driftDrainIn: ExactKeys = true; +const _driftSubIn: ExactKeys = true; +const _driftAckIn: ExactKeys = true; +const _driftGrantsIn: ExactKeys = true; + +// Per-method CLOSED-row result shapes +const _driftPingRes: ExactKeys = true; +const _driftSubRes: ExactKeys = true; + +// Error body key sets +const _driftStdErr: ExactKeys = true; +const _driftAppErr: ExactKeys = true; + +// Per-arm application error data +const _driftReasonData: ExactKeys = true; +const _driftCodeData: ExactKeys = true; + +// Suppress "unused" — these are compile-time-only sentinels. +void _driftSuccessResp; void _driftErrorResp; void _driftRequest; void _driftNotification; +void _driftParams; void _driftMeta; +void _driftPingIn; void _driftDrainIn; void _driftSubIn; void _driftAckIn; void _driftGrantsIn; +void _driftPingRes; void _driftSubRes; +void _driftStdErr; void _driftAppErr; +void _driftReasonData; void _driftCodeData; + // --------------------------------------------------------------------------- // Schema JSON source path (test-only) // --------------------------------------------------------------------------- @@ -88,12 +183,13 @@ test('MESSAGING_ERROR_CODE_SET has same cardinality as array', () => { }); // --------------------------------------------------------------------------- -// Structural drift tests: envelope key sets +// Runtime drift tests: envelope key sets // -// These verify the key sets have the expected cardinality and members. -// When the contract adds/removes interface fields, update both the -// mirror constant and this test. The contract source file and line -// are documented in contract-mirror.ts for each constant. +// These verify the mirror Set has the expected cardinality and members. +// Compile-time ExactKeys assertions (above) anchor each key set to the +// contract interface — if the contract adds/removes a field, tsc fails +// before these runtime tests even run. These runtime tests catch mirror +// literal drift (someone changes the Set without updating ExactKeys). // --------------------------------------------------------------------------- test('RESPONSE_SUCCESS_KEYS matches WireSuccessResponse interface', () => { diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index aa5ac15..e1ea8a6 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -724,6 +724,40 @@ test('ping success with valid shape but no oracle snapshot is T-H (fail-closed)' assert.equal(result.outcome, 'close'); }); +// --------------------------------------------------------------------------- +// RESERVED row 9 (deliver) oracle fail-closed regression (Sol R4 F1) +// --------------------------------------------------------------------------- + +test('deliver response with missing snapshot is T-H (fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"abc"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // In-flight entry for deliver with NO requestSnapshot + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'missing deliver snapshot must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver response with empty snapshot (no deliveryId) is T-H (fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"d2","result":{"deliveryId":"abc"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // In-flight entry for deliver with snapshot but no deliveryId field + const inFlight = new Map([ + ['d2', { method: 'host.messaging.deliver', requestSnapshot: {} }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'empty deliver snapshot must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + test('ping success with snapshot nonce present is T-L (oracle pass)', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"hello"}}'; const frame: DecodedNdjsonFrame = { diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 785fb3b..b54a348 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -491,9 +491,22 @@ function validateReservedRowOracle( result: unknown, entry: InFlightEntry, ): DispatchResult | null { + // Method-specific oracle requirements: row 9 (host.messaging.deliver) + // requires deliveryId snapshot — fail-closed if absent. + // Mirrors the ping oracle fail-closed pattern (R3 fix). + if (entry.method === 'host.messaging.deliver') { + if (entry.requestSnapshot?.deliveryId === undefined) return close('T-H'); + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return close('T-H'); + } + const resultObj = result as Record; + if (resultObj.deliveryId !== entry.requestSnapshot.deliveryId) return close('T-H'); + return null; + } + + // Other RESERVED rows: oracle check only when snapshot has fields. if (entry.requestSnapshot === undefined) return null; - // Oracle fields require result to be an object const hasOracleField = entry.requestSnapshot.nonce !== undefined || entry.requestSnapshot.deliveryId !== undefined; @@ -504,12 +517,12 @@ function validateReservedRowOracle( } const resultObj = result as Record; - // Nonce byte-equality (ping oracle — carried forward for RESERVED rows) + // Nonce byte-equality if (entry.requestSnapshot.nonce !== undefined) { if (resultObj.nonce !== entry.requestSnapshot.nonce) return close('T-H'); } - // DeliveryId byte-equality (deliver oracle — row 9) + // DeliveryId byte-equality if (entry.requestSnapshot.deliveryId !== undefined) { if (resultObj.deliveryId !== entry.requestSnapshot.deliveryId) return close('T-H'); } From fdea0b1790ef23d44d8daeb7414713b16fb9078b Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 22:09:50 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(plugin-sdk):=20complete=20drift=20mat?= =?UTF-8?q?rix=20=E2=80=94=20all=20shared=20mirrors=20anchor=20every=20arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol R5 finding: compile-time drift assertions only anchored one representative type per shared mirror constant. Mutation proof showed adding a field to DeliveryRejectedError.data passed green because REASON_DATA_KEYS only checked HandshakeRejectedError.data. Fix: expand ExactKeys matrix from 17 to 38 assertions, covering every independent contract type that each shared mirror serves: - RESPONSE_ERROR_KEYS: all 11 concrete error envelopes (was 1) - ERROR_BODY_STANDARD_KEYS: all 5 standard error arms (was 1) - ERROR_BODY_APPLICATION_KEYS: all 5 application error arms (was 1) - REASON_DATA_KEYS: all 3 reason-bearing data arms (was 1) - PARAMS_ALLOWED_KEYS: both Request and Notification params (was 1) Mutation proof: DeliveryRejectedError.data + mutationProbe field now correctly fails _d36 at tsc time (previously passed green). Tests: 118/118 pass. Contract diff: 0. Typecheck: green. Co-Authored-By: Claude Opus 4.6 --- .../plugin-sdk/src/contract-mirror.test.ts | 125 +++++++++++++----- 1 file changed, 90 insertions(+), 35 deletions(-) diff --git a/packages/plugin-sdk/src/contract-mirror.test.ts b/packages/plugin-sdk/src/contract-mirror.test.ts index a89e4b9..f591fed 100644 --- a/packages/plugin-sdk/src/contract-mirror.test.ts +++ b/packages/plugin-sdk/src/contract-mirror.test.ts @@ -58,11 +58,25 @@ import { // Contract type imports — compile-time drift anchors // --------------------------------------------------------------------------- -// Public barrel: types available in published @clowder-ai/plugin-contract +// Public barrel: types available in published @clowder-ai/plugin-contract. +// Every contract type that a shared mirror constant serves is imported +// here — not just one representative per group (Sol R5 matrix closure). import type { CallMeta, WireSuccessResponse, + // Concrete error envelopes — all 11 variants for RESPONSE_ERROR_KEYS ParseErrorEnvelope, + HandshakeRejectedEnvelope, + DeliveryRejectedEnvelope, + DomainErrorEnvelope, + DeadlineExpiredEnvelope, + SnapshotUnavailableEnvelope, + InvalidRequestNullIdEnvelope, + InvalidRequestValidIdEnvelope, + MethodNotFoundEnvelope, + InvalidParamsEnvelope, + InternalErrorEnvelope, + // Per-method CLOSED-row input/result shapes PingInput, PingResult, DrainInput, @@ -70,9 +84,18 @@ import type { SubscribeResult, MessagingAckRequest, GrantsChangedInput, + // Standard error body types — all 5 for ERROR_BODY_STANDARD_KEYS ParseError, + InvalidRequestError, + MethodNotFoundError, + InvalidParamsError, + InternalError, + // Application error body types — all 5 for ERROR_BODY_APPLICATION_KEYS HandshakeRejectedError, + DeliveryRejectedError, DomainError, + DeadlineExpiredError, + SnapshotUnavailableError, } from '@clowder-ai/plugin-contract'; // Test-only relative import: generic envelopes NOT in public barrel (Q1 ruling). @@ -99,42 +122,74 @@ import type { type ExactKeys = [keyof T & string] extends [K] ? [K] extends [keyof T & string] ? true : false : false; -// Envelope outer key sets -const _driftSuccessResp: ExactKeys, 'jsonrpc' | 'id' | 'result'> = true; -const _driftErrorResp: ExactKeys = true; -const _driftRequest: ExactKeys = true; -const _driftNotification: ExactKeys = true; - -// Nested params / meta -const _driftParams: ExactKeys = true; -const _driftMeta: ExactKeys = true; - -// Per-method CLOSED-row input shapes -const _driftPingIn: ExactKeys = true; -const _driftDrainIn: ExactKeys = true; -const _driftSubIn: ExactKeys = true; -const _driftAckIn: ExactKeys = true; -const _driftGrantsIn: ExactKeys = true; - -// Per-method CLOSED-row result shapes -const _driftPingRes: ExactKeys = true; -const _driftSubRes: ExactKeys = true; - -// Error body key sets -const _driftStdErr: ExactKeys = true; -const _driftAppErr: ExactKeys = true; - -// Per-arm application error data -const _driftReasonData: ExactKeys = true; -const _driftCodeData: ExactKeys = true; +// ── RESPONSE_SUCCESS_KEYS ── (1 type: WireSuccessResponse) +const _d01: ExactKeys, 'jsonrpc' | 'id' | 'result'> = true; + +// ── RESPONSE_ERROR_KEYS ── (11 concrete error envelopes, all must match) +const _d02: ExactKeys = true; +const _d03: ExactKeys = true; +const _d04: ExactKeys = true; +const _d05: ExactKeys = true; +const _d06: ExactKeys = true; +const _d07: ExactKeys = true; +const _d08: ExactKeys = true; +const _d09: ExactKeys = true; +const _d10: ExactKeys = true; +const _d11: ExactKeys = true; +const _d12: ExactKeys = true; + +// ── REQUEST_ALLOWED_KEYS / NOTIFICATION_ALLOWED_KEYS ── +const _d13: ExactKeys = true; +const _d14: ExactKeys = true; + +// ── PARAMS_ALLOWED_KEYS ── (both Request and Notification params) +const _d15: ExactKeys = true; +const _d16: ExactKeys = true; + +// ── META_ALLOWED_KEYS ── +const _d17: ExactKeys = true; + +// ── Per-method CLOSED-row input shapes (1:1, no shared mirrors) ── +const _d18: ExactKeys = true; +const _d19: ExactKeys = true; +const _d20: ExactKeys = true; +const _d21: ExactKeys = true; +const _d22: ExactKeys = true; + +// ── Per-method CLOSED-row result shapes (1:1, no shared mirrors) ── +const _d23: ExactKeys = true; +const _d24: ExactKeys = true; + +// ── ERROR_BODY_STANDARD_KEYS ── (all 5 standard error arms) +const _d25: ExactKeys = true; +const _d26: ExactKeys = true; +const _d27: ExactKeys = true; +const _d28: ExactKeys = true; +const _d29: ExactKeys = true; + +// ── ERROR_BODY_APPLICATION_KEYS ── (all 5 application error arms) +const _d30: ExactKeys = true; +const _d31: ExactKeys = true; +const _d32: ExactKeys = true; +const _d33: ExactKeys = true; +const _d34: ExactKeys = true; + +// ── REASON_DATA_KEYS ── (all 3 reason-bearing error data arms) +const _d35: ExactKeys = true; +const _d36: ExactKeys = true; +const _d37: ExactKeys = true; + +// ── CODE_DATA_KEYS ── (1 type: DomainError.data) +const _d38: ExactKeys = true; // Suppress "unused" — these are compile-time-only sentinels. -void _driftSuccessResp; void _driftErrorResp; void _driftRequest; void _driftNotification; -void _driftParams; void _driftMeta; -void _driftPingIn; void _driftDrainIn; void _driftSubIn; void _driftAckIn; void _driftGrantsIn; -void _driftPingRes; void _driftSubRes; -void _driftStdErr; void _driftAppErr; -void _driftReasonData; void _driftCodeData; +void _d01; void _d02; void _d03; void _d04; void _d05; void _d06; +void _d07; void _d08; void _d09; void _d10; void _d11; void _d12; +void _d13; void _d14; void _d15; void _d16; void _d17; void _d18; +void _d19; void _d20; void _d21; void _d22; void _d23; void _d24; +void _d25; void _d26; void _d27; void _d28; void _d29; void _d30; +void _d31; void _d32; void _d33; void _d34; void _d35; void _d36; +void _d37; void _d38; // --------------------------------------------------------------------------- // Schema JSON source path (test-only) From 6d656f1c31d93f19f07427cdced6daec019c46df Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 22:31:39 +0800 Subject: [PATCH 08/13] fix(plugin-sdk): add direction gate + non-scalar string detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex cloud review findings on PR #13: P1: Direction gate — plugin SDK did not enforce method direction. A host could send plugin-to-host methods (e.g. messaging.subscribe) with valid params and receive an accept disposition. Fix: add direction check (`row.direction === 'plugin-to-host'` → T-F MethodNotFound) positioned after all envelope/value checks so that in-flight collision (T-I) and RESERVED row rejection (T-G) take precedence per contract fixtures. Only CLOSED plugin-to-host rows with valid params reach it. P2: Non-scalar strings — T-C predicate explicitly includes "non-scalar strings" but byte-equality alone cannot catch lone surrogates (they roundtrip through JSON.stringify as \uXXXX escapes). Fix: add containsNonScalarString() deep traversal that detects lone surrogates (U+D800–U+DFFF) in all string values and object keys after the byte-equality check. 4 new tests. Tests: 122/122 pass. Contract diff: 0. Typecheck: green. Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/src/wire-dispatch.test.ts | 63 +++++++++++++++++++ packages/plugin-sdk/src/wire-dispatch.ts | 58 ++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index e1ea8a6..ec2ace2 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -183,6 +183,69 @@ test('reserved method with valid envelope is T-G (input type never → value vio assert.ok(result.response !== undefined); }); +// --------------------------------------------------------------------------- +// Direction gate: plugin SDK rejects inbound plugin-to-host methods +// (codex R1 P1 — CLOSED plugin-to-host row with valid params) +// --------------------------------------------------------------------------- + +test('CLOSED plugin-to-host method (messaging.subscribe) with valid input is T-F (direction gate)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"s1","method":"messaging.subscribe","params":{"meta":{"deadlineUnixMs":1},"input":{"handle":"chan"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + // Plugin SDK does not serve plugin-to-host methods — T-F MethodNotFound. + // Direction gate runs after all envelope/value checks; only CLOSED rows + // with valid params reach it (RESERVED rows hit T-G first). + assert.equal(result.disposition, 'T-F'); + assert.equal(result.outcome, 'respond'); + assert.ok(result.response !== undefined); +}); + +test('CLOSED plugin-to-host method (messaging.ack) with valid input is T-F (direction gate)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"a1","method":"messaging.ack","params":{"meta":{"deadlineUnixMs":1},"input":{"subscriptionId":"sub-1","ackToken":"tok-1"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-F'); + assert.equal(result.outcome, 'respond'); +}); + +// --------------------------------------------------------------------------- +// T-C canonicality: non-scalar strings (lone surrogates) +// (codex R1 P2 — JSON.stringify roundtrips surrogates, byte-equality passes) +// --------------------------------------------------------------------------- + +test('frame with lone high surrogate in string value is T-C (non-scalar string)', () => { + // \ud800 is a lone high surrogate — not a valid Unicode scalar value. + // JSON.parse produces U+D800, JSON.stringify escapes it back to \ud800, + // so byte-equality passes. The non-scalar string check catches it. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"\\ud800"}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'lone surrogate must be rejected as non-scalar string'); + assert.equal(result.outcome, 'close'); +}); + +test('frame with lone low surrogate in object key is T-C (non-scalar string)', () => { + const rawFrame = '{"jsonrpc":"2.0","\\udcba":"extra"}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'lone surrogate in key must be rejected'); + assert.equal(result.outcome, 'close'); +}); + // --------------------------------------------------------------------------- // Fail-closed anti-examples — each proves a specific fail-open gap is sealed. // These are the independent refutation vectors from the R1 review. diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index b54a348..41988ad 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -164,6 +164,48 @@ function accept(disposition: DispositionClass): DispatchResult { return { disposition, outcome: 'accept' }; } +// --------------------------------------------------------------------------- +// Non-scalar string detection (T-C canonicality, lone surrogate check) +// --------------------------------------------------------------------------- + +/** + * Returns true if any string value or object key in the parsed JSON tree + * contains a lone surrogate (U+D800–U+DFFF). These are non-scalar strings + * per the Unicode specification and fail the T-C canonicality predicate. + * + * Lone surrogates roundtrip through JSON.stringify (ES2019+ escapes them + * as \uXXXX), so byte-equality alone cannot catch them. + */ +function containsNonScalarString(value: unknown): boolean { + if (typeof value === 'string') { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c >= 0xD800 && c <= 0xDBFF) { + // High surrogate — must be followed by a low surrogate (U+DC00–U+DFFF) + const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0; + if (next < 0xDC00 || next > 0xDFFF) return true; + i++; // Skip the valid low surrogate pair partner + } else if (c >= 0xDC00 && c <= 0xDFFF) { + // Lone low surrogate (not preceded by a high surrogate) + return true; + } + } + return false; + } + if (value === null || typeof value !== 'object') return false; + if (Array.isArray(value)) { + for (const item of value) { + if (containsNonScalarString(item)) return true; + } + return false; + } + for (const key of Object.keys(value as Record)) { + if (containsNonScalarString(key)) return true; + if (containsNonScalarString((value as Record)[key])) return true; + } + return false; +} + // --------------------------------------------------------------------------- // Contract-mirror imports (key sets from contract-mirror.ts) // @@ -682,7 +724,17 @@ function classifyRequest( return respondInvalidParamsValue(id); } - // All checks passed — valid CLOSED-row request for dispatch + // Direction gate: plugin SDK only accepts host-to-plugin methods as + // inbound requests. Plugin-to-host methods (rows 1–8) received by + // the plugin are protocol violations → T-F MethodNotFound. + // + // Positioned after all envelope/value checks so that: + // - In-flight collision (T-I) takes precedence (per contract fixtures) + // - RESERVED row rejection (T-G) takes precedence (input type `never`) + // - Only CLOSED plugin-to-host rows with valid params reach here + if (row.direction === 'plugin-to-host') return respondMethodNotFound(id); + + // All checks passed — valid host-to-plugin CLOSED-row request for dispatch return { disposition: null, outcome: 'accept' }; } @@ -780,9 +832,13 @@ export function classifyFrame( const { raw, value } = frame; // ── T-C: canonicality check ────────────────────────────────────────── + // The T-C predicate covers: whitespace, duplicate keys, non-scalar + // strings, and non-canonical numbers. Byte-equality catches all but + // non-scalar strings (lone surrogates roundtrip through JSON.stringify). const rawStr = new TextDecoder('utf-8', { fatal: true }).decode(raw); const canonical = JSON.stringify(value); if (rawStr !== canonical) return close('T-C'); + if (containsNonScalarString(value)) return close('T-C'); // ── Response candidate detection ───────────────────────────────────── const hasMethod = 'method' in value; From 9fdcf894f7eb9d9ff8ae81b2ac5ad58d3c812220 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 22:38:08 +0800 Subject: [PATCH 09/13] fix(plugin-sdk): resolve CI typecheck for self-reference import CI runs SDK typecheck before SDK build, so dist/ does not exist. stdio-runtime.test.ts imports from @clowder-ai/plugin-sdk (self- reference), causing TS2307. Add paths mapping in tsconfig.test.json to resolve the self-reference to ./src/index.ts at type-check time. Verified: typecheck passes with dist/ deleted; 122/122 tests green. Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/tsconfig.test.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/plugin-sdk/tsconfig.test.json b/packages/plugin-sdk/tsconfig.test.json index 4c4a6bb..60ed0dc 100644 --- a/packages/plugin-sdk/tsconfig.test.json +++ b/packages/plugin-sdk/tsconfig.test.json @@ -8,7 +8,10 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "verbatimModuleSyntax": true, - "noEmit": true + "noEmit": true, + "paths": { + "@clowder-ai/plugin-sdk": ["./src/index.ts"] + } }, "include": ["src/**/*.test.ts"], "exclude": ["node_modules", "dist"] From 3c3dafa592fdb29a53eeea5da6281847af48a501 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 22:56:56 +0800 Subject: [PATCH 10/13] fix(plugin-sdk): T-C catches BOM-prefixed frames and exponent-form numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex R2 findings: P2-1 (BOM): TextDecoder default strips UTF-8 BOM, so a BOM-prefixed frame passes byte-equality. Fix: ignoreBOM:true keeps BOM visible in rawStr, causing the expected mismatch → T-C close. P2-2 (exponent): V8's JSON.stringify serialises numbers ≥10^21 in exponent notation (e.g. 1e+21) which roundtrips identically, so byte-equality passes. Fix: containsExponentNumber() deep-traversal detects any number whose String() form contains 'e' → T-C close. This aligns with WireUInt53's raw decimal-digit-only profile. Tests: 122 → 125 (3 new: BOM, V8-canonical exponent, non-V8 exponent). Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/src/wire-dispatch.test.ts | 57 +++++++++++++++++++ packages/plugin-sdk/src/wire-dispatch.ts | 41 ++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index ec2ace2..ab6668e 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -246,6 +246,63 @@ test('frame with lone low surrogate in object key is T-C (non-scalar string)', ( assert.equal(result.outcome, 'close'); }); +// --------------------------------------------------------------------------- +// T-C canonicality: BOM-prefixed frame +// (codex R2 P2-1 — TextDecoder default strips BOM, byte-equality passes) +// --------------------------------------------------------------------------- + +test('frame with UTF-8 BOM prefix is T-C (BOM is non-canonical)', () => { + // UTF-8 BOM (EF BB BF) is stripped by TextDecoder default (ignoreBOM:false). + // With ignoreBOM:true the BOM stays in rawStr, causing byte mismatch → T-C. + const json = '{"jsonrpc":"2.0","id":"a","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"x"}}}'; + const bomBytes = new Uint8Array([0xEF, 0xBB, 0xBF, ...Buffer.from(json)]); + const frame: DecodedNdjsonFrame = { + raw: bomBytes, + value: JSON.parse(json) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'BOM-prefixed frame must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// T-C canonicality: exponent-form numbers +// (codex R2 P2-2 — V8 JSON.stringify(1e+21) → "1e+21", byte-equality passes) +// --------------------------------------------------------------------------- + +test('frame with V8-canonical exponent-form number is T-C (non-canonical number)', () => { + // 1e+21 ≥ 10^21, so V8's JSON.stringify uses exponent notation "1e+21". + // Byte-equality passes because both raw and canonical have the same form. + // The containsExponentNumber check catches it — exponent form violates + // the WireUInt53 raw decimal-digit-only profile. + const json = '{"jsonrpc":"2.0","id":"a","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1e+21},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(json) as JsonObject; + // Sanity: byte-equality would pass without the exponent check + assert.equal(json, JSON.stringify(parsed), 'exponent form roundtrips through JSON'); + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(json, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'exponent-form number must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('frame with non-V8-canonical exponent form is already T-C via byte-equality', () => { + // 1e3 → JSON.parse → 1000 → JSON.stringify → "1000" (not "1e3"). + // Byte-equality catches this without needing the exponent check. + const json = '{"jsonrpc":"2.0","id":"a","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1e3},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(json) as JsonObject; + assert.notEqual(json, JSON.stringify(parsed), 'non-V8-canonical exponent does not roundtrip'); + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(json, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'non-canonical exponent is T-C via byte-equality'); + assert.equal(result.outcome, 'close'); +}); + // --------------------------------------------------------------------------- // Fail-closed anti-examples — each proves a specific fail-open gap is sealed. // These are the independent refutation vectors from the R1 review. diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 41988ad..4d30e0b 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -206,6 +206,34 @@ function containsNonScalarString(value: unknown): boolean { return false; } +/** + * Detect numbers whose JSON.stringify form uses exponent notation. + * + * V8 serialises numbers ≥ 10^21 (and very small fractions) in exponent + * form (e.g. `1e+21`). When the raw frame also uses the same exponent + * form, byte-equality passes — but exponent-form numeric tokens are + * non-canonical per the WireUInt53 profile (which requires raw decimal + * digits `0|[1-9][0-9]*`, no exponent). Deep traversal mirrors + * containsNonScalarString. + */ +function containsExponentNumber(value: unknown): boolean { + if (typeof value === 'number') { + const s = String(value); + return s.includes('e') || s.includes('E'); + } + if (value === null || typeof value !== 'object') return false; + if (Array.isArray(value)) { + for (const item of value) { + if (containsExponentNumber(item)) return true; + } + return false; + } + for (const v of Object.values(value as Record)) { + if (containsExponentNumber(v)) return true; + } + return false; +} + // --------------------------------------------------------------------------- // Contract-mirror imports (key sets from contract-mirror.ts) // @@ -833,12 +861,19 @@ export function classifyFrame( // ── T-C: canonicality check ────────────────────────────────────────── // The T-C predicate covers: whitespace, duplicate keys, non-scalar - // strings, and non-canonical numbers. Byte-equality catches all but - // non-scalar strings (lone surrogates roundtrip through JSON.stringify). - const rawStr = new TextDecoder('utf-8', { fatal: true }).decode(raw); + // strings, non-canonical numbers, and BOM-prefixed frames. + // Byte-equality catches whitespace, duplicate keys, and most non- + // canonical numbers. Three supplementary measures close the gaps: + // 1. ignoreBOM:true — keeps BOM visible so it fails byte-equality. + // 2. containsNonScalarString — lone surrogates roundtrip thru stringify. + // 3. containsExponentNumber — V8-canonical exponent form (≥10^21) + // also roundtrips, but exponent tokens violate the WireUInt53 + // raw decimal-digit-only profile. + const rawStr = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw); const canonical = JSON.stringify(value); if (rawStr !== canonical) return close('T-C'); if (containsNonScalarString(value)) return close('T-C'); + if (containsExponentNumber(value)) return close('T-C'); // ── Response candidate detection ───────────────────────────────────── const hasMethod = 'method' in value; From 29a272c8cb65e1bef31e12a1c2ea1ba795c2fbfe Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 27 Jul 2026 23:04:43 +0800 Subject: [PATCH 11/13] fix(plugin-sdk): guard T-C against stack overflow on deeply nested frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V8's JSON.parse uses iterative descent and handles arbitrarily deep nesting, but JSON.stringify and the deep-traversal helpers (non-scalar string, exponent number) use recursive descent and overflow the call stack at ~10K levels. A canonical deeply nested frame (well under the 1 MiB frame ceiling) would make classifyFrame throw instead of returning a deterministic disposition. Fix: wrap the entire T-C canonicality section in try-catch; stack overflow (or any other edge-case failure) maps to T-C close. Codex R3 P2-1 (RESERVED input T-F vs T-G): pushed back — T-F is correct per the disposition priority chain (envelope > value). Tests: 125 → 126 (1 new: 10K-deep nesting → T-C, not thrown). Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/src/wire-dispatch.test.ts | 26 +++++++++++++++++++ packages/plugin-sdk/src/wire-dispatch.ts | 21 +++++++++++---- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index ab6668e..950561c 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -303,6 +303,32 @@ test('frame with non-V8-canonical exponent form is already T-C via byte-equality assert.equal(result.outcome, 'close'); }); +// --------------------------------------------------------------------------- +// T-C canonicality: deeply nested frame → stack overflow guard +// (codex R3 P2-2 — JSON.stringify is recursive, V8 JSON.parse is iterative) +// --------------------------------------------------------------------------- + +test('deeply nested canonical frame is T-C, not a thrown exception', () => { + // Build a deeply nested canonical JSON string that V8's iterative + // JSON.parse handles but recursive JSON.stringify overflows on. + const depth = 10_000; + const prefix = '{"a":'.repeat(depth); + const core = '{"x":1}'; + const suffix = '}'.repeat(depth); + const json = prefix + core + suffix; + const parsed = JSON.parse(json) as JsonObject; + // Sanity: JSON.stringify throws for this depth + assert.throws(() => JSON.stringify(parsed), RangeError, 'stringify must overflow'); + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(json, 'utf8'), + value: parsed, + }; + // classifyFrame must NOT throw — it must return T-C close + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'deep nesting must be T-C, not thrown'); + assert.equal(result.outcome, 'close'); +}); + // --------------------------------------------------------------------------- // Fail-closed anti-examples — each proves a specific fail-open gap is sealed. // These are the independent refutation vectors from the R1 review. diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 4d30e0b..62d79b8 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -869,11 +869,22 @@ export function classifyFrame( // 3. containsExponentNumber — V8-canonical exponent form (≥10^21) // also roundtrips, but exponent tokens violate the WireUInt53 // raw decimal-digit-only profile. - const rawStr = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw); - const canonical = JSON.stringify(value); - if (rawStr !== canonical) return close('T-C'); - if (containsNonScalarString(value)) return close('T-C'); - if (containsExponentNumber(value)) return close('T-C'); + // + // Guard: JSON.stringify and the deep-traversal helpers use recursive + // descent. A canonical frame nested thousands of levels deep passes + // V8's iterative JSON.parse but overflows the call stack on stringify. + // The try-catch ensures classifyFrame never throws — stack overflow + // is mapped to T-C (close, no response). + try { + const rawStr = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw); + const canonical = JSON.stringify(value); + if (rawStr !== canonical) return close('T-C'); + if (containsNonScalarString(value)) return close('T-C'); + if (containsExponentNumber(value)) return close('T-C'); + } catch { + // Stack overflow from deep nesting, or other canonicality edge case. + return close('T-C'); + } // ── Response candidate detection ───────────────────────────────────── const hasMethod = 'method' in value; From 5ed849000750e47574eb105ca197cf5e78971673 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Wed, 29 Jul 2026 14:57:47 +0800 Subject: [PATCH 12/13] fix(plugin-sdk): per-method response validation + WireUInt53 raw-token T-C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two P1 findings from maintainer REQUEST_CHANGES on PR #13: P1-1: WireUInt53 raw-token grammar at T-C - Add hasNonCanonicalUInt53Token() path-aware pre-parse check - Validates raw tokens at deadlineUnixMs and grantRevision positions - After byte-equality, String(parsedValue) IS the raw token — checked against isCanonicalUInt53Token from contract - Catches negative (-1) and fractional (1.5) tokens at T-C instead of deferring to T-G/T-K - Covers Request (meta + drain input) and Notification (meta + grants) P1-2: Method-specific response validation a) Per-method application error allowlists: - messaging.subscribe/ack: domain_error + deadline_expired - Lifecycle (ping, drain): standard only - RESERVED rows: standard only (fail-closed) b) RESERVED row result fail-closed: - All RESERVED rows except row 9 → T-H for any result - broker.hello result:null no longer passes (was fail-open) c) Row 9 deliver closed member set: - Enforce {deliveryId} only — extras → T-H - Maintains existing deliveryId byte-equality oracle d) DELIVER_RESULT_KEYS added to contract-mirror.ts Tests: 101 wire-dispatch + 38 contract-mirror = 139 pass. Contract diff: 0 (no contract changes). Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/src/contract-mirror.ts | 13 + packages/plugin-sdk/src/wire-dispatch.test.ts | 268 ++++++++++++++++-- packages/plugin-sdk/src/wire-dispatch.ts | 151 +++++++--- 3 files changed, 376 insertions(+), 56 deletions(-) diff --git a/packages/plugin-sdk/src/contract-mirror.ts b/packages/plugin-sdk/src/contract-mirror.ts index 63e652b..5c0e985 100644 --- a/packages/plugin-sdk/src/contract-mirror.ts +++ b/packages/plugin-sdk/src/contract-mirror.ts @@ -118,6 +118,19 @@ export const PING_RESULT_KEYS = new Set(['nonce']); /** Mirror of: SubscribeResult (row-shapes.ts:35-39). Keys: {subscriptionId} */ export const SUBSCRIBE_RESULT_KEYS = new Set(['subscriptionId']); +/** + * Mirror of: deliver acknowledgement result closed member set. + * Row 9 (host.messaging.deliver) has a frozen ack result shape + * {deliveryId: string} — additionalProperties: false. + * + * Despite row 9 being RESERVED overall (DeliverResult = never in types), + * the ack shape is frozen per the #1165 protocol specification. The + * deliveryId echo is the fundamental delivery acknowledgement mechanism. + * + * P1-2 maintainer requirement: enforce closed member set, no extras. + */ +export const DELIVER_RESULT_KEYS = new Set(['deliveryId']); + // ═══════════════════════════════════════════════════════════════════════════ // Error body key sets (closed per error variant) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index 950561c..762e9e4 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -553,17 +553,19 @@ test('valid standard error (Internal error) with in-flight is T-L', () => { assert.equal(result.outcome, 'accept'); }); -test('valid application error (deadline_expired) with in-flight is T-L', () => { +test('valid application error (deadline_expired) on messaging.ack is T-L', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // Use messaging.ack (CLOSED row 7) — allows deadline_expired. + // Ping (row 11) is standard-only per P1-2. const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping' }], + ['r1', { method: 'messaging.ack' }], ]); const result = classifyFrame(frame, inFlight); - assert.equal(result.disposition, 'T-L', 'valid application error with in-flight must accept'); + assert.equal(result.disposition, 'T-L', 'valid application error on allowed method must accept'); assert.equal(result.outcome, 'accept'); }); @@ -697,42 +699,44 @@ test('error body with extra field beyond code/message is T-H (standard error)', // R3 Finding 1: Per-arm application error data schema validation // --------------------------------------------------------------------------- -test('handshake rejection with missing reason is T-H', () => { +test('handshake rejection with missing reason is T-H (data validation)', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // broker.hello (RESERVED) — data validation rejects missing reason + // before the per-method check would also reject it. const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'broker.hello' }], ]); const result = classifyFrame(frame, inFlight); assert.equal(result.disposition, 'T-H', 'handshake rejection missing reason must reject'); assert.equal(result.outcome, 'close'); }); -test('handshake rejection with unknown reason is T-H', () => { +test('handshake rejection with unknown reason is T-H (data validation)', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"UNKNOWN_REASON"}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'broker.hello' }], ]); const result = classifyFrame(frame, inFlight); assert.equal(result.disposition, 'T-H', 'unknown handshake rejection reason must reject'); assert.equal(result.outcome, 'close'); }); -test('handshake rejection with extra data key is T-H', () => { +test('handshake rejection with extra data key is T-H (data validation)', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"MALFORMED_HELLO","extra":1}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'broker.hello' }], ]); const result = classifyFrame(frame, inFlight); assert.equal(result.disposition, 'T-H', 'extra key in handshake rejection data must reject'); @@ -745,8 +749,9 @@ test('deadline_expired with non-empty data is T-H (must be Record) raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // Use messaging.ack (allows deadline_expired) to exercise DATA validation const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'messaging.ack' }], ]); const result = classifyFrame(frame, inFlight); assert.equal(result.disposition, 'T-H', 'non-empty deadline_expired data must reject'); @@ -759,8 +764,9 @@ test('domain_error with extra data key is T-H', () => { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // Use messaging.subscribe (allows domain_error) to exercise DATA validation const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'messaging.subscribe' }], ]); const result = classifyFrame(frame, inFlight); assert.equal(result.disposition, 'T-H', 'extra key in domain error data must reject'); @@ -773,25 +779,27 @@ test('domain_error with unknown MessagingErrorCode is T-H', () => { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // Use messaging.subscribe (allows domain_error) to exercise DATA validation const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'messaging.subscribe' }], ]); const result = classifyFrame(frame, inFlight); assert.equal(result.disposition, 'T-H', 'unknown MessagingErrorCode must reject'); assert.equal(result.outcome, 'close'); }); -test('domain_error with valid MessagingErrorCode is T-L', () => { +test('domain_error with valid MessagingErrorCode on messaging.subscribe is T-L', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION"}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // Use messaging.subscribe (CLOSED row 5) — allows domain_error. const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'messaging.subscribe' }], ]); const result = classifyFrame(frame, inFlight); - assert.equal(result.disposition, 'T-L', 'valid MessagingErrorCode must accept'); + assert.equal(result.disposition, 'T-L', 'valid MessagingErrorCode on allowed method must accept'); assert.equal(result.outcome, 'accept'); }); @@ -801,29 +809,35 @@ test('domain_error with non-string code is T-H', () => { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // Use messaging.subscribe (allows domain_error) to exercise DATA validation const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'messaging.subscribe' }], ]); const result = classifyFrame(frame, inFlight); assert.equal(result.disposition, 'T-H', 'non-string domain error code must reject'); assert.equal(result.outcome, 'close'); }); -test('snapshot_unavailable with valid reason is T-L', () => { +test('snapshot_unavailable with valid reason on ping is T-H (per-method error restriction)', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32094,"message":"snapshot unavailable","data":{"reason":"VIEW_EXPIRED"}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; + // Ping (row 11) is standard-only. snapshot_unavailable belongs to + // rows 6/8 (RESERVED) which also reject application errors. + // No CLOSED row currently allows snapshot_unavailable → always T-H. const inFlight = new Map([ ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], ]); const result = classifyFrame(frame, inFlight); - assert.equal(result.disposition, 'T-L', 'valid snapshot_unavailable must accept'); - assert.equal(result.outcome, 'accept'); + assert.equal(result.disposition, 'T-H', 'snapshot_unavailable on standard-only row must reject'); + assert.equal(result.outcome, 'close'); }); -test('valid handshake_rejected with known reason is T-L', () => { +test('valid handshake_rejected on ping is T-H (per-method error restriction, maintainer RED)', () => { + // Maintainer P1-2 RED: ping (row 11) permits standard errors only. + // HANDSHAKE_REJECTED belongs to rows 1-2 (RESERVED) — not row 11. const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"PACKAGE_MISMATCH"}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), @@ -833,8 +847,8 @@ test('valid handshake_rejected with known reason is T-L', () => { ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], ]); const result = classifyFrame(frame, inFlight); - assert.equal(result.disposition, 'T-L', 'valid handshake_rejected must accept'); - assert.equal(result.outcome, 'accept'); + assert.equal(result.disposition, 'T-H', 'handshake_rejected on ping must reject (per-method)'); + assert.equal(result.outcome, 'close'); }); test('ParseError (-32700) with correlated string id is T-H (must have null id)', () => { @@ -922,6 +936,216 @@ test('ping success with snapshot nonce present is T-L (oracle pass)', () => { // Mutual-exclusivity proof pair (pre-existing) // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// P1-1: WireUInt53 raw-token grammar at T-C +// (maintainer requirement — non-canonical numeric tokens at WireUInt53 +// positions must be T-C/close, not deferred to T-G/T-K) +// --------------------------------------------------------------------------- + +test('request with deadlineUnixMs:-1 is T-C (negative token violates WireUInt53 grammar)', () => { + // -1 passes byte-equality (JSON.stringify(-1) = "-1") but violates + // the WireUInt53 raw grammar 0|[1-9][0-9]{0,15} — no sign allowed. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":-1},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'negative deadlineUnixMs must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('request with deadlineUnixMs:1.5 is T-C (fractional token violates WireUInt53 grammar)', () => { + // 1.5 passes byte-equality but violates WireUInt53 — no decimal allowed. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1.5},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'fractional deadlineUnixMs must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('notification with grantRevision:-1 is T-C (negative token at WireUInt53 position)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":-1,"effectiveGrants":[]}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'negative grantRevision must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('notification with deadlineUnixMs:0.5 is T-C (fractional meta token)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":0.5},"input":{"grantRevision":0,"effectiveGrants":[]}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'fractional notification deadlineUnixMs must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('drain input with deadlineUnixMs:-100 is T-C (negative drain deadline)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.drain","params":{"meta":{"deadlineUnixMs":1},"input":{"deadlineUnixMs":-100}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'negative drain input deadline must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// P1-2: Per-method error code restriction +// (maintainer requirement — application errors only allowed on their +// designated rows, standard-only rows reject application errors) +// --------------------------------------------------------------------------- + +test('ping response with deadline_expired is T-H (row 11 standard-only)', () => { + // Ping (row 11) permits standard errors only (maintainer-confirmed). + // deadline_expired is an application error → per-method rejection. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'application error on standard-only row must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('drain response with domain_error is T-H (row 12 standard-only)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'application error on drain must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('RESERVED row (broker.hello) response with handshake_rejected is T-H (RESERVED = standard only)', () => { + // handshake_rejected belongs to rows 1-2 per the maintainer, but + // rows 1-2 are RESERVED — application errors not yet frozen → T-H. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"PACKAGE_MISMATCH"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'application error on RESERVED row must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('RESERVED row (broker.hello) standard error is T-L (standard errors always allowed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'standard error on RESERVED row must accept'); + assert.equal(result.outcome, 'accept'); +}); + +// --------------------------------------------------------------------------- +// P1-2: RESERVED row result fail-closed +// (maintainer requirement — no executable result schema → T-H) +// --------------------------------------------------------------------------- + +test('broker.hello response with result:null is T-H (RESERVED row fail-closed)', () => { + // Maintainer P1-2 RED: broker.hello in-flight entry accepts result:null. + // RESERVED rows have no executable result schema → T-H. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'RESERVED row result must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +test('messaging.send response with result:{} is T-H (RESERVED row fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.send' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'RESERVED row result must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// P1-2: Row 9 deliver closed member set enforcement +// (maintainer requirement — {deliveryId} only, no extras) +// --------------------------------------------------------------------------- + +test('deliver result with extra field is T-H (closed ack member set)', () => { + // Maintainer P1-2 RED: row 9 accepts {deliveryId:"d1",extra:true} + // despite the frozen closed acknowledgement member set {deliveryId}. + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"abc","extra":true}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'abc' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra field in deliver result must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver result with only deliveryId (matching) is T-L (closed ack shape)', () => { + // Positive counterexample: exact closed member set {deliveryId}, matches oracle. + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"correct"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'correct' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'exact closed ack shape with matching oracle must accept'); + assert.equal(result.outcome, 'accept'); +}); + +// --------------------------------------------------------------------------- +// Mutual-exclusivity proof pair (pre-existing) +// --------------------------------------------------------------------------- + test('the same raw frame classified as T-H without in-flight and T-L with in-flight', () => { const rawFrame = '{"jsonrpc":"2.0","id":"corr-test","result":{"nonce":"x"}}'; diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 62d79b8..0dc5689 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -24,6 +24,7 @@ import { validateEffectiveGrants, isWireMethod, isWireUInt53, + isCanonicalUInt53Token, NOTIFICATION_METHODS, WIRE_METHOD_REGISTRY, INVALID_REQUEST_CODE, @@ -234,6 +235,56 @@ function containsExponentNumber(value: unknown): boolean { return false; } +// --------------------------------------------------------------------------- +// WireUInt53 raw-token validation (P1-1 maintainer requirement) +// --------------------------------------------------------------------------- + +/** + * Check whether any WireUInt53 position in the parsed frame value has a + * numeric token whose V8-canonical form violates the WireUInt53 raw grammar + * (0|[1-9][0-9]{0,15}, no sign, no decimal, no exponent). + * + * After byte-equality passes (rawStr === JSON.stringify(value)), the raw + * token at any numeric position IS String(parsedValue). So we walk the + * parsed structure to WireUInt53 positions and validate String(n) against + * isCanonicalUInt53Token. This catches negative integers (-1), fractions + * (1.5), and oversized values (>2^53-1) at the T-C layer, before any + * method-specific validation runs. + * + * WireUInt53 positions in the frozen schema: + * - params.meta.deadlineUnixMs (every request/notification) + * - params.input.deadlineUnixMs (host.lifecycle.drain input) + * - params.input.grantRevision (host.grants.changed input) + */ +function hasNonCanonicalUInt53Token(value: JsonObject): boolean { + const params = value.params; + if (params === null || typeof params !== 'object' || Array.isArray(params)) return false; + const paramsObj = params as Record; + + // ── params.meta.deadlineUnixMs ── + const meta = paramsObj.meta; + if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) { + const metaObj = meta as Record; + if (typeof metaObj.deadlineUnixMs === 'number') { + if (!isCanonicalUInt53Token(String(metaObj.deadlineUnixMs))) return true; + } + } + + // ── params.input.deadlineUnixMs (drain) + params.input.grantRevision (grants.changed) ── + const input = paramsObj.input; + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const inputObj = input as Record; + if (typeof inputObj.deadlineUnixMs === 'number') { + if (!isCanonicalUInt53Token(String(inputObj.deadlineUnixMs))) return true; + } + if (typeof inputObj.grantRevision === 'number') { + if (!isCanonicalUInt53Token(String(inputObj.grantRevision))) return true; + } + } + + return false; +} + // --------------------------------------------------------------------------- // Contract-mirror imports (key sets from contract-mirror.ts) // @@ -257,6 +308,7 @@ import { GRANTS_CHANGED_INPUT_KEYS, PING_RESULT_KEYS, SUBSCRIBE_RESULT_KEYS, + DELIVER_RESULT_KEYS, ERROR_BODY_STANDARD_KEYS, ERROR_BODY_APPLICATION_KEYS, REASON_DATA_KEYS, @@ -282,6 +334,31 @@ const SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS); // (verified upstream), so ParseError with string id is invalid. const NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]); +// --------------------------------------------------------------------------- +// Per-method application error allowlists (P1-2 maintainer requirement) +// --------------------------------------------------------------------------- +// +// The frozen disposition table says T-L requires the response to satisfy +// "the correlated row's result/error schema". Each CLOSED row has a +// specific set of allowed application error codes. RESERVED rows and +// lifecycle rows accept standard errors only (no application errors). +// +// Row 5 (messaging.subscribe): DOMAIN_ERROR, DEADLINE_EXPIRED +// Row 7 (messaging.ack): DOMAIN_ERROR, DEADLINE_EXPIRED +// Row 11 (host.lifecycle.ping): standard only (maintainer-confirmed) +// Row 12 (host.lifecycle.drain): standard only +// RESERVED rows: standard only (fail-closed — no frozen error set) +// +// Any application error code NOT in the per-method allowlist is T-H. +// Absence from this map = empty allowlist (standard errors only). + +const METHOD_APPLICATION_ERROR_ALLOW: Readonly>>> = { + 'messaging.subscribe': new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]), + 'messaging.ack': new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]), +}; + +// DELIVER_RESULT_KEYS imported from contract-mirror.ts (row 9 ack shape). + // --------------------------------------------------------------------------- // Response candidate sub-classifier (T-H / T-L) // --------------------------------------------------------------------------- @@ -365,6 +442,15 @@ function classifyResponseCandidate( } } + // ── P1-2: Per-method error code restriction ────────────────────── + // Application errors are only valid on methods whose frozen row + // includes them. Standard errors are always allowed. RESERVED rows + // and lifecycle rows (11, 12) accept standard errors only. + if (APPLICATION_CODES.has(errObj.code)) { + const allowed = METHOD_APPLICATION_ERROR_ALLOW[inFlightEntry.method]; + if (!allowed || !allowed.has(errObj.code)) return close('T-H'); + } + return accept('T-L'); } @@ -472,10 +558,10 @@ function validateResponseResult( const method = entry.method; const row = WIRE_METHOD_REGISTRY[method]; - // RESERVED rows: no shape contract to validate against. - // Only cross-frame oracle checks apply (nonce/deliveryId byte-equality). + // RESERVED rows: fail closed — no executable result schema exists. + // Row 9 (deliver) has a frozen ack shape; all others → T-H. if (row.leafClosure !== 'CLOSED') { - return validateReservedRowOracle(result, entry); + return validateReservedRowResult(result, entry); } switch (method) { @@ -553,51 +639,44 @@ function validateResponseResult( // --------------------------------------------------------------------------- /** - * For RESERVED rows (no closed result shape), the only validation - * available is the cross-frame oracle — byte-equality checks on - * nonce/deliveryId from the original request snapshot. + * Validate results for RESERVED rows. + * + * P1-2 maintainer requirement: fail closed when no executable result + * schema exists, rather than treating absence of an oracle as validity. + * + * Row 9 (host.messaging.deliver) is the ONE exception among RESERVED + * rows — its acknowledgement result shape {deliveryId} is frozen as a + * closed member set. Validated with deliveryId byte-equality oracle + * AND strict closed-key enforcement. + * + * ALL other RESERVED rows: no executable result schema exists → T-H + * for any result value (fail-closed). */ -function validateReservedRowOracle( +function validateReservedRowResult( result: unknown, entry: InFlightEntry, ): DispatchResult | null { - // Method-specific oracle requirements: row 9 (host.messaging.deliver) - // requires deliveryId snapshot — fail-closed if absent. - // Mirrors the ping oracle fail-closed pattern (R3 fix). + // ── Row 9 deliver: frozen ack shape {deliveryId} ────────────────── if (entry.method === 'host.messaging.deliver') { + // Fail-closed if snapshot absent (caller bug) if (entry.requestSnapshot?.deliveryId === undefined) return close('T-H'); if (result === null || typeof result !== 'object' || Array.isArray(result)) { return close('T-H'); } const resultObj = result as Record; + // Closed member set: {deliveryId} only — no extras + for (const key of Object.keys(resultObj)) { + if (!DELIVER_RESULT_KEYS.has(key)) return close('T-H'); + } + // deliveryId must be present and byte-equal to snapshot if (resultObj.deliveryId !== entry.requestSnapshot.deliveryId) return close('T-H'); return null; } - // Other RESERVED rows: oracle check only when snapshot has fields. - if (entry.requestSnapshot === undefined) return null; - - const hasOracleField = - entry.requestSnapshot.nonce !== undefined || - entry.requestSnapshot.deliveryId !== undefined; - if (!hasOracleField) return null; - - if (result === null || typeof result !== 'object' || Array.isArray(result)) { - return close('T-H'); - } - const resultObj = result as Record; - - // Nonce byte-equality - if (entry.requestSnapshot.nonce !== undefined) { - if (resultObj.nonce !== entry.requestSnapshot.nonce) return close('T-H'); - } - - // DeliveryId byte-equality - if (entry.requestSnapshot.deliveryId !== undefined) { - if (resultObj.deliveryId !== entry.requestSnapshot.deliveryId) return close('T-H'); - } - - return null; + // ── All other RESERVED rows: no executable result schema → T-H ──── + // The row's result shape is RESERVED (type = `never`). No legal result + // value exists in v0 — accepting anything would be fail-open. + return close('T-H'); } // --------------------------------------------------------------------------- @@ -881,6 +960,10 @@ export function classifyFrame( if (rawStr !== canonical) return close('T-C'); if (containsNonScalarString(value)) return close('T-C'); if (containsExponentNumber(value)) return close('T-C'); + // P1-1: WireUInt53 raw-token grammar — after byte-equality, the raw + // token at each WireUInt53 position is String(parsedValue). Tokens + // like "-1" or "1.5" violate 0|[1-9][0-9]{0,15} → T-C. + if (hasNonCanonicalUInt53Token(value)) return close('T-C'); } catch { // Stack overflow from deep nesting, or other canonicality edge case. return close('T-C'); From 7f6414dbca23ad762b6e7149bdb1cff7ddda3217 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Wed, 29 Jul 2026 18:27:55 +0800 Subject: [PATCH 13/13] fix(plugin-sdk): complete per-row error map + deliveryId bounds enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two P1 findings from maintainer re-review on 5ed8490: P1-1: Application-error allowlist was incomplete - Was: only messaging.subscribe/ack had allowlists; RESERVED rows and lifecycle rows were standard-only. - Now: complete frozen per-row map for all 12 rows, keyed off the row (not leafClosure). Every row has its exact error set: Rows 1-2: HANDSHAKE_REJECTED Rows 3-7: DOMAIN_ERROR, DEADLINE_EXPIRED Row 8: DOMAIN_ERROR, DEADLINE_EXPIRED, SNAPSHOT_UNAVAILABLE Row 9: DELIVERY_REJECTED Row 11: standard only Row 12: DEADLINE_EXPIRED - Maintainer probes now pass: drain + DEADLINE_EXPIRED → T-L (was T-H) deliver + DELIVERY_REJECTED → T-L (was T-H) P1-2: Row 9 deliveryId bounds not enforced - Added DELIVER_DELIVERY_ID_MIN_LENGTH (1) and MAX_LENGTH (128) to contract-mirror.ts - validateReservedRowResult now validates: 1. Snapshot deliveryId is string within 1..128 (fail-closed) 2. Result deliveryId is string within 1..128 (schema check) 3. Byte-equality oracle (unchanged) - Tests: min (1), max (128), N+1 (129), empty (""), snapshot OOB Also added positive/negative tests for every error family: 5 new per-row positive tests, 3 wrong-row negative tests, 5 deliveryId bounds tests. Total: 149 tests pass. Contract diff: 0 (no contract changes). Co-Authored-By: Claude Opus 4.6 --- packages/plugin-sdk/src/contract-mirror.ts | 10 +- packages/plugin-sdk/src/wire-dispatch.test.ts | 176 ++++++++++++++++-- packages/plugin-sdk/src/wire-dispatch.ts | 93 ++++++--- 3 files changed, 238 insertions(+), 41 deletions(-) diff --git a/packages/plugin-sdk/src/contract-mirror.ts b/packages/plugin-sdk/src/contract-mirror.ts index 5c0e985..9fb6efd 100644 --- a/packages/plugin-sdk/src/contract-mirror.ts +++ b/packages/plugin-sdk/src/contract-mirror.ts @@ -121,16 +121,22 @@ export const SUBSCRIBE_RESULT_KEYS = new Set(['subscriptionId']); /** * Mirror of: deliver acknowledgement result closed member set. * Row 9 (host.messaging.deliver) has a frozen ack result shape - * {deliveryId: string} — additionalProperties: false. + * {deliveryId: string, length 1..128 code points} — additionalProperties: false. * * Despite row 9 being RESERVED overall (DeliverResult = never in types), * the ack shape is frozen per the #1165 protocol specification. The * deliveryId echo is the fundamental delivery acknowledgement mechanism. * - * P1-2 maintainer requirement: enforce closed member set, no extras. + * Maintainer requirement: enforce closed member set + string bounds. */ export const DELIVER_RESULT_KEYS = new Set(['deliveryId']); +/** Minimum deliveryId code-point length (frozen row 9 ack shape). */ +export const DELIVER_DELIVERY_ID_MIN_LENGTH = 1 as const; + +/** Maximum deliveryId code-point length (frozen row 9 ack shape). */ +export const DELIVER_DELIVERY_ID_MAX_LENGTH = 128 as const; + // ═══════════════════════════════════════════════════════════════════════════ // Error body key sets (closed per error variant) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts index 762e9e4..1b458a6 100644 --- a/packages/plugin-sdk/src/wire-dispatch.test.ts +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -818,21 +818,19 @@ test('domain_error with non-string code is T-H', () => { assert.equal(result.outcome, 'close'); }); -test('snapshot_unavailable with valid reason on ping is T-H (per-method error restriction)', () => { +test('snapshot_unavailable with valid reason on messaging.snapshot is T-L', () => { const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32094,"message":"snapshot unavailable","data":{"reason":"VIEW_EXPIRED"}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), value: JSON.parse(rawFrame) as JsonObject, }; - // Ping (row 11) is standard-only. snapshot_unavailable belongs to - // rows 6/8 (RESERVED) which also reject application errors. - // No CLOSED row currently allows snapshot_unavailable → always T-H. + // Row 8 (messaging.snapshot) allows snapshot_unavailable per the frozen error table. const inFlight = new Map([ - ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ['r1', { method: 'messaging.snapshot' }], ]); const result = classifyFrame(frame, inFlight); - assert.equal(result.disposition, 'T-H', 'snapshot_unavailable on standard-only row must reject'); - assert.equal(result.outcome, 'close'); + assert.equal(result.disposition, 'T-L', 'snapshot_unavailable on messaging.snapshot must accept'); + assert.equal(result.outcome, 'accept'); }); test('valid handshake_rejected on ping is T-H (per-method error restriction, maintainer RED)', () => { @@ -1011,6 +1009,80 @@ test('drain input with deadlineUnixMs:-100 is T-C (negative drain deadline)', () // designated rows, standard-only rows reject application errors) // --------------------------------------------------------------------------- +test('drain response with deadline_expired is T-L (row 12 allows deadline)', () => { + // Maintainer probe: drain + canonical DEADLINE_EXPIRED must be T-L. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'deadline_expired on drain must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('deliver response with delivery_rejected is T-L (row 9 allows it)', () => { + // Maintainer probe: deliver + canonical DELIVERY_REJECTED must be T-L. + const rawFrame = '{"jsonrpc":"2.0","id":"d1","error":{"code":-32091,"message":"delivery rejected","data":{"reason":"PLUGIN_BUSY"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'abc' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'delivery_rejected on deliver must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('drain response with handshake_rejected is T-H (wrong-row error)', () => { + // Negative: HANDSHAKE_REJECTED only allowed on rows 1-2, not row 12. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"MALFORMED_HELLO"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'handshake_rejected on drain must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver response with domain_error is T-H (wrong-row error)', () => { + // Negative: DOMAIN_ERROR not allowed on row 9 (only DELIVERY_REJECTED). + const rawFrame = '{"jsonrpc":"2.0","id":"d1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'abc' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'domain_error on deliver must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('snapshot_unavailable on ping is T-H (wrong-row error, standard-only)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32094,"message":"snapshot unavailable","data":{"reason":"VIEW_EXPIRED"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'snapshot_unavailable on ping must reject'); + assert.equal(result.outcome, 'close'); +}); + test('ping response with deadline_expired is T-H (row 11 standard-only)', () => { // Ping (row 11) permits standard errors only (maintainer-confirmed). // deadline_expired is an application error → per-method rejection. @@ -1041,9 +1113,9 @@ test('drain response with domain_error is T-H (row 12 standard-only)', () => { assert.equal(result.outcome, 'close'); }); -test('RESERVED row (broker.hello) response with handshake_rejected is T-H (RESERVED = standard only)', () => { - // handshake_rejected belongs to rows 1-2 per the maintainer, but - // rows 1-2 are RESERVED — application errors not yet frozen → T-H. +test('broker.hello response with valid handshake_rejected is T-L (row 1 allows it)', () => { + // Rows 1-2 allow HANDSHAKE_REJECTED per the frozen per-row error table. + // Error eligibility is keyed off the row, not leafClosure. const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"PACKAGE_MISMATCH"}}}'; const frame: DecodedNdjsonFrame = { raw: Buffer.from(rawFrame, 'utf8'), @@ -1053,8 +1125,8 @@ test('RESERVED row (broker.hello) response with handshake_rejected is T-H (RESER ['r1', { method: 'broker.hello' }], ]); const result = classifyFrame(frame, inFlight); - assert.equal(result.disposition, 'T-H', 'application error on RESERVED row must reject'); - assert.equal(result.outcome, 'close'); + assert.equal(result.disposition, 'T-L', 'handshake_rejected on broker.hello must accept'); + assert.equal(result.outcome, 'accept'); }); test('RESERVED row (broker.hello) standard error is T-L (standard errors always allowed)', () => { @@ -1142,6 +1214,86 @@ test('deliver result with only deliveryId (matching) is T-L (closed ack shape)', assert.equal(result.outcome, 'accept'); }); +// --------------------------------------------------------------------------- +// P1-2 (R2): deliveryId string bounds enforcement (1..128 code points) +// --------------------------------------------------------------------------- + +test('deliver result with deliveryId length 1 is T-L (min bound)', () => { + const id = 'x'; + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'deliveryId at min bound must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('deliver result with deliveryId length 128 is T-L (max bound)', () => { + const id = 'a'.repeat(128); + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'deliveryId at max bound must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('deliver result with deliveryId length 129 is T-H (N+1 above max)', () => { + // Maintainer P1-2: frozen row-9 ack has deliveryId 1..128; 129 is invalid. + const id = 'a'.repeat(129); + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'deliveryId exceeding max bound must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver result with empty deliveryId is T-H (below min bound)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":""}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: '' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'empty deliveryId must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver snapshot with deliveryId length 129 is T-H (snapshot fail-closed)', () => { + // Snapshot itself must also be a legal oracle value (1..128 code points). + const id = 'b'.repeat(129); + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'out-of-bounds snapshot deliveryId must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + // --------------------------------------------------------------------------- // Mutual-exclusivity proof pair (pre-existing) // --------------------------------------------------------------------------- diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts index 0dc5689..98fbc00 100644 --- a/packages/plugin-sdk/src/wire-dispatch.ts +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -309,6 +309,8 @@ import { PING_RESULT_KEYS, SUBSCRIBE_RESULT_KEYS, DELIVER_RESULT_KEYS, + DELIVER_DELIVERY_ID_MIN_LENGTH, + DELIVER_DELIVERY_ID_MAX_LENGTH, ERROR_BODY_STANDARD_KEYS, ERROR_BODY_APPLICATION_KEYS, REASON_DATA_KEYS, @@ -335,26 +337,44 @@ const SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS); const NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]); // --------------------------------------------------------------------------- -// Per-method application error allowlists (P1-2 maintainer requirement) +// Per-method application error allowlists (frozen per-row registry) // --------------------------------------------------------------------------- // -// The frozen disposition table says T-L requires the response to satisfy -// "the correlated row's result/error schema". Each CLOSED row has a -// specific set of allowed application error codes. RESERVED rows and -// lifecycle rows accept standard errors only (no application errors). +// Every registry row's application-error set resolves through the closed +// application table in #1165. Eligibility is keyed off the row, NOT +// leafClosure — RESERVED rows have frozen error sets too. // -// Row 5 (messaging.subscribe): DOMAIN_ERROR, DEADLINE_EXPIRED -// Row 7 (messaging.ack): DOMAIN_ERROR, DEADLINE_EXPIRED -// Row 11 (host.lifecycle.ping): standard only (maintainer-confirmed) -// Row 12 (host.lifecycle.drain): standard only -// RESERVED rows: standard only (fail-closed — no frozen error set) +// Rows 1-2 (broker.hello/ready): HANDSHAKE_REJECTED +// Rows 3-7 (messaging send/append/sub/read/ack): DOMAIN_ERROR, DEADLINE_EXPIRED +// Row 8 (messaging.snapshot): DOMAIN_ERROR, DEADLINE_EXPIRED, SNAPSHOT_UNAVAILABLE +// Row 9 (host.messaging.deliver): DELIVERY_REJECTED +// Row 10 (host.grants.changed): notification-only (no response) +// Row 11 (host.lifecycle.ping): standard only (no application errors) +// Row 12 (host.lifecycle.drain): DEADLINE_EXPIRED // -// Any application error code NOT in the per-method allowlist is T-H. -// Absence from this map = empty allowlist (standard errors only). - -const METHOD_APPLICATION_ERROR_ALLOW: Readonly>>> = { - 'messaging.subscribe': new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]), - 'messaging.ack': new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]), +// Standard errors are always allowed on every row. Application error +// codes NOT in the per-row allowlist → T-H. + +const EMPTY_ERROR_SET: ReadonlySet = new Set(); +const HANDSHAKE_ERROR_SET: ReadonlySet = new Set([HANDSHAKE_REJECTED_CODE]); +const MESSAGING_ERROR_SET: ReadonlySet = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]); +const SNAPSHOT_ERROR_SET: ReadonlySet = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE, SNAPSHOT_UNAVAILABLE_CODE]); +const DELIVERY_ERROR_SET: ReadonlySet = new Set([DELIVERY_REJECTED_CODE]); +const DEADLINE_ONLY_SET: ReadonlySet = new Set([DEADLINE_EXPIRED_CODE]); + +const METHOD_APPLICATION_ERROR_ALLOW: Readonly>> = { + 'broker.hello': HANDSHAKE_ERROR_SET, + 'broker.ready': HANDSHAKE_ERROR_SET, + 'messaging.send': MESSAGING_ERROR_SET, + 'messaging.appendElements': MESSAGING_ERROR_SET, + 'messaging.subscribe': MESSAGING_ERROR_SET, + 'messaging.read': MESSAGING_ERROR_SET, + 'messaging.ack': MESSAGING_ERROR_SET, + 'messaging.snapshot': SNAPSHOT_ERROR_SET, + 'host.messaging.deliver': DELIVERY_ERROR_SET, + 'host.grants.changed': EMPTY_ERROR_SET, // notification-only + 'host.lifecycle.ping': EMPTY_ERROR_SET, // standard only + 'host.lifecycle.drain': DEADLINE_ONLY_SET, }; // DELIVER_RESULT_KEYS imported from contract-mirror.ts (row 9 ack shape). @@ -442,13 +462,14 @@ function classifyResponseCandidate( } } - // ── P1-2: Per-method error code restriction ────────────────────── - // Application errors are only valid on methods whose frozen row - // includes them. Standard errors are always allowed. RESERVED rows - // and lifecycle rows (11, 12) accept standard errors only. + // ── Per-method error code restriction ──────────────────────────── + // Application errors are only valid on methods whose frozen per-row + // error set includes them. Standard errors are always allowed. + // The complete map covers all 12 rows — no fallback needed. if (APPLICATION_CODES.has(errObj.code)) { - const allowed = METHOD_APPLICATION_ERROR_ALLOW[inFlightEntry.method]; - if (!allowed || !allowed.has(errObj.code)) return close('T-H'); + if (!METHOD_APPLICATION_ERROR_ALLOW[inFlightEntry.method].has(errObj.code)) { + return close('T-H'); + } } return accept('T-L'); @@ -656,20 +677,38 @@ function validateReservedRowResult( result: unknown, entry: InFlightEntry, ): DispatchResult | null { - // ── Row 9 deliver: frozen ack shape {deliveryId} ────────────────── + // ── Row 9 deliver: frozen ack shape {deliveryId: string, 1..128 cp} ── if (entry.method === 'host.messaging.deliver') { - // Fail-closed if snapshot absent (caller bug) - if (entry.requestSnapshot?.deliveryId === undefined) return close('T-H'); + // Fail-closed if snapshot absent or snapshot deliveryId is not a + // legal oracle value (string within 1..128 code points). + const snapId = entry.requestSnapshot?.deliveryId; + if (snapId === undefined) return close('T-H'); + if (typeof snapId !== 'string') return close('T-H'); + const snapLen = [...snapId].length; + if (snapLen < DELIVER_DELIVERY_ID_MIN_LENGTH || snapLen > DELIVER_DELIVERY_ID_MAX_LENGTH) { + return close('T-H'); + } + + // Result must be a non-null object if (result === null || typeof result !== 'object' || Array.isArray(result)) { return close('T-H'); } const resultObj = result as Record; + // Closed member set: {deliveryId} only — no extras for (const key of Object.keys(resultObj)) { if (!DELIVER_RESULT_KEYS.has(key)) return close('T-H'); } - // deliveryId must be present and byte-equal to snapshot - if (resultObj.deliveryId !== entry.requestSnapshot.deliveryId) return close('T-H'); + + // deliveryId must be a string within frozen bounds (1..128 code points) + if (typeof resultObj.deliveryId !== 'string') return close('T-H'); + const resultIdLen = [...(resultObj.deliveryId as string)].length; + if (resultIdLen < DELIVER_DELIVERY_ID_MIN_LENGTH || resultIdLen > DELIVER_DELIVERY_ID_MAX_LENGTH) { + return close('T-H'); + } + + // Byte-equality oracle: result.deliveryId must match snapshot + if (resultObj.deliveryId !== snapId) return close('T-H'); return null; }