neoctl-web 0.1.16 → 0.1.18

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.
package/isolation.mjs CHANGED
@@ -33,7 +33,7 @@ export function sanitizeIsolatedSnapshot(value) {
33
33
  }
34
34
 
35
35
  /** Web-only identity boundary. The core receives neither credentials nor user identities. */
36
- export async function createIsolationMode({ dataRoot, workspaceRoot, pluginDir, configFile, memoryState = () => ({ current: null, history: [] }), cpaQuotaMonitor, pluginSettings, toolSettings }) {
36
+ export async function createIsolationMode({ dataRoot, workspaceRoot, pluginDir, pluginManager, configFile, memoryState = () => ({ current: null, history: [] }), cpaQuotaMonitor, pluginSettings, toolSettings }) {
37
37
  const config = await loadIsolationConfig(dataRoot, configFile);
38
38
  if (!config.enabled) return {
39
39
  enabled: false,
@@ -51,14 +51,14 @@ export async function createIsolationMode({ dataRoot, workspaceRoot, pluginDir,
51
51
  const { installRuntimeRouterIdleCleanup } = await import('./runtime-router-cleanup.mjs');
52
52
  installRuntimeRouterIdleCleanup();
53
53
  const { createWorkspaceRuntimeManager } = await import('./runtime-workspaces.mjs');
54
- const { createWebPluginHost } = await import('./plugins.mjs');
54
+ const { createPluginManager } = await import('./plugin-manager.mjs');
55
+ pluginManager ||= await createPluginManager({ directory: path.join(dataRoot, 'installed-plugins'), builtInDirectory: pluginDir, loadPlugins: core.loadNeoPlugins });
55
56
  const { createWebPluginSettings } = await import('./plugin-settings.mjs');
56
57
  const { createWebToolSettings } = await import('./tool-settings.mjs');
57
58
  const { createChunkUploadHandler } = await import('./chunk-uploads.mjs');
58
59
  const { workspaceFs, openWorkspaceRead, containerMode } = await import('./execution-backend.mjs');
59
60
  const globalPlugins = pluginSettings || await createWebPluginSettings(path.join(dataRoot, 'plugins.json'));
60
61
  const globalTools = toolSettings || await createWebToolSettings(path.join(dataRoot, 'tools.json'));
61
- const startupPlugins = process.env.NEO_WEB_PLUGINS?.trim() || globalPlugins.globalEnabledIds();
62
62
  const users = new Map();
63
63
  let modelConfigQueue = Promise.resolve();
64
64
  const withModelConfigLock = operation => {
@@ -101,11 +101,10 @@ export async function createIsolationMode({ dataRoot, workspaceRoot, pluginDir,
101
101
  for (const key of ['NEO_DOWNLOADS_DIR', 'NEO_VIDEO_SHARE_DIR', 'NEO_XHS_ARTIFACTS_DIR']) delete pluginEnv[key];
102
102
  const settings = await createWebPluginSettings(path.join(root, 'plugins.json'));
103
103
  const tools = await createWebToolSettings(path.join(root, 'tools.json'));
104
- const pluginHost = createWebPluginHost({
105
- plugins: await core.loadNeoPlugins({ directories: pluginDir, appDataDir: root, env: pluginEnv }),
106
- enabled: startupPlugins, locked: Boolean(process.env.NEO_WEB_PLUGINS?.trim()),
104
+ const pluginHost = await pluginManager.createHost({
105
+ enabled: process.env.NEO_WEB_PLUGINS?.trim() || globalPlugins.globalEnabledIds(), locked: Boolean(process.env.NEO_WEB_PLUGINS?.trim()),
107
106
  settings: { ...settings, globalEnabledIds: () => globalPlugins.globalEnabledIds(), setGlobalEnabled: ids => globalPlugins.setGlobalEnabled(ids) },
108
- });
107
+ }, { appDataDir: root, env: pluginEnv });
109
108
  const manager = createWorkspaceRuntimeManager({
110
109
  projectRoot: workRoot, workspaceRoot: workRoot, registryFile: path.join(root, 'workspaces.json'),
111
110
  createRuntime: options => core.createWebRuntime({
@@ -231,7 +230,7 @@ export async function createIsolationMode({ dataRoot, workspaceRoot, pluginDir,
231
230
  const state = cpaQuotaMonitor?.getPublicState() || { config: { url: '', hasPassword: false }, quotas: [] };
232
231
  jsonReply(res, isAdmin ? state : { quotas: state.quotas }); return true;
233
232
  }
234
- if (['/api/cpa-config', '/api/plugins/global', '/api/prompt-config', '/api/tools/global'].includes(url.pathname) || (isAdmin && ['/api/plugins', '/api/tools'].includes(url.pathname))) {
233
+ if (['/api/cpa-config', '/api/plugins/global', '/api/plugins/install', '/api/plugins/uninstall', '/api/prompt-config', '/api/tools/global'].includes(url.pathname) || (isAdmin && ['/api/plugins', '/api/tools'].includes(url.pathname))) {
235
234
  if (!isAdmin) throw httpError(403, '仅超管可修改全局配置');
236
235
  if (!['GET', 'POST'].includes(req.method)) throw httpError(405, '请求方法无效');
237
236
  if (url.pathname === '/api/cpa-config') {
@@ -0,0 +1,15 @@
1
+ // Optional host capability. Resource plugins know only this helper, not the desktop shell.
2
+ export function createLocalResourceHeaders({ enabled = false } = {}) {
3
+ return (req, absolutePath) => {
4
+ if (!enabled || req.method !== 'HEAD' || req.headers['x-neo-resource-action'] !== 'reveal') return {};
5
+ const address = req.socket?.remoteAddress;
6
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) return {};
7
+ if (req.headers['sec-fetch-site'] === 'cross-site') return {};
8
+ if (req.headers.origin) {
9
+ try { if (new URL(req.headers.origin).host !== req.headers.host) return {}; }
10
+ catch { return {}; }
11
+ }
12
+ // Percent encoding keeps Unicode and control characters out of HTTP header syntax.
13
+ return { 'X-Neo-Resource-Path': encodeURIComponent(absolutePath), Vary: 'X-Neo-Resource-Action' };
14
+ };
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoctl-web",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Neo browser workspace with an embedded agent runtime.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -36,7 +36,7 @@
36
36
  "@tanstack/vue-virtual": "^3.13.36",
37
37
  "highlight.js": "^11.11.1",
38
38
  "marked": "^18.0.3",
39
- "neoctl": "0.2.42",
39
+ "neoctl": "0.2.44",
40
40
  "streaming-markdown": "^0.2.15",
41
41
  "vue": "^3.5.13"
42
42
  },
@@ -61,7 +61,9 @@
61
61
  "chunk-uploads.mjs",
62
62
  "plugins.mjs",
63
63
  "plugin-settings.mjs",
64
+ "plugin-manager.mjs",
64
65
  "platform-paths.mjs",
66
+ "local-resources.mjs",
65
67
  "tool-settings.mjs",
66
68
  "runtime-workspaces.mjs",
67
69
  "runtime-router-cleanup.mjs",
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createWebPluginHost } from './plugins.mjs';
5
+
6
+ /** Trusted local plugin packages only. Code is copied to immutable, unique module URLs. */
7
+ export async function createPluginManager({ directory, builtInDirectory, loadPlugins, onError = console.error }) {
8
+ const root = path.resolve(directory);
9
+ const indexFile = path.join(root, 'catalog.json');
10
+ await fs.mkdir(root, { recursive: true });
11
+ let state;
12
+ try { state = JSON.parse(await fs.readFile(indexFile, 'utf8')); }
13
+ catch (error) { if (error.code !== 'ENOENT') throw error; state = { installed: {}, removed: [] }; }
14
+ if (!state || !state.installed || !Array.isArray(state.removed)) throw new Error('invalid plugin catalog');
15
+ const builtins = new Map();
16
+ const builtInEntries = await fs.readdir(builtInDirectory, { withFileTypes: true }).catch(error => {
17
+ if (error.code === 'ENOENT') return [];
18
+ throw error;
19
+ });
20
+ for (const entry of builtInEntries) {
21
+ if (!entry.isDirectory()) continue;
22
+ const dir = path.join(builtInDirectory, entry.name);
23
+ try { const manifest = JSON.parse(await fs.readFile(path.join(dir, 'neo-plugin.json'), 'utf8')); builtins.set(manifest.id, dir); }
24
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
25
+ }
26
+ const hosts = new Set();
27
+ let queue = Promise.resolve();
28
+ const serial = operation => { const result = queue.catch(() => {}).then(operation); queue = result; return result; };
29
+ function sources(value) {
30
+ const result = new Map([...builtins].filter(([id]) => !value.removed.includes(id)));
31
+ for (const [id, folder] of Object.entries(value.installed)) {
32
+ if (!/^[a-f0-9-]{36}$/.test(folder)) throw new Error('invalid installed plugin directory');
33
+ result.set(id, path.join(root, folder));
34
+ }
35
+ return result;
36
+ }
37
+ async function persist(next) {
38
+ const temp = `${indexFile}.${randomUUID()}.tmp`;
39
+ try { await fs.writeFile(temp, JSON.stringify(next, null, 2) + '\n'); await fs.rename(temp, indexFile); }
40
+ finally { await fs.rm(temp, { force: true }); }
41
+ }
42
+ async function load(source, context) {
43
+ const plugins = await loadPlugins({ directories: [], pluginDirectories: [source], ...context });
44
+ if (plugins.length !== 1) throw new Error('package must contain exactly one neo-plugin.json at its root');
45
+ return plugins[0];
46
+ }
47
+ async function publish(next) {
48
+ const nextSources = sources(next), prepared = [], created = [];
49
+ try {
50
+ for (const entry of hosts) {
51
+ const plugins = new Map();
52
+ for (const [id, source] of nextSources) {
53
+ let plugin = entry.plugins.get(id);
54
+ if (!plugin || plugin.sourceDir !== source) { plugin = await load(source, entry.context); created.push(plugin); }
55
+ if (plugin.id !== id) throw new Error(`plugin id mismatch: ${id}`);
56
+ plugins.set(id, plugin);
57
+ }
58
+ prepared.push({ entry, plugins, commit: entry.host.prepare([...plugins.values()]) });
59
+ }
60
+ await persist(next);
61
+ } catch (error) {
62
+ await Promise.allSettled(created.map(p => Promise.resolve().then(() => p.dispose?.())));
63
+ throw error;
64
+ }
65
+ const previous = state;
66
+ state = next;
67
+ const drains = prepared.map(({ entry, plugins, commit }) => { entry.plugins = plugins; return commit(); });
68
+ // Never wait for an active model turn in a management request. Logical removal is complete.
69
+ void Promise.all(drains).then(async () => {
70
+ const retained = new Set(Object.values(next.installed));
71
+ for (const folder of Object.values(previous.installed)) {
72
+ if (!retained.has(folder)) await fs.rm(path.join(root, folder), { recursive: true, force: true });
73
+ }
74
+ }).catch(onError);
75
+ }
76
+ const manager = {
77
+ createHost(options = {}, context = {}) {
78
+ return serial(async () => {
79
+ const plugins = new Map();
80
+ try {
81
+ for (const [id, source] of sources(state)) plugins.set(id, await load(source, context));
82
+ const host = createWebPluginHost({ ...options, plugins: [...plugins.values()], management: manager, onError });
83
+ hosts.add({ host, plugins, context });
84
+ return host;
85
+ } catch (error) {
86
+ await Promise.allSettled([...plugins.values()].map(p => Promise.resolve().then(() => p.dispose?.())));
87
+ throw error;
88
+ }
89
+ });
90
+ },
91
+ install(directory) {
92
+ return serial(async () => {
93
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw new Error('install requires an absolute trusted local package directory');
94
+ const source = await fs.realpath(directory);
95
+ const relative = path.relative(source, root);
96
+ if (!relative || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative))) throw new Error('package cannot contain the plugin installation store');
97
+ const folder = randomUUID(), target = path.join(root, folder);
98
+ try {
99
+ await fs.cp(source, target, { recursive: true, filter: async file => {
100
+ const stat = await fs.lstat(file);
101
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) throw new Error('plugin packages must contain only regular files and directories, no links');
102
+ return true;
103
+ } });
104
+ const manifest = JSON.parse(await fs.readFile(path.join(target, 'neo-plugin.json'), 'utf8'));
105
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(manifest.id || '')) throw new Error('invalid plugin id');
106
+ if (!hosts.size) throw new Error('no plugin host is available to validate the package');
107
+ await publish({ installed: { ...state.installed, [manifest.id]: folder }, removed: state.removed.filter(id => id !== manifest.id) });
108
+ } catch (error) { await fs.rm(target, { recursive: true, force: true }); throw error; }
109
+ });
110
+ },
111
+ uninstall(id) {
112
+ return serial(async () => {
113
+ if (typeof id !== 'string' || !sources(state).has(id)) throw new Error(`unknown plugin: ${id}`);
114
+ const installed = { ...state.installed }; delete installed[id];
115
+ await publish({ installed, removed: [...new Set([...state.removed, id])] });
116
+ });
117
+ },
118
+ };
119
+ return manager;
120
+ }
@@ -9,10 +9,14 @@ export async function createWebPluginSettings(storageFile) {
9
9
  return structuredClone(state);
10
10
  }
11
11
 
12
- async function update(next) {
13
- state = next;
14
- writeQueue = writeQueue.catch(() => undefined).then(() => writeState(storageFile, state));
15
- await writeQueue;
12
+ function update(reduce) {
13
+ const operation = writeQueue.catch(() => undefined).then(async () => {
14
+ const next = reduce(state);
15
+ await writeState(storageFile, next);
16
+ state = next;
17
+ });
18
+ writeQueue = operation;
19
+ return operation;
16
20
  }
17
21
 
18
22
  return {
@@ -25,16 +29,18 @@ export async function createWebPluginSettings(storageFile) {
25
29
  return value && typeof value === 'object' ? { ...value } : {};
26
30
  },
27
31
  async setGlobalEnabled(ids) {
28
- await update({ ...state, globalEnabled: [...new Set(ids)].sort() });
32
+ await update(current => ({ ...current, globalEnabled: [...new Set(ids)].sort() }));
29
33
  },
30
34
  async setSessionOverrides(sessionId, overrides) {
31
35
  const id = String(sessionId || '').trim();
32
36
  if (!id) throw new Error('session id is required');
33
- const sessions = { ...state.sessions };
37
+ await update(current => {
38
+ const sessions = { ...current.sessions };
34
39
  const normalized = Object.fromEntries(Object.entries(overrides).filter(([, value]) => typeof value === 'boolean'));
35
40
  if (Object.keys(normalized).length) sessions[id] = normalized;
36
41
  else delete sessions[id];
37
- await update({ ...state, sessions });
42
+ return { ...current, sessions };
43
+ });
38
44
  },
39
45
  };
40
46
  }
@@ -86,7 +86,7 @@ export function createExposeDownloadsTool(options) {
86
86
  return tool;
87
87
  }
88
88
 
89
- export async function serveDownload(registry, req, res, id) {
89
+ export async function serveDownload(registry, req, res, id, helpers = {}) {
90
90
  let handle;
91
91
  try {
92
92
  const entry = await registry.get(id);
@@ -95,6 +95,7 @@ export async function serveDownload(registry, req, res, id) {
95
95
  const stat = await handle.stat();
96
96
  if (!stat.isFile()) throw Object.assign(new Error('Not a file'), { code: 'ENOENT' });
97
97
  res.writeHead(200, {
98
+ ...helpers.localResourceHeaders?.(req, entry.absolutePath),
98
99
  'Content-Type': 'application/octet-stream', 'Content-Length': stat.size,
99
100
  'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16))}`,
100
101
  'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
@@ -11,11 +11,11 @@ export function createPlugin(context = {}) {
11
11
  name: 'Web Downloads', requiresTools: ['expose_downloads'], cacheStable: true,
12
12
  content: 'When you create, modify, export, package, or identify local files that the web user should receive as downloads, call expose_downloads with the relevant absolute paths before the final response. For every download link, copy downloads[].markdown verbatim (neoctl.resource-link.v1): never construct links or add/remove a sandbox: prefix. Only the link-to-original-absolute-path mapping is persisted, with no file copy and no directory restriction. Links do not automatically expire and expiresAt is null. If the original path is moved, deleted, or unreadable, the link stops working. Anyone with a link can download. Historical links from the old in-memory plugin cannot be restored; re-expose the original file if needed.',
13
13
  }],
14
- async route(req, res, url) {
14
+ async route(req, res, url, helpers = {}) {
15
15
  if (!url.pathname.startsWith('/api/downloads/')) return false;
16
16
  if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, { Allow: 'GET, HEAD' }); res.end(); return true; }
17
17
  const id = url.pathname.slice('/api/downloads/'.length);
18
- await serveDownload(registry, req, res, id);
18
+ await serveDownload(registry, req, res, id, helpers);
19
19
  return true;
20
20
  },
21
21
  };
package/plugins.mjs CHANGED
@@ -4,108 +4,201 @@ function normalizePlugin(plugin) {
4
4
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error(`invalid web plugin id: ${id || '(empty)'}`);
5
5
  if (!String(plugin.name || '').trim()) throw new Error(`web plugin ${id} is missing name`);
6
6
  if (!String(plugin.version || '').trim()) throw new Error(`web plugin ${id} is missing version`);
7
- if (plugin.route !== undefined && typeof plugin.route !== 'function') throw new Error(`web plugin ${id} route must be a function`);
8
- if (plugin.presentToolResult !== undefined && typeof plugin.presentToolResult !== 'function') throw new Error(`web plugin ${id} presentToolResult must be a function`);
9
- return {
10
- ...plugin,
11
- id,
12
- name: String(plugin.name).trim(),
13
- version: String(plugin.version).trim(),
7
+ for (const key of ['route', 'presentToolResult', 'dispose']) {
8
+ if (plugin[key] !== undefined && typeof plugin[key] !== 'function') throw new Error(`web plugin ${id} ${key} must be a function`);
9
+ }
10
+ return { ...plugin, id, name: String(plugin.name).trim(), version: String(plugin.version).trim(),
14
11
  defaultEnabled: plugin.defaultEnabled !== false,
15
12
  tools: Array.isArray(plugin.tools) ? plugin.tools : [],
16
- promptSections: Array.isArray(plugin.promptSections) ? plugin.promptSections : [],
17
- };
13
+ promptSections: Array.isArray(plugin.promptSections) ? plugin.promptSections : [] };
18
14
  }
19
15
 
16
+ /** Owns instances, not plugin-specific routes or storage. Published catalogs change synchronously. */
20
17
  export function createWebPluginHost(options = {}) {
21
- const catalog = (options.plugins || []).map(normalizePlugin).sort((left, right) => left.id.localeCompare(right.id));
22
- const ids = catalog.map((plugin) => plugin.id);
23
- if (new Set(ids).size !== ids.length) throw new Error('duplicate web plugin id');
24
- const enabledIds = resolveEnabledPluginIds(catalog, options.enabled);
25
- const enabled = catalog.filter((plugin) => enabledIds.has(plugin.id));
26
- const tools = enabled.flatMap((plugin) => plugin.tools);
27
- const toolNames = tools.map((tool) => String(tool?.name || '').trim()).filter(Boolean);
28
- if (new Set(toolNames).size !== toolNames.length) throw new Error('duplicate tool name across enabled web plugins');
18
+ let catalog = [];
19
+ let revision = 0;
20
+ const entries = new Map();
21
+ const reserved = new Map();
22
+ const retired = new Set();
23
+ let enabledSetting = options.enabled;
24
+ let mutation = Promise.resolve();
25
+ const report = error => (options.onError || console.error)(error);
29
26
 
30
- return {
31
- ids: enabled.map((plugin) => plugin.id),
32
- tools,
33
- promptSections: enabled.flatMap((plugin) => plugin.promptSections),
27
+ function enabledIds() {
28
+ // Shared settings make global switches immediately visible to isolated user hosts too.
29
+ const configured = options.locked ? enabledSetting : options.settings?.globalEnabledIds() ?? enabledSetting;
30
+ const value = Array.isArray(configured) ? configured.filter(id => catalog.some(p => p.id === id)) : configured;
31
+ return resolveEnabledPluginIds(catalog, value);
32
+ }
33
+ function active() { const ids = enabledIds(); return catalog.filter(p => ids.has(p.id)); }
34
+ function validate(plugins) {
35
+ const next = plugins.map(normalizePlugin).sort((a, b) => a.id.localeCompare(b.id));
36
+ if (new Set(next.map(p => p.id)).size !== next.length) throw new Error('duplicate web plugin id');
37
+ const names = new Set();
38
+ for (const plugin of next) for (const tool of plugin.tools) {
39
+ for (const name of [tool.name, ...(tool.aliases || [])]) {
40
+ if (names.has(name)) throw new Error(`duplicate tool name or alias across web plugins: ${name}`);
41
+ if (reserved.has(name)) throw new Error(`plugin tool conflicts with host tool: ${name}`);
42
+ names.add(name);
43
+ }
44
+ }
45
+ return next;
46
+ }
47
+ function drain(entry) {
48
+ if (!entry.retired || entry.leases || entry.disposal) return;
49
+ entry.disposal = Promise.resolve().then(() => entry.plugin.dispose?.()).catch(report).finally(() => {
50
+ retired.delete(entry);
51
+ entry.finish();
52
+ });
53
+ }
54
+ function lease(plugins) {
55
+ const owned = plugins.map(p => entries.get(p.id));
56
+ for (const entry of owned) entry.leases++;
57
+ let released = false;
58
+ return () => {
59
+ if (released) return;
60
+ released = true;
61
+ for (const entry of owned) { entry.leases--; drain(entry); }
62
+ };
63
+ }
64
+ function publish(next) {
65
+ const waits = [];
66
+ for (const [id, entry] of entries) {
67
+ if (next.some(p => p.plugin.id === id && p.source === entry.source)) continue;
68
+ entries.delete(id);
69
+ entry.retired = true;
70
+ retired.add(entry);
71
+ waits.push(entry.drained);
72
+ drain(entry);
73
+ }
74
+ catalog = next.map(({ source, plugin }) => {
75
+ if (!entries.has(plugin.id)) {
76
+ let finish;
77
+ const drained = new Promise(resolve => { finish = resolve; });
78
+ entries.set(plugin.id, { source, plugin, leases: 0, retired: false, drained, finish });
79
+ }
80
+ return entries.get(plugin.id).plugin;
81
+ });
82
+ revision++;
83
+ return Promise.all(waits);
84
+ }
85
+ const initial = options.plugins || [];
86
+ // Explicit startup configuration is strict; persisted removed ids are filtered when resolving.
87
+ if (typeof enabledSetting === 'string') resolveEnabledPluginIds(initial, enabledSetting);
88
+ const normalized = validate(initial);
89
+ publish(normalized.map(plugin => ({ plugin, source: initial.find(p => p.id === plugin.id) })));
90
+
91
+ function definitions() {
92
+ const enabled = enabledIds();
93
+ return catalog.map(plugin => ({ id: plugin.id, name: plugin.name, version: plugin.version,
94
+ globallyEnabled: enabled.has(plugin.id), tools: plugin.tools, promptSections: plugin.promptSections,
95
+ presentToolResult: plugin.presentToolResult }));
96
+ }
97
+ const host = {
98
+ get ids() { return active().map(p => p.id); },
99
+ get tools() { return active().flatMap(p => p.tools); },
100
+ get promptSections() { return active().flatMap(p => p.promptSections); },
101
+ /** Validate before any persistent commit; publication itself cannot fail. */
102
+ prepare(plugins) {
103
+ const normalized = validate(plugins);
104
+ const next = normalized.map(plugin => ({ plugin, source: plugins.find(p => p.id === plugin.id) }));
105
+ return () => publish(next);
106
+ },
107
+ reserveToolNames(names) {
108
+ const own = new Set(catalog.flatMap(p => p.tools.flatMap(t => [t.name, ...(t.aliases || [])])));
109
+ for (const name of names) if (own.has(name)) throw new Error(`plugin tool conflicts with host tool: ${name}`);
110
+ for (const name of names) reserved.set(name, true);
111
+ },
34
112
  runtimePlugins(sessionId) {
35
- const overrides = options.settings?.sessionOverrides(sessionId) || {};
36
113
  return {
37
- externalPlugins: catalog.map((plugin) => ({
38
- id: plugin.id,
39
- name: plugin.name,
40
- version: plugin.version,
41
- globallyEnabled: enabledIds.has(plugin.id),
42
- tools: plugin.tools,
43
- promptSections: plugin.promptSections,
44
- presentToolResult: plugin.presentToolResult,
45
- })),
46
- sessionPluginOverrides: overrides,
47
- persistSessionPluginOverrides: (resolvedSessionId, next) => options.settings?.setSessionOverrides(resolvedSessionId, next),
48
- resolveSessionPluginOverrides: (resolvedSessionId) => options.settings?.sessionOverrides(resolvedSessionId) || {},
114
+ externalPlugins: definitions(),
115
+ resolveExternalPlugins: definitions,
116
+ reservePluginToolNames: names => host.reserveToolNames(names),
117
+ acquirePluginSnapshot(overrides = {}) {
118
+ const enabled = active().filter(p => overrides[p.id] !== false);
119
+ return { plugins: definitions(), release: lease(enabled) };
120
+ },
121
+ sessionPluginOverrides: options.settings?.sessionOverrides(sessionId) || {},
122
+ persistSessionPluginOverrides: (id, next) => options.settings?.setSessionOverrides(id, next),
123
+ resolveSessionPluginOverrides: id => options.settings?.sessionOverrides(id) || {},
49
124
  };
50
125
  },
51
126
  snapshot() {
52
- const configuredIds = new Set(options.settings?.globalEnabledIds() ?? [...enabledIds]);
53
- return {
54
- items: catalog.map((plugin) => ({
55
- id: plugin.id,
56
- name: plugin.name,
57
- version: plugin.version,
58
- enabled: enabledIds.has(plugin.id),
59
- configuredEnabled: configuredIds.has(plugin.id),
60
- tools: plugin.tools.map((tool) => tool.name),
61
- })),
62
- restartRequired: true,
63
- locked: options.locked === true,
64
- };
127
+ const enabled = enabledIds();
128
+ return { revision, items: catalog.map(p => ({ id: p.id, name: p.name, version: p.version,
129
+ enabled: enabled.has(p.id), configuredEnabled: enabled.has(p.id), tools: p.tools.map(t => t.name) })),
130
+ restartRequired: false, locked: options.locked === true, installationSupported: Boolean(options.management) };
65
131
  },
66
132
  async route(req, res, url, helpers = {}) {
67
133
  if (req.method === 'GET' && url.pathname === '/api/plugins') {
68
- helpers.sendJson?.(res, this.snapshot());
69
- return true;
134
+ helpers.sendJson?.(res, host.snapshot()); return true;
70
135
  }
71
- if (req.method === 'POST' && url.pathname === '/api/plugins/global') {
136
+ if (req.method === 'POST' && ['/api/plugins/global', '/api/plugins/install', '/api/plugins/uninstall'].includes(url.pathname)) {
137
+ const origin = req.headers?.origin;
138
+ if (req.headers?.['sec-fetch-site'] === 'cross-site' || (origin && !sameHostOrigin(origin, req.headers?.host))) {
139
+ helpers.sendJson?.(res, { errorCode: 'PLUGIN_ORIGIN_DENIED', error: 'cross-origin plugin management is not allowed' }, 403); return true;
140
+ }
72
141
  if (options.locked) {
73
- helpers.sendJson?.(res, { errorCode: 'PLUGINS_LOCKED', error: 'plugins are locked by NEO_WEB_PLUGINS' }, 409);
74
- return true;
142
+ helpers.sendJson?.(res, { errorCode: 'PLUGINS_LOCKED', error: 'plugins are locked by NEO_WEB_PLUGINS' }, 409); return true;
75
143
  }
76
144
  const body = await helpers.readJsonBody?.(req);
77
- const requested = Array.isArray(body?.enabledIds) ? body.enabledIds.map(String) : [];
78
- const unknown = requested.filter((id) => !ids.includes(id));
79
- if (unknown.length) {
80
- helpers.sendJson?.(res, { errorCode: 'PLUGIN_INVALID', error: `unknown web plugin: ${unknown.join(', ')}` }, 400);
81
- return true;
82
- }
83
- await options.settings?.setGlobalEnabled(requested);
84
- helpers.sendJson?.(res, { ok: true, enabledIds: [...new Set(requested)].sort(), restartRequired: true });
145
+ const operation = mutation.catch(() => {}).then(async () => {
146
+ if (url.pathname !== '/api/plugins/global') {
147
+ if (!options.management) throw new Error('plugin installation is not configured');
148
+ if (url.pathname.endsWith('/install')) await options.management.install(body?.directory);
149
+ else await options.management.uninstall(body?.id);
150
+ } else {
151
+ if (!Array.isArray(body?.enabledIds)) throw new Error('enabledIds must be an array');
152
+ const requested = [...resolveEnabledPluginIds(catalog, body.enabledIds)];
153
+ await options.settings?.setGlobalEnabled(requested);
154
+ enabledSetting = requested;
155
+ revision++;
156
+ }
157
+ return { ok: true, enabledIds: host.ids, ...host.snapshot() };
158
+ });
159
+ mutation = operation;
160
+ try { helpers.sendJson?.(res, await operation); }
161
+ catch (error) { helpers.sendJson?.(res, { errorCode: 'PLUGIN_UPDATE_FAILED', error: error.message || String(error) }, 400); }
85
162
  return true;
86
163
  }
87
- for (const plugin of enabled) {
88
- if (typeof plugin.route === 'function' && await plugin.route(req, res, url, helpers)) return true;
164
+ // Hold a lease through response finish/close, not merely through the route promise (streams).
165
+ for (const plugin of active()) {
166
+ if (entries.get(plugin.id)?.plugin !== plugin || !enabledIds().has(plugin.id) || typeof plugin.route !== 'function') continue;
167
+ const release = lease([plugin]);
168
+ let routeDone = false, responseDone = false, handled = false;
169
+ const finish = () => { responseDone = true; if (routeDone) release(); };
170
+ res.once?.('finish', finish); res.once?.('close', finish);
171
+ try { handled = await plugin.route(req, res, url, helpers); }
172
+ finally {
173
+ routeDone = true;
174
+ if (!handled || responseDone || res.writableFinished || res.destroyed || !res.once) {
175
+ res.off?.('finish', finish); res.off?.('close', finish); release();
176
+ }
177
+ }
178
+ if (handled) return true;
89
179
  }
90
180
  return false;
91
181
  },
92
182
  };
183
+ return host;
184
+ }
185
+
186
+ function sameHostOrigin(origin, host) {
187
+ try { const url = new URL(origin); return ['http:', 'https:'].includes(url.protocol) && url.host === host; }
188
+ catch { return false; }
93
189
  }
94
190
 
95
191
  export function resolveEnabledPluginIds(catalog, configured) {
96
- const available = new Set(catalog.map((plugin) => plugin.id));
192
+ const available = new Set(catalog.map(plugin => plugin.id));
97
193
  if (Array.isArray(configured)) {
98
194
  const requested = configured.map(String);
99
- const unknown = requested.filter((id) => !available.has(id));
195
+ const unknown = requested.filter(id => !available.has(id));
100
196
  if (unknown.length) throw new Error(`unknown web plugin: ${unknown.join(', ')}`);
101
197
  return new Set(requested);
102
198
  }
103
199
  const raw = configured === undefined || configured === null ? '' : String(configured).trim();
104
- if (!raw) return new Set(catalog.filter((plugin) => plugin.defaultEnabled !== false).map((plugin) => plugin.id));
200
+ if (!raw) return new Set(catalog.filter(plugin => plugin.defaultEnabled !== false).map(plugin => plugin.id));
105
201
  if (raw.toLowerCase() === 'none') return new Set();
106
202
  if (raw.toLowerCase() === 'all') return available;
107
- const requested = raw.split(',').map((id) => id.trim()).filter(Boolean);
108
- const unknown = requested.filter((id) => !available.has(id));
109
- if (unknown.length) throw new Error(`unknown web plugin: ${unknown.join(', ')}`);
110
- return new Set(requested);
203
+ return resolveEnabledPluginIds(catalog, raw.split(',').map(id => id.trim()).filter(Boolean));
111
204
  }
package/server.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { createChunkUploadHandler } from './chunk-uploads.mjs';
3
+ import { createLocalResourceHeaders } from './local-resources.mjs';
3
4
  import fs from 'node:fs';
4
5
  import fsp from 'node:fs/promises';
5
6
  import path from 'node:path';
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url';
7
8
  const { containerMode, workspaceFs, deliverUpload, verifyExecutionBackend } = await import('./execution-backend.mjs');
8
9
  await verifyExecutionBackend();
9
10
  const { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } = await import('./core-runtime.mjs');
10
- const { createWebPluginHost } = await import('./plugins.mjs');
11
+ const { createPluginManager } = await import('./plugin-manager.mjs');
11
12
  const { createWebPluginSettings } = await import('./plugin-settings.mjs');
12
13
  const { createWebToolSettings } = await import('./tool-settings.mjs');
13
14
  const { createWorkspaceRuntimeManager } = await import('./runtime-workspaces.mjs');
@@ -23,6 +24,10 @@ process.env.NEO_CLIENT_REVISION ||= `${coreRuntimeInfo.version}-${Date.now().toS
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  const root = path.resolve(process.env.DIST_DIR || path.join(__dirname, 'dist'));
25
26
  const host = process.env.APP_HOST || '0.0.0.0';
27
+ const localResourceHeaders = createLocalResourceHeaders({
28
+ enabled: process.env.NEO_DESKTOP_LOCAL_RESOURCES === '1' && host === '127.0.0.1'
29
+ && process.env.NEO_EXECUTION_BACKEND !== 'docker',
30
+ });
26
31
  const port = Number(process.env.APP_PORT || process.env.PORT || 5173);
27
32
  const runtimeTarget = new URL(process.env.NEO_RUNTIME_TARGET || 'http://127.0.0.1:3101');
28
33
  const storage = resolveWebStorage();
@@ -40,13 +45,11 @@ const chunkUploads = createChunkUploadHandler({ uploadsDir, baseDir: __dirname,
40
45
  const pluginSettings = await createWebPluginSettings(pluginSettingsFile);
41
46
  const toolSettings = await createWebToolSettings(toolSettingsFile);
42
47
  const pluginEnv = process.env.NEO_WEB_PLUGINS;
43
- const pluginResources = await loadNeoPlugins({ directories: pluginDir, appDataDir: pluginDataDir });
44
- const pluginHost = createWebPluginHost({
45
- plugins: pluginResources,
48
+ const pluginManager = await createPluginManager({ directory: path.join(dataRoot, 'installed-plugins'), builtInDirectory: pluginDir, loadPlugins: loadNeoPlugins });
49
+ const pluginHost = await pluginManager.createHost({
46
50
  enabled: pluginEnv?.trim() ? pluginEnv : pluginSettings.globalEnabledIds(),
47
- locked: Boolean(pluginEnv?.trim()),
48
- settings: pluginSettings,
49
- });
51
+ locked: Boolean(pluginEnv?.trim()), settings: pluginSettings,
52
+ }, { appDataDir: pluginDataDir });
50
53
  const embedRuntime = process.env.NEO_EMBED_RUNTIME !== 'false';
51
54
  const cpaQuotaMonitor = createCpaQuotaMonitor({ configFile: cpaConfigFile });
52
55
  const memoryMonitor = createMemoryMonitor({
@@ -74,7 +77,7 @@ const workspaceRuntime = createWorkspaceRuntimeManager({
74
77
  });
75
78
 
76
79
  const { createIsolationMode } = await import('./isolation.mjs');
77
- const isolation = await createIsolationMode({ dataRoot, workspaceRoot, pluginDir, pluginSettings, toolSettings, cpaQuotaMonitor, memoryState: () => memoryMonitor.getPublicState() });
80
+ const isolation = await createIsolationMode({ dataRoot, workspaceRoot, pluginDir, pluginManager, pluginSettings, toolSettings, cpaQuotaMonitor, memoryState: () => memoryMonitor.getPublicState() });
78
81
 
79
82
  const DEFAULT_APP_PROMPT_LIBRARY = [];
80
83
 
@@ -128,7 +131,7 @@ async function routeRequest(req, res) {
128
131
  const url = new URL(req.url || '/', 'http://localhost');
129
132
  try {
130
133
  if (await isolation.route(req, res, url)) return;
131
- if (await pluginHost.route(req, res, url, { readJsonBody, sendJson })) return;
134
+ if (await pluginHost.route(req, res, url, { readJsonBody, sendJson, localResourceHeaders })) return;
132
135
  if (req.method === 'GET' && url.pathname === '/api/prompt-library') {
133
136
  return sendJson(res, { items: await readPromptLibrary() });
134
137
  }