From 8d9214c6d0a4eb3d54ffed53565e551d41f0f827 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:59:43 +0200 Subject: [PATCH 1/8] experiment(#252): explicit Artist Context processor with guarded workspace I/O Preserve the modly.intent.v1 schema while avoiding automatic interception of existing workflows. --- .../nodes/intent-travel/manifest.json | 67 ++++ .../nodes/intent-travel/processor.ts | 311 ++++++++++++++++++ tsconfig.builtins.json | 21 +- 3 files changed, 389 insertions(+), 10 deletions(-) create mode 100644 src/areas/workflows/nodes/intent-travel/manifest.json create mode 100644 src/areas/workflows/nodes/intent-travel/processor.ts diff --git a/src/areas/workflows/nodes/intent-travel/manifest.json b/src/areas/workflows/nodes/intent-travel/manifest.json new file mode 100644 index 00000000..58052aba --- /dev/null +++ b/src/areas/workflows/nodes/intent-travel/manifest.json @@ -0,0 +1,67 @@ +{ + "id": "intent-travel", + "name": "Artist Context", + "type": "process", + "entry": "processor.js", + "version": "1.0.0", + "author": "Modly", + "description": "Carries the artist's exact request alongside every workflow asset.", + "nodes": [ + { + "id": "start", + "name": "Remember Artist Context", + "input": "image", + "output": "image", + "params_schema": [ + { + "id": "project", + "label": "Project", + "type": "text", + "default": "" + }, + { + "id": "extraDetails", + "label": "Extra details", + "type": "text", + "multiline": true, + "default": "" + }, + { + "id": "movement", + "label": "Movement brief", + "type": "text", + "multiline": true, + "default": "" + }, + { + "id": "styleName", + "label": "Style name", + "type": "text", + "default": "" + }, + { + "id": "stylePrompt", + "label": "Style prompt", + "type": "text", + "multiline": true, + "default": "" + } + ] + }, + { + "id": "carry", + "name": "Carry Artist Context", + "input": "mesh", + "output": "mesh", + "params_schema": [ + { + "id": "destinationPath", + "label": "Destination asset (existing)", + "type": "string", + "pickerIntent": "mesh", + "default": "" + } + ] + } + ] +} diff --git a/src/areas/workflows/nodes/intent-travel/processor.ts b/src/areas/workflows/nodes/intent-travel/processor.ts new file mode 100644 index 00000000..1162b126 --- /dev/null +++ b/src/areas/workflows/nodes/intent-travel/processor.ts @@ -0,0 +1,311 @@ +import crypto = require('crypto') +import fs = require('fs') +import path = require('path') + +interface ProcessInput { + filePath?: string + text?: string + nodeId?: string +} + +interface ProcessResult { + filePath?: string +} + +interface ProcessContext { + workspaceDir: string + progress: (pct: number, label: string) => void +} + +interface IntentContext { + sourceImage?: string | null + project?: string | null + extraDetails?: string | null + movement?: string | null + styleName?: string | null + stylePrompt?: string | null + workflowId?: string | null + workflowName?: string | null +} + +type JsonObject = Record + +const SCHEMA = 'modly.intent.v1' +// Explicit workflow nodes only. Carrying metadata may never write outside the +// physical workspace; original user-selected images are first copied inside. +const MAX_IMAGE_BYTES = 64 * 1024 * 1024 +const MAX_SIDECAR_BYTES = 1024 * 1024 + +function managedAsset(workspaceDir: string, candidate: string): string { + if (!path.isAbsolute(candidate)) throw new Error('Artist context expects an absolute asset path') + const root = fs.realpathSync(workspaceDir) + const real = fs.realpathSync(candidate) + if (!fs.statSync(real).isFile()) throw new Error('Artist context requires an existing file') + const relative = path.relative(root, real) + if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { + throw new Error('Artist context asset must stay inside the workspace') + } + return real +} + +function checkedImageExtension(source: string): string { + const ext = path.extname(source).toLowerCase() + if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { + throw new Error('Artist context source must be an image file') + } + const stat = fs.statSync(source) + if (!stat.isFile() || stat.size > MAX_IMAGE_BYTES) throw new Error('Artist context image exceeds 64 MiB') + return ext +} + +const APPEND_ONLY_LISTS = ['artist_words', 'style_choices', 'findings', 'stage_events'] as const + +function now(): string { + return new Date().toISOString() +} + +function entry(kind: string, fields: JsonObject = {}): JsonObject { + return { id: crypto.randomUUID(), kind, at: now(), ...fields } +} + +function sidecarPath(assetPath: string): string { + return assetPath.replace(/\.[^./\\]+$/, '') + '.tags.json' +} + +function readObject(filePath: string, missingOk: boolean): JsonObject { + if (!fs.existsSync(filePath)) { + if (missingOk) return {} + throw new Error(`Artist context is missing: ${filePath}`) + } + let parsed: unknown + try { + if (fs.statSync(filePath).size > MAX_SIDECAR_BYTES) { + throw new Error('Artist context sidecar exceeds 1 MiB') + } + parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) + } catch (error) { + throw new Error(`Artist context cannot be read from ${filePath}: ${String(error)}`) + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Artist context is not a JSON object: ${filePath}`) + } + return parsed as JsonObject +} + +function writeObjectAtomic(filePath: string, value: JsonObject): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp` + try { + const encoded = JSON.stringify(value, null, 2) + '\n' + if (Buffer.byteLength(encoded, 'utf8') > MAX_SIDECAR_BYTES) { + throw new Error('Artist context record exceeds 1 MiB') + } + fs.writeFileSync(temporary, encoded, 'utf-8') + fs.renameSync(temporary, filePath) + } finally { + if (fs.existsSync(temporary)) fs.unlinkSync(temporary) + } +} + +function nonEmpty(value: unknown): string | null { + if (typeof value !== 'string') return null + const text = value.trim() + return text.length > 0 ? text : null +} + +function requireIntent(sidecar: JsonObject, assetPath: string): JsonObject { + const intent = sidecar.intent + if (!intent || typeof intent !== 'object' || Array.isArray(intent)) { + throw new Error(`Artist context is missing beside ${assetPath}`) + } + const record = intent as JsonObject + if (record.schema !== SCHEMA || typeof record.intent_id !== 'string') { + throw new Error(`Artist context beside ${assetPath} has an unsupported format`) + } + for (const key of APPEND_ONLY_LISTS) { + if (!Array.isArray(record[key])) { + throw new Error(`Artist context beside ${assetPath} has no ${key} history`) + } + } + if (!record.request || typeof record.request !== 'object' || Array.isArray(record.request)) { + throw new Error(`Artist context beside ${assetPath} has no request`) + } + return JSON.parse(JSON.stringify(record)) as JsonObject +} + +function word(kind: 'movement' | 'context', text: string, source: string): JsonObject { + return entry(kind, { text, source, origin: 'artist' }) +} + +function startRecord(assetPath: string, context: IntentContext): void { + const metadataPath = sidecarPath(assetPath) + const sidecar = readObject(metadataPath, true) + if (sidecar.intent) { + throw new Error(`Refusing to replace artist context in ${metadataPath}`) + } + + const extraDetails = nonEmpty(context.extraDetails) + const movement = nonEmpty(context.movement) + const styleName = nonEmpty(context.styleName) + const stylePrompt = nonEmpty(context.stylePrompt) + const project = nonEmpty(context.project) + const words: JsonObject[] = [] + if (movement) words.push(word('movement', movement, 'request.movement')) + if (extraDetails) words.push(word('context', extraDetails, 'request.extra_details')) + const movementWord = words.find((row) => row.kind === 'movement') + const created = now() + const record: JsonObject = { + schema: SCHEMA, + intent_id: crypto.randomUUID(), + created_at: created, + updated_at: created, + request: { + what_word_id: null, + movement_word_id: movementWord?.id ?? null, + source_image: nonEmpty(context.sourceImage), + project, + }, + artist_words: words, + style_choices: styleName || stylePrompt + ? [entry('style', { + stage: 'request', + name: styleName, + prompt: stylePrompt, + })] + : [], + findings: [], + stage_events: [ + entry('created', { + stage: 'request', + asset: assetPath, + note: 'Artist request captured', + workflow: { + id: nonEmpty(context.workflowId), + name: nonEmpty(context.workflowName), + }, + }), + ], + } + sidecar.intent = record + if (sidecar.project == null) sidecar.project = project + writeObjectAtomic(metadataPath, sidecar) +} + +function rows(record: JsonObject, key: typeof APPEND_ONLY_LISTS[number]): JsonObject[] { + return (record[key] as JsonObject[]).map((row) => JSON.parse(JSON.stringify(row)) as JsonObject) +} + +function mergeIntent(older: JsonObject, newer: JsonObject): JsonObject { + if (older.intent_id !== newer.intent_id) { + throw new Error('Cannot merge artist context from different requests') + } + if (JSON.stringify(older.request) !== JSON.stringify(newer.request)) { + throw new Error('Copies of the artist request disagree') + } + const merged = JSON.parse(JSON.stringify(newer)) as JsonObject + for (const key of APPEND_ONLY_LISTS) { + const byId = new Map() + for (const row of [...rows(older, key), ...rows(newer, key)]) { + const id = nonEmpty(row.id) + if (!id) throw new Error(`Artist context ${key} entry has no id`) + const prior = byId.get(id) + if (prior && JSON.stringify(prior) !== JSON.stringify(row)) { + throw new Error(`Artist context ${key} entry ${id} has conflicting contents`) + } + if (!prior) byId.set(id, row) + } + merged[key] = [...byId.values()] + } + merged.created_at = String(older.created_at) < String(newer.created_at) + ? older.created_at + : newer.created_at + merged.updated_at = String(older.updated_at) > String(newer.updated_at) + ? older.updated_at + : newer.updated_at + return merged +} + +function carryRecord(sourceAsset: string, destinationAsset: string, params: JsonObject, workspaceDir: string): void { + sourceAsset = managedAsset(workspaceDir, sourceAsset) + destinationAsset = managedAsset(workspaceDir, destinationAsset) + if (sourceAsset === destinationAsset) throw new Error('Cannot carry to the same asset') + const sourceSidecar = readObject(sidecarPath(sourceAsset), false) + const sourceIntent = requireIntent(sourceSidecar, sourceAsset) + const destinationPath = sidecarPath(destinationAsset) + const destination = readObject(destinationPath, true) + destination.intent = destination.intent + ? mergeIntent(requireIntent(destination, destinationAsset), sourceIntent) + : sourceIntent + + const carried = destination.intent as JsonObject + ;(carried.stage_events as JsonObject[]).push(entry('carried', { + stage: nonEmpty(params.stage) ?? 'unknown', + from_asset: sourceAsset, + to_asset: destinationAsset, + details: params.details && typeof params.details === 'object' ? params.details : {}, + })) + carried.updated_at = now() + if (destination.project == null) { + const request = carried.request as JsonObject + destination.project = sourceSidecar.project ?? request.project ?? null + } + writeObjectAtomic(destinationPath, destination) +} + +function stageSource(input: ProcessInput, params: JsonObject, context: ProcessContext): string { + const originalPath = nonEmpty(input.filePath) + const encoded = nonEmpty(input.text) + if (!originalPath && !encoded) { + throw new Error('Artist context needs the source image') + } + + const suffix = originalPath ? checkedImageExtension(originalPath) : '.png' + const runDir = path.join(context.workspaceDir, 'Workflows', 'intent-sources', crypto.randomUUID()) + fs.mkdirSync(runDir, { recursive: true }) + const staged = path.join(runDir, `source${suffix}`) + if (originalPath) { + if (!fs.existsSync(originalPath)) throw new Error(`Source image does not exist: ${originalPath}`) + fs.copyFileSync(originalPath, staged) + } else { + const base64 = encoded!.includes(';base64,') ? encoded!.split(';base64,', 2)[1] : encoded! + if (base64.length > Math.ceil(MAX_IMAGE_BYTES * 4 / 3) + 8) { + throw new Error('Artist context image exceeds 64 MiB') + } + const bytes = Buffer.from(base64, 'base64') + if (bytes.length > MAX_IMAGE_BYTES) throw new Error('Artist context image exceeds 64 MiB') + fs.writeFileSync(staged, bytes) + } + const contextData = params.context && typeof params.context === 'object' && !Array.isArray(params.context) + ? params.context as IntentContext + : params as IntentContext + startRecord(staged, { + ...contextData, + sourceImage: originalPath ? path.basename(originalPath) : null, + }) + return staged +} + +const processor = async ( + input: ProcessInput, + params: JsonObject, + context: ProcessContext, +): Promise => { + if (input.nodeId === 'start') { + context.progress(20, 'Remembering your request…') + const staged = stageSource(input, params, context) + context.progress(100, 'Request remembered') + return { filePath: staged } + } + + const source = nonEmpty(input.filePath) + const destination = nonEmpty(params.destinationPath) + if (!source || !destination) { + throw new Error('Carrying artist context needs both stage input and output') + } + context.progress(50, 'Carrying artist context…') + carryRecord(source, destination, params, context.workspaceDir) + context.progress(100, 'Artist context carried') + return { filePath: destination } +} + +export = processor diff --git a/tsconfig.builtins.json b/tsconfig.builtins.json index 15c7e29d..20161eba 100644 --- a/tsconfig.builtins.json +++ b/tsconfig.builtins.json @@ -1,17 +1,18 @@ { "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", - "strict": true, - "skipLibCheck": true, - "outDir": "out/builtin-extensions", - "rootDir": "src/areas/workflows/nodes", - "declaration": false, - "sourceMap": false, - "esModuleInterop": true + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "outDir": "out/builtin-extensions", + "rootDir": "src/areas/workflows/nodes", + "declaration": false, + "sourceMap": false, + "esModuleInterop": true }, "include": [ + "src/areas/workflows/nodes/intent-travel/**/*.ts", "src/areas/workflows/nodes/mesh-exporter/**/*.ts" ] } From afb69d2960c71d99a795c9abda64a93135db998c Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:01:54 +0200 Subject: [PATCH 2/8] fix(intent): refuse sidecar symlinks and workspace staging escapes --- .../nodes/intent-travel/processor.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/areas/workflows/nodes/intent-travel/processor.ts b/src/areas/workflows/nodes/intent-travel/processor.ts index 1162b126..6478adf9 100644 --- a/src/areas/workflows/nodes/intent-travel/processor.ts +++ b/src/areas/workflows/nodes/intent-travel/processor.ts @@ -48,6 +48,27 @@ function managedAsset(workspaceDir: string, candidate: string): string { return real } +function assertWorkspaceDirectory(workspaceDir: string, folder: string): string { + const root = fs.realpathSync(workspaceDir) + const physical = fs.realpathSync(folder) + if (!fs.statSync(physical).isDirectory()) throw new Error('Artist context needs a directory') + const relative = path.relative(root, physical) + if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { + throw new Error('Artist context directory must stay inside the workspace') + } + return physical +} + +function refuseSymlink(filePath: string): void { + try { + if (fs.lstatSync(filePath).isSymbolicLink()) { + throw new Error('Artist context refuses a sidecar symlink') + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + function checkedImageExtension(source: string): string { const ext = path.extname(source).toLowerCase() if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { @@ -73,6 +94,7 @@ function sidecarPath(assetPath: string): string { } function readObject(filePath: string, missingOk: boolean): JsonObject { + refuseSymlink(filePath) if (!fs.existsSync(filePath)) { if (missingOk) return {} throw new Error(`Artist context is missing: ${filePath}`) @@ -93,6 +115,7 @@ function readObject(filePath: string, missingOk: boolean): JsonObject { } function writeObjectAtomic(filePath: string, value: JsonObject): void { + refuseSymlink(filePath) fs.mkdirSync(path.dirname(filePath), { recursive: true }) const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp` try { @@ -260,8 +283,14 @@ function stageSource(input: ProcessInput, params: JsonObject, context: ProcessCo } const suffix = originalPath ? checkedImageExtension(originalPath) : '.png' - const runDir = path.join(context.workspaceDir, 'Workflows', 'intent-sources', crypto.randomUUID()) - fs.mkdirSync(runDir, { recursive: true }) + const workflowRoot = path.join(context.workspaceDir, 'Workflows') + fs.mkdirSync(workflowRoot, { recursive: true }) + const checkedWorkflowRoot = assertWorkspaceDirectory(context.workspaceDir, workflowRoot) + const intentRoot = path.join(checkedWorkflowRoot, 'intent-sources') + fs.mkdirSync(intentRoot, { recursive: true }) + const checkedIntentRoot = assertWorkspaceDirectory(context.workspaceDir, intentRoot) + const runDir = path.join(checkedIntentRoot, crypto.randomUUID()) + fs.mkdirSync(runDir) const staged = path.join(runDir, `source${suffix}`) if (originalPath) { if (!fs.existsSync(originalPath)) throw new Error(`Source image does not exist: ${originalPath}`) From 6277493bc60f060e6261e15436bbf178bc1b3e40 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:02:31 +0200 Subject: [PATCH 3/8] test(intent): regressions for v1 capture, carry, conflicts and storage safety --- package.json | 2 +- .../nodes/intent-travel/processor.test.mjs | 183 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/areas/workflows/nodes/intent-travel/processor.test.mjs diff --git a/package.json b/package.json index 2a36e85a..7d26c1ba 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs && node --experimental-strip-types --test src/areas/workflows/workflowParamBindings.test.mjs src/areas/workflows/nodes/mesh-exporter/lineage.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs && node --experimental-strip-types --test src/areas/workflows/workflowParamBindings.test.mjs src/areas/workflows/nodes/mesh-exporter/lineage.test.mjs && node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/workflows/nodes/intent-travel/processor.test.mjs b/src/areas/workflows/nodes/intent-travel/processor.test.mjs new file mode 100644 index 00000000..3595fc12 --- /dev/null +++ b/src/areas/workflows/nodes/intent-travel/processor.test.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { + mkdtempSync, mkdirSync, readFileSync, writeFileSync, + rmSync, symlinkSync, existsSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +function loadModule() { + const outDir = mkdtempSync(join(tmpdir(), 'modly-intent-test-module-')) + const outfile = join(outDir, 'processor.cjs') + const compiled = buildSync({ + entryPoints: [resolve('src/areas/workflows/nodes/intent-travel/processor.ts')], + bundle: true, platform: 'node', format: 'cjs', write: false, + }) + writeFileSync(outfile, compiled.outputFiles[0].text, 'utf8') + return createRequire(import.meta.url)(outfile) +} + +const processor = loadModule() +const context = (workspaceDir) => ({ workspaceDir, progress() {} }) +const sidecar = (p) => p.replace(/\.[^./\\]+$/, '') + '.tags.json' +const metadata = (p) => JSON.parse(readFileSync(sidecar(p), 'utf8')) + +async function withWorkspace(run) { + const root = mkdtempSync(join(tmpdir(), 'modly-intent-')) + const workspace = join(root, 'workspace') + mkdirSync(workspace) + try { await run(root, workspace) } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +test('start makes unique v1 records and leaves the original source untouched', async () => { + await withWorkspace(async (root, workspace) => { + const image = join(root, 'input.png') + writeFileSync(image, Buffer.from([137, 80, 78, 71])) + const params = { + project: 'Azeroth', extraDetails: 'A patient scout.', + movement: 'Swim slowly.', styleName: 'Low-poly', stylePrompt: 'Warm light', + } + const a = await processor({ filePath: image, nodeId: 'start' }, params, context(workspace)) + const b = await processor({ filePath: image, nodeId: 'start' }, params, context(workspace)) + assert.notEqual(a.filePath, b.filePath) + assert.equal(readFileSync(image).length, 4) + const intent = metadata(a.filePath).intent + assert.equal(intent.schema, 'modly.intent.v1') + assert.equal(intent.request.source_image, 'input.png') + assert.equal(intent.request.project, 'Azeroth') + assert.equal(intent.artist_words.some((word) => word.text === 'A patient scout.'), true) + assert.equal(intent.artist_words.some((word) => word.text === 'Swim slowly.'), true) + assert.equal(intent.style_choices[0].name, 'Low-poly') + assert.equal(intent.style_choices[0].prompt, 'Warm light') + }) +}) + +test('carry preserves request identity and appends one immutable stage event', async () => { + await withWorkspace(async (root, workspace) => { + const image = join(root, 'source.png') + writeFileSync(image, Buffer.from([137, 80, 78, 71])) + const input = (await processor({ filePath: image, nodeId: 'start' }, { + project: 'Azeroth', + }, context(workspace))).filePath + const result = join(workspace, 'asset.glb') + writeFileSync(result, 'glb') + await processor({ filePath: input, nodeId: 'carry' }, + { destinationPath: result, stage: 'mesh-generation' }, context(workspace)) + const before = metadata(input).intent + const after = metadata(result).intent + assert.equal(after.intent_id, before.intent_id) + assert.deepEqual(after.request, before.request) + assert.equal(after.stage_events.length, before.stage_events.length + 1) + assert.equal(after.stage_events.at(-1).stage, 'mesh-generation') + }) +}) + +test('carry never writes outside the physical workspace', async () => { + await withWorkspace(async (root, workspace) => { + const image = join(root, 'source.png') + writeFileSync(image, 'png') + const input = (await processor({ filePath: image, nodeId: 'start' }, {}, + context(workspace))).filePath + const outside = join(root, 'personal.glb') + writeFileSync(outside, 'personal') + await assert.rejects(() => processor({ filePath: input, nodeId: 'carry' }, + { destinationPath: outside }, context(workspace)), /inside the workspace/) + assert.equal(readFileSync(outside, 'utf8'), 'personal') + assert.equal(existsSync(sidecar(outside)), false) + }) +}) + +test('carry rejects a missing destination instead of writing orphaned metadata', async () => { + await withWorkspace(async (root, workspace) => { + const image = join(root, 'source.png') + writeFileSync(image, 'png') + const input = (await processor({ filePath: image, nodeId: 'start' }, {}, + context(workspace))).filePath + const missing = join(workspace, 'missing.glb') + await assert.rejects(() => processor({ filePath: input, nodeId: 'carry' }, + { destinationPath: missing }, context(workspace))) + assert.equal(existsSync(sidecar(missing)), false) + }) +}) + +test('nonimage source is rejected before any staging', async () => { + await withWorkspace(async (root, workspace) => { + const file = join(root, 'secret.txt') + writeFileSync(file, 'secret') + await assert.rejects(() => processor({ filePath: file, nodeId: 'start' }, {}, + context(workspace)), /image file/) + assert.equal(existsSync(join(workspace, 'Workflows', 'intent-sources')), false) + }) +}) + +test('conflicting artist identity does not overwrite the destination sidecar', async () => { + await withWorkspace(async (root, workspace) => { + const image = join(root, 'source.png') + writeFileSync(image, 'png') + const input = (await processor({ filePath: image, nodeId: 'start' }, {}, + context(workspace))).filePath + const output = join(workspace, 'asset.glb') + writeFileSync(output, 'mesh') + const contents = JSON.stringify({ + intent: { schema: 'modly.intent.v1', intent_id: 'different' }, + }) + writeFileSync(sidecar(output), contents) + await assert.rejects(() => processor({ filePath: input, nodeId: 'carry' }, + { destinationPath: output }, context(workspace))) + assert.equal(readFileSync(sidecar(output), 'utf8'), contents) + }) +}) + +test('oversized source sidecar is rejected before carry', async () => { + await withWorkspace(async (root, workspace) => { + const image = join(root, 'source.png') + writeFileSync(image, 'png') + const input = (await processor({ filePath: image, nodeId: 'start' }, {}, + context(workspace))).filePath + writeFileSync(sidecar(input), ' '.repeat(1024 * 1024 + 1)) + const output = join(workspace, 'asset.glb') + writeFileSync(output, 'mesh') + await assert.rejects(() => processor({ filePath: input, nodeId: 'carry' }, + { destinationPath: output }, context(workspace)), /too large|exceeds|cannot be read/) + assert.equal(existsSync(sidecar(output)), false) + }) +}) + +test('stage rejects Workflows symlink escaping the workspace', { + skip: process.platform === 'win32', +}, async () => { + await withWorkspace(async (root, workspace) => { + const outside = join(root, 'outside') + mkdirSync(outside) + symlinkSync(outside, join(workspace, 'Workflows'), 'dir') + const image = join(root, 'image.png') + writeFileSync(image, 'png') + await assert.rejects(() => processor({ filePath: image, nodeId: 'start' }, + {}, context(workspace)), /inside the workspace/) + assert.equal(existsSync(join(outside, 'intent-sources')), false) + }) +}) + +test('carry refuses a sidecar symlink outside the workspace', { + skip: process.platform === 'win32', +}, async () => { + await withWorkspace(async (root, workspace) => { + const image = join(root, 'source.png') + writeFileSync(image, 'png') + const input = (await processor({ filePath: image, nodeId: 'start' }, {}, + context(workspace))).filePath + const output = join(workspace, 'asset.glb') + writeFileSync(output, 'mesh') + const personal = join(root, 'personal.json') + writeFileSync(personal, JSON.stringify({ preserve: true })) + symlinkSync(personal, sidecar(output)) + await assert.rejects(() => processor({ filePath: input, nodeId: 'carry' }, + { destinationPath: output }, context(workspace)), /symlink/) + assert.equal(readFileSync(personal, 'utf8'), '{"preserve":true}') + }) +}) From 9aef53a5f5621db272a644ff4faf2c2c1f1e275a Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:03:53 +0200 Subject: [PATCH 4/8] docs(intent): explicit v1 scope, validation gate and remaining #252 work --- docs/experimental-artist-intent.md | 58 +++++++++++++++++++ ...experimental-library-integration-ledger.md | 18 +++++- 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 docs/experimental-artist-intent.md diff --git a/docs/experimental-artist-intent.md b/docs/experimental-artist-intent.md new file mode 100644 index 00000000..eb56e848 --- /dev/null +++ b/docs/experimental-artist-intent.md @@ -0,0 +1,58 @@ +# Experimental Artist Context — first slice of upstream #252 + +This is a **candidate**, not a validated Modly release. Original feature author: +[OmarB97](https://github.com/OmarB97), source: +[lightningpixel/modly#252](https://github.com/lightningpixel/modly/pull/252). + +## Why a separate experiment? + +The upstream PR combines a versioned artist-intent sidecar, automatic workflow +propagation, animation/quality hints and live texture hot reload. It predates +the current v0.4.3 shared mesh operations, scene artifacts and llama.cpp agent. +Importing the entire old branch would replace working new code. + +This branch includes *only* the explicit `intent-travel` process extension: +- **Remember Artist Context** accepts an image, creates a unique staged copy + below `workspace/Workflows/intent-sources//source.`, and writes its + `.tags.json` with an append-only-style `modly.intent.v1` record. +- **Carry Artist Context** accepts a workspace asset *whose sidecar already + has intent*, plus a separate **existing** workspace destination asset + selected by `destinationPath`. It verifies both paths physically remain + inside the workspace and merges the intent by ID, refusing disagreement. + +Each start has a fresh ID. Fields cover project, movement, extra details, +style name and style prompt, and the source image's filename. The original +external image is not modified. + +**Critical limitation:** this does **not** automatically carry intent +across a generator that outputs a new mesh. After a normal image→3D step, +the resulting mesh will not necessarily have the source image's sidecar. +The carry node therefore requires an explicit source asset that already +contains an intent record. Do not advertise this as a complete end-to-end +artist-memory workflow yet. + +## Safety contract + +- Managed sidecars and carry source/destination must remain within the + canonical workspace; symlinked sidecars and staging ancestors that escape + the workspace are rejected. +- A source image can be read from outside only after its extension is + verified and its size is capped at 64 MiB, then it is copied into the + managed workspace. Sidecars and records are limited to 1 MiB. +- A conflicting `intent_id` fails without overwriting the destination + metadata. Existing metadata fields are retained. +- This is not an OS-level sandbox, and symlink races/external modifications + still require broader runtime/security review. + +## Verification ledger + +- [x] Files written to a dedicated branch, separate from `main`. +- [x] Nine regression test cases added, including workspace escapes, + symlinks, oversized sidecars, missing destinations, and intent conflicts. +- [ ] Run targeted Node tests against the full repository dependencies. +- [ ] Run full Node/Python test suites, TypeScript checking, lint and Electron. +- [ ] Test on Windows, including junctions, long paths, and normal asset flows. +- [ ] Test actual UI/extension wiring, existing project data and data retention. + +The live-texture-watch component of #252 is intentionally **not** part of this +slice. Integrate and validate it separately. diff --git a/docs/experimental-library-integration-ledger.md b/docs/experimental-library-integration-ledger.md index 340de7c3..bed2beda 100644 --- a/docs/experimental-library-integration-ledger.md +++ b/docs/experimental-library-integration-ledger.md @@ -38,9 +38,14 @@ Modly repository. The adaptations in this fork do not supersede their source PRs and responsive Library placement. Seven isolated Node tests passed for the binding helper, including orphaned/self references and prototype-safe keys. Native Electron/browser smoke tests and combined project CI remain unrun. -- **#252 (Artist intent record and texture hot reload):** much larger divergent - patch (workflow and API changes, new watcher services, extra proof assets). - Requires separate rebase and validation against 0.4.3 and #246/#251. +- **#252 (Artist intent + texture hot reload):** divided into separately + reviewable features. The child branch `integration/artist-intent-core-2026-10-10` + now stages **explicit** Artist Context start/carry nodes and the versioned + `modly.intent.v1` record. The carrying operations verify physical workspace + containment, reject sidecar symlinks and cap input and metadata sizes. + **Automatic** intent propagation through generated model stages, texture + hot-reloading, and proof assets are **NOT** integrated. The nine regression + tests are added to the Node test command; full suite and Windows smoke are pending. ## Validation checklist — currently not executed on the combined branch @@ -62,6 +67,13 @@ Modly repository. The adaptations in this fork do not supersede their source PRs ancestry labels in the exported .tags.json, with symlink and multi-parent cases. - [ ] Check chat context with sidecars, embedded GLB extras, long/malicious labels, and its tool responses under both local and external LLM providers. +- [ ] Exercise the explicit #252 start/carry nodes in a real workflow with valid + PNG input and GLB output, including restart, conflicting intent IDs, + temporary workspace cleanup and sidecar read/write failures. +- [ ] Design separate additive automation for carrying `modly.intent.v1` through + model generation; do not gate ordinary existing workflows on its presence. +- [ ] Review #252 live texture watcher on another branch; avoid importing the + old fork UI/renderer files wholesale. - [ ] Verify source #370 destructive-storage safeguards and related tests in the final combined branch. - [ ] Review source #266 local API security separately (not included). From 65903065e5d010b4d639e84ceea4f833f2e1a049 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:04:55 +0200 Subject: [PATCH 5/8] ci(intent): run isolated Artist Context test and built-in TS gate on experimental branch --- .../workflows/artist-intent-experimental.yml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/artist-intent-experimental.yml diff --git a/.github/workflows/artist-intent-experimental.yml b/.github/workflows/artist-intent-experimental.yml new file mode 100644 index 00000000..fba32be9 --- /dev/null +++ b/.github/workflows/artist-intent-experimental.yml @@ -0,0 +1,29 @@ +name: Artist Context experimental gate + +on: + push: + branches: + - integration/artist-intent-core-2026-10-10 + workflow_dispatch: + +permissions: + contents: read + +jobs: + intent-contract: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Install project dependencies (no lifecycle scripts) + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Assert test source parses + run: node --check src/areas/workflows/nodes/intent-travel/processor.test.mjs + - name: Artist Context unit tests + run: node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs + - name: Typed built-in processors + run: ./node_modules/.bin/tsc --noEmit -p tsconfig.builtins.json From ed4009e202e3c9be08f13b0645d54c5b8b1c5a66 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:06:01 +0200 Subject: [PATCH 6/8] ci(integration): add full Node, main-process typecheck and lint gates --- .../workflows/artist-intent-experimental.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/artist-intent-experimental.yml b/.github/workflows/artist-intent-experimental.yml index fba32be9..ccc8817c 100644 --- a/.github/workflows/artist-intent-experimental.yml +++ b/.github/workflows/artist-intent-experimental.yml @@ -27,3 +27,20 @@ jobs: run: node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs - name: Typed built-in processors run: ./node_modules/.bin/tsc --noEmit -p tsconfig.builtins.json + - name: Full Node suite (staged integration) + id: node_suite + continue-on-error: true + run: npm run test:node + - name: Main-process typecheck (staged integration) + id: main_types + continue-on-error: true + run: ./node_modules/.bin/tsc --noEmit -p tsconfig.node.json + - name: ESLint (staged integration) + id: lint + continue-on-error: true + run: npm run lint + - name: Integrations gate + if: steps.node_suite.outcome != 'success' || steps.main_types.outcome != 'success' || steps.lint.outcome != 'success' + run: | + echo 'At least one integrated check failed; see the individual check logs.' + exit 1 From 8eb1ffdea55177553eebc4e5036fc95446740cde Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:13:55 +0200 Subject: [PATCH 7/8] ci(integration): add Electron bundle and full Python suite to experimental gate --- .../workflows/artist-intent-experimental.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/artist-intent-experimental.yml b/.github/workflows/artist-intent-experimental.yml index ccc8817c..d5852088 100644 --- a/.github/workflows/artist-intent-experimental.yml +++ b/.github/workflows/artist-intent-experimental.yml @@ -12,7 +12,7 @@ permissions: jobs: intent-contract: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -39,8 +39,21 @@ jobs: id: lint continue-on-error: true run: npm run lint + - name: Electron desktop bundle (not a packaged Windows app) + id: build + continue-on-error: true + run: npm run build + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install API dependencies + run: python -m pip install --disable-pip-version-check -r api/requirements.txt + - name: Python suite (staged integration) + id: python_suite + continue-on-error: true + run: npm run test:py - name: Integrations gate - if: steps.node_suite.outcome != 'success' || steps.main_types.outcome != 'success' || steps.lint.outcome != 'success' + if: steps.node_suite.outcome != 'success' || steps.main_types.outcome != 'success' || steps.lint.outcome != 'success' || steps.build.outcome != 'success' || steps.python_suite.outcome != 'success' run: | echo 'At least one integrated check failed; see the individual check logs.' exit 1 From 963ae52a1ce81e0dc8c74603c31c06bb498c7149 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:15:31 +0200 Subject: [PATCH 8/8] ci(integration): smoke-test guarded storage and Artist Context on Windows --- .../workflows/artist-intent-experimental.yml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/artist-intent-experimental.yml b/.github/workflows/artist-intent-experimental.yml index d5852088..9b4355e3 100644 --- a/.github/workflows/artist-intent-experimental.yml +++ b/.github/workflows/artist-intent-experimental.yml @@ -57,3 +57,25 @@ jobs: run: | echo 'At least one integrated check failed; see the individual check logs.' exit 1 + + windows-smoke: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Install dependencies (no lifecycle scripts) + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Windows destructive-storage regression + run: node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs electron/main/storage-directory-guard.test.ts + - name: Windows artist-intent regression + run: node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs + - name: Windows main-process typecheck + run: npx --no-install tsc --noEmit -p tsconfig.node.json + - name: Windows built-ins typecheck + run: npx --no-install tsc --noEmit -p tsconfig.builtins.json + - name: Build desktop bundle on Windows + run: npm run build