neoctl-web 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ import { workspaceFs, openWorkspaceRead, containerMode } from '../../execution-backend.mjs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+
6
+ export const VALID_ID = /^[a-f0-9]{48}$/;
7
+ export const VIDEO_TYPES = Object.freeze({
8
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime',
9
+ '.webm': 'video/webm', '.ogv': 'video/ogg',
10
+ });
11
+
12
+ // Each publication has its own atomic directory: no shared mutable index or host imports.
13
+ export class VideoStore {
14
+ constructor(directory) { this.directory = path.resolve(directory); }
15
+
16
+ async publish(source) {
17
+ if (typeof source !== 'string' || !path.isAbsolute(source)) throw new Error('Video path must be absolute');
18
+ const type = VIDEO_TYPES[path.extname(source).toLowerCase()];
19
+ if (!type) throw new Error('Supported video extensions: .mp4, .m4v, .mov, .webm, .ogv');
20
+ const sourceStat = await workspaceFs.stat(source);
21
+ if (!sourceStat.isFile() || sourceStat.size === 0) throw new Error('Video must be a non-empty regular file');
22
+ await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
23
+ const id = randomBytes(24).toString('hex');
24
+ const staging = path.join(this.directory, `.pending-${id}`);
25
+ await fs.mkdir(staging, { mode: 0o700 });
26
+ try {
27
+ // Validate only a small header, then persist the original path, never a copy.
28
+ const header = Buffer.alloc(64);
29
+ if (containerMode) {
30
+ const handle = await openWorkspaceRead(source);
31
+ try { let offset = 0; for await (const chunk of handle.createReadStream({ start: 0, end: 63 })) { chunk.copy(header, offset); offset += chunk.length; } } finally { await handle.close(); }
32
+ } else {
33
+ const handle = await fs.open(source, 'r');
34
+ try { await handle.read(header, 0, header.length, 0); } finally { await handle.close(); }
35
+ }
36
+ const extension = path.extname(source).toLowerCase();
37
+ const valid = extension === '.webm' ? header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
38
+ : extension === '.ogv' ? header.toString('ascii', 0, 4) === 'OggS'
39
+ : ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(header.toString('ascii', 4, 8));
40
+ if (!valid) throw new Error('File does not have a recognized video container signature');
41
+ const entry = { version: 2, id, absolutePath: path.resolve(source), filename: path.basename(source), contentType: type, sizeBytes: sourceStat.size, createdAt: new Date().toISOString() };
42
+ await fs.writeFile(path.join(staging, 'entry.json'), JSON.stringify(entry), { mode: 0o600 });
43
+ await fs.rename(staging, path.join(this.directory, id));
44
+ return entry;
45
+ } catch (error) {
46
+ await fs.rm(staging, { recursive: true, force: true });
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ async get(id) {
52
+ if (!VALID_ID.test(id)) return undefined;
53
+ try {
54
+ const entry = JSON.parse(await fs.readFile(path.join(this.directory, id, 'entry.json'), 'utf8'));
55
+ // Old v1 snapshots have no source path; do not silently serve a copy.
56
+ if (entry.version === 1) return undefined;
57
+ if (entry.version !== 2 || typeof entry.absolutePath !== 'string' || !path.isAbsolute(entry.absolutePath)
58
+ || entry.id !== id || typeof entry.filename !== 'string'
59
+ || !Object.values(VIDEO_TYPES).includes(entry.contentType)) throw new Error('Invalid video metadata');
60
+ return { ...entry, mediaPath: entry.absolutePath };
61
+ } catch (error) {
62
+ if (error.code === 'ENOENT') return undefined;
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ async revoke(id) {
68
+ if (!VALID_ID.test(id)) throw new Error('Invalid video id');
69
+ const tombstone = path.join(this.directory, `.revoked-${id}-${randomBytes(6).toString('hex')}`);
70
+ try { await fs.rename(path.join(this.directory, id), tombstone); }
71
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
72
+ // Renaming removes the public mapping first; never reuse a revoked token.
73
+ await fs.rm(tombstone, { recursive: true, force: true });
74
+ return true;
75
+ }
76
+ }
@@ -55,16 +55,16 @@ export function renderXhsEditorPage(artifact, apiUrl) {
55
55
  </section>
56
56
  </main>
57
57
  <script>
58
- const initial=${serialize(artifact)};const apiUrl=${serialize(apiUrl)};let draft=clone(initial.payload||{}),active=0,saveTimer=0;const $=id=>document.getElementById(id);
58
+ const initial=${serialize(artifact)};const appBase=${serialize((process.env.NEO_WEB_BASE_PATH || '').replace(/\/$/, ''))};const appUrl=value=>typeof value==='string'&&value.startsWith('/api/')?appBase+value:value;const apiUrl=appUrl(${serialize(apiUrl)});let draft=clone(initial.payload||{}),active=0,saveTimer=0;const $=id=>document.getElementById(id);
59
59
  const params=new URLSearchParams(location.search);const requestedTheme=params.get('theme');document.documentElement.dataset.theme=requestedTheme==='dark'?'dark':requestedTheme==='light'?'light':matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
60
60
  function clone(v){return JSON.parse(JSON.stringify(v))}function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}function tags(v){return [...new Set(String(v||'').split(/[\\s,,、]+/).filter(Boolean).map(x=>x.startsWith('#')?x:'#'+x))]}
61
61
  function field(id,key){$(id).value=key==='hashtags'?(draft[key]||[]).join(' '):draft[key]||'';$(id).addEventListener('input',e=>{draft[key]=key==='hashtags'?tags(e.target.value):e.target.value;renderPreview();scheduleSave()})}
62
62
  field('title','title');field('body','body');field('interactionInput','interaction');field('hashtags','hashtags');field('review','review');
63
- function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(image.url)+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
64
- function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(im.url)+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(im.url)+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
63
+ function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(appUrl(image.url))+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
64
+ function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(appUrl(im.url))+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(appUrl(im.url))+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
65
65
  $('images').addEventListener('input',e=>{const row=e.target.closest('[data-index]');if(!row||!e.target.dataset.key)return;draft.images[+row.dataset.index][e.target.dataset.key]=e.target.value;renderPreview();scheduleSave()});$('images').addEventListener('click',e=>{const pick=e.target.closest('[data-pick]');if(pick){active=+pick.dataset.pick;renderPreview()}const remove=e.target.closest('[data-remove]');if(remove){draft.images.splice(+remove.dataset.remove,1);renderImages();renderPreview();scheduleSave()}const upload=e.target.closest('[data-upload]');if(upload){$('files').dataset.target=upload.dataset.upload;$('files').click()}});
66
66
  $('prev').onclick=()=>{active=(active-1+(draft.images||[]).length)%(draft.images||[]).length;renderPreview()};$('next').onclick=()=>{active=(active+1)%(draft.images||[]).length;renderPreview()};let wheelLock=0;$('mediaStage').addEventListener('wheel',e=>{const images=draft.images||[];if(images.length<2)return;e.preventDefault();const now=Date.now();if(now<wheelLock)return;wheelLock=now+320;const delta=Math.abs(e.deltaY)>=Math.abs(e.deltaX)?e.deltaY:e.deltaX;active=(active+(delta>=0?1:-1)+images.length)%images.length;renderPreview()},{passive:false});$('add').onclick=()=>{(draft.images||(draft.images=[])).push({url:'',caption:'配图 '+((draft.images?.length||0)+1),overlay:'',note:''});renderImages();scheduleSave()};$('upload').onclick=()=>{$('files').dataset.target='';$('files').click()};
67
- $('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch('/api/uploads',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}if(versioning.readonly()){setStatus('已过期,上传未应用;请加载最新版',true);return}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
67
+ $('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch(appUrl('/api/uploads')+new URL(apiUrl,location.href).search,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}if(versioning.readonly()){setStatus('已过期,上传未应用;请加载最新版',true);return}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
68
68
  function setMode(mode){localStorage.setItem('neoctl.plugin.xhs.mode.v2',mode);document.querySelectorAll('[data-mode]').forEach(b=>b.classList.toggle('active',b.dataset.mode===mode));$('grid').className='grid '+mode;$('preview').classList.toggle('hidden',mode==='edit');$('form').classList.toggle('hidden',mode==='preview');resize()}document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
69
69
  $('fullscreen').onclick=async()=>{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen()};document.addEventListener('fullscreenchange',()=>{$('fullscreen').textContent=document.fullscreenElement?'退出全屏':'全屏'});
70
70
  function payload(){return{title:draft.title||'',body:draft.body||'',interaction:draft.interaction||'',hashtags:draft.hashtags||[],images:draft.images||[],review:draft.review||''}}
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
- import { mkdir, readFile, readdir, rmdir, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir as localMkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { workspaceFs, workspaceHome, containerMode } from './execution-backend.mjs';
4
+ const { mkdir, readdir, rmdir, stat } = workspaceFs;
3
5
  import os from 'node:os';
4
6
  import { QueryEngine, WebRepl } from './core-runtime.mjs';
5
7
 
@@ -370,7 +372,7 @@ export class SessionWorkspaceRegistry {
370
372
  : options.cwdNoticePending === true,
371
373
  };
372
374
  this.writeQueue = this.writeQueue.then(async () => {
373
- await mkdir(path.dirname(this.file), { recursive: true });
375
+ await localMkdir(path.dirname(this.file), { recursive: true });
374
376
  await writeFile(this.file, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
375
377
  });
376
378
  await this.writeQueue;
@@ -406,7 +408,7 @@ export async function browseWorkspace(value, currentCwd) {
406
408
  requested,
407
409
  fallback: path.resolve(current) !== path.resolve(requested),
408
410
  parent: current === path.parse(current).root ? undefined : path.dirname(current),
409
- home: os.homedir(),
411
+ home: (workspaceHome() || os.homedir()),
410
412
  locations,
411
413
  entries: entries
412
414
  .filter((entry) => entry.isDirectory())
@@ -437,8 +439,9 @@ let workspaceLocationsCache;
437
439
  let workspaceLocationsCachedAt = 0;
438
440
 
439
441
  export async function discoverWorkspaceLocations() {
442
+ if (containerMode) return [{ id: "workspace", label: "workspace", path: "/workspace", kind: "favorite" }, { id: "root", label: "/", path: "/", kind: "root" }, { id: "home", label: "root", path: "/root", kind: "home" }];
440
443
  if (workspaceLocationsCache && Date.now() - workspaceLocationsCachedAt < 5000) return workspaceLocationsCache;
441
- const home = os.homedir();
444
+ const home = (workspaceHome() || os.homedir());
442
445
  const candidates = [
443
446
  { id: 'home', label: '主目录', path: home, kind: 'home' },
444
447
  ...[
@@ -486,8 +489,8 @@ export async function discoverWorkspaceLocations() {
486
489
  export function resolveWorkspaceInput(value, currentCwd) {
487
490
  let input = String(value || '').trim().replace(/^["']|["']$/g, '');
488
491
  if (!input) return path.resolve(currentCwd || process.cwd());
489
- if (input === '~') input = os.homedir();
490
- else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join(os.homedir(), input.slice(2));
492
+ if (input === '~') input = (workspaceHome() || os.homedir());
493
+ else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join((workspaceHome() || os.homedir()), input.slice(2));
491
494
  input = input.replace(/[\\/]+/g, path.sep);
492
495
  if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(input)) input += path.sep;
493
496
  return path.resolve(currentCwd || process.cwd(), input);
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { hashPassword, usernameKey, validUsername } from '../isolation-auth.mjs';
4
+
5
+ const [filename, username, role] = process.argv.slice(2);
6
+ if (!filename || !validUsername(username) || (role !== undefined && !['user', 'admin'].includes(role))) {
7
+ console.error('用法: node scripts/isolation-user.mjs <配置文件> <用户名> [user|admin]');
8
+ process.exit(1);
9
+ }
10
+
11
+ async function readPassword() {
12
+ if (!process.stdin.isTTY) {
13
+ let text = '';
14
+ for await (const chunk of process.stdin) { text += chunk; if (text.length > 4096) throw new Error('输入过长'); }
15
+ return text.replace(/\r?\n$/, '');
16
+ }
17
+ process.stderr.write('密码: ');
18
+ process.stdin.setRawMode(true);
19
+ process.stdin.resume();
20
+ process.stdin.setEncoding('utf8');
21
+ return new Promise((resolve, reject) => {
22
+ let value = '';
23
+ const done = () => { process.stdin.off('data', onData); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); };
24
+ function onData(chunk) {
25
+ for (const c of chunk) {
26
+ if (c === '\u0003') { done(); reject(new Error('已取消')); return; }
27
+ if (c === '\r' || c === '\n') { done(); resolve(value); return; }
28
+ if (c === '\u007f' || c === '\b') value = value.slice(0, -1);
29
+ else if (value.length < 1024) value += c;
30
+ }
31
+ }
32
+ process.stdin.on('data', onData);
33
+ });
34
+ }
35
+
36
+ const file = path.resolve(filename);
37
+ let config;
38
+ try { config = JSON.parse(await fs.readFile(file, 'utf8')); }
39
+ catch (error) {
40
+ if (error.code !== 'ENOENT') throw error;
41
+ config = { enabled: false, secureCookie: false, cookiePath: '/', sessionHours: 12, retiredUsernames: [], users: [] };
42
+ }
43
+ config.users ||= [];
44
+ config.retiredUsernames ||= [];
45
+ const key = usernameKey(username);
46
+ if (config.users.some(user => user.id !== undefined && user.id !== user.username)) throw new Error('旧用户 ID 与用户名不同,请先迁移');
47
+ if (config.retiredUserIds?.length) throw new Error('请先将 retiredUserIds 迁移为 retiredUsernames');
48
+ if (config.retiredUsernames.some(value => usernameKey(value) === key)) throw new Error('已删除的用户名不可复用');
49
+ const existing = config.users.find(user => usernameKey(user.username) === key);
50
+ const nextRole = role || existing?.role || 'user';
51
+ const next = { username, role: nextRole };
52
+ if (nextRole === 'admin') next.passwordHash = await hashPassword(await readPassword());
53
+ else if (existing?.passwordHash) next.passwordHash = existing.passwordHash;
54
+ const users = config.users.map(user => {
55
+ const { id: _oldId, ...account } = user;
56
+ return account;
57
+ });
58
+ const index = users.findIndex(user => usernameKey(user.username) === key);
59
+ if (index >= 0) users[index] = next; else users.push(next);
60
+ const { retiredUserIds: _oldRetiredUserIds, ...stored } = config;
61
+ stored.users = users;
62
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
63
+ const pending = file + `.tmp-${process.pid}`;
64
+ try {
65
+ await fs.writeFile(pending, JSON.stringify(stored, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
66
+ await fs.rename(pending, file);
67
+ } finally { await fs.rm(pending, { force: true }); }
68
+ console.log(`用户已保存: ${username}。修改 enabled 后重启 Web 生效。`);
package/server.mjs CHANGED
@@ -1,17 +1,11 @@
1
1
  import http from 'node:http';
2
+ import { createChunkUploadHandler } from './chunk-uploads.mjs';
2
3
  import fs from 'node:fs';
3
4
  import fsp from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
- // Consume the private launcher secret before importing application/runtime modules.
7
- // Imports can initialize plugins or spawn children; none may inherit this value.
8
- let desktopControlConfig;
9
- {
10
- const raw = process.env.NEO_DESKTOP_CONTROL_CONFIG;
11
- delete process.env.NEO_DESKTOP_CONTROL_CONFIG;
12
- try { desktopControlConfig = raw ? JSON.parse(raw) : undefined; } catch {}
13
- }
14
-
7
+ const { containerMode, workspaceFs, deliverUpload, verifyExecutionBackend } = await import('./execution-backend.mjs');
8
+ await verifyExecutionBackend();
15
9
  const { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } = await import('./core-runtime.mjs');
16
10
  const { createWebPluginHost } = await import('./plugins.mjs');
17
11
  const { createWebPluginSettings } = await import('./plugin-settings.mjs');
@@ -21,12 +15,6 @@ const { installRuntimeRouterIdleCleanup } = await import('./runtime-router-clean
21
15
  const { createCpaQuotaMonitor } = await import('./cpa-quota.mjs');
22
16
  const { createMemoryMonitor } = await import('./memory-monitor.mjs');
23
17
  const { resolveWebStorage } = await import('./platform-paths.mjs');
24
- const { createControlSync, createLoginApplier, validateControlConfig } = await import('./control-sync.mjs');
25
-
26
- const controlConfig = validateControlConfig(desktopControlConfig);
27
- desktopControlConfig = undefined;
28
- const controlEnabled = Boolean(controlConfig);
29
-
30
18
  installRuntimeRouterIdleCleanup();
31
19
  console.log(`neo core: ${coreRuntimeInfo.source} ${coreRuntimeInfo.version} (${coreRuntimeInfo.location})`);
32
20
  process.env.NEO_CORE_VERSION = coreRuntimeInfo.version;
@@ -37,7 +25,9 @@ const root = path.resolve(process.env.DIST_DIR || path.join(__dirname, 'dist'));
37
25
  const host = process.env.APP_HOST || '0.0.0.0';
38
26
  const port = Number(process.env.APP_PORT || process.env.PORT || 5173);
39
27
  const runtimeTarget = new URL(process.env.NEO_RUNTIME_TARGET || 'http://127.0.0.1:3101');
40
- const { dataRoot, workspaceRoot } = resolveWebStorage();
28
+ const storage = resolveWebStorage();
29
+ const dataRoot = storage.dataRoot;
30
+ const workspaceRoot = containerMode ? '/workspace' : storage.workspaceRoot;
41
31
  const promptLibraryFile = path.resolve(process.env.NEO_PROMPT_LIBRARY_FILE || path.join(dataRoot, 'prompt-library.json'));
42
32
  const uploadsDir = path.resolve(process.env.NEO_UPLOADS_DIR || path.join(dataRoot, 'uploads'));
43
33
  const pluginDir = path.resolve(process.env.NEO_WEB_PLUGIN_DIR || path.join(__dirname, 'plugins'));
@@ -46,7 +36,7 @@ const cpaConfigFile = path.resolve(process.env.NEO_CPA_CONFIG_FILE || path.join(
46
36
  const memoryMonitorFile = path.resolve(process.env.NEO_MEMORY_MONITOR_FILE || path.join(dataRoot, 'memory-monitor.json'));
47
37
  const pluginSettingsFile = path.resolve(process.env.NEO_WEB_PLUGIN_SETTINGS_FILE || path.join(dataRoot, 'plugins.json'));
48
38
  const toolSettingsFile = path.resolve(process.env.NEO_WEB_TOOL_SETTINGS_FILE || path.join(dataRoot, 'tools.json'));
49
- const maxUploadBytes = Number(process.env.NEO_UPLOAD_MAX_BYTES || 25 * 1024 * 1024);
39
+ const chunkUploads = createChunkUploadHandler({ uploadsDir, baseDir: __dirname, finalize: containerMode ? (file, url) => deliverUpload(file, url, runtimeTarget) : undefined });
50
40
  const pluginSettings = await createWebPluginSettings(pluginSettingsFile);
51
41
  const toolSettings = await createWebToolSettings(toolSettingsFile);
52
42
  const pluginEnv = process.env.NEO_WEB_PLUGINS;
@@ -67,17 +57,6 @@ const memoryMonitor = createMemoryMonitor({
67
57
  maxPersistedSamples: process.env.NEO_MEMORY_MAX_PERSISTED_SAMPLES,
68
58
  maxPersistedBytes: process.env.NEO_MEMORY_MAX_PERSISTED_BYTES,
69
59
  });
70
- // Weak references do not defeat the existing router's idle-session cleanup.
71
- const controlRepls = new Set();
72
- function activeControlRepls() {
73
- const active = [];
74
- for (const reference of controlRepls) {
75
- const repl = reference.deref();
76
- if (repl) active.push(repl);
77
- else controlRepls.delete(reference);
78
- }
79
- return active;
80
- }
81
60
  const workspaceRuntime = createWorkspaceRuntimeManager({
82
61
  projectRoot: process.cwd(),
83
62
  workspaceRoot,
@@ -94,6 +73,9 @@ const workspaceRuntime = createWorkspaceRuntimeManager({
94
73
  }),
95
74
  });
96
75
 
76
+ const { createIsolationMode } = await import('./isolation.mjs');
77
+ const isolation = await createIsolationMode({ dataRoot, workspaceRoot, pluginDir, pluginSettings, toolSettings, cpaQuotaMonitor, memoryState: () => memoryMonitor.getPublicState() });
78
+
97
79
  const DEFAULT_APP_PROMPT_LIBRARY = [];
98
80
 
99
81
  const mime = {
@@ -118,9 +100,10 @@ const server = http.createServer((req, res) => {
118
100
  void routeRequest(req, res);
119
101
  });
120
102
 
103
+ server.once('close', () => isolation.close());
121
104
  server.keepAliveTimeout = 70_000;
122
105
  server.headersTimeout = 75_000;
123
- if (embedRuntime) await startEmbeddedRuntime();
106
+ if (embedRuntime && !isolation.enabled) await startEmbeddedRuntime();
124
107
  await cpaQuotaMonitor.start();
125
108
  await memoryMonitor.start();
126
109
  await new Promise((resolve, reject) => {
@@ -132,32 +115,19 @@ await new Promise((resolve, reject) => {
132
115
  });
133
116
  });
134
117
 
135
- // Optional Desktop-only background work starts after HTTP listen and never delays UI.
136
- if (controlEnabled) {
137
- const controlSync = createControlSync({
138
- config: controlConfig,
139
- dataDir: dataRoot,
140
- applyProfile: embedRuntime ? createLoginApplier({ runtimeUrl: runtimeTarget, getActiveRepls: activeControlRepls }) : undefined,
141
- }).start();
142
- server.once('close', () => { void controlSync.stop(); });
143
- }
144
-
145
118
  async function startEmbeddedRuntime() {
146
119
  const runtimeHost = runtimeTarget.hostname || '127.0.0.1';
147
120
  const runtimePort = runtimeTarget.port || '3101';
148
121
  await runWebServer(['--host', runtimeHost, '--port', runtimePort], {
149
122
  createRuntime: workspaceRuntime.createRuntime,
150
- createRepl(runtime) {
151
- const repl = workspaceRuntime.createRepl(runtime);
152
- if (controlEnabled) controlRepls.add(new WeakRef(repl));
153
- return repl;
154
- },
123
+ createRepl: workspaceRuntime.createRepl,
155
124
  });
156
125
  }
157
126
 
158
127
  async function routeRequest(req, res) {
159
128
  const url = new URL(req.url || '/', 'http://localhost');
160
129
  try {
130
+ if (await isolation.route(req, res, url)) return;
161
131
  if (await pluginHost.route(req, res, url, { readJsonBody, sendJson })) return;
162
132
  if (req.method === 'GET' && url.pathname === '/api/prompt-library') {
163
133
  return sendJson(res, { items: await readPromptLibrary() });
@@ -213,6 +183,7 @@ async function routeRequest(req, res) {
213
183
  await writePromptLibrary(ordered);
214
184
  return sendJson(res, { ok: true, items: ordered });
215
185
  }
186
+ if (await chunkUploads(req, res, url)) return;
216
187
  if (req.method === 'GET' && url.pathname.startsWith('/api/uploads/')) {
217
188
  const storedName = decodeURIComponent(url.pathname.slice('/api/uploads/'.length));
218
189
  return serveUploadedFile(res, storedName);
@@ -223,7 +194,7 @@ async function routeRequest(req, res) {
223
194
  }
224
195
  if (req.method === 'POST' && url.pathname === '/api/uploads') {
225
196
  const body = await readJsonBody(req);
226
- const file = await storeUploadedFile(body);
197
+ const file = await deliverUpload(await storeUploadedFile(body), url, runtimeTarget);
227
198
  return sendJson(res, { ok: true, file });
228
199
  }
229
200
  if (shouldProxy(url.pathname)) {
@@ -385,7 +356,6 @@ async function storeUploadedFile(payload) {
385
356
  if (!data) throw new Error('missing upload data');
386
357
  const buffer = Buffer.from(data, 'base64');
387
358
  if (!buffer.length) throw new Error('empty upload data');
388
- if (buffer.length > maxUploadBytes) throw new Error(`upload too large: max ${maxUploadBytes} bytes`);
389
359
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
390
360
  const random = Math.random().toString(36).slice(2, 8);
391
361
  const storedName = `${stamp}-${random}-${name}`;
@@ -419,12 +389,14 @@ async function serveUploadedFile(res, storedName) {
419
389
  return;
420
390
  }
421
391
  try {
422
- const body = await fsp.readFile(filePath);
392
+ const stat = await fsp.stat(filePath);
393
+ if (!stat.isFile()) throw new Error('not a file');
423
394
  res.writeHead(200, {
395
+ 'Content-Length': String(stat.size),
424
396
  'Content-Type': mime[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
425
397
  'Cache-Control': 'public, max-age=31536000, immutable',
426
398
  });
427
- res.end(body);
399
+ fs.createReadStream(filePath).on('error', () => res.destroy()).pipe(res);
428
400
  } catch {
429
401
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
430
402
  res.end('not found');
@@ -448,6 +420,12 @@ async function serveLocalImage(res, encodedPath) {
448
420
  return;
449
421
  }
450
422
  try {
423
+ if (containerMode) {
424
+ const body = await workspaceFs.readFile(absolutePath);
425
+ res.writeHead(200, { "Content-Type": contentType, "Content-Length": body.length, "Cache-Control": "no-store" });
426
+ res.end(body);
427
+ return;
428
+ }
451
429
  const fileStat = await fsp.stat(absolutePath);
452
430
  if (!fileStat.isFile()) throw new Error('not a file');
453
431
  res.writeHead(200, {
@@ -1,44 +0,0 @@
1
- // Browser + Node >=20 native Web Crypto; no Node-only imports.
2
- const encoder = new TextEncoder();
3
- const decoder = new TextDecoder('utf-8', { fatal: true });
4
- const directions = new Set(['up', 'down']);
5
- export function decodeBase64(value) {
6
- if (typeof value !== 'string' || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid base64');
7
- const binary = atob(value);
8
- const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
9
- if (encodeBase64(bytes) !== value) throw new Error('Invalid base64');
10
- return bytes;
11
- }
12
- export function encodeBase64(bytes) {
13
- let binary = '';
14
- for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
15
- return btoa(binary);
16
- }
17
- function aad(deviceId, direction) {
18
- if (typeof deviceId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(deviceId) || !directions.has(direction)) throw new Error('Invalid context');
19
- return encoder.encode(JSON.stringify([deviceId, direction]));
20
- }
21
- async function importKey(keyBase64, usage) {
22
- const bytes = decodeBase64(keyBase64);
23
- if (bytes.byteLength !== 32) throw new Error('Key must be 32 bytes');
24
- return crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, [usage]);
25
- }
26
- export async function seal(keyBase64, deviceId, direction, payload) {
27
- const additionalData = aad(deviceId, direction);
28
- const key = await importKey(keyBase64, 'encrypt');
29
- const nonce = crypto.getRandomValues(new Uint8Array(12));
30
- const plaintext = JSON.stringify(payload);
31
- if (plaintext === undefined) throw new Error('Payload must be JSON');
32
- const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, encoder.encode(plaintext));
33
- return { v: 1, nonce: encodeBase64(nonce), ciphertext: encodeBase64(new Uint8Array(encrypted)) };
34
- }
35
- export async function open(keyBase64, deviceId, direction, envelope) {
36
- const additionalData = aad(deviceId, direction);
37
- if (!envelope || envelope.v !== 1) throw new Error('Invalid envelope');
38
- const nonce = decodeBase64(envelope.nonce);
39
- const ciphertext = decodeBase64(envelope.ciphertext);
40
- if (nonce.length !== 12 || ciphertext.length < 16) throw new Error('Invalid envelope');
41
- const key = await importKey(keyBase64, 'decrypt');
42
- const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, ciphertext);
43
- return JSON.parse(decoder.decode(plaintext));
44
- }