neoctl-web 0.1.17 → 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/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
  }