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/README.md +21 -0
- package/dist/assets/App-BBTgdI-j.js +105 -0
- package/dist/assets/{App-DmtsTXw2.css → App-ha0ZLeCJ.css} +1 -1
- package/dist/assets/{index-BkoW_zgA.js → index-B0Wkp2VZ.js} +2 -2
- package/dist/assets/index-BXAwT7V2.css +1 -0
- package/dist/index.html +2 -2
- 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-Bn6HVDCw.js +0 -105
- package/dist/assets/index-Dgfhk2Ym.css +0 -1
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 {
|
|
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 =
|
|
105
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/plugin-settings.mjs
CHANGED
|
@@ -9,10 +9,14 @@ export async function createWebPluginSettings(storageFile) {
|
|
|
9
9
|
return structuredClone(state);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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({ ...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|