import fs from 'node:fs'; import path from 'node:path'; import {reserveNativeSessionWorkspace,prepareNativeSessionWorkspace,markNativeSessionWorkspace} from './native-session-workspace.mjs'; import crypto from 'node:crypto'; import {cancelServerJobProcess} from './server-job-process.mjs'; import {materializeResources,captureResourceBundle} from './native-resources.mjs'; import {persistNativePreferences,persistNativeStartup} from './native-preferences.mjs'; import {startNativeExtensionProxy} from './native-extension-proxy.mjs'; const sessions=new Map(); let startingSessions=0; const hash=bytes=>crypto.createHash('sha256').update(bytes).digest('hex'); function write(file,value){const tmp=file+'.tmp';fs.writeFileSync(tmp,JSON.stringify(value));fs.renameSync(tmp,file);} function read(file,limit=2*1024*1024){const info=fs.lstatSync(file);if(!info.isFile()||info.size>limit)throw new Error('SESSION_RESULT_INVALID');return JSON.parse(fs.readFileSync(file,'utf8'));} async function close(entry){ if(entry.closed)return; entry.closed=true;clearInterval(entry.monitor);sessions.delete(entry.id); await entry.extensionProxy?.close(); await cancelServerJobProcess(entry.process,async()=>{try{if(!entry.lease.persistent)fs.rmSync(entry.workspace,{recursive:true,force:true});}finally{entry.lease.release();}},{graceMs:500}); } function envelope(data,validate){ if(data.length<12||data.subarray(0,4).toString()!=='BWF1')throw new Error('SESSION_ENVELOPE'); const metadata=data.readUInt32LE(4),length=data.readUInt32LE(8); if(metadata>256*1024||length>512*1024*1024||12+metadata+length!==data.length)throw new Error('SESSION_ENVELOPE_LENGTH'); const request=JSON.parse(data.subarray(12,12+metadata).toString());validate(request); const bytes=data.subarray(12+metadata); if(hash(bytes)!==request.sourceBlendSha256||bytes.length!==request.sourceBlendByteLength)throw new Error('SESSION_SOURCE_HASH'); return {request,bytes}; } function userFile(entry,relative){ if(typeof relative!=='string'||Buffer.byteLength(relative)>2048||relative.includes('\\')||relative.includes('\0')||relative.startsWith('/')||relative.split('/').some(p=>!p||p==='.'||p==='..'||p.length>255))throw new Error('SESSION_FILE_PATH'); let target=path.join(entry.workspace,'resources','user'); for(const segment of relative.split('/')){target=path.join(target,segment);if(fs.existsSync(target)&&fs.lstatSync(target).isSymbolicLink())throw new Error('SESSION_FILE_SYMLINK');} return target; } function userFiles(entry){ const root=path.join(entry.workspace,'resources','user'),files=[]; const visit=folder=>{if(!fs.existsSync(folder))return;for(const item of fs.readdirSync(folder,{withFileTypes:true})){ const absolute=path.join(folder,item.name),info=fs.lstatSync(absolute); if(info.isSymbolicLink())throw new Error('SESSION_FILE_SYMLINK'); if(info.isDirectory())visit(absolute); else if(info.isFile()){if(files.length>=4096)throw new Error('SESSION_FILE_COUNT');files.push({path:path.relative(root,absolute).split(path.sep).join('/'),bytes:info.size,modified:info.mtimeMs});} }};visit(root);return files; } function events(items,width=1280,height=900){ if(!Array.isArray(items)||items.length>64)throw new Error('SESSION_INPUT_BUDGET'); for(const e of items){ if(!e||!['move','button','key','wheel','release','pen'].includes(e.type))throw new Error('SESSION_INPUT_TYPE'); if(['move','button','pen'].includes(e.type)&&(!Number.isInteger(e.x)||!Number.isInteger(e.y)||e.x<0||e.x>=width||e.y<0||e.y>=height))throw new Error('SESSION_POINTER_INVALID'); if(e.type==='button'&&(![1,2,3].includes(e.button)||typeof e.down!=='boolean'))throw new Error('SESSION_BUTTON_INVALID'); if(e.type==='key'&&(typeof e.code!=='string'||!/^[A-Za-z0-9]{1,20}$/.test(e.code)||typeof e.down!=='boolean'))throw new Error('SESSION_KEY_INVALID'); if(e.type==='key'&&e.key!==undefined&&(typeof e.key!=='string'||Buffer.byteLength(e.key)>64||e.key.includes('\0')))throw new Error('SESSION_LOGICAL_KEY_INVALID'); if(e.type==='key'&&e.locks!==undefined&&(!e.locks||typeof e.locks.caps!=='boolean'||typeof e.locks.num!=='boolean'))throw new Error('SESSION_KEY_LOCKS_INVALID'); if(e.type==='wheel'&&(!Number.isInteger(e.delta)||Math.abs(e.delta)>5))throw new Error('SESSION_WHEEL_INVALID'); if(e.type==='wheel'&&e.horizontal!==undefined&&typeof e.horizontal!=='boolean')throw new Error('SESSION_WHEEL_AXIS_INVALID'); if(e.type==='pen'&&(!['move','press','release'].includes(e.action)||![1,2,3].includes(e.button)||typeof e.eraser!=='boolean'||!Number.isFinite(e.pressure)||e.pressure<0||e.pressure>1||![e.tiltX,e.tiltY].every(value=>Number.isFinite(value)&&value>=-1&&value<=1)))throw new Error('SESSION_TABLET_INVALID'); } return items; } export async function handleNativeSession(req,res,options,services){ const {localRequest,collect,json,fail,validate,launch,workspaceUsage}=services; const url=new URL(req.url??'/','http://localhost');if(url.pathname!=='/api/blender-session')return false; let reserved=false; try{ localRequest(req);const action=req.headers['x-blender-session-action']; if(action==='start'){ if(req.method!=='POST')fail('SESSION_METHOD',405); if(sessions.size+startingSessions>=2)fail('SESSION_CAPACITY',429); startingSessions++;reserved=true; const {request,bytes}=envelope(await collect(req,512*1024*1024+256*1024+12),validate); if(!['PYTHON','DESKTOP_OPERATOR'].includes(request.kind))fail('SESSION_KIND_INVALID'); const lease=reserveNativeSessionWorkspace(request,workspaceUsage),{workspace}=lease;let processHandle,extensionProxy; try{ prepareNativeSessionWorkspace(lease); for(const dir of ['commands','responses','events'])fs.mkdirSync(path.join(workspace,dir)); write(path.join(workspace,'request.json'),request);fs.writeFileSync(path.join(workspace,'source.blend'),bytes); if(lease.retained)write(path.join(workspace,'resources.json'),captureResourceBundle(workspace,true)??{files:[]});else materializeResources(request.resources,workspace); fs.mkdirSync(path.join(workspace,'resources','user'),{recursive:true}); if(!fs.existsSync(path.join(workspace,'resources.json')))write(path.join(workspace,'resources.json'),{files:[]}); extensionProxy=await startNativeExtensionProxy(workspace); markNativeSessionWorkspace(lease);processHandle=launch(workspace,request,options,true,lease); }catch(error){await extensionProxy?.close();if(!lease.persistent)fs.rmSync(workspace,{recursive:true,force:true});lease.release();throw error;} const entry={id:crypto.randomUUID(),token:crypto.randomBytes(32).toString('hex'),request,workspace,lease,process:processHandle,extensionProxy, sequence:0,pending:null,eventSequence:0,lastInput:0,lastUse:Date.now(),started:Date.now(),closed:false,stderr:''}; const preferencesFile=path.join(workspace,'config','userpref.blend'); if(fs.existsSync(preferencesFile)){const stat=fs.lstatSync(preferencesFile);entry.preferencePublished=`${stat.mtimeMs}:${stat.size}`;} const startupFile=path.join(workspace,'config','startup.blend'); if(fs.existsSync(startupFile)){const stat=fs.lstatSync(startupFile);entry.startupPublished=`${stat.mtimeMs}:${stat.size}`;} sessions.set(entry.id,entry); processHandle.child.on('error',error=>{entry.stderr=String(error);}); processHandle.child.stderr.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-8192);}); processHandle.child.stdout.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-8192);}); processHandle.completion.then(()=>{if(!entry.closed)entry.exitError=`SESSION_PROCESS_EXITED (${processHandle.child.exitCode ?? processHandle.child.signalCode}): ${entry.stderr}`;}); entry.monitor=setInterval(()=>{ if(entry.closed)return; try{ if(Date.now()-entry.lastUse>5*60*1000||Date.now()-entry.started>(entry.request.desktop?.preserveLayout?12:2)*60*60*1000||entry.exitError)throw new Error('SESSION_EXPIRED'); workspaceUsage(workspace); if(entry.request.desktop?.preserveLayout){ const startup=path.join(workspace,'config','startup.blend'); if(fs.existsSync(startup)){ const stat=fs.lstatSync(startup),stamp=`${stat.mtimeMs}:${stat.size}`; const ready=path.join(workspace,'startup-ready.json'); if(stamp===entry.startupCandidate&&stamp!==entry.startupPublished&&fs.existsSync(ready)&&read(ready).sha256===hash(fs.readFileSync(startup))){ try{persistNativeStartup(workspace);entry.preferenceError=null;}catch(error){entry.preferenceError=String(error.message);} entry.startupPublished=stamp; } entry.startupCandidate=stamp; } const file=path.join(workspace,'config','userpref.blend'); if(fs.existsSync(file)){ const stat=fs.lstatSync(file),stamp=`${stat.mtimeMs}:${stat.size}`; if(stamp===entry.preferenceCandidate&&stamp!==entry.preferencePublished&&stat.isFile()&&stat.size<=16*1024*1024){ try{persistNativePreferences(workspace,{id:'config/userpref.blend',sha256:hash(fs.readFileSync(file))});entry.preferenceError=null;} catch(error){entry.preferenceError=String(error.message);} entry.preferencePublished=stamp; } entry.preferenceCandidate=stamp; } } if(entry.pending&&Date.now()-entry.pending.started>120000&&!fs.existsSync(path.join(workspace,'responses',`${String(entry.pending.sequence).padStart(8,'0')}.json`)))throw new Error('SESSION_COMMAND_TIMEOUT'); }catch(error){entry.exitError=String(error.message);void close(entry);} },1000);entry.monitor.unref(); json(res,202,{id:entry.id,token:entry.token,status:'STARTING',desktopProtocolVersion:2,sourceRevision:request.sourceRevision,sourceBlendSha256:request.sourceBlendSha256,width:request.desktop?.width??1280,height:request.desktop?.height??900});return true; } const body=req.method==='POST'&&action!=='upload'?JSON.parse((await collect(req,256*1024)).toString()):{}; const id=body.id??url.searchParams.get('id'),token=body.token??req.headers['x-blender-session-token']; const entry=sessions.get(id);if(!entry||entry.token!==token||entry.closed)fail('SESSION_NOT_FOUND_OR_EXPIRED',404); entry.lastUse=Date.now(); if(action==='close'){ if(req.method!=='POST')fail('SESSION_METHOD',405);await close(entry);json(res,200,{status:'CLOSED'});return true; } if(entry.exitError)fail(entry.exitError,503); if(action==='upload'){ if(req.method!=='POST'||entry.pending)fail('SESSION_BUSY',409); const relative=decodeURIComponent(req.headers['x-blender-file-path']??''),target=userFile(entry,relative); if(fs.existsSync(target))fail('SESSION_FILE_EXISTS',409); const bytes=await collect(req,256*1024*1024); if(workspaceUsage(entry.workspace).bytes+bytes.length>1024*1024*1024)fail('SESSION_WORKSPACE_BYTE_BUDGET'); fs.mkdirSync(path.dirname(target),{recursive:true});fs.writeFileSync(target,bytes,{flag:'wx'}); json(res,200,{status:'UPLOADED',path:relative,bytes:bytes.length});return true; } if(action==='files'){ if(req.method!=='GET')fail('SESSION_METHOD',405); const statusFile=path.join(entry.workspace,'desktop-state.json'); const directory=path.join(entry.workspace,'browser-links'),links=[]; if(fs.existsSync(directory))for(const name of fs.readdirSync(directory).filter(name=>/^[a-f0-9]{32}\.json$/.test(name)).slice(0,32)){ const item=read(path.join(directory,name),16384),target=new URL(item.url); if(['http:','https:'].includes(target.protocol)&&target.hostname)links.push({id:name.slice(0,-5),url:target.href}); } json(res,200,{files:userFiles(entry),links,network:entry.extensionProxy?.status,...(fs.existsSync(statusFile)?{status:read(statusFile)}:{}),...(entry.preferenceError?{preferenceError:entry.preferenceError}:{})});return true; } if(action==='link-ack'){ if(req.method!=='POST'||!/^[a-f0-9]{32}$/.test(body.linkId??''))fail('SESSION_LINK_ID'); fs.rmSync(path.join(entry.workspace,'browser-links',body.linkId+'.json'),{force:true});json(res,200,{status:'ACKNOWLEDGED'});return true; } if(action==='download'){ if(req.method!=='GET')fail('SESSION_METHOD',405); const file=userFile(entry,url.searchParams.get('file')),before=fs.lstatSync(file); if(!before.isFile()||before.size>256*1024*1024)fail('SESSION_DOWNLOAD_BUDGET'); const bytes=fs.readFileSync(file),after=fs.lstatSync(file); if(before.mtimeMs!==after.mtimeMs||bytes.length!==before.size)fail('SESSION_FILE_CHANGING_RETRY',409); res.setHeader('Content-Type','application/octet-stream');res.setHeader('Cache-Control','no-store');res.setHeader('Content-Length',bytes.length);res.end(bytes);return true; } if(action==='screen'){ if(req.method!=='GET')fail('SESSION_METHOD',405); const readyFile=path.join(entry.workspace,'session-ready.json'); if(fs.existsSync(readyFile)){const state=read(readyFile);if(state.status==='FAILED')fail(state.error??'SESSION_DESKTOP_FAILED',503);} const file=path.join(entry.workspace,'screen.png'); if(!fs.existsSync(file)){if(Date.now()-entry.started>45000)fail('SESSION_SCREEN_TIMEOUT',503);res.statusCode=204;res.end();return true;} const size=fs.lstatSync(file);if(!size.isFile()||size.size>24*1024*1024)fail('SESSION_SCREEN_INVALID'); const metadata=path.join(entry.workspace,'screen-meta.json');if(fs.existsSync(metadata)&&read(metadata).cursorEmbedded)res.setHeader('X-Blender-Cursor','embedded'); const etag=`"${size.mtimeMs}-${size.size}"`;res.setHeader('ETag',etag); if(req.headers['if-none-match']===etag){res.statusCode=304;res.end();return true;} const bytes=fs.readFileSync(file);res.setHeader('Content-Type','image/png');res.setHeader('Cache-Control','no-store');res.setHeader('Content-Length',bytes.length);res.end(bytes);return true; } if(action==='events'){ if(req.method!=='POST')fail('SESSION_METHOD',405); if(entry.pending)fail('SESSION_COMMIT_PENDING',409); if(Date.now()-entry.lastInput<8)fail('SESSION_INPUT_RATE',429);entry.lastInput=Date.now(); const batch=events(body.events,entry.request.desktop?.width??1280,entry.request.desktop?.height??900); if(fs.readdirSync(path.join(entry.workspace,'events')).length>128)fail('SESSION_INPUT_BACKLOG',429); write(path.join(entry.workspace,'events',`${String(++entry.eventSequence).padStart(12,'0')}.json`),batch);json(res,200,{status:'QUEUED'});return true; } if(action==='command'){ if(req.method!=='POST'||entry.pending)fail('SESSION_BUSY',409); const command=body.command; if(!command||!['read-main','python','undo','redo','invoke','commit','cancel-modal','preferences-save','editor','clipboard','clipboard-read','file-browser','download-project','open-blend'].includes(command.kind))fail('SESSION_COMMAND_INVALID'); if(command.kind==='clipboard'&&(typeof command.text!=='string'||Buffer.byteLength(command.text)>65536||command.text.includes('\0')))fail('SESSION_CLIPBOARD_BUDGET'); if(command.kind==='file-browser'&&command.path!=='')userFile(entry,command.path); if(command.kind==='file-browser'&&command.filename!==undefined&&(typeof command.filename!=='string'||command.filename.includes('/')||command.filename.includes('\\')||command.filename.includes('\0')||command.filename.length>255))fail('SESSION_FILENAME_INVALID'); if(command.kind==='open-blend'){ if(!entry.request.desktop?.preserveLayout)fail('SESSION_FULL_WORKSPACE_REQUIRED'); const file=userFile(entry,command.path);if(!file.toLowerCase().endsWith('.blend')||!fs.lstatSync(file).isFile())fail('SESSION_BLEND_REQUIRED'); } if(command.kind==='editor'){ const request={...entry.request,context:{...entry.request.context,areaType:command.areaType}}; if(typeof command.areaType!=='string')fail('SESSION_EDITOR_REQUIRED'); const {requestSha256,...unsigned}=request; request.requestSha256=hash(Buffer.from(services.canonical(unsigned)));validate(request); } if(command.kind==='python'&&(typeof command.source!=='string'||Buffer.byteLength(command.source)>65536||command.source.includes('\0')))fail('SESSION_PYTHON_BUDGET'); if(command.kind==='invoke'){ const request={...entry.request,kind:'DESKTOP_OPERATOR',operator:{id:command.operator,properties:command.properties??{}}}; const {requestSha256,...unsigned}=request; // Reuse the native operator validator using a freshly computed canonical request binding. request.requestSha256=hash(Buffer.from(services.canonical(unsigned)));validate(request); } if(!fs.existsSync(path.join(entry.workspace,'session-ready.json'))||read(path.join(entry.workspace,'session-ready.json')).status!=='READY')fail('SESSION_NOT_READY',409); const sequence=++entry.sequence; entry.pending={sequence,kind:command.kind,started:Date.now(),response:null}; write(path.join(entry.workspace,'commands',`${String(sequence).padStart(8,'0')}.json`),{...command,sequence,afterInput:entry.eventSequence}); json(res,202,{sequence,status:'RUNNING'});return true; } if(action==='ack'){ if(req.method!=='POST'||!entry.pending||entry.pending.sequence!==body.sequence||!entry.pending.response)fail('SESSION_ACK_INVALID',409); for(const item of entry.pending.response.artifacts??[])fs.rmSync(path.join(entry.workspace,item.id),{force:true}); fs.rmSync(path.join(entry.workspace,'responses',`${String(body.sequence).padStart(8,'0')}.json`),{force:true});entry.pending=null; json(res,200,{status:'READY'});return true; } if(action==='artifact'){ if(req.method!=='GET')fail('SESSION_METHOD',405); const item=entry.pending?.response?.artifacts?.find(a=>a.id===url.searchParams.get('artifact')); if(!item)fail('SESSION_ARTIFACT_NOT_FOUND',404); const file=path.join(entry.workspace,item.id),stat=fs.lstatSync(file);if(!stat.isFile()||stat.size!==item.bytes)fail('SESSION_ARTIFACT_CHANGED'); res.setHeader('Content-Type',item.mime);res.setHeader('Content-Length',item.bytes);res.setHeader('Cache-Control','no-store'); fs.createReadStream(file).on('error',()=>res.destroy()).pipe(res);return true; } if(action!=='read'||req.method!=='GET')fail('SESSION_ACTION_INVALID'); const readyFile=path.join(entry.workspace,'session-ready.json'); const ready=fs.existsSync(readyFile)?read(readyFile):{status:'STARTING'}; if(ready.status==='READY'&&!fs.existsSync(path.join(entry.workspace,'screen.png'))&&Date.now()-entry.started>45000){ fail('SESSION_SCREEN_HELPER_NOT_READY',503); } if(entry.pending){ const file=path.join(entry.workspace,'responses',`${String(entry.pending.sequence).padStart(8,'0')}.json`); if(fs.existsSync(file)&&!entry.pending.response){ const result=read(file); if(result.sequence!==entry.pending.sequence||result.sessionId!==entry.request.jobId||result.requestSha256!==entry.request.requestSha256)fail('SESSION_RESPONSE_BINDING'); let bytes=0;const ids=new Set(); for(const item of result.artifacts??[]){ if(!/^(result|resources|preferences|project)-[0-9]{8}\.(blend|zip)$/.test(item.id)||ids.has(item.id))fail('SESSION_ARTIFACT_INVALID');ids.add(item.id); const p=path.join(entry.workspace,item.id),stat=fs.lstatSync(p); if(!stat.isFile()||stat.size!==item.bytes||(bytes+=stat.size)>512*1024*1024||hash(fs.readFileSync(p))!==item.sha256)fail('SESSION_ARTIFACT_BINDING'); } if(entry.pending.kind==='preferences-save'&&result.status==='SUCCEEDED'){ const preference=result.artifacts?.find(a=>a.id===`preferences-${String(entry.pending.sequence).padStart(8,'0')}.blend`&&a.mime==='application/x-blender-preferences'); if(!preference)fail('PREFERENCES_ARTIFACT_MISSING'); try{persistNativePreferences(entry.workspace,preference);} catch(error){result.status='FAILED';result.error=String(error.message);result.output='';} } if(['SUCCEEDED','PYTHON_ERROR'].includes(result.status)&&result.artifacts?.some(a=>a.mime==='application/x-blender'))result.resources=captureResourceBundle(entry.workspace); entry.pending.response=result; } json(res,200,{...ready,command:entry.pending.response??{sequence:entry.pending.sequence,status:'RUNNING'}});return true; } json(res,200,ready);return true; }catch(error){json(res,error.statusCode??500,{status:'FAILED',error:String(error.message??error)});return true;} finally{if(reserved)startingSessions--;} }