Restrict Studio 2 host cleanup to exact preview paths

This commit is contained in:
Towsty
2026-09-10 20:56:37 -05:00
parent 70b6b70fcd
commit 70c17695e4
4 changed files with 93 additions and 0 deletions
+6
View File
@@ -62,3 +62,9 @@ IMAGE_COMFY_LAUNCH_CMD=
# NUXT_SESSION_SECRET=<generate a new preview-only secret>
# Same Beast Comfy 8198 / control 8199; one shared GPU, distinct purge prefix.
# Open /studio-2. Do not create a long-lived aigen-next Coolify app.
# Studio 2 cleanup requires this branch's /studio2/purge host endpoint and its
# scripts/studio2-purge.mjs + shared/studio2/contracts.mjs dependencies.
# Update/reload the host agent only when existing production tests are idle.
# Older agents are supported for generation; cleanup reports "Left on host"
# rather than falling back to the legacy purge endpoint.
+7
View File
@@ -1,4 +1,5 @@
import { createUpscaleHost } from './upscale-host.mjs'
import { purgeStudio2Files } from './studio2-purge.mjs'
import { stableMemoryArgs } from './comfy-memory-policy.mjs'
import { createGpuReservation } from './gpu-reservation.mjs'
import { createGpuProxy } from './gpu-proxy.mjs'
@@ -989,6 +990,12 @@ async function handleControl(req, res) {
markWork()
return streamFile(res, path)
}
if (req.method === 'POST' && url.pathname === '/studio2/purge') {
if (!gpuReservation.isOwner(String(req.headers['x-aigen-gpu-lease'] || ''))) return json(res, 409, { ok: false, error: 'GPU lease required' })
const body = await readJson(req)
try { return json(res, 200, purgeStudio2Files(body, rootsForType)) }
catch (error) { return json(res, 400, { ok: false, error: String(error.message || error) }) }
}
if (req.method === 'POST' && url.pathname === '/purge') {
const body = await readJson(req)
return json(res, 200, purgeDesktopFiles(body))
+26
View File
@@ -0,0 +1,26 @@
import { resolve, relative, isAbsolute } from 'node:path'
import { existsSync, realpathSync, statSync, unlinkSync } from 'node:fs'
import { scopedFile } from '../shared/studio2/contracts.mjs'
/** Exact Studio 2 files only: no folder fallback, sibling deletion, or sweeping. */
export function purgeStudio2Files({prefix, files}, rootsForType) {
if (!Array.isArray(files) || !files.length || files.length>100 || !prefix) throw new Error('Invalid Studio 2 purge manifest')
const targets=[]
for (const file of files) {
if (!['input','output'].includes(file.type) || !scopedFile(file,prefix) || !String(file.subfolder).replaceAll('\\','/').startsWith(String(prefix).replace(/\/$/,'')+'/studio2/')) throw new Error('Purge file is outside the Studio 2 prefix')
const roots=rootsForType(file.type)
if(!roots.length)throw new Error('No media root is configured for cleanup')
for (const root of roots) {
const target=resolve(root,file.subfolder,file.filename),rel=relative(resolve(root),target)
if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('Purge path escapes its media root')
if (!existsSync(target)) continue
const realRoot=realpathSync(root),realTarget=realpathSync(target),realRel=relative(realRoot,realTarget)
if (!realRel || realRel.startsWith('..') || isAbsolute(realRel) || !statSync(realTarget).isFile()) throw new Error('Purge target is not a media file inside its root')
const prefixRel=relative(resolve(realRoot,prefix),realTarget)
if(!prefixRel || prefixRel.startsWith('..') || isAbsolute(prefixRel))throw new Error('Purge target resolves outside the instance prefix')
targets.push(target)
}
}
for(const target of new Set(targets))unlinkSync(target)
return {ok:true,cleared:files.length,deleted:[...new Set(targets)]}
}
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {readFileSync,mkdtempSync,mkdirSync,writeFileSync,existsSync} from 'node:fs'
import {tmpdir} from 'node:os'
import {join} from 'node:path'
import ts from 'typescript'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {purgeStudio2Files} from '../scripts/studio2-purge.mjs'
const text=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
const tree=ts.createSourceFile('runner.ts',text,ts.ScriptTarget.Latest,true)
function runner(scope){const node=tree.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name?.text==='run');const js=ts.transpileModule(node.getText(tree),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText;return new Function(...Object.keys(scope),`${js};return run`)(...Object.values(scope))}
function fixture(mode='video') {
const phases=[],queued=[],saved=[],purged=[]
const request=validateRequest({folderId:'f',mode,engine:mode==='video'?'minimax':'flux',identityStillId:'hero',promptSections:{action:'walk'},batch:['turn','sit']})
const record={id:'r',owner:'o',request,outputs:[],queuedAt:1,startedAt:2,familyId:'family'}
const job={status:'running',clientId:'client',library:{}}
let id=0
const scope={
compilePrompt,saveRecord(){},update(r,state){r.state=state;phases.push(state)},acquireSharedGpu:async()=>true,ensureComfyReady:async()=>{},emitJob(){},
prepareGraph:async r=>{queued.push({hero:r.request.identityStillId,start:r.request.startClipId,action:r.request.promptSections.action});return {}},queuePrompt:async()=>({prompt_id:`p${++id}`}),
fetchHistory:async p=>({[p]:{status:{status_str:'success'},outputs:{}}}),comfyFetch:()=>{throw new Error('Unexpected network call')},
extractVideo:()=>({filename:'video.mp4',subfolder:'preview/studio2/r',type:'output'}),extractEditedImage:()=>({filename:'image.png',subfolder:'preview/studio2/r',type:'output'}),
downloadComfyVideo:async()=>Buffer.from('video'),downloadComfyImage:async()=>Buffer.from('image'),imageDimensions:()=>({width:960,height:960}),
videoSourcePaths:()=>['source'],clipVideoPath:()=>'/fake/source',studio2Root:()=>'/fake',join,mkdirSync(){},stitchExtension:async()=>Buffer.from('stitched'),
saveClip:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a},saveStill:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a},
attachStudio2Metadata:async(owner,id,data)=>{assert.ok(saved.some(a=>a.id===id));assert.equal(data.identityStillId,'hero')},
purge:async r=>{assert.ok(saved.length>purged.length);purged.push(r.index);r.purgeResult='Cleared input + output'},
getClip:()=>saved.at(-1),getStill:()=>saved.at(-1),persistClipAnchorFrame:async()=>{},onLiveVideoSettled:async()=>{}
}
return {scope,record,job,phases,queued,saved,purged}
}
for(const mode of ['video','iterate']) test(`${mode} runner saves each output before purge and keeps identity through the batch`,async()=>{
const f=fixture(mode);await runner(f.scope)(f.record,f.job)
assert.equal(f.record.state,'complete');assert.equal(f.saved.length,3);assert.equal(f.purged.length,3)
assert.deepEqual(f.queued.map(q=>q.hero),['hero','hero','hero']);assert.deepEqual(f.queued.map(q=>q.action),['walk','turn','sit'])
if(mode==='video')assert.deepEqual(f.queued.map(q=>q.start),['','asset0','asset1'])
assert.deepEqual(f.phases.slice(0,4),['waking','submitting','rendering','saving'])
})
test('failed library save never purges host media',async()=>{
const f=fixture();f.scope.saveClip=async()=>{throw new Error('Disk full')};await runner(f.scope)(f.record,f.job);assert.equal(f.record.state,'failed');assert.equal(f.purged.length,0)
})
test('resume uses persisted Comfy prompt and never submits it again',async()=>{
const f=fixture();f.record.request.batch=[];f.record.promptId='existing';f.record.files=[];f.record.index=0
f.scope.queuePrompt=async()=>{throw new Error('Duplicate submit')};await runner(f.scope)(f.record,f.job)
assert.equal(f.record.state,'complete');assert.equal(f.queued.length,0);assert.equal(f.saved.length,1)
})
test('strict purge leaves production and root fallback files intact',()=>{
const root=mkdtempSync(join(tmpdir(),'studio2-purge-')),prefix='video/MiniMax_H3_preview',subfolder=prefix+'/studio2/job/0'
mkdirSync(join(root,subfolder),{recursive:true});mkdirSync(join(root,'video/MiniMax_H3'),{recursive:true})
for(const path of ['same.png','video/MiniMax_H3/same.png',subfolder+'/same.png'])writeFileSync(join(root,path),'image')
const result=purgeStudio2Files({prefix,files:[{filename:'same.png',subfolder,type:'output'}]},()=>[root])
assert.equal(result.cleared,1);assert.equal(existsSync(join(root,subfolder,'same.png')),false);assert.ok(existsSync(join(root,'same.png')));assert.ok(existsSync(join(root,'video/MiniMax_H3/same.png')))
assert.throws(()=>purgeStudio2Files({prefix,files:[{filename:'same.png',subfolder:'video/MiniMax_H3',type:'output'}]},()=>[root]),/outside/)
})