fix(runner): precompile Phase 7 validators for strict CSP

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-09 20:32:56 +00:00
1 parent 4e625a5dc2
commit da0d32d74a
5 files changed
+88 -39

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace",
"build:browser": "vite build --config vite.config.ts",
"build:phase5": "vite build --config vite.phase5.config.ts",
"build:phase7": "vite build --config vite.phase7.config.ts",
"build:phase7": "vite build --config vite.phase7.config.ts && node scripts/check-phase7-csp-bundle.mjs",
"typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust && pnpm run typecheck:browser",
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
@@ -0,0 +1,27 @@
import { readFile, readdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const assetsDirectory = resolve(packageRoot, "dist-phase7/assets");
const bundleNames = (await readdir(assetsDirectory)).filter((name) => name.endsWith(".js"));
if (bundleNames.length === 0) {
throw new Error("Phase 7 build produced no JavaScript bundle");
}
const forbidden = [
["new Function", "dynamic Function construction"],
["Error compiling schema, function code:", "AJV runtime schema compilation"],
];
for (const name of bundleNames) {
const source = await readFile(resolve(assetsDirectory, name), "utf8");
for (const [needle, description] of forbidden) {
if (source.includes(needle)) {
throw new Error(`${name} contains ${description}, which strict Phase 7I CSP blocks`);
}
}
}
process.stdout.write("Phase 7 bundle is compatible with the strict Phase 7I CSP.\n");
@@ -2,9 +2,16 @@ import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import standaloneCode from "ajv/dist/standalone/index.js";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const schemaDirectory = resolve(packageRoot, "protocol/schemas");
const outputPath = resolve(packageRoot, "src/protocol/generated/schema-bundle.ts");
const validatorsOutputPath = resolve(
packageRoot,
"src/protocol/generated/standalone-validators.ts",
);
const schemaNames = [
"identity",
"capabilities",
@@ -29,15 +36,45 @@ const declarations = schemas
const bundle = schemaNames.map((name) => ` ${name}: ${name}Schema,`).join("\n");
const generated = `// Generated by scripts/generate-protocol-schema-module.mjs. Do not edit.\n\n${declarations}\n\nexport const prpSchemaBundle = {\n${bundle}\n} as const;\n`;
const schemaByName = Object.fromEntries(schemas.map(({ name, value }) => [name, value]));
const ajv = new Ajv2020({
allErrors: true,
strict: true,
strictRequired: false,
code: { esm: true, source: true },
formats: {
"date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/,
},
});
for (const { value } of schemas) ajv.addSchema(value);
const standaloneValidators = standaloneCode(ajv, {
fixtureValidator: schemaByName.fixture.$id,
eventValidator: schemaByName.event.$id,
resultValidator: schemaByName.result.$id,
});
const ucs2RuntimePattern = /const (func\d+) = require\("ajv\/dist\/runtime\/ucs2length"\)\.default;/;
if (!ucs2RuntimePattern.test(standaloneValidators)) {
throw new Error("Ajv standalone output no longer has the expected ucs2length runtime binding");
}
const validators = standaloneValidators.replace(
ucs2RuntimePattern,
"const $1 = typeof ucs2LengthModule === \"function\" ? ucs2LengthModule : ucs2LengthModule.default;",
);
const generatedValidators = `// Generated by scripts/generate-protocol-schema-module.mjs. Do not edit.\n// @ts-nocheck -- Ajv standalone output is JavaScript compiled from the checked-in schemas.\n\nimport ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";\n\n${validators}\n`;
if (process.argv.includes("--check")) {
const current = await readFile(outputPath, "utf8").catch(() => "");
if (current !== generated) {
process.stderr.write("Generated PRP schema module is stale. Run pnpm generate:protocol-types.\n");
const currentValidators = await readFile(validatorsOutputPath, "utf8").catch(() => "");
if (current !== generated || currentValidators !== generatedValidators) {
process.stderr.write("Generated PRP schema modules are stale. Run pnpm generate:protocol-types.\n");
process.exitCode = 1;
} else {
process.stdout.write("Generated PRP schema module matches the JSON Schema sources.\n");
process.stdout.write("Generated PRP schema modules match the JSON Schema sources.\n");
}
} else {
await writeFile(outputPath, generated);
process.stdout.write("Generated src/protocol/generated/schema-bundle.ts.\n");
await Promise.all([
writeFile(outputPath, generated),
writeFile(validatorsOutputPath, generatedValidators),
]);
process.stdout.write("Generated PRP schema and standalone validator modules.\n");
}
@@ -0,0 +1,6 @@
// Generated by scripts/generate-protocol-schema-module.mjs. Do not edit.
// @ts-nocheck -- Ajv standalone output is JavaScript compiled from the checked-in schemas.
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","session.snapshot","session.close","run.cancel","runner.drain","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_request.v1"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"enum":["permission","input"]},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1}},"additionalProperties":true}}},"additionalProperties":true},{"type":"object","required":["schema","requestKind","requestId","kind","blocking","payload"],"properties":{"schema":{"const":"paperclip.interaction_request.v1"},"requestKind":{"const":"issue_thread"},"requestId":{"type":"string","minLength":1,"maxLength":160},"kind":{"enum":["request_confirmation","request_checkbox_confirmation","request_item_verdicts","ask_user_questions","suggest_tasks"]},"blocking":{"type":"boolean"},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true}]};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/result.schema.json","title":"PRP structured run result","type":"object","required":["schema","reportedWorkDisposition","summary","completionClaim","evidence","verification","attentionRequests","artifacts"],"pLine truncated
@@ -1,21 +1,20 @@
import {
Ajv2020,
type ErrorObject,
type ValidateFunction,
} from "ajv/dist/2020.js";
import type { ErrorObject, ValidateFunction } from "ajv/dist/2020.js";
import type { FromSchema } from "json-schema-to-ts";
import {
capabilitiesSchema,
commandSchema,
eventSchema,
fixtureSchema,
identitySchema,
prpSchemaBundle,
requestSchema,
resultSchema,
terminalSchema,
} from "./generated/schema-bundle.js";
import {
eventValidator as standaloneEventValidator,
fixtureValidator as standaloneFixtureValidator,
resultValidator as standaloneResultValidator,
} from "./generated/standalone-validators.js";
export const PRP_PROTOCOL_NAME = "paperclip.runner";
export const PRP_PROTOCOL_VERSION = 1;
@@ -69,32 +68,12 @@ export interface ProtocolVersionRange {
max: number;
}
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });
ajv.addFormat("date-time", {
type: "string",
validate(value: string) {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value);
},
});
for (const schema of Object.values(prpSchemaBundle)) {
ajv.addSchema(schema);
}
const registeredFixtureValidator = ajv.getSchema(fixtureSchema.$id);
if (registeredFixtureValidator === undefined) {
throw new Error("PRP fixture validator was not registered");
}
const fixtureValidator = registeredFixtureValidator as ValidateFunction<PrpFixture>;
const registeredEventValidator = ajv.getSchema(eventSchema.$id);
if (registeredEventValidator === undefined) {
throw new Error("PRP event validator was not registered");
}
const eventValidator = registeredEventValidator as ValidateFunction<PrpEvent>;
const registeredResultValidator = ajv.getSchema(resultSchema.$id);
if (registeredResultValidator === undefined) {
throw new Error("PRP structured result validator was not registered");
}
const resultValidator = registeredResultValidator as ValidateFunction<PrpStructuredRunResult>;
// The validators are generated from the same checked-in schemas as the types.
// Keeping compilation out of the runtime lets strict CSP deployments retain
// `script-src 'self'` without AJV attempting dynamic JavaScript evaluation.
const fixtureValidator = standaloneFixtureValidator as ValidateFunction<PrpFixture>;
const eventValidator = standaloneEventValidator as ValidateFunction<PrpEvent>;
const resultValidator = standaloneResultValidator as ValidateFunction<PrpStructuredRunResult>;
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)