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/README.md +21 -0
- package/dist/assets/App-BBTgdI-j.js +105 -0
- package/dist/assets/{index-B3c8NCtO.js → index-B0Wkp2VZ.js} +2 -2
- package/dist/index.html +1 -1
- package/isolation.mjs +7 -8
- package/local-resources.mjs +15 -0
- package/package.json +4 -2
- package/plugin-manager.mjs +120 -0
- package/plugin-settings.mjs +13 -7
- package/plugins/downloads/downloads.mjs +2 -1
- package/plugins/downloads/index.mjs +2 -2
- package/plugins.mjs +162 -69
- package/server.mjs +12 -9
- package/dist/assets/App-DAtURpfb.js +0 -105
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
53
|
-
return {
|
|
54
|
-
|
|
55
|
-
|
|
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,
|
|
69
|
-
return true;
|
|
134
|
+
helpers.sendJson?.(res, host.snapshot()); return true;
|
|
70
135
|
}
|
|
71
|
-
if (req.method === 'POST' &&
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
44
|
-
const pluginHost =
|
|
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
|
-
|
|
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
|
}
|