Advance N-023 through N-026 audited parity
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
"id": "web-engine-bootstrap",
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/web_engine.wasm",
|
||||
"sha256": "acc2d6808dac4590aa3c3915495a6e24396a946b257a2d137f64ccf4c3ba6fbf",
|
||||
"sha256": "7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
|
||||
2
web/app/public/vendor/blender/web_engine.js
vendored
2
web/app/public/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
2
web/app/src/vendor/blender/web_engine.js
vendored
2
web/app/src/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -1,15 +1,17 @@
|
||||
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
|
||||
|
||||
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], acceptance: [], dependencies });
|
||||
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false } };
|
||||
const base = { schemaVersion: 2, source: "docs/status/parity-ledger.json", generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] };
|
||||
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] };
|
||||
const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
|
||||
const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)];
|
||||
try { parseReleaseManifest({ ...base, schemaVersion: 1 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, schemaVersion: 2 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -36,8 +36,11 @@
|
||||
"test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"",
|
||||
"test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs",
|
||||
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
|
||||
"test:library-main-reader": "node ../tools/web/check-library-main-reader.mjs",
|
||||
"test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"",
|
||||
"test:editor-main-reader": "node ../tools/web/check-editor-main-reader.mjs",
|
||||
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",
|
||||
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
|
||||
"test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"",
|
||||
"test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"",
|
||||
"test:cross-browser-smoke": "npm run test:browser-smoke",
|
||||
@@ -52,7 +55,10 @@
|
||||
"test:nonmesh-usd-blender-roundtrip": "USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs",
|
||||
"test:release-performance": "node ../tools/web/check-release-performance.mjs",
|
||||
"test:malicious-blends": "node ../tools/web/check-malicious-blends.mjs",
|
||||
"test:release-package": "npm run build && node ../tools/web/check-release-package.mjs",
|
||||
"test:release-package": "npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs",
|
||||
"release:sbom": "node ../tools/web/generate-sbom.mjs",
|
||||
"release:evidence": "node ../tools/web/collect-release-evidence.mjs",
|
||||
"test:release-evidence": "node ../tools/web/check-release-evidence.mjs",
|
||||
"release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs",
|
||||
"diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const RELEASE_GATE_SCHEMA = 2 as const;
|
||||
export const RELEASE_GATE_SCHEMA = 3 as const;
|
||||
export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED";
|
||||
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; acceptance: string[]; dependencies: string[] }
|
||||
export interface ReleaseEvidenceIR {
|
||||
@@ -10,8 +10,10 @@ export interface ReleaseEvidenceIR {
|
||||
performance: { geometry1M: boolean; geometry10M: boolean; texture4K: boolean; texture8K: boolean; longMedia: boolean; simulationCache: boolean };
|
||||
faults: { oom: boolean; deviceLoss: boolean; networkInterrupt: boolean; malformedBlend: boolean; zipBomb: boolean };
|
||||
provenance: { license: boolean; sbom: boolean; sourceOffer: boolean; deterministicPackage: boolean };
|
||||
records: ReleaseEvidenceRecordIR[];
|
||||
}
|
||||
export interface ReleaseManifestIR { schemaVersion: typeof RELEASE_GATE_SCHEMA; source: string; generatedAt: string; families: ParityFamilyEvidenceIR[]; evidence: ReleaseEvidenceIR }
|
||||
export interface ReleaseEvidenceRecordIR { id: string; fields: string[]; command: string; exitCode: 0; durationMs: number; output: string; artifactSha256: string[] }
|
||||
export interface ReleaseManifestIR { schemaVersion: typeof RELEASE_GATE_SCHEMA; source: string; sourceSha256: string; generatedAt: string; families: ParityFamilyEvidenceIR[]; evidence: ReleaseEvidenceIR }
|
||||
export interface ReleaseGateEvaluationIR { status: "READY" | "BLOCKED"; issueCodes: ErrorCode[]; missing: string[] }
|
||||
|
||||
export class ReleaseGateValidationError extends Error {
|
||||
@@ -28,7 +30,21 @@ function strings(value: unknown, name: string, maximum = 100_000): string[] { if
|
||||
function parseEvidence(value: unknown): ReleaseEvidenceIR {
|
||||
if (!record(value) || !record(value.browser) || !record(value.runtime) || !record(value.performance) || !record(value.faults) || !record(value.provenance)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "Release evidence groups are missing");
|
||||
const group = (name: string, keys: readonly string[]): Record<string, boolean> => { const item = value[name]; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `evidence.${name} is invalid`); return Object.fromEntries(keys.map((key) => [key, bool(item[key], `evidence.${name}.${key}`)])); };
|
||||
return { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"] };
|
||||
if (!Array.isArray(value.records) || value.records.length > 1024) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "evidence.records is invalid");
|
||||
const recordIds = new Set<string>();
|
||||
const records = value.records.map((item, index): ReleaseEvidenceRecordIR => {
|
||||
const name = `evidence.records[${index}]`; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`);
|
||||
const id = text(item.id, `${name}.id`); if (recordIds.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate evidence record ${id}`); recordIds.add(id);
|
||||
const fields = strings(item.fields, `${name}.fields`, 64); if (new Set(fields).size !== fields.length || fields.some((field) => !/^(browser|runtime|performance|faults|provenance)\.[A-Za-z0-9]+$/.test(field))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.fields is invalid`);
|
||||
if (item.exitCode !== 0 || typeof item.durationMs !== "number" || !Number.isSafeInteger(item.durationMs) || item.durationMs < 0 || item.durationMs > 86_400_000) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} did not complete successfully`);
|
||||
const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if (artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is invalid`);
|
||||
return { id, fields, command: text(item.command, `${name}.command`, 2048), exitCode: 0, durationMs: item.durationMs, output: text(item.output, `${name}.output`, 4096), artifactSha256 };
|
||||
});
|
||||
const parsed = { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"], records };
|
||||
for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record<string, boolean>]>) {
|
||||
for (const [key, enabled] of Object.entries(groupValues)) if (enabled && !records.some((item) => item.fields.includes(`${groupName}.${key}`))) throw new ReleaseGateValidationError("RELEASE_EVIDENCE_MISSING", `Enabled evidence ${groupName}.${key} has no successful record`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
|
||||
@@ -41,7 +57,8 @@ export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema");
|
||||
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
|
||||
assertDependencies(families);
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
|
||||
const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid");
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
|
||||
}
|
||||
|
||||
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { parseGreasePencilData, type GreasePencilDataIR } from "./grease-pencil"
|
||||
import { parseCompositorGraph, type CompositorGraphIR } from "./compositor";
|
||||
import { parseSequencerTimeline, type SequencerTimelineIR } from "./sequencer";
|
||||
import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking-mask";
|
||||
import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
|
||||
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
|
||||
|
||||
export type SceneNodeType =
|
||||
| "EMPTY"
|
||||
@@ -474,6 +477,11 @@ export interface SceneSnapshotIR {
|
||||
greasePencils?: GreasePencilDataIR[];
|
||||
trackingMasks?: TrackingMaskProjectIR;
|
||||
trackingMaskStatus?: "AVAILABLE" | "BLOCKED";
|
||||
libraryStatus?: "AVAILABLE" | "BLOCKED";
|
||||
editorWorkflow?: EditorWorkflowIR;
|
||||
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
libraries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -482,6 +490,8 @@ export interface SceneSnapshotIR {
|
||||
packedByteLength?: number;
|
||||
status: "PACKED" | "EXTERNAL_REQUIRED";
|
||||
errorCode?: "LINKED_LIBRARY_RESOURCE_REQUIRED";
|
||||
dependencyIds: string[];
|
||||
readOnly: true;
|
||||
}>;
|
||||
animations: AnimationIR[];
|
||||
nlaTracks?: NlaTrackIR[];
|
||||
@@ -1055,5 +1065,72 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
parseTrackingMaskProject(value.trackingMasks);
|
||||
if (value.trackingMaskStatus !== "AVAILABLE") throw new Error("SceneIR.trackingMaskStatus must be AVAILABLE when trackingMasks is present");
|
||||
}
|
||||
if (value.libraryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.libraryStatus as string)) {
|
||||
throw new Error("SceneIR.libraryStatus is invalid");
|
||||
}
|
||||
if (value.libraries !== undefined) {
|
||||
const libraries = value.libraries as unknown[];
|
||||
if (libraries.length > 0 && value.libraryStatus !== "AVAILABLE") throw new Error("SceneIR.libraryStatus must be AVAILABLE when libraries are present");
|
||||
if (libraries.length > 1024) throw new Error("SceneIR.libraries exceeds the budget");
|
||||
const libraryIds = new Set<string>();
|
||||
for (const [index, library] of libraries.entries()) {
|
||||
if (!isRecord(library)) throw new Error(`SceneIR.libraries[${index}] must be an object`);
|
||||
const id = requireString(library.id, `libraries[${index}].id`);
|
||||
if (!id || id.length > 256 || libraryIds.has(id)) throw new Error(`SceneIR.libraries[${index}].id is invalid`);
|
||||
libraryIds.add(id);
|
||||
requireString(library.name, `libraries[${index}].name`);
|
||||
const sourcePath = requireString(library.sourcePath, `libraries[${index}].sourcePath`);
|
||||
if (typeof library.packed !== "boolean" || library.readOnly !== true || !Array.isArray(library.dependencyIds) ||
|
||||
library.dependencyIds.length > 1024 || library.dependencyIds.some((dependency) => typeof dependency !== "string") ||
|
||||
new Set(library.dependencyIds).size !== library.dependencyIds.length ||
|
||||
!["PACKED", "EXTERNAL_REQUIRED"].includes(library.status as string)) {
|
||||
throw new Error(`SceneIR.libraries[${index}] is invalid`);
|
||||
}
|
||||
let projectPath = true;
|
||||
try { normalizeProjectAssetPath(sourcePath); } catch { projectPath = false; }
|
||||
if (!projectPath) {
|
||||
throw new Error(`SceneIR.libraries[${index}].sourcePath is outside the project`);
|
||||
}
|
||||
if (library.status === "PACKED" && (!library.packed || !Number.isSafeInteger(library.packedByteLength) || (library.packedByteLength as number) <= 0)) {
|
||||
throw new Error(`SceneIR.libraries[${index}] packed payload is invalid`);
|
||||
}
|
||||
if (library.status === "EXTERNAL_REQUIRED" && (library.packed || library.errorCode !== "LINKED_LIBRARY_RESOURCE_REQUIRED")) {
|
||||
throw new Error(`SceneIR.libraries[${index}] external resource state is invalid`);
|
||||
}
|
||||
}
|
||||
for (const [index, library] of libraries.entries()) {
|
||||
const dependencies = (library as Record<string, unknown>).dependencyIds as string[];
|
||||
if (dependencies.some((dependency) => !libraryIds.has(dependency))) throw new Error(`SceneIR.libraries[${index}] references a missing dependency`);
|
||||
}
|
||||
const byId = new Map(libraries.map((library) => {
|
||||
const record = library as Record<string, unknown>;
|
||||
return [record.id as string, record.dependencyIds as string[]] as const;
|
||||
}));
|
||||
const active = new Set<string>();
|
||||
const complete = new Set<string>();
|
||||
const visit = (id: string): void => {
|
||||
if (active.has(id)) throw new Error(`SceneIR.libraries dependency cycle includes ${id}`);
|
||||
if (complete.has(id)) return;
|
||||
active.add(id);
|
||||
for (const dependency of byId.get(id) ?? []) visit(dependency);
|
||||
active.delete(id);
|
||||
complete.add(id);
|
||||
};
|
||||
byId.forEach((_dependencies, id) => visit(id));
|
||||
}
|
||||
if (value.editorWorkflowStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.editorWorkflowStatus as string)) {
|
||||
throw new Error("SceneIR.editorWorkflowStatus is invalid");
|
||||
}
|
||||
if (value.editorWorkflow !== undefined) {
|
||||
parseEditorWorkflow(value.editorWorkflow);
|
||||
if (value.editorWorkflowStatus !== "AVAILABLE") throw new Error("SceneIR.editorWorkflowStatus must be AVAILABLE when editorWorkflow is present");
|
||||
}
|
||||
if (value.scriptSourceStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptSourceStatus as string)) {
|
||||
throw new Error("SceneIR.scriptSourceStatus is invalid");
|
||||
}
|
||||
if (value.scriptSources !== undefined) {
|
||||
parseScriptSourceInventory(value.scriptSources);
|
||||
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
|
||||
}
|
||||
return value as unknown as SceneSnapshotIR;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } fr
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
|
||||
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000 } as const;
|
||||
export const SCRIPT_SOURCE_SCHEMA = 1 as const;
|
||||
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536 } as const;
|
||||
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
|
||||
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
|
||||
|
||||
@@ -27,6 +28,21 @@ export interface ScriptManifestIR {
|
||||
addonInstall: false;
|
||||
}
|
||||
export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] }
|
||||
export interface ScriptSourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
sourceSha256: string;
|
||||
byteLength: number;
|
||||
lineCount: number;
|
||||
sourcePath?: string;
|
||||
internal: boolean;
|
||||
moduleAutorunRequested: boolean;
|
||||
readOnly: true;
|
||||
executionStatus: "BLOCKED";
|
||||
errorCode: "SCRIPT_POLICY_DENIED" | "SCRIPT_SANDBOX_UNAVAILABLE";
|
||||
}
|
||||
export interface ScriptSourceInventoryIR { schemaVersion: typeof SCRIPT_SOURCE_SCHEMA; sources: ScriptSourceIR[] }
|
||||
export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" }
|
||||
|
||||
export class ScriptingPlatformValidationError extends Error {
|
||||
@@ -41,6 +57,31 @@ function digest(value: unknown, name: string): string { if (typeof value !== "st
|
||||
function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } }
|
||||
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; }
|
||||
|
||||
export function parseScriptSourceInventory(value: unknown): ScriptSourceInventoryIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPT_SOURCE_SCHEMA || !Array.isArray(value.sources)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script source inventory schema");
|
||||
if (value.sources.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script source count exceeds the budget");
|
||||
const ids = new Set<string>(); let totalBytes = 0;
|
||||
const sources = value.sources.map((item, index): ScriptSourceIR => {
|
||||
const name = `sources[${index}]`; if (!record(item) || typeof item.source !== "string") throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
||||
const id = text(item.id, `${name}.id`); if (!id.startsWith("text:") || ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.id is invalid`); ids.add(id);
|
||||
const byteLength = integer(item.byteLength, `${name}.byteLength`, 0, SCRIPTING_BUDGET.maxSourceBytes); totalBytes += byteLength;
|
||||
if (totalBytes > SCRIPTING_BUDGET.maxSourceBytes || new TextEncoder().encode(item.source).byteLength !== byteLength) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.source exceeds or disagrees with the byte budget`);
|
||||
const moduleAutorunRequested = item.moduleAutorunRequested === true;
|
||||
if (item.readOnly !== true || item.executionStatus !== "BLOCKED" || item.internal !== (item.sourcePath === undefined) ||
|
||||
item.errorCode !== (moduleAutorunRequested ? "SCRIPT_POLICY_DENIED" : "SCRIPT_SANDBOX_UNAVAILABLE")) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name} execution policy is invalid`);
|
||||
const sourcePath = item.sourcePath === undefined ? undefined : path(item.sourcePath, `${name}.sourcePath`);
|
||||
const lineCount = integer(item.lineCount, `${name}.lineCount`, 1, SCRIPTING_BUDGET.maxSourceLines);
|
||||
if (item.source.split("\n").length !== lineCount) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.lineCount disagrees with source`);
|
||||
return { id, name: text(item.name, `${name}.name`), source: item.source, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), byteLength, lineCount, sourcePath, internal: item.internal as boolean, moduleAutorunRequested, readOnly: true, executionStatus: "BLOCKED", errorCode: item.errorCode as ScriptSourceIR["errorCode"] };
|
||||
});
|
||||
return { schemaVersion: SCRIPT_SOURCE_SCHEMA, sources };
|
||||
}
|
||||
|
||||
export async function verifyScriptSource(source: ScriptSourceIR): Promise<boolean> {
|
||||
const digestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source.source));
|
||||
return [...new Uint8Array(digestBytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("") === source.sourceSha256;
|
||||
}
|
||||
|
||||
export function parseScriptingManifest(value: unknown): ScriptingManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema");
|
||||
if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget");
|
||||
|
||||
@@ -690,6 +690,7 @@ test("keeps N-026 release manifest deterministic and blocks missing evidence", a
|
||||
expect(result.oldSchema).toContain("PROTOCOL_MISMATCH");
|
||||
expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE");
|
||||
expect(result.status).toContain("RELEASE_MANIFEST_INVALID");
|
||||
expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING");
|
||||
});
|
||||
|
||||
test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user