From 484ae82283eedbc3c9d761a6f67d95a5930fe7da Mon Sep 17 00:00:00 2001 From: Tom Girard Date: Mon, 31 Aug 2026 15:43:47 -0400 Subject: [PATCH] feat: add guarded rehearsal integration --- AGENT_RULES.md | 8 +- DESIGN.md | 17 +- README.md | 22 ++- schemas/tools-v1.schema.json | 80 +++++++- scripts/launch.mjs | 5 +- scripts/runtime-path.mjs | 30 ++- scripts/studio-call.mjs | 9 +- src/bridge/protocol.mjs | 1 + src/mcp/tool-schemas.mjs | 58 ++++-- src/mcp/tools.mjs | 6 +- src/runtime/json-artifact.mjs | 124 +++++++++++++ src/runtime/project-artifact-exchange.mjs | 130 +++++++++++++ src/runtime/rehearsal-sidecar-client.mjs | 192 ++++++++++++++++++++ src/runtime/studio-application.mjs | 136 +++++++++++++- tests/bridge-rpc.test.mjs | 6 + tests/mcp-tools.test.mjs | 72 +++++++- tests/project-artifact-exchange.test.mjs | 97 ++++++++++ tests/rehearsal-sidecar-client.test.mjs | 96 ++++++++++ tests/runtime-application-boundary.test.mjs | 123 ++++++++++++- tests/runtime-operations.test.mjs | 62 ++++++- tests/runtime-path.test.mjs | 8 +- 21 files changed, 1219 insertions(+), 63 deletions(-) create mode 100644 src/runtime/json-artifact.mjs create mode 100644 src/runtime/project-artifact-exchange.mjs create mode 100644 src/runtime/rehearsal-sidecar-client.mjs create mode 100644 tests/project-artifact-exchange.test.mjs create mode 100644 tests/rehearsal-sidecar-client.test.mjs diff --git a/AGENT_RULES.md b/AGENT_RULES.md index e20c641..141fc5a 100644 --- a/AGENT_RULES.md +++ b/AGENT_RULES.md @@ -161,6 +161,8 @@ Use it only after both status and the current tool schema expose it. - `three_studio_job` `sceneExport` writes a Three.js-loadable glTF/GLB of the active scene or an exact entity subtree. Shader-graph looks export as authored PBR factors (and a bound albedo map when present), not compiled TSL. +- A configured `three_studio_job` `rehearsalRun` is not a command runner: + provide only its repository-relative run-spec path. - Create with `template: "blank"` for an empty scene. Use `scene.clear` with the inspected `sceneHash` to wipe the current scene without the starter composition. `template: "starter"` remains the optional lit primitive stage. @@ -377,8 +379,10 @@ operations. - Use project-relative logical asset IDs, never arbitrary absolute paths in scene documents. -- Asset import jobs are unavailable in the current slice; do not place - arbitrary absolute paths into a project as a workaround. +- General asset import jobs are unavailable. The project tool may exchange a + schema- and hash-guarded JSON artifact beneath the configured rehearsal root; + do not place absolute paths into a project or use this seam for arbitrary + files. - Record source, license/provenance, colour role, and importer settings. - Do not overwrite a user-authored script or asset without inspecting it and making the replacement explicit in the transaction label. diff --git a/DESIGN.md b/DESIGN.md index 349520d..8037549 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -624,11 +624,15 @@ backward. Dedicated diff and revision-compare actions are not exposed. ### 9. `three_studio_job` -This is an explicit reserved slot so the nine-tool topology need not change. -It currently exposes no start/inspect/cancel action and returns -`job_not_implemented`; `status.capabilities.jobs` is false. Asset import, -texture/mesh bakes, reconstruction, lightmaps, and packaging are future job -kinds, not callable operations. +The ninth slot exposes bounded, typed jobs without admitting a generic process +runner. `textureBake` deterministically compiles one procedural graph output +through the guarded kernel. `sceneExport` writes an active scene or exact +entity subtree as glTF/GLB. When a machine-local rehearsal root is configured, +`rehearsalRun` invokes exactly `tools/rehearsal-sidecar/src/cli.mjs` with +`shell: false`, a minimal environment, and a repository-relative run-spec path; +the response is reduced to verified bundle evidence. Arbitrary commands, +reconstruction, lightmaps, mesh bakes, whole-project/application export, and +packaging remain unavailable. ## Shared request and response contract @@ -637,7 +641,8 @@ an idempotency key, and a human-readable label. Undo/redo, non-query Play controls, and project mutations carry the correlation fields required by their schemas; project create/open have no meaningful prior project revision. Read actions accept the bounded connection/target fields declared by their own -schemas. There is currently no long-running job request. +schemas. Jobs carry the same exact project, revision, and idempotency guards as +other stateful requests and honor bridge cancellation. Responses are action-specific. Committed `apply`/undo/redo responses include the revision, transaction ID, resolved IDs, changed/deleted IDs, compact diff, diff --git a/README.md b/README.md index 84d3ace..be70300 100644 --- a/README.md +++ b/README.md @@ -63,18 +63,24 @@ The nine-tool shape is stable, but each tool advertises only its live slice: - `play`: deterministic Action animation and timeline-driven Ocean displacement enter/stop/pause/resume/seek/step plus recorded input, without script, blueprint, physics, or game-logic execution; -- `project`: list, create, open, and atomic save; and +- `project`: list, create, open, atomic save, and guarded import/export of one + configured external JSON artifact as canonical project data; - `history`: list, inspect, undo, and redo. -The jobs tool remains as an explicit reserved ninth slot and always reports -`job_not_implemented`. Shader and texture graph resources are authored through -ordinary resource operations: the live subset compiles to TSL/WebGPU and the -procedural subset can be deterministically CPU-baked. Bounded inline +The jobs tool exposes deterministic procedural texture baking and scene or +entity-subtree glTF/GLB export. When a machine-local rehearsal root is +configured, it also exposes `rehearsalRun`: Studio accepts only a +repository-relative run-spec path and invokes the fixed sidecar with +`shell: false` and a minimal environment. It never accepts a command. Shader +and texture graph resources are authored through ordinary resource operations: +the live subset compiles to TSL/WebGPU and the procedural subset can be +deterministically CPU-baked. Bounded inline `dataTexture` resources compile once to shared RGBA8 WebGPU textures and bind to supported raster material map slots. Existing format-v1 generic texture placeholders remain valid/indexable but are not live raster inputs. Script operations, layout generators, scoped or code/asset/render validation, diagnostic passes, -export, and RTX evidence remain absent. Status capabilities are authoritative. +whole-project/application export, and RTX evidence remain absent. Status +capabilities are authoritative. ## Lean dependency boundary @@ -180,7 +186,9 @@ sibling checkout at `..\ThreeBrowser\ThreeBrowserRuntime`. For another location, set `THREEBROWSER_RUNTIME_ROOT` to the absolute `ThreeBrowserRuntime` directory, or put `{ "runtimeRoot": "C:\\path\\to\\ThreeBrowserRuntime" }` in the ignored -`.studio-local.json` file. Machine-local paths never enter a saved project. +`.studio-local.json` file. A controlled external rehearsal can additionally set +`"rehearsalRoot"` there (or `THREE_STUDIO_REHEARSAL_ROOT`). Machine-local paths +never enter a saved project. `npm run launch` opens one native WebGPU window and restores the last opened project (falling back to `projects/live`). A genuinely new fallback project is diff --git a/schemas/tools-v1.schema.json b/schemas/tools-v1.schema.json index 92b2cf1..cda8eab 100644 --- a/schemas/tools-v1.schema.json +++ b/schemas/tools-v1.schema.json @@ -2841,6 +2841,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "op": { + "type": "string", + "const": "artifact.json.patch" + }, + "artifactId": { + "$ref": "#/$defs/toolSchemas/three_studio_apply/$defs/__schema6" + }, + "pointer": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?:\\/(?:[^~/]|~[01])*)+$" + }, + "value": { + "$ref": "#/$defs/toolSchemas/three_studio_apply/$defs/__schema2" + }, + "expectedArtifactHash": { + "$ref": "#/$defs/toolSchemas/three_studio_apply/$defs/__schema7" + } + }, + "required": [ + "op", + "artifactId", + "pointer", + "value", + "expectedArtifactHash" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -7305,7 +7337,8 @@ "type": "string", "enum": [ "textureBake", - "sceneExport" + "sceneExport", + "rehearsalRun" ] }, "projectId": { @@ -7348,6 +7381,11 @@ "gltf" ] }, + "runSpecReference": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, "name": { "type": "string", "minLength": 1, @@ -7406,14 +7444,13 @@ "list", "create", "open", - "save" + "save", + "artifactImport", + "artifactExport" ] }, "projectId": { - "type": "string", - "minLength": 1, - "maxLength": 160, - "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?:\\/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)*$" + "$ref": "#/$defs/toolSchemas/three_studio_project/$defs/__schema0" }, "path": { "type": "string", @@ -7432,6 +7469,21 @@ "starter" ] }, + "artifactId": { + "$ref": "#/$defs/toolSchemas/three_studio_project/$defs/__schema0" + }, + "schemaId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/@-]*$" + }, + "expectedFileSha256": { + "$ref": "#/$defs/toolSchemas/three_studio_project/$defs/__schema1" + }, + "expectedArtifactHash": { + "$ref": "#/$defs/toolSchemas/three_studio_project/$defs/__schema1" + }, "baseRevision": { "type": "integer", "minimum": 0, @@ -7452,7 +7504,21 @@ "required": [ "action" ], - "additionalProperties": false + "additionalProperties": false, + "$defs": { + "__schema0": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?:\\/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)*$" + }, + "__schema1": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + } + } }, "three_studio_play": { "type": "object", diff --git a/scripts/launch.mjs b/scripts/launch.mjs index e1efda0..0044099 100644 --- a/scripts/launch.mjs +++ b/scripts/launch.mjs @@ -2,13 +2,15 @@ import { spawn } from "node:child_process"; import { rm } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveRuntimeRoot } from "./runtime-path.mjs"; +import { readStudioLocalConfig, resolveRuntimeRoot } from "./runtime-path.mjs"; import { defaultSessionMarkerPath, readSessionMarker } from "../src/bridge/index.mjs"; const studioRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const entry = path.join(studioRoot, "site-entry.mjs"); +const localConfig = await readStudioLocalConfig({ studioRoot }); const runtime = await resolveRuntimeRoot({ studioRoot }); const projectArgument = process.argv.slice(2).find(argument => !argument.startsWith("--")); +const rehearsalRoot = process.env.THREE_STUDIO_REHEARSAL_ROOT || localConfig.rehearsalRoot; console.error(`[ThreeBrowser Studio] runtime: ${runtime.root}`); console.error(`[ThreeBrowser Studio] entry: ${entry}`); @@ -19,6 +21,7 @@ const child = spawn(process.execPath, [runtime.launcher, entry], { ...process.env, THREE_STUDIO_ROOT: studioRoot, ...(projectArgument ? { THREE_STUDIO_PROJECT: path.resolve(projectArgument) } : {}), + ...(rehearsalRoot ? { THREE_STUDIO_REHEARSAL_ROOT: rehearsalRoot } : {}), }, stdio: "inherit", windowsHide: false, diff --git a/scripts/runtime-path.mjs b/scripts/runtime-path.mjs index 000dce0..c0d3b4d 100644 --- a/scripts/runtime-path.mjs +++ b/scripts/runtime-path.mjs @@ -16,6 +16,24 @@ function cleanRoot(value) { return text ? path.resolve(text) : null; } +export async function readStudioLocalConfig({ + studioRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))), +} = {}) { + const localConfigPath = path.join(studioRoot, ".studio-local.json"); + if (!(await exists(localConfigPath))) return Object.freeze({}); + try { + const config = JSON.parse(await readFile(localConfigPath, "utf8")); + const normalized = {}; + const runtimeRoot = cleanRoot(config.runtimeRoot); + const rehearsalRoot = cleanRoot(config.rehearsalRoot); + if (runtimeRoot) normalized.runtimeRoot = runtimeRoot; + if (rehearsalRoot) normalized.rehearsalRoot = rehearsalRoot; + return Object.freeze(normalized); + } catch (error) { + throw new Error(`Invalid ${localConfigPath}: ${error.message}`); + } +} + export async function resolveRuntimeRoot({ studioRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))), environment = process.env, @@ -25,15 +43,9 @@ export async function resolveRuntimeRoot({ const configuredEnvironment = cleanRoot(environment.THREEBROWSER_RUNTIME_ROOT); if (configuredEnvironment) candidates.push({ source: "environment", root: configuredEnvironment }); - const localConfigPath = path.join(studioRoot, ".studio-local.json"); - if (await exists(localConfigPath)) { - try { - const config = JSON.parse(await readFile(localConfigPath, "utf8")); - const configuredFile = cleanRoot(config.runtimeRoot); - if (configuredFile) candidates.push({ source: ".studio-local.json", root: configuredFile }); - } catch (error) { - throw new Error(`Invalid ${localConfigPath}: ${error.message}`); - } + const localConfig = await readStudioLocalConfig({ studioRoot }); + if (localConfig.runtimeRoot) { + candidates.push({ source: ".studio-local.json", root: localConfig.runtimeRoot }); } const packaged = path.resolve(studioRoot, "..", "host"); diff --git a/scripts/studio-call.mjs b/scripts/studio-call.mjs index 5688702..3f61e47 100644 --- a/scripts/studio-call.mjs +++ b/scripts/studio-call.mjs @@ -1,5 +1,10 @@ import path from 'node:path'; -import { LiveBridgeClient, defaultSessionMarkerPath, readSessionMarker } from '../src/bridge/index.mjs'; +import { + LiveBridgeClient, + defaultSessionMarkerPath, + readSessionMarker, + requestTimeoutMsForMethod, +} from '../src/bridge/index.mjs'; const args = process.argv.slice(2); let markerPath = defaultSessionMarkerPath(); @@ -19,7 +24,7 @@ if (args[0]) { const client = new LiveBridgeClient(marker); try { await client.connect(); - const result = await client.request(method, params); + const result = await client.request(method, params, { timeoutMs: requestTimeoutMsForMethod(method) }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } finally { await client.close(); diff --git a/src/bridge/protocol.mjs b/src/bridge/protocol.mjs index 0ddd6cd..5060ee0 100644 --- a/src/bridge/protocol.mjs +++ b/src/bridge/protocol.mjs @@ -9,6 +9,7 @@ export const COMPILE_HEAVY_METHODS = Object.freeze(new Set([ 'three_studio_render', 'three_studio_project', 'three_studio_history', + 'three_studio_job', ])); export function requestTimeoutMsForMethod(method, fallback = DEFAULT_REQUEST_TIMEOUT_MS) { diff --git a/src/mcp/tool-schemas.mjs b/src/mcp/tool-schemas.mjs index edd4f7b..04cdbcc 100644 --- a/src/mcp/tool-schemas.mjs +++ b/src/mcp/tool-schemas.mjs @@ -240,12 +240,15 @@ export const OPERATION_TYPES = Object.freeze([ 'modifier.create', 'modifier.patch', 'modifier.move', 'modifier.delete', 'modifier.stack.edit', 'geometry.edit', 'geometry.realize', 'geometry.loft.edit', 'geometry.selection.edit', 'material.variant.create', 'material.look.create', 'material.look.patch', + 'artifact.json.patch', 'resource.create', 'resource.createMany', 'resource.patch', 'resource.delete', ]); const alias = z.string().min(2).max(65).regex(/^\$[a-z][a-z0-9_-]{0,63}$/); const reference = z.union([identifier, alias]); const hash = z.string().length(64).regex(/^[a-f0-9]{64}$/); +const jsonPointer = z.string().min(1).max(1024).regex(/^(?:\/(?:[^~/]|~[01])*)+$/u); +const artifactSchemaId = z.string().min(1).max(160).regex(/^[A-Za-z0-9][A-Za-z0-9._/@-]*$/u); const insertionIndex = z.number().int().min(0).max(20_000); const modifierIndex = z.number().int().min(0).max(63); const exactEntityReferences = z.array(reference).min(1).max(200).refine( @@ -1377,6 +1380,12 @@ const directOperations = [ look: materialLookEnum.optional(), ...materialLookOverrides, }), + operation('artifact.json.patch', { + artifactId: reference, + pointer: jsonPointer, + value: jsonValueSchema, + expectedArtifactHash: hash, + }), operation('resource.create', { resourceType, resource: resourceJsonObjectSchema, alias: alias.optional() }), operation('resource.createMany', { items: z.array(z.object({ resourceType, @@ -1487,7 +1496,7 @@ export const SCENE_EXPORT_FORMATS = Object.freeze(['glb', 'gltf']); export const jobSchema = z.object({ ...connectionFields, - action: z.enum(['textureBake', 'sceneExport']), + action: z.enum(['textureBake', 'sceneExport', 'rehearsalRun']), projectId: identifier, graphId: identifier.optional(), textureId: identifier.optional(), @@ -1496,36 +1505,45 @@ export const jobSchema = z.object({ sceneId: identifier.optional(), entityId: identifier.optional(), format: z.enum(SCENE_EXPORT_FORMATS).optional(), + runSpecReference: z.string().min(1).max(1024).optional(), name: z.string().min(1).max(160).optional(), baseRevision: nonNegativeInteger, idempotencyKey, label, }).strict().superRefine((value, context) => { - if (value.action !== 'textureBake') return; - for (const field of ['graphId', 'textureId', 'output', 'resolution']) { - if (value[field] === undefined) { - context.addIssue({ code: 'custom', path: [field], message: `${field} is required for textureBake.` }); + if (value.action === 'textureBake') { + for (const field of ['graphId', 'textureId', 'output', 'resolution']) { + if (value[field] === undefined) context.addIssue({ code: 'custom', path: [field], message: `${field} is required for textureBake.` }); } - } - if (value.resolution && value.output) { + if (!value.resolution || !value.output) return; const channels = value.output === 'roughness' ? 1 : 4; if (value.resolution[0] * value.resolution[1] * channels > DATA_TEXTURE_LIMITS.maxEncodedBytes) { - context.addIssue({ - code: 'custom', - path: ['resolution'], - message: 'Texture bake decoded output exceeds the canonical encoded-source byte budget.', - }); + context.addIssue({ code: 'custom', path: ['resolution'], message: 'Texture bake decoded output exceeds the canonical encoded-source byte budget.' }); } + return; + } + if (value.action === 'sceneExport') return; + if (value.runSpecReference === undefined) { + context.addIssue({ code: 'custom', path: ['runSpecReference'], message: 'runSpecReference is required for rehearsalRun.' }); + } else if (!/^(?![A-Za-z]:)(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(value.runSpecReference)) { + context.addIssue({ code: 'custom', path: ['runSpecReference'], message: 'runSpecReference must be a normalized repository-relative path.' }); + } + for (const field of ['graphId', 'textureId', 'output', 'resolution', 'sceneId', 'entityId', 'format', 'name']) { + if (value[field] !== undefined) context.addIssue({ code: 'custom', path: [field], message: `${field} is not accepted for rehearsalRun.` }); } }); export const projectSchema = z.object({ ...connectionFields, - action: z.enum(['list', 'create', 'open', 'save']), + action: z.enum(['list', 'create', 'open', 'save', 'artifactImport', 'artifactExport']), projectId: identifier.optional(), path: z.string().min(1).max(1024).optional(), name: z.string().min(1).max(160).optional(), template: z.enum(PROJECT_TEMPLATES).optional(), + artifactId: identifier.optional(), + schemaId: artifactSchemaId.optional(), + expectedFileSha256: hash.optional(), + expectedArtifactHash: hash.optional(), baseRevision: nonNegativeInteger.optional(), idempotencyKey: idempotencyKey.optional(), label: label.optional(), @@ -1536,8 +1554,18 @@ export const projectSchema = z.object({ } if (value.action === 'create' && value.path === undefined) context.addIssue({ code: 'custom', path: ['path'], message: 'path is required to create a project.' }); if (value.action === 'open' && value.path === undefined && value.projectId === undefined) context.addIssue({ code: 'custom', path: ['path'], message: 'path or projectId is required to open a project.' }); - if (value.action === 'save' && value.projectId === undefined) context.addIssue({ code: 'custom', path: ['projectId'], message: 'projectId is required for save.' }); - if (value.action === 'save' && value.baseRevision === undefined) context.addIssue({ code: 'custom', path: ['baseRevision'], message: 'baseRevision is required for save.' }); + if (['save', 'artifactImport', 'artifactExport'].includes(value.action) && value.projectId === undefined) context.addIssue({ code: 'custom', path: ['projectId'], message: `projectId is required for ${value.action}.` }); + if (['save', 'artifactImport', 'artifactExport'].includes(value.action) && value.baseRevision === undefined) context.addIssue({ code: 'custom', path: ['baseRevision'], message: `baseRevision is required for ${value.action}.` }); + if (['artifactImport', 'artifactExport'].includes(value.action)) { + for (const field of ['path', 'artifactId', 'expectedFileSha256']) { + if (value[field] === undefined) context.addIssue({ code: 'custom', path: [field], message: `${field} is required for ${value.action}.` }); + } + if (value.path !== undefined && !/^(?![A-Za-z]:)(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(value.path)) { + context.addIssue({ code: 'custom', path: ['path'], message: 'Artifact paths must be normalized repository-relative paths.' }); + } + } + if (value.action === 'artifactImport' && value.schemaId === undefined) context.addIssue({ code: 'custom', path: ['schemaId'], message: 'schemaId is required for artifactImport.' }); + if (value.action === 'artifactExport' && value.expectedArtifactHash === undefined) context.addIssue({ code: 'custom', path: ['expectedArtifactHash'], message: 'expectedArtifactHash is required for artifactExport.' }); }); export const playSchema = z.object({ diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index 11cb136..c520730 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -10,7 +10,7 @@ import { computeToolContractHash, } from './tool-schemas.mjs'; -const INITIAL_SERVER_INSTRUCTIONS = `Studio is an LLM-first WebGPU editor. Start with three_studio_status; the live-refreshed schemas and capability contract are authoritative. Use exact inspection digests before dense mesh, modifier-stack, graph, material, or RTX edits. Play evaluates Action animation only; file-producing jobs remain capability-gated. Never infer support from an earlier Studio session.`; +const INITIAL_SERVER_INSTRUCTIONS = `Studio is an LLM-first WebGPU editor. Start with three_studio_status; the live-refreshed schemas and capability contract are authoritative. Use exact inspection digests before dense mesh, modifier-stack, graph, material, or RTX edits. Play evaluates Action animation only; file-producing jobs remain capability-gated, and rehearsal jobs require machine-local configuration. Never infer support from an earlier Studio session.`; export const SERVER_INSTRUCTIONS = `${INITIAL_SERVER_INSTRUCTIONS.padEnd(512, ' ')}Inspect only bounded context. Mutate with exact stable IDs, the latest baseRevision, a unique idempotencyKey, and one coherent label. Carry selection and membership hashes into guarded bulk edits. Groups own transforms; collections are independent organization. Dry-run risky or large changes. Never claim gameplay works while behaviorRuntime is false. Save verified milestones. Never edit project JSON, history, recovery, or session-marker files directly, and never enable trusted-project mode. Units are metres, radians, and seconds.`; @@ -47,12 +47,12 @@ export const TOOL_DEFINITIONS = Object.freeze({ }, three_studio_job: { title: 'Three Studio Jobs', - description: 'Run a capability-gated job: bake one procedural graph output into a texture, or export a scene or entity subtree as glTF/GLB for Three.js and WebGPU loaders.', + description: 'Run a capability-gated job: bake one deterministic procedural graph output, export a scene or entity subtree as glTF/GLB, or invoke a configured external rehearsal through its fixed sidecar and repository-relative run spec. No command or shell input is accepted.', annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, }, three_studio_project: { title: 'Three Studio Project', - description: 'List, create, open, or atomically save Studio projects while keeping the viewport alive. Create with template blank for an empty scene, or starter for the lit primitive stage. Checkpoint/snapshot, close, duplication, rename, and deletion are not exposed.', + description: 'List, create, open, or atomically save Studio projects while keeping the viewport alive; create blank or starter projects; import or guarded-export a configured external JSON artifact as canonical project data. Checkpoint/snapshot, close, whole-project export, duplication, rename, and deletion are not exposed.', annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, }, three_studio_play: { diff --git a/src/runtime/json-artifact.mjs b/src/runtime/json-artifact.mjs new file mode 100644 index 0000000..f9d2335 --- /dev/null +++ b/src/runtime/json-artifact.mjs @@ -0,0 +1,124 @@ +import { StudioError } from '../core/errors.mjs'; + +function pointerToken(value) { + return String(value).replaceAll('~', '~0').replaceAll('/', '~1'); +} + +function locateJsonValues(source) { + let offset = 0; + const spans = new Map(); + const whitespace = () => { + while (/\s/u.test(source[offset] ?? '')) offset += 1; + }; + const string = () => { + const start = offset; + if (source[offset] !== '"') throw new SyntaxError(`Expected JSON string at byte ${offset}.`); + offset += 1; + while (offset < source.length) { + if (source[offset] === '\\') { + offset += 2; + continue; + } + if (source[offset] === '"') { + offset += 1; + return JSON.parse(source.slice(start, offset)); + } + offset += 1; + } + throw new SyntaxError('Unterminated JSON string.'); + }; + const value = (pointer = '') => { + whitespace(); + const start = offset; + if (source[offset] === '{') { + offset += 1; + whitespace(); + if (source[offset] !== '}') { + while (true) { + whitespace(); + const key = string(); + whitespace(); + if (source[offset] !== ':') throw new SyntaxError(`Expected ':' at byte ${offset}.`); + offset += 1; + value(`${pointer}/${pointerToken(key)}`); + whitespace(); + if (source[offset] === '}') break; + if (source[offset] !== ',') throw new SyntaxError(`Expected ',' at byte ${offset}.`); + offset += 1; + } + } + offset += 1; + } else if (source[offset] === '[') { + offset += 1; + whitespace(); + let index = 0; + if (source[offset] !== ']') { + while (true) { + value(`${pointer}/${index}`); + index += 1; + whitespace(); + if (source[offset] === ']') break; + if (source[offset] !== ',') throw new SyntaxError(`Expected ',' at byte ${offset}.`); + offset += 1; + } + } + offset += 1; + } else if (source[offset] === '"') { + string(); + } else { + const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/u.exec(source.slice(offset))?.[0]; + if (!token) throw new SyntaxError(`Invalid JSON value at byte ${offset}.`); + offset += token.length; + } + spans.set(pointer, { start, end: offset }); + }; + value(); + whitespace(); + if (offset !== source.length) throw new SyntaxError(`Unexpected JSON content at byte ${offset}.`); + return spans; +} + +function collectLeafDifferences(before, after, pointer = '', differences = []) { + if (Object.is(before, after)) return differences; + const beforeObject = before !== null && typeof before === 'object'; + const afterObject = after !== null && typeof after === 'object'; + if (!beforeObject || !afterObject || Array.isArray(before) !== Array.isArray(after)) { + differences.push({ pointer, value: after }); + return differences; + } + const beforeKeys = Object.keys(before); + const afterKeys = Object.keys(after); + if (beforeKeys.length !== afterKeys.length || beforeKeys.some(key => !Object.hasOwn(after, key))) { + differences.push({ pointer, value: after, structural: true }); + return differences; + } + for (const key of beforeKeys) { + collectLeafDifferences(before[key], after[key], `${pointer}/${pointerToken(key)}`, differences); + if (differences.length > 1) return differences; + } + return differences; +} + +export function synchronizeJsonSource(sourceText, document) { + if (typeof sourceText !== 'string') return `${JSON.stringify(document, null, 2)}\n`; + let parsed; + let spans; + try { + parsed = JSON.parse(sourceText); + spans = locateJsonValues(sourceText); + } catch (error) { + throw new StudioError('artifact_source_invalid', `Stored JSON artifact source is invalid: ${error.message}`); + } + const differences = collectLeafDifferences(parsed, document); + if (differences.length === 0) return sourceText; + if (differences.length !== 1 || differences[0].structural || differences[0].pointer === '') { + throw new StudioError('artifact_source_diverged', 'Artifact export requires exactly one existing JSON leaf change.', { + differenceCount: differences.length, + }); + } + const span = spans.get(differences[0].pointer); + if (!span) throw new StudioError('artifact_pointer_not_found', `Artifact JSON pointer ${differences[0].pointer} is absent from stored source.`); + const encoded = JSON.stringify(differences[0].value); + if (encoded === undefined) throw new StudioError('artifact_value_invalid', 'Artifact JSON values must be serializable.'); + return `${sourceText.slice(0, span.start)}${encoded}${sourceText.slice(span.end)}`; +} diff --git a/src/runtime/project-artifact-exchange.mjs b/src/runtime/project-artifact-exchange.mjs new file mode 100644 index 0000000..a6425b8 --- /dev/null +++ b/src/runtime/project-artifact-exchange.mjs @@ -0,0 +1,130 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { contentHash } from '../core/util.mjs'; +import { StudioError } from '../core/errors.mjs'; +import { synchronizeJsonSource } from './json-artifact.mjs'; + +const MAX_ARTIFACT_BYTES = 1_048_576; +const REFERENCE_PATTERN = /^(?![A-Za-z]:)(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u; + +function digest(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fail(code, message, details) { + throw new StudioError(code, message, details); +} + +async function trustedRoot(repositoryRoot) { + if (typeof repositoryRoot !== 'string' || repositoryRoot.trim() === '') fail('artifact_exchange_not_configured', 'Artifact exchange requires a configured repository root.'); + const supplied = path.resolve(repositoryRoot); + const parsed = path.parse(supplied); + let current = parsed.root; + for (const segment of path.relative(parsed.root, supplied).split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + const info = await lstat(current).catch(() => fail('artifact_root_invalid', 'Configured artifact root could not be inspected.')); + if (info.isSymbolicLink()) fail('artifact_path_reparse', 'Configured artifact root must not traverse a reparse point.'); + if (!info.isDirectory()) fail('artifact_root_invalid', 'Configured artifact root must be a directory.'); + } + return realpath(supplied); +} + +async function containedFile(repositoryRoot, reference) { + if (typeof reference !== 'string' || !REFERENCE_PATTERN.test(reference)) { + fail('artifact_path_invalid', 'Artifact paths must be normalized repository-relative paths.', { reference }); + } + const root = await trustedRoot(repositoryRoot); + let current = root; + for (const segment of reference.split('/')) { + current = path.join(current, segment); + const info = await lstat(current).catch(error => { + if (error.code === 'ENOENT') fail('artifact_not_found', 'Artifact file was not found.', { reference }); + throw error; + }); + if (info.isSymbolicLink()) fail('artifact_path_reparse', 'Artifact paths must not traverse a reparse point.', { reference }); + } + const canonical = await realpath(current); + const relative = path.relative(root, canonical); + if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + fail('artifact_path_invalid', 'Artifact path escaped the configured repository root.', { reference }); + } + const info = await stat(canonical); + if (!info.isFile()) fail('artifact_not_found', 'Artifact reference must identify a regular file.', { reference }); + if (info.size > MAX_ARTIFACT_BYTES) fail('artifact_too_large', `JSON artifacts may not exceed ${MAX_ARTIFACT_BYTES} bytes.`, { reference, bytes: info.size }); + return canonical; +} + +function assertArtifact(artifact, expectedArtifactHash) { + if (artifact?.kind !== 'jsonArtifact' || artifact.mediaType !== 'application/json' + || artifact.document === null || typeof artifact.document !== 'object' || Array.isArray(artifact.document)) { + fail('invalid_artifact_target', 'Artifact export requires a canonical JSON artifact.'); + } + const actualArtifactHash = contentHash(artifact); + if (actualArtifactHash !== expectedArtifactHash) { + fail('artifact_hash_mismatch', 'Artifact changed after it was inspected.', { expectedArtifactHash, actualArtifactHash }); + } +} + +export function createProjectArtifactExchange({ repositoryRoot } = {}) { + return Object.freeze({ + configured: typeof repositoryRoot === 'string' && repositoryRoot.trim() !== '', + async importArtifact({ reference, artifactId, name, schemaId, expectedFileSha256 }) { + const filePath = await containedFile(repositoryRoot, reference); + const bytes = await readFile(filePath); + const fileSha256 = digest(bytes); + if (fileSha256 !== expectedFileSha256) { + fail('artifact_file_hash_mismatch', 'Artifact source bytes do not match the expected SHA-256.', { + reference, expectedFileSha256, actualFileSha256: fileSha256, + }); + } + let document; + try { + document = JSON.parse(bytes.toString('utf8')); + } catch (error) { + fail('artifact_json_invalid', `Artifact source is not valid JSON: ${error.message}`, { reference }); + } + if (document === null || typeof document !== 'object' || Array.isArray(document)) { + fail('artifact_json_invalid', 'Artifact source must contain a JSON object.', { reference }); + } + const artifact = { + id: artifactId, + kind: 'jsonArtifact', + name: name ?? artifactId.split('/').at(-1), + mediaType: 'application/json', + schemaId, + document, + sourceText: bytes.toString('utf8'), + metadata: { importedFrom: reference, importedFileSha256: fileSha256 }, + }; + return { artifact, fileSha256, artifactHash: contentHash(artifact), reference }; + }, + async exportArtifact({ reference, artifact, expectedArtifactHash, expectedFileSha256 }) { + assertArtifact(artifact, expectedArtifactHash); + const filePath = await containedFile(repositoryRoot, reference); + const before = await readFile(filePath); + const actualFileSha256 = digest(before); + if (actualFileSha256 !== expectedFileSha256) { + fail('artifact_file_hash_mismatch', 'Artifact destination changed after it was inspected.', { + reference, expectedFileSha256, actualFileSha256, + }); + } + const sourceText = synchronizeJsonSource(artifact.sourceText, artifact.document); + const bytes = Buffer.from(sourceText, 'utf8'); + if (bytes.length > MAX_ARTIFACT_BYTES) fail('artifact_too_large', `JSON artifacts may not exceed ${MAX_ARTIFACT_BYTES} bytes.`, { reference, bytes: bytes.length }); + const fileSha256 = digest(bytes); + const changed = !before.equals(bytes); + if (changed) { + const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); + await rename(temporary, filePath); + } catch (error) { + await unlink(temporary).catch(() => {}); + throw error; + } + } + return { reference, fileSha256, artifactHash: expectedArtifactHash, changed, bytes: bytes.length }; + }, + }); +} diff --git a/src/runtime/rehearsal-sidecar-client.mjs b/src/runtime/rehearsal-sidecar-client.mjs new file mode 100644 index 0000000..bf06e40 --- /dev/null +++ b/src/runtime/rehearsal-sidecar-client.mjs @@ -0,0 +1,192 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstat, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { StudioError } from '../core/errors.mjs'; + +const FIXED_CLI = ['tools', 'rehearsal-sidecar', 'src', 'cli.mjs']; +const REFERENCE_PATTERN = /^(?![A-Za-z]:)(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u; +const MAX_PROTOCOL_BYTES = 4 * 1024 * 1024; +const MAX_DIAGNOSTIC_BYTES = 64 * 1024; +const ENVIRONMENT_ALLOWLIST = new Set([ + 'appdata', 'comspec', 'localappdata', 'path', 'pathext', 'programdata', 'programfiles', + 'programfiles(x86)', 'programw6432', 'systemdrive', 'systemroot', 'temp', 'tmp', 'userprofile', 'windir', +]); + +function minimalEnvironment(environment) { + return Object.fromEntries(Object.entries(environment ?? {}).filter(([key]) => ENVIRONMENT_ALLOWLIST.has(key.toLowerCase()))); +} + +async function resolveFixedCli(repositoryRoot) { + if (typeof repositoryRoot !== 'string' || repositoryRoot.trim() === '') { + throw new StudioError('rehearsal_not_configured', 'Rehearsal jobs require THREE_STUDIO_REHEARSAL_ROOT.'); + } + const supplied = path.resolve(repositoryRoot); + const parsed = path.parse(supplied); + let current = parsed.root; + for (const segment of path.relative(parsed.root, supplied).split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + const info = await lstat(current).catch(() => null); + if (!info?.isDirectory()) throw new StudioError('rehearsal_root_invalid', 'Configured rehearsal root must be a real directory.'); + if (info.isSymbolicLink()) throw new StudioError('rehearsal_root_reparse', 'Configured rehearsal root must not traverse a reparse point.'); + } + const root = await realpath(supplied); + current = root; + for (const segment of FIXED_CLI) { + current = path.join(current, segment); + const info = await lstat(current).catch(() => null); + if (!info) throw new StudioError('rehearsal_cli_missing', 'The fixed rehearsal sidecar entrypoint was not found.'); + if (info.isSymbolicLink()) throw new StudioError('rehearsal_cli_reparse', 'The fixed rehearsal entrypoint must not traverse a reparse point.'); + } + if (!(await stat(current)).isFile()) throw new StudioError('rehearsal_cli_missing', 'The fixed rehearsal sidecar entrypoint is not a regular file.'); + return { root, cli: await realpath(current) }; +} + +function cloneEvidence(value) { + return value === undefined ? undefined : structuredClone(value); +} + +function summarizeTerminal(terminal, progressEvents) { + if (terminal?.error) { + throw new StudioError('rehearsal_failed', 'The fixed rehearsal sidecar rejected the run.', { + sidecarCode: terminal.error.code, + bundle: cloneEvidence(terminal.error.data?.bundle), + }); + } + const result = terminal?.result; + const manifest = result?.bundle?.manifest; + if (!result || typeof result.runId !== 'string' || !Number.isInteger(result.exitCode) + || typeof result.reason !== 'string' || !/^[a-f0-9]{64}$/u.test(result.bundle?.bundleId ?? '') + || !manifest || !['passed', 'failed'].includes(manifest.outcome)) { + throw new StudioError('rehearsal_protocol_invalid', 'The fixed rehearsal sidecar returned an invalid terminal result.'); + } + const summary = { + runId: result.runId, + exitCode: result.exitCode, + reason: result.reason, + bundle: { + bundleId: result.bundle.bundleId, + outcome: manifest.outcome, + git: cloneEvidence(manifest.git), + source: cloneEvidence(manifest.source), + stateDigest: cloneEvidence(manifest.stateDigest), + assertions: cloneEvidence(manifest.assertions ?? []), + captures: cloneEvidence(manifest.captures ?? []), + }, + progressEvents, + }; + if (result.exitCode !== 0 || manifest.outcome !== 'passed') { + throw new StudioError('rehearsal_failed', 'The authoritative rehearsal did not pass.', summary); + } + return summary; +} + +export function createRehearsalSidecarClient({ + repositoryRoot, + nodeExecutable = process.execPath, + environment = process.env, + spawnProcess = spawn, + shutdownGraceMs = 2_000, +} = {}) { + return Object.freeze({ + configured: typeof repositoryRoot === 'string' && repositoryRoot.trim() !== '', + async run({ runSpecReference, signal, timeoutMs = 115_000 } = {}) { + if (typeof runSpecReference !== 'string' || !REFERENCE_PATTERN.test(runSpecReference)) { + throw new StudioError('rehearsal_run_spec_invalid', 'Rehearsal jobs accept only a normalized repository-relative run-spec reference.'); + } + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 240_000) { + throw new StudioError('rehearsal_timeout_invalid', 'Rehearsal client timeout must be from 1 to 240000 milliseconds.'); + } + if (signal?.aborted) throw new StudioError('rehearsal_cancelled', 'Rehearsal request was cancelled before launch.'); + const { root, cli } = await resolveFixedCli(repositoryRoot); + const child = spawnProcess(nodeExecutable, [cli], { + cwd: root, + env: minimalEnvironment(environment), + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const requestId = `studio-${randomUUID()}`; + let stdoutBytes = 0; + let stderrBytes = 0; + let buffer = ''; + let progressEvents = 0; + let settled = false; + let settleTerminal; + let rejectTerminal; + const terminalPromise = new Promise((resolve, reject) => { + settleTerminal = resolve; + rejectTerminal = reject; + }); + const closePromise = new Promise(resolve => child.once('close', resolve)); + const fail = error => { + if (settled) return; + settled = true; + rejectTerminal(error); + }; + child.once('error', error => fail(new StudioError('rehearsal_launch_failed', `The fixed rehearsal sidecar did not launch: ${error.message}`))); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { + stdoutBytes += Buffer.byteLength(chunk); + if (stdoutBytes > MAX_PROTOCOL_BYTES) { + fail(new StudioError('rehearsal_protocol_too_large', 'Rehearsal protocol output exceeded its byte limit.')); + child.stdin.end(); + return; + } + buffer += chunk; + const lines = buffer.split(/\r?\n/u); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (line === '') continue; + let message; + try { + message = JSON.parse(line); + } catch { + fail(new StudioError('rehearsal_protocol_invalid', 'Rehearsal sidecar emitted non-JSON protocol output.')); + child.stdin.end(); + return; + } + if (message.method === 'progress') progressEvents += 1; + if (message.id === requestId && !settled) { + settled = true; + settleTerminal(message); + } + } + }); + child.stderr.on('data', chunk => { + stderrBytes += chunk.length; + if (stderrBytes > MAX_DIAGNOSTIC_BYTES) { + fail(new StudioError('rehearsal_diagnostics_too_large', 'Rehearsal diagnostic output exceeded its byte limit.')); + child.stdin.end(); + } + }); + child.once('close', code => { + if (!settled) fail(new StudioError('rehearsal_protocol_closed', `Rehearsal sidecar closed before a terminal response (exit ${code}).`)); + }); + const terminate = error => { + fail(error); + child.stdin.end(); + const force = setTimeout(() => child.kill(), shutdownGraceMs); + force.unref?.(); + }; + const abort = () => terminate(new StudioError('rehearsal_cancelled', 'Rehearsal request was cancelled.')); + signal?.addEventListener('abort', abort, { once: true }); + const timeout = setTimeout(() => terminate(new StudioError('rehearsal_timeout', 'Rehearsal request exceeded the Studio client timeout.')), timeoutMs); + timeout.unref?.(); + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', id: requestId, method: 'run', params: { runSpecReference }, + })}\n`); + try { + const terminal = await terminalPromise; + child.stdin.end(); + await closePromise; + return summarizeTerminal(terminal, progressEvents); + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + if (!child.stdin.destroyed) child.stdin.end(); + await closePromise; + } + }, + }); +} diff --git a/src/runtime/studio-application.mjs b/src/runtime/studio-application.mjs index 12e448c..e8fb8d8 100644 --- a/src/runtime/studio-application.mjs +++ b/src/runtime/studio-application.mjs @@ -108,6 +108,9 @@ import { createTransactionId } from '../core/util.mjs'; export { materialLookResource, queryLookCatalog } from './material-looks.mjs'; import { bakeProceduralTextureGraph } from './procedural-texture-compiler.mjs'; import { createLogicControllerRuntime, LOGIC_CONTROLLER_LIMITS } from './logic-controller-runtime.mjs'; +import { createProjectArtifactExchange } from './project-artifact-exchange.mjs'; +import { createRehearsalSidecarClient } from './rehearsal-sidecar-client.mjs'; +import { synchronizeJsonSource } from './json-artifact.mjs'; const INSPECT_RESPONSE_ENVELOPE_RESERVE_BYTES = 2_048; @@ -533,6 +536,52 @@ function translateStrokeOperation(operation, document) { export function translateToolOperation(operation, document) { const data = operation.data ?? {}; + if (operation.op === 'artifact.json.patch') { + const { resource } = new ProjectIndex(document).getResource(operation.artifactId, 'assets'); + if (resource.kind !== 'jsonArtifact' || resource.mediaType !== 'application/json' || !isRecord(resource.document)) { + throw new StudioError('invalid_artifact_target', `${operation.artifactId} is not a canonical JSON artifact.`, { + artifactId: operation.artifactId, + }); + } + const actualArtifactHash = contentHash(resource); + if (operation.expectedArtifactHash !== actualArtifactHash) { + throw new StudioError('artifact_hash_mismatch', `Artifact ${operation.artifactId} changed after it was inspected.`, { + artifactId: operation.artifactId, + expectedArtifactHash: operation.expectedArtifactHash, + actualArtifactHash, + }); + } + const next = structuredClone(resource.document); + const tokens = operation.pointer.slice(1).split('/').map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')); + const forbidden = new Set(['__proto__', 'prototype', 'constructor']); + let target = next; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (forbidden.has(token)) { + throw new StudioError('artifact_pointer_forbidden', 'Artifact JSON pointers cannot target prototype properties.', { + artifactId: operation.artifactId, + pointer: operation.pointer, + }); + } + const key = Array.isArray(target) && /^(?:0|[1-9][0-9]*)$/u.test(token) ? Number(token) : token; + if ((typeof target !== 'object' || target === null) || !Object.hasOwn(target, key)) { + throw new StudioError('artifact_pointer_not_found', `Artifact JSON pointer ${operation.pointer} does not identify an existing value.`, { + artifactId: operation.artifactId, + pointer: operation.pointer, + }); + } + if (index === tokens.length - 1) target[key] = structuredClone(operation.value); + else target = target[key]; + } + const patch = { document: next }; + if (typeof resource.sourceText === 'string') patch.sourceText = synchronizeJsonSource(resource.sourceText, next); + return { + type: 'resource.patch', + resourceType: 'assets', + resourceId: operation.artifactId, + patch, + }; + } if (operation.op === 'stroke.apply') return translateStrokeOperation(operation, document); if (operation.op === 'entity.createMany') return operation.items.map(item => ({ type: 'entity.create', sceneId: operation.sceneId, entity: item.entity, @@ -1510,6 +1559,8 @@ export class StudioApplication { #beginCommand = null; #commandMetrics = null; #activeApplyMetrics = null; + #artifactExchange; + #rehearsalClient; constructor({ THREE, TSL, viewport, bootstrap, markerPath, credentials, beginCommand, commandMetrics, environment = process.env, projectsRoot } = {}) { this.#THREE = THREE; @@ -1524,6 +1575,9 @@ export class StudioApplication { this.studioRoot = path.resolve(studioRoot); const configuredProjects = String(projectsRoot ?? env.THREE_STUDIO_PROJECTS ?? '').trim(); this.projectsRoot = path.resolve(configuredProjects || path.join(this.studioRoot, 'projects')); + const rehearsalRoot = String(env.THREE_STUDIO_REHEARSAL_ROOT ?? '').trim(); + this.#artifactExchange = createProjectArtifactExchange({ repositoryRoot: rehearsalRoot }); + this.#rehearsalClient = createRehearsalSidecarClient({ repositoryRoot: rehearsalRoot, environment: env }); this.#markerPath = path.resolve(markerPath ?? env.THREE_STUDIO_SESSION_MARKER ?? defaultSessionMarkerPath({ env })); this.#localStatePath = path.join(path.dirname(this.#markerPath), 'studio-state.json'); } @@ -2071,8 +2125,15 @@ export class StudioApplication { timelineGeometryMaxSamples: GEOMETRY_MODIFIER_LIMITS.maxOceanTimelineSamples, dynamicRtxGeometry: 'excluded-from-static-scene', jobs: true, - jobKinds: ['textureBake', 'sceneExport'], + jobKinds: ['textureBake', 'sceneExport', ...(this.#rehearsalClient.configured ? ['rehearsalRun'] : [])], projectTemplates: ['blank', 'starter'], + rehearsal: { + configured: this.#rehearsalClient.configured, + action: 'rehearsalRun', + protocol: 'fixed-sidecar-json-rpc', + runSpecPolicy: 'configured-root-relative-only', + shell: false, + }, graphDomains: ['shader', 'texture', 'blueprint'], entityKinds: [ 'scene', 'group', 'empty', 'gameObject', 'mesh', 'instancedMesh', @@ -2177,7 +2238,14 @@ export class StudioApplication { applyPixelForecast: true, compileHeavyRpcTimeoutMs: 120_000, validationChecks: ['schemas', 'references', 'hierarchy', 'graphs', 'animations', 'budgets'], - projectActions: ['list', 'create', 'open', 'save'], + projectActions: ['list', 'create', 'open', 'save', 'artifactImport', 'artifactExport'], + projectArtifacts: { + configured: this.#artifactExchange.configured, + kind: 'jsonArtifact', + mediaType: 'application/json', + editOperation: 'artifact.json.patch', + pathPolicy: 'configured-root-relative-only', + }, historyActions: ['list', 'inspect', 'undo', 'redo'], playSimulation: 'actions-controller-physics-and-timeline-modifiers', maxShadowLights: 16, @@ -2246,6 +2314,24 @@ export class StudioApplication { async #job(params, context = {}) { this.#assertTarget(params, { requireActiveScene: false }); if (params.action === 'sceneExport') return this.#exportScene(params); + if (params.action === 'rehearsalRun') { + if (!this.#rehearsalClient.configured) throw new StudioError('rehearsal_not_configured', 'Rehearsal jobs require THREE_STUDIO_REHEARSAL_ROOT.'); + if (params.baseRevision !== this.#kernel.revision) { + throw new StudioError('revision_conflict', `Base revision ${params.baseRevision} does not match ${this.#kernel.revision}.`); + } + const rehearsal = await this.#rehearsalClient.run({ + runSpecReference: params.runSpecReference, + signal: context.signal, + }); + return { + success: true, + projectId: this.#kernel.projectId, + revision: this.#kernel.revision, + savedRevision: this.#kernel.document.savedRevision, + dirty: this.#kernel.dirty, + job: { action: params.action, ...rehearsal }, + }; + } if (params.action !== 'textureBake') throw new StudioError('job_not_implemented', `Job ${params.action} is not enabled.`); const resource = this.#kernel.document.resources?.graphs?.[params.graphId]; if (!resource) throw new StudioError('not_found', `Graph ${params.graphId} does not exist.`, { id: params.graphId, kind: 'graph' }); @@ -2402,6 +2488,52 @@ export class StudioApplication { } if (params.projectId !== this.#kernel.projectId) throw new StudioError('project_mismatch', `Active project is ${this.#kernel.projectId}.`); if (params.baseRevision !== this.#kernel.revision) throw new StudioError('revision_conflict', `Base revision ${params.baseRevision} does not match ${this.#kernel.revision}.`); + if (params.action === 'artifactImport') { + if (!this.#artifactExchange.configured) throw new StudioError('artifact_exchange_not_configured', 'Artifact exchange requires THREE_STUDIO_REHEARSAL_ROOT.'); + const imported = await this.#artifactExchange.importArtifact({ + reference: params.path, + artifactId: params.artifactId, + name: params.name, + schemaId: params.schemaId, + expectedFileSha256: params.expectedFileSha256, + }); + const result = await this.#apply({ + protocolVersion: params.protocolVersion, + sessionId: params.sessionId, + projectId: params.projectId, + baseRevision: params.baseRevision, + idempotencyKey: params.idempotencyKey, + label: params.label, + operations: [{ op: 'resource.create', resourceType: 'asset', resource: imported.artifact }], + }); + return { + ...result, + artifact: { + id: params.artifactId, + schemaId: params.schemaId, + reference: params.path, + fileSha256: imported.fileSha256, + artifactHash: contentHash(this.#kernel.document.resources.assets[params.artifactId]), + }, + }; + } + if (params.action === 'artifactExport') { + if (!this.#artifactExchange.configured) throw new StudioError('artifact_exchange_not_configured', 'Artifact exchange requires THREE_STUDIO_REHEARSAL_ROOT.'); + const { resource } = new ProjectIndex(this.#kernel.document).getResource(params.artifactId, 'assets'); + const exported = await this.#artifactExchange.exportArtifact({ + reference: params.path, + artifact: resource, + expectedArtifactHash: params.expectedArtifactHash, + expectedFileSha256: params.expectedFileSha256, + }); + return { + success: true, + projectId: this.#kernel.projectId, + revision: this.#kernel.revision, + savedRevision: this.#kernel.document.savedRevision, + artifact: { id: params.artifactId, ...exported }, + }; + } if (params.action === 'save' || params.action === 'checkpoint') { const result = await this.#kernel.save(); this.#viewport.setTitle({ project: this.#kernel.document.name, scene: this.#kernel.document.scenes[this.#kernel.document.activeSceneId]?.name, revision: this.#kernel.revision, dirty: false }); diff --git a/tests/bridge-rpc.test.mjs b/tests/bridge-rpc.test.mjs index 5867a8c..545503a 100644 --- a/tests/bridge-rpc.test.mjs +++ b/tests/bridge-rpc.test.mjs @@ -11,6 +11,7 @@ import { LiveBridgeClient, MAX_MESSAGE_BYTES, PathwireCodec, + MAX_REQUEST_TIMEOUT_MS, PROTOCOL_VERSION, RpcError, assertLiveSessionIdentity, @@ -19,11 +20,16 @@ import { createSessionMarker, defaultSessionMarkerPath, readSessionMarker, + requestTimeoutMsForMethod, safeError, writeSessionMarker, } from '../src/bridge/index.mjs'; import { resolveLiveConnectionOptions } from '../src/mcp/server.mjs'; +test('compile-heavy timeout policy includes controlled jobs', () => { + assert.equal(requestTimeoutMsForMethod('three_studio_job'), MAX_REQUEST_TIMEOUT_MS); +}); + const execFileAsync = promisify(execFile); test('Pathwire round-trips arbitrary JSON-shaped values as grep-friendly pointer lines', () => { diff --git a/tests/mcp-tools.test.mjs b/tests/mcp-tools.test.mjs index 548a228..647a5dd 100644 --- a/tests/mcp-tools.test.mjs +++ b/tests/mcp-tools.test.mjs @@ -145,7 +145,7 @@ test('checked-in JSON contract is generated exactly from compact live schemas', })); const encoder = new TextEncoder(); assert.ok(encoder.encode(JSON.stringify(TOOL_INPUT_SCHEMAS.three_studio_apply)).byteLength <= 73_000); - assert.ok(encoder.encode(JSON.stringify(TOOL_INPUT_SCHEMAS)).byteLength <= 85_000); + assert.ok(encoder.encode(JSON.stringify(TOOL_INPUT_SCHEMAS)).byteLength <= 86_000); }); test('modifier schemas expose every strict per-type control and mirror the checked-in contract', async () => { @@ -273,7 +273,8 @@ test('MCP contract exposes only the live inspect and mutation slice', () => { 'lighting.rig.create', 'modifier.create', 'modifier.patch', 'modifier.move', 'modifier.delete', 'modifier.stack.edit', 'geometry.edit', 'geometry.realize', 'geometry.loft.edit', 'geometry.selection.edit', - 'material.variant.create', 'material.look.create', 'material.look.patch', + 'material.variant.create', 'material.look.create', 'material.look.patch', + 'artifact.json.patch', 'resource.create', 'resource.createMany', 'resource.patch', 'resource.delete', ]); assert.equal(inspectSchema.safeParse({ query: 'selector', selector: { tag: 'hero' }, include: ['tree', 'transform', 'bounds', 'references'] }).success, true); @@ -603,6 +604,73 @@ test('project, play, and history mutation actions require correlation metadata', }).success, false); }); +test('project artifact exchange requires revision, content, and destination guards', () => { + const common = { + protocolVersion: 'three-studio/1', + sessionId: 'live-session', + projectId: 'project/demo', + baseRevision: 3, + idempotencyKey: 'artifact-exchange-1', + label: 'Exchange the rehearsal artifact', + }; + assert.equal(projectSchema.safeParse({ + ...common, + action: 'artifactImport', + path: 'examples/hero-chamber.labyrinth.json', + artifactId: 'artifact/hero-chamber', + schemaId: 'project-labyrinth/labyrinth-spec@0.1.0', + expectedFileSha256: 'a'.repeat(64), + }).success, true); + assert.equal(projectSchema.safeParse({ + ...common, + action: 'artifactExport', + path: 'examples/hero-chamber.labyrinth.json', + artifactId: 'artifact/hero-chamber', + expectedArtifactHash: 'b'.repeat(64), + expectedFileSha256: 'a'.repeat(64), + }).success, true); + assert.equal(projectSchema.safeParse({ + ...common, + action: 'artifactExport', + path: '../outside.json', + artifactId: 'artifact/hero-chamber', + expectedArtifactHash: 'b'.repeat(64), + }).success, false); +}); + +test('artifact JSON patches are exact guarded replacements in the existing apply tool', () => { + const result = applySchema.safeParse({ + protocolVersion: 'three-studio/1', + sessionId: 'live-session', + projectId: 'project/demo', + baseRevision: 3, + idempotencyKey: 'artifact-patch-1', + label: 'Move the split-link shuttle', + operations: [{ + op: 'artifact.json.patch', + artifactId: 'artifact/hero-chamber', + pointer: '/poses/2/overrides/0/transform/position/0', + value: 7, + expectedArtifactHash: 'b'.repeat(64), + }], + }); + assert.equal(result.success, true); +}); + +test('rehearsal jobs accept only a repository-relative run-spec reference', () => { + const common = { + action: 'rehearsalRun', + projectId: 'project/demo', + runSpecReference: 'run-specs/p1-hero-chamber.run.json', + baseRevision: 3, + idempotencyKey: 'rehearsal-run-1', + label: 'Run the authoritative rehearsal', + }; + assert.equal(jobSchema.safeParse(common).success, true); + assert.equal(jobSchema.safeParse({ ...common, runSpecReference: '../escape.json' }).success, false); + assert.equal(jobSchema.safeParse({ ...common, command: 'pwsh -File anything.ps1' }).success, false); +}); + test('registered handler forwards exact tool name and cancellation signal', async () => { const calls = []; const server = createThreeStudioMcpServer({ diff --git a/tests/project-artifact-exchange.test.mjs b/tests/project-artifact-exchange.test.mjs new file mode 100644 index 0000000..581be8e --- /dev/null +++ b/tests/project-artifact-exchange.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { contentHash } from '../src/core/index.mjs'; + +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +async function loadExchangeFactory() { + try { + return (await import('../src/runtime/project-artifact-exchange.mjs')).createProjectArtifactExchange; + } catch { + return undefined; + } +} + +test('JSON artifact import and guarded export preserve formatting outside the authored value', async (t) => { + const createProjectArtifactExchange = await loadExchangeFactory(); + assert.equal(typeof createProjectArtifactExchange, 'function'); + const root = await mkdtemp(path.join(tmpdir(), 'three-studio-artifact-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'examples')); + const reference = 'examples/hero-chamber.labyrinth.json'; + const filePath = path.join(root, ...reference.split('/')); + const original = '{\n "schemaVersion": "0.1.0",\n "poses": [\n {\n "id": "split-link",\n "position": [6, 0, 0]\n }\n ],\n "validation": {\n "maxModules": 32\n }\n}\n'; + await writeFile(filePath, original); + + const exchange = createProjectArtifactExchange({ repositoryRoot: root }); + const imported = await exchange.importArtifact({ + reference, + artifactId: 'artifact/hero-chamber', + name: 'Hero Chamber', + schemaId: 'project-labyrinth/labyrinth-spec@0.1.0', + expectedFileSha256: sha256(original), + }); + assert.equal(imported.fileSha256, sha256(original)); + assert.equal(imported.artifact.document.poses[0].position[0], 6); + assert.equal(imported.artifact.metadata.importedFrom, reference); + + const roundTrip = await exchange.exportArtifact({ + reference, + artifact: imported.artifact, + expectedArtifactHash: contentHash(imported.artifact), + expectedFileSha256: sha256(original), + }); + assert.equal(await readFile(filePath, 'utf8'), original); + assert.equal(roundTrip.changed, false); + + const edited = structuredClone(imported.artifact); + edited.document.poses[0].position[0] = 7; + const exported = await exchange.exportArtifact({ + reference, + artifact: edited, + expectedArtifactHash: contentHash(edited), + expectedFileSha256: roundTrip.fileSha256, + }); + const expected = original.replace('[6, 0, 0]', '[7, 0, 0]'); + assert.equal(await readFile(filePath, 'utf8'), expected); + assert.equal(exported.fileSha256, sha256(expected)); + assert.equal(exported.changed, true); +}); + +test('JSON artifact exchange rejects traversal, stale destination bytes, and junction escapes', async (t) => { + const createProjectArtifactExchange = await loadExchangeFactory(); + assert.equal(typeof createProjectArtifactExchange, 'function'); + const root = await mkdtemp(path.join(tmpdir(), 'three-studio-artifact-root-')); + const outside = await mkdtemp(path.join(tmpdir(), 'three-studio-artifact-outside-')); + t.after(() => Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ])); + await writeFile(path.join(root, 'artifact.json'), '{"safe":true}\n'); + await writeFile(path.join(outside, 'escaped.json'), '{"escaped":true}\n'); + await symlink(outside, path.join(root, 'linked'), 'junction'); + const exchange = createProjectArtifactExchange({ repositoryRoot: root }); + + await assert.rejects(() => exchange.importArtifact({ + reference: '../artifact.json', artifactId: 'artifact/test', schemaId: 'test/schema@1', + expectedFileSha256: sha256('{"safe":true}\n'), + }), error => error.code === 'artifact_path_invalid'); + await assert.rejects(() => exchange.importArtifact({ + reference: 'linked/escaped.json', artifactId: 'artifact/test', schemaId: 'test/schema@1', + expectedFileSha256: sha256('{"escaped":true}\n'), + }), error => error.code === 'artifact_path_reparse'); + + const artifact = { + id: 'artifact/test', kind: 'jsonArtifact', name: 'test', mediaType: 'application/json', + schemaId: 'test/schema@1', document: { safe: false }, metadata: {}, + }; + await assert.rejects(() => exchange.exportArtifact({ + reference: 'artifact.json', artifact, expectedArtifactHash: contentHash(artifact), + expectedFileSha256: '0'.repeat(64), + }), error => error.code === 'artifact_file_hash_mismatch'); + assert.equal(await readFile(path.join(root, 'artifact.json'), 'utf8'), '{"safe":true}\n'); +}); diff --git a/tests/rehearsal-sidecar-client.test.mjs b/tests/rehearsal-sidecar-client.test.mjs new file mode 100644 index 0000000..c3df55e --- /dev/null +++ b/tests/rehearsal-sidecar-client.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +async function loadClientFactory() { + try { + return (await import('../src/runtime/rehearsal-sidecar-client.mjs')).createRehearsalSidecarClient; + } catch { + return undefined; + } +} + +async function fixtureRepository(t, source) { + const root = await mkdtemp(path.join(tmpdir(), 'three-studio-rehearsal-client-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, 'tools', 'rehearsal-sidecar', 'src'); + await mkdir(sourceRoot, { recursive: true }); + await writeFile(path.join(sourceRoot, 'cli.mjs'), source); + return root; +} + +test('fixed rehearsal client returns only verified bundle evidence and a minimal environment', async (t) => { + const createRehearsalSidecarClient = await loadClientFactory(); + assert.equal(typeof createRehearsalSidecarClient, 'function'); + const root = await fixtureRepository(t, ` + import { createInterface } from 'node:readline'; + const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); + lines.once('line', line => { + const request = JSON.parse(line); + if (process.cwd() !== ${JSON.stringify('__ROOT__')}.replace('__ROOT__', process.cwd())) process.exit(8); + if (process.env.SECRET_TOKEN !== undefined) process.exit(9); + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'progress', params: { event: { type: 'started' } } }) + '\\n'); + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { + runId: 'run-verified', exitCode: 0, reason: 'completed', stagingDirectory: 'PRIVATE', + unityResult: { status: 'passed', passed: true }, + bundle: { bundleId: 'a'.repeat(64), bundleDirectory: 'PRIVATE', manifest: { + outcome: 'passed', git: { commit: 'b'.repeat(40), dirty: false }, + source: { path: 'examples/hero.json', sha256: 'c'.repeat(64) }, + stateDigest: { algorithm: 'SHA-256', digest: 'd'.repeat(64) }, + assertions: [{ id: 'game.assertion', owner: 'game', passed: true }], + captures: [{ path: 'captures/tick-0.png', sha256: 'e'.repeat(64), requestedAuthoritativeTick: 0, presentedAuthoritativeTick: 0 }] + } } + } }) + '\\n'); + }); + `); + const client = createRehearsalSidecarClient({ + repositoryRoot: root, + environment: { ...process.env, SECRET_TOKEN: 'must-not-cross-boundary' }, + }); + const result = await client.run({ runSpecReference: 'run-specs/p1-hero-chamber.run.json', timeoutMs: 5_000 }); + assert.deepEqual(result, { + runId: 'run-verified', + exitCode: 0, + reason: 'completed', + bundle: { + bundleId: 'a'.repeat(64), + outcome: 'passed', + git: { commit: 'b'.repeat(40), dirty: false }, + source: { path: 'examples/hero.json', sha256: 'c'.repeat(64) }, + stateDigest: { algorithm: 'SHA-256', digest: 'd'.repeat(64) }, + assertions: [{ id: 'game.assertion', owner: 'game', passed: true }], + captures: [{ path: 'captures/tick-0.png', sha256: 'e'.repeat(64), requestedAuthoritativeTick: 0, presentedAuthoritativeTick: 0 }], + }, + progressEvents: 1, + }); + assert.equal(JSON.stringify(result).includes('PRIVATE'), false); +}); + +test('fixed rehearsal client rejects traversal before launch and closes on cancellation', async (t) => { + const createRehearsalSidecarClient = await loadClientFactory(); + assert.equal(typeof createRehearsalSidecarClient, 'function'); + const root = await fixtureRepository(t, ` + import { writeFile } from 'node:fs/promises'; + import path from 'node:path'; + process.stdin.resume(); + process.stdin.once('end', async () => { + await writeFile(path.join(process.cwd(), 'cancelled.txt'), 'closed'); + }); + `); + const client = createRehearsalSidecarClient({ repositoryRoot: root }); + await assert.rejects( + () => client.run({ runSpecReference: '../escape.json', timeoutMs: 100 }), + error => error.code === 'rehearsal_run_spec_invalid', + ); + const controller = new AbortController(); + const pending = client.run({ + runSpecReference: 'run-specs/p1-hero-chamber.run.json', + signal: controller.signal, + timeoutMs: 5_000, + }); + setTimeout(() => controller.abort(), 50); + await assert.rejects(pending, error => error.code === 'rehearsal_cancelled'); + assert.equal(await readFile(path.join(root, 'cancelled.txt'), 'utf8'), 'closed'); +}); diff --git a/tests/runtime-application-boundary.test.mjs b/tests/runtime-application-boundary.test.mjs index 543ad46..3a29e31 100644 --- a/tests/runtime-application-boundary.test.mjs +++ b/tests/runtime-application-boundary.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -304,7 +305,7 @@ async function saveProject(projectRoot, document) { await new AtomicProjectStore(projectRoot).save(document); } -async function applicationFixture(t, { document } = {}) { +async function applicationFixture(t, { document, environment = {} } = {}) { const studioRoot = await mkdtemp(path.join(os.tmpdir(), 'three-studio-app-boundary-')); const activeRoot = path.join(studioRoot, 'projects', 'active'); await saveProject(activeRoot, document ?? createProjectDocument({ @@ -312,8 +313,6 @@ async function applicationFixture(t, { document } = {}) { name: 'Active', })); - const previousStudioRoot = process.env.THREE_STUDIO_ROOT; - process.env.THREE_STUDIO_ROOT = studioRoot; const THREE = fakeThree(); const TSL = fakeTsl(); const viewport = fakeViewport(); @@ -322,9 +321,8 @@ async function applicationFixture(t, { document } = {}) { TSL, viewport, markerPath: path.join(studioRoot, 'session', 'live-session.json'), + environment: { ...process.env, THREE_STUDIO_ROOT: studioRoot, ...environment }, }); - if (previousStudioRoot === undefined) delete process.env.THREE_STUDIO_ROOT; - else process.env.THREE_STUDIO_ROOT = previousStudioRoot; t.after(async () => { await application.dispose(); @@ -480,6 +478,121 @@ test('project creation cannot escape the managed root or overwrite a populated d assert.equal(await readFile(sentinelPath, 'utf8'), 'user-owned'); }); +test('project artifact actions import, revision-edit, export, save, and reload canonical JSON', async (t) => { + const repositoryRoot = await mkdtemp(path.join(os.tmpdir(), 'three-studio-rehearsal-root-')); + t.after(() => rm(repositoryRoot, { recursive: true, force: true })); + await mkdir(path.join(repositoryRoot, 'examples')); + const reference = 'examples/hero-chamber.labyrinth.json'; + const sourcePath = path.join(repositoryRoot, ...reference.split('/')); + const original = '{\n "schemaVersion": "0.1.0",\n "poses": [{"id":"split-link","position":[6,0,0]}],\n "validation": {"maxModules":32}\n}\n'; + const sha256 = value => createHash('sha256').update(value).digest('hex'); + await writeFile(sourcePath, original); + const { application, activeRoot } = await applicationFixture(t, { + environment: { THREE_STUDIO_REHEARSAL_ROOT: repositoryRoot }, + }); + + const imported = await application.dispatch('three_studio_project', { + action: 'artifactImport', + sessionId: application.sessionId, + projectId: 'project/active', + baseRevision: 0, + path: reference, + artifactId: 'artifact/hero-chamber', + schemaId: 'project-labyrinth/labyrinth-spec@0.1.0', + expectedFileSha256: sha256(original), + idempotencyKey: 'artifact-import-0001', + label: 'Import the hero chamber manifest', + }); + assert.equal(imported.revision, 1); + const artifact = application.kernel.document.resources.assets['artifact/hero-chamber']; + assert.equal(artifact.document.poses[0].position[0], 6); + + const edited = await application.dispatch('three_studio_apply', { + protocolVersion: 'three-studio/1', + sessionId: application.sessionId, + projectId: 'project/active', + baseRevision: 1, + idempotencyKey: 'artifact-edit-0001', + label: 'Move the split-link shuttle', + operations: [{ + op: 'artifact.json.patch', + artifactId: 'artifact/hero-chamber', + pointer: '/poses/0/position/0', + value: 7, + expectedArtifactHash: contentHash(artifact), + }], + }); + assert.equal(edited.revision, 2); + const editedArtifact = application.kernel.document.resources.assets['artifact/hero-chamber']; + + const exported = await application.dispatch('three_studio_project', { + action: 'artifactExport', + sessionId: application.sessionId, + projectId: 'project/active', + baseRevision: 2, + path: reference, + artifactId: 'artifact/hero-chamber', + expectedArtifactHash: contentHash(editedArtifact), + expectedFileSha256: sha256(original), + idempotencyKey: 'artifact-export-0001', + label: 'Export the edited hero chamber manifest', + }); + assert.equal(exported.revision, 2); + assert.equal(await readFile(sourcePath, 'utf8'), original.replace('[6,0,0]', '[7,0,0]')); + + await application.dispatch('three_studio_project', { + action: 'save', sessionId: application.sessionId, projectId: 'project/active', baseRevision: 2, + idempotencyKey: 'artifact-save-0001', label: 'Save the artifact-bearing project', + }); + const persisted = await new AtomicProjectStore(activeRoot).load(); + assert.equal(persisted.document.resources.assets['artifact/hero-chamber'].document.poses[0].position[0], 7); +}); + +test('rehearsalRun job invokes the fixed sidecar and advertises scene export plus the configured rehearsal kind', async (t) => { + const repositoryRoot = await mkdtemp(path.join(os.tmpdir(), 'three-studio-rehearsal-job-')); + t.after(() => rm(repositoryRoot, { recursive: true, force: true })); + const cliRoot = path.join(repositoryRoot, 'tools', 'rehearsal-sidecar', 'src'); + await mkdir(cliRoot, { recursive: true }); + await writeFile(path.join(cliRoot, 'cli.mjs'), ` + import { createInterface } from 'node:readline'; + createInterface({ input: process.stdin, crlfDelay: Infinity }).once('line', line => { + const request = JSON.parse(line); + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { + runId: 'run-from-studio', exitCode: 0, reason: 'completed', + bundle: { bundleId: 'a'.repeat(64), manifest: { + outcome: 'passed', git: { commit: 'b'.repeat(40), dirty: false }, + source: { path: 'examples/hero.json', sha256: 'c'.repeat(64) }, + stateDigest: { algorithm: 'SHA-256', digest: 'd'.repeat(64) }, + assertions: [{ id: 'game.assertion', owner: 'game', passed: true }], + captures: [{ path: 'captures/tick-0.png', sha256: 'e'.repeat(64), requestedAuthoritativeTick: 0, presentedAuthoritativeTick: 0 }] + } } + } }) + '\\n'); + }); + `); + const { application } = await applicationFixture(t, { + environment: { THREE_STUDIO_REHEARSAL_ROOT: repositoryRoot }, + }); + const status = await application.dispatch('three_studio_status', { preset: 'full' }); + assert.deepEqual(status.capabilities.jobKinds, ['textureBake', 'sceneExport', 'rehearsalRun']); + assert.equal(status.capabilities.rehearsal.configured, true); + + const result = await application.dispatch('three_studio_job', { + action: 'rehearsalRun', + sessionId: application.sessionId, + projectId: 'project/active', + runSpecReference: 'run-specs/p1-hero-chamber.run.json', + baseRevision: 0, + idempotencyKey: 'rehearsal-job-0001', + label: 'Run the authoritative rehearsal', + }); + assert.equal(result.success, true); + assert.equal(result.revision, 0); + assert.equal(result.job.action, 'rehearsalRun'); + assert.equal(result.job.runId, 'run-from-studio'); + assert.equal(result.job.bundle.bundleId, 'a'.repeat(64)); + assert.equal(Object.hasOwn(result.job, 'stagingDirectory'), false); +}); + test('project-scoped requests reject a mismatched stable project ID', async (t) => { const { application } = await applicationFixture(t); await assert.rejects( diff --git a/tests/runtime-operations.test.mjs b/tests/runtime-operations.test.mjs index a701625..0fc814a 100644 --- a/tests/runtime-operations.test.mjs +++ b/tests/runtime-operations.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createProjectDocument } from '../src/core/index.mjs'; +import { contentHash, createProjectDocument } from '../src/core/index.mjs'; import { translateToolOperation } from '../src/runtime/studio-application.mjs'; test('runtime forwards strict core-shaped MCP operations without weakening them', () => { @@ -133,6 +133,66 @@ test('runtime rejects reserved pipelines instead of silently accepting them', () ); }); +test('artifact.json.patch lowers one guarded JSON-pointer replacement without touching unrelated content', () => { + const artifact = { + id: 'artifact/hero-chamber', + kind: 'jsonArtifact', + name: 'Hero Chamber', + mediaType: 'application/json', + schemaId: 'project-labyrinth/labyrinth-spec@0.1.0', + document: { + schemaVersion: '0.1.0', + displayName: 'Hero Chamber', + poses: [{ id: 'split-link', overrides: [{ moduleId: 'shuttle', transform: { position: [6, 0, 0] } }] }], + validation: { maxModules: 32 }, + }, + metadata: { importedFrom: 'examples/hero-chamber.labyrinth.json' }, + }; + artifact.sourceText = `${JSON.stringify(artifact.document)}\n`; + const project = createProjectDocument({ projectId: 'project/test', resources: { assets: [artifact] } }); + const canonical = project.resources.assets[artifact.id]; + const translated = translateToolOperation({ + op: 'artifact.json.patch', + artifactId: artifact.id, + pointer: '/poses/0/overrides/0/transform/position/0', + value: 7, + expectedArtifactHash: contentHash(canonical), + }, project); + assert.deepEqual(translated, { + type: 'resource.patch', + resourceType: 'assets', + resourceId: artifact.id, + patch: { + document: { + schemaVersion: '0.1.0', + displayName: 'Hero Chamber', + poses: [{ id: 'split-link', overrides: [{ moduleId: 'shuttle', transform: { position: [7, 0, 0] } }] }], + validation: { maxModules: 32 }, + }, + sourceText: artifact.sourceText.replace('[6,0,0]', '[7,0,0]'), + }, + }); + assert.equal(project.resources.assets[artifact.id].document.poses[0].overrides[0].transform.position[0], 6); +}); + +test('artifact.json.patch rejects stale hashes and missing pointer targets', () => { + const project = createProjectDocument({ + projectId: 'project/test', + resources: { assets: [{ + id: 'artifact/hero-chamber', kind: 'jsonArtifact', mediaType: 'application/json', + schemaId: 'project-labyrinth/labyrinth-spec@0.1.0', document: { poses: [] }, + }] }, + }); + assert.throws(() => translateToolOperation({ + op: 'artifact.json.patch', artifactId: 'artifact/hero-chamber', pointer: '/poses/0', value: {}, + expectedArtifactHash: '0'.repeat(64), + }, project), error => error.code === 'artifact_hash_mismatch'); + assert.throws(() => translateToolOperation({ + op: 'artifact.json.patch', artifactId: 'artifact/hero-chamber', pointer: '/poses/0', value: {}, + expectedArtifactHash: contentHash(project.resources.assets['artifact/hero-chamber']), + }, project), error => error.code === 'artifact_pointer_not_found'); +}); + test('runtime validates typed graph resources before they reach the kernel', () => { const project = createProjectDocument({ projectId: 'project/test' }); assert.throws( diff --git a/tests/runtime-path.test.mjs b/tests/runtime-path.test.mjs index 51d72bb..75643d7 100644 --- a/tests/runtime-path.test.mjs +++ b/tests/runtime-path.test.mjs @@ -30,6 +30,8 @@ test("runtime resolver prefers the explicit environment without copying runtime }); test("runtime resolver accepts a machine-local configuration", async () => { + const readStudioLocalConfig = (await import("../scripts/runtime-path.mjs")).readStudioLocalConfig; + assert.equal(typeof readStudioLocalConfig, "function"); const temporary = await mkdtemp(path.join(os.tmpdir(), "three-studio-config-")); const studioRoot = path.join(temporary, "studio"); const runtimeRoot = path.join(temporary, "runtime"); @@ -37,7 +39,7 @@ test("runtime resolver accepts a machine-local configuration", async () => { await fakeRuntime(runtimeRoot); await writeFile( path.join(studioRoot, ".studio-local.json"), - JSON.stringify({ runtimeRoot }), + JSON.stringify({ runtimeRoot, rehearsalRoot: path.join(temporary, "infinity") }), "utf8", ); const result = await resolveRuntimeRoot({ @@ -47,6 +49,10 @@ test("runtime resolver accepts a machine-local configuration", async () => { }); assert.equal(result.source, ".studio-local.json"); assert.equal(result.root, path.resolve(runtimeRoot)); + assert.deepEqual(await readStudioLocalConfig({ studioRoot }), { + runtimeRoot: path.resolve(runtimeRoot), + rehearsalRoot: path.resolve(temporary, "infinity"), + }); }); test("runtime resolver finds a packaged host beside the app folder", async () => {