neoctl-web 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomBytes, scrypt as scryptCallback, timingSafeEqual, createHash } from 'node:crypto';
4
+ import { promisify } from 'node:util';
5
+
6
+ const scrypt = promisify(scryptCallback);
7
+ const HASH = /^scrypt\$([a-f0-9]{32})\$([a-f0-9]{128})$/;
8
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/i;
9
+ export const isolationCookie = 'neo_isolation';
10
+ export const publicUser = user => ({ username: user.username, role: user.role || 'user' });
11
+ export const usernameKey = username => String(username).normalize('NFKC').toLowerCase();
12
+ export const validUsername = username => typeof username === 'string'
13
+ && username.length >= 1 && username.length <= 100 && Buffer.byteLength(username, 'utf8') <= 240
14
+ && username === username.trim() && !username.endsWith('.')
15
+ && username !== '.' && username !== '..' && !WINDOWS_RESERVED.test(username)
16
+ && !/[\u0000-\u001f<>:"/\\|?*]/.test(username);
17
+ export const jsonReply = (res, value, status = 200) => {
18
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
19
+ res.end(JSON.stringify(value));
20
+ };
21
+ export const httpError = (status, message) => Object.assign(new Error(message), { status });
22
+
23
+ export async function readBoundedJson(req, maxBytes = 1024 * 1024) {
24
+ const chunks = [];
25
+ let bytes = 0;
26
+ for await (const chunk of req) {
27
+ bytes += chunk.length;
28
+ if (bytes > maxBytes) throw httpError(413, '请求过大');
29
+ chunks.push(Buffer.from(chunk));
30
+ }
31
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); }
32
+ catch { throw httpError(400, 'JSON 无效'); }
33
+ }
34
+
35
+ export async function hashPassword(password) {
36
+ if (typeof password !== 'string' || !password.length || /[^A-Za-z0-9]/.test(password)) throw new Error('密码至少 1 位,仅允许字母和数字');
37
+ const salt = randomBytes(16).toString('hex');
38
+ return `scrypt$${salt}$${Buffer.from(await scrypt(password, salt, 64)).toString('hex')}`;
39
+ }
40
+
41
+ export async function loadIsolationConfig(dataRoot, configFile = process.env.NEO_ISOLATION_CONFIG) {
42
+ const filename = path.resolve(configFile || path.join(dataRoot, 'isolation.json'));
43
+ let config;
44
+ try { config = JSON.parse(await fs.readFile(filename, 'utf8')); }
45
+ catch (error) {
46
+ if (error.code === 'ENOENT' && !configFile) return { enabled: false };
47
+ throw new Error(`无法读取隔离配置: ${filename}`, { cause: error });
48
+ }
49
+ if (!config || typeof config.enabled !== 'boolean') throw new Error('isolation.enabled 必须为布尔值');
50
+ if (!config.enabled) return { enabled: false };
51
+ if (!Array.isArray(config.users) || !config.users.length || config.users.length > 1000) throw new Error('隔离模式须配置 1–1000 个用户');
52
+ const users = config.users.map(user => {
53
+ if (user?.id !== undefined && user.id !== user.username) throw new Error('旧用户 ID 与用户名不同,请先迁移数据目录');
54
+ const { id: _oldId, ...account } = user || {};
55
+ return account;
56
+ });
57
+ const names = new Set();
58
+ for (const user of users) {
59
+ const key = usernameKey(user?.username);
60
+ if (!user || !validUsername(user.username) || (user.passwordHash !== undefined && !HASH.test(user.passwordHash))
61
+ || (user.role !== undefined && !['user', 'admin'].includes(user.role)) || names.has(key)) {
62
+ throw new Error('用户名、角色或密码哈希无效或重复');
63
+ }
64
+ names.add(key);
65
+ }
66
+ const cookiePath = config.cookiePath || '/';
67
+ if (!/^\/(?:[a-zA-Z0-9_/-]*)$/.test(cookiePath)) throw new Error('cookiePath 无效');
68
+ if (config.secureCookie !== undefined && typeof config.secureCookie !== 'boolean') throw new Error('secureCookie 必须为布尔值');
69
+ const sessionHours = config.sessionHours ?? 12;
70
+ if (!Number.isFinite(sessionHours) || sessionHours < 0.01 || sessionHours > 168) throw new Error('sessionHours 无效');
71
+ if (config.retiredUserIds !== undefined && config.retiredUsernames === undefined && config.retiredUserIds.length) {
72
+ throw new Error('旧 retiredUserIds 非空,请先迁移为 retiredUsernames');
73
+ }
74
+ const retiredUsernames = config.retiredUsernames ?? config.retiredUserIds ?? [];
75
+ const retiredKeys = new Set();
76
+ if (!Array.isArray(retiredUsernames) || retiredUsernames.some(username => {
77
+ const key = usernameKey(username);
78
+ if (!validUsername(username) || names.has(key) || retiredKeys.has(key)) return true;
79
+ retiredKeys.add(key); return false;
80
+ })) throw new Error('retiredUsernames 无效');
81
+ const { retiredUserIds: _oldRetiredUserIds, ...current } = config;
82
+ return { ...current, enabled: true, filename, users, retiredUsernames, cookiePath, secureCookie: config.secureCookie === true, sessionHours };
83
+ }
84
+
85
+ export function createIsolationAccounts(config, { dataRoot, onDelete = () => {} }) {
86
+ let mutation = Promise.resolve();
87
+ const serial = operation => {
88
+ const pending = mutation.then(operation);
89
+ mutation = pending.catch(() => {});
90
+ return pending;
91
+ };
92
+ async function persist(users, retiredUsernames) {
93
+ const { filename, retiredUserIds: _oldRetiredUserIds, ...stored } = config;
94
+ const temporary = `${filename}.tmp-${randomBytes(8).toString('hex')}`;
95
+ try {
96
+ await fs.writeFile(temporary, JSON.stringify({ ...stored, users, retiredUsernames }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
97
+ await fs.rename(temporary, filename);
98
+ } finally { await fs.rm(temporary, { force: true }); }
99
+ config.users = users;
100
+ config.retiredUsernames = retiredUsernames;
101
+ }
102
+ return {
103
+ list: () => config.users.map(publicUser),
104
+ create: body => serial(async () => {
105
+ const { username, role } = body || {};
106
+ if (body && (Object.hasOwn(body, 'password') || Object.hasOwn(body, 'passwordHash'))) throw httpError(400, '创建用户只需用户名,不可设置密码');
107
+ if (!validUsername(username) || (role !== undefined && role !== 'user')) throw httpError(400, '账号信息无效,只能创建普通用户');
108
+ if (config.users.length >= 1000) throw httpError(400, '用户数量已达上限');
109
+ const key = usernameKey(username);
110
+ if (config.users.some(user => usernameKey(user.username) === key) || config.retiredUsernames.some(value => usernameKey(value) === key)) throw httpError(409, '用户名已使用');
111
+ try { await fs.lstat(path.join(dataRoot, 'isolated-users', username)); throw httpError(409, '该用户名已有历史数据'); }
112
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
113
+ const user = { username, role: 'user' };
114
+ await persist([...config.users, user], config.retiredUsernames);
115
+ return publicUser(user);
116
+ }),
117
+ claim: (user, password) => serial(async () => {
118
+ const current = config.users.find(value => value.username === user.username);
119
+ if (!current) return;
120
+ if (current.passwordHash !== undefined) return current;
121
+ let passwordHash;
122
+ try { passwordHash = await hashPassword(password); } catch (error) { throw httpError(400, error.message); }
123
+ const claimed = { ...current, passwordHash };
124
+ await persist(config.users.map(value => value === current ? claimed : value), config.retiredUsernames);
125
+ return claimed;
126
+ }),
127
+ remove: username => serial(async () => {
128
+ if (!validUsername(username)) throw httpError(400, '用户名无效');
129
+ const key = usernameKey(username);
130
+ const user = config.users.find(value => usernameKey(value.username) === key);
131
+ if (!user) throw httpError(404, '用户不存在');
132
+ if (user.role === 'admin') throw httpError(403, '不能删除超管');
133
+ await persist(config.users.filter(value => value !== user), [...config.retiredUsernames, user.username]);
134
+ onDelete(user.username);
135
+ }),
136
+ };
137
+ }
138
+
139
+ export function createIsolationAuth(config, accounts) {
140
+ const sessions = new Map(), attempts = new Map();
141
+ let activeLogins = 0;
142
+ const digest = value => createHash('sha256').update(value).digest('hex');
143
+ const cookie = (token, maxAge) => `${isolationCookie}=${token}; Path=${config.cookiePath}; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${config.secureCookie ? '; Secure' : ''}`;
144
+ const tokenFrom = req => {
145
+ const tokens = String(req.headers.cookie || '').split(';').map(x => x.trim()).filter(x => x.startsWith(isolationCookie + '='));
146
+ return tokens.length === 1 ? tokens[0].slice(isolationCookie.length + 1) : '';
147
+ };
148
+ function revoke(key) {
149
+ const session = sessions.get(key);
150
+ if (session) for (const res of session.streams) res.end();
151
+ sessions.delete(key);
152
+ }
153
+ function authenticate(req) {
154
+ const token = tokenFrom(req);
155
+ if (!/^[a-f0-9]{64}$/.test(token)) return;
156
+ const key = digest(token), session = sessions.get(key);
157
+ if (session && session.expires > Date.now() && config.users.includes(session.user)) return { ...session, key };
158
+ revoke(key);
159
+ }
160
+ function sameOrigin(req) {
161
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false;
162
+ if (!req.headers.origin) return true;
163
+ try { return new URL(req.headers.origin).host === req.headers.host; } catch { return false; }
164
+ }
165
+ const timer = setInterval(() => {
166
+ for (const [key, session] of sessions) if (session.expires <= Date.now()) revoke(key);
167
+ for (const [key, attempt] of attempts) if (attempt.until <= Date.now()) attempts.delete(key);
168
+ }, 1000);
169
+ timer.unref();
170
+
171
+ return {
172
+ authenticate, sameOrigin,
173
+ revokeUser(username) { for (const [key, session] of sessions) if (session.user.username === username) revoke(key); },
174
+ close() { clearInterval(timer); for (const key of sessions.keys()) revoke(key); },
175
+ track(session, res) {
176
+ if (!sessions.has(session.key) || session.expires <= Date.now()) { res.end(); return false; }
177
+ session.streams.add(res); res.once('close', () => session.streams.delete(res)); return true;
178
+ },
179
+ async route(req, res, url) {
180
+ const current = authenticate(req);
181
+ if (!sameOrigin(req)) { jsonReply(res, { error: '跨站请求被拒绝' }, 403); return true; }
182
+ if (url.pathname === '/api/auth/status' && req.method === 'GET') {
183
+ jsonReply(res, { isolation: true, user: current ? publicUser(current.user) : null });
184
+ return true;
185
+ }
186
+ if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
187
+ if (current) revoke(current.key);
188
+ res.setHeader('Set-Cookie', cookie('', 0));
189
+ jsonReply(res, { ok: true });
190
+ return true;
191
+ }
192
+ if (url.pathname === '/api/auth/login' && req.method === 'POST') {
193
+ const body = await readBoundedJson(req);
194
+ const username = typeof body?.username === 'string' ? body.username : '';
195
+ const password = typeof body?.password === 'string' ? body.password : '';
196
+ const rateKey = `${req.socket.remoteAddress}`;
197
+ let attempt = attempts.get(rateKey);
198
+ if (!attempt || attempt.until <= Date.now()) attempt = { count: 0, until: Date.now() + 60_000 };
199
+ if (attempt.count >= 10 || activeLogins >= 4 || attempts.size >= 10000 || sessions.size >= 10000) {
200
+ res.setHeader('Retry-After', '60'); jsonReply(res, { error: '尝试过多,请稍后重试' }, 429); return true;
201
+ }
202
+ attempt.count++; attempts.set(rateKey, attempt);
203
+ let user = config.users.find(value => value.username === username);
204
+ activeLogins++;
205
+ let valid = false;
206
+ try {
207
+ if (user && user.passwordHash === undefined) user = await accounts.claim(user, password);
208
+ const parts = HASH.exec(user?.passwordHash || '') || ['', '0'.repeat(32), '0'.repeat(128)];
209
+ const derived = await scrypt(password, parts[1], 64);
210
+ valid = !!user && config.users.includes(user) && timingSafeEqual(derived, Buffer.from(parts[2], 'hex'));
211
+ } finally { activeLogins--; }
212
+ if (!valid) { jsonReply(res, { error: '用户名或密码错误' }, 401); return true; }
213
+ attempts.delete(rateKey);
214
+ if (current) revoke(current.key);
215
+ const token = randomBytes(32).toString('hex');
216
+ const maxAge = Math.round(config.sessionHours * 3600);
217
+ sessions.set(digest(token), { user, expires: Date.now() + maxAge * 1000, streams: new Set() });
218
+ res.setHeader('Set-Cookie', cookie(token, maxAge));
219
+ jsonReply(res, { ok: true, user: publicUser(user) });
220
+ return true;
221
+ }
222
+ return false;
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "enabled": false,
3
+ "secureCookie": false,
4
+ "cookiePath": "/",
5
+ "sessionHours": 12,
6
+ "retiredUsernames": [],
7
+ "users": []
8
+ }
package/isolation.mjs ADDED
@@ -0,0 +1,385 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { Readable } from 'node:stream';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { createIsolationAuth, createIsolationAccounts, publicUser, loadIsolationConfig, jsonReply, readBoundedJson, httpError } from './isolation-auth.mjs';
6
+
7
+ const SESSION_ID = /^(?!\.{1,2}$)[a-zA-Z0-9_.-]{1,220}$/;
8
+ const GET_ROUTES = new Set(['/api/client-info', '/api/state', '/api/runtime-context', '/api/sessions', '/api/cwd', '/api/tools', '/api/session-tools', '/api/session-plugins', '/api/tool-call-detail', '/api/terminal-output', '/api/agent-content', '/events']);
9
+ const POST_ROUTES = new Set(['/api/submit', '/api/submit-now', '/api/interrupt', '/api/queue/cancel', '/api/queue/send-now', '/api/sessions/resume', '/api/sessions/new', '/api/sessions/delete', '/api/cwd/change', '/api/cwd/create', '/api/cwd/delete', '/api/session-model', '/api/session-tools', '/api/session-plugins', '/api/compact', '/api/fast-mode', '/api/context-window']);
10
+ const SAFE_COMMANDS = new Set(['/help', '/cost', '/compact', '/pure', '/new', '/sessions', '/state', '/reset']);
11
+ const inside = (root, target) => { const relative = path.relative(root, target); return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative)); };
12
+
13
+ export async function assertOwnedSession(root, sessionId) {
14
+ if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId) || sessionId === 'latest') throw httpError(404, '会话不存在');
15
+ try {
16
+ const folder = path.join(root, sessionId);
17
+ const info = await fs.lstat(folder);
18
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error();
19
+ const transcript = await fs.lstat(path.join(folder, 'transcript.jsonl')).catch(error => {
20
+ if (error.code === 'ENOENT') return undefined;
21
+ throw error;
22
+ });
23
+ if (transcript && (!transcript.isFile() || transcript.isSymbolicLink())) throw new Error();
24
+ } catch { throw httpError(404, '会话不存在'); }
25
+ }
26
+
27
+ export function sanitizeIsolatedSnapshot(value) {
28
+ const result = { ...value };
29
+ if (result.interactive) result.interactive = { sessions: true };
30
+ if (result.catalog) result.catalog = { ...result.catalog, envPath: undefined, commands: result.catalog.commands?.filter(x => SAFE_COMMANDS.has(x.name)) };
31
+ result.appPrompt = undefined;
32
+ return result;
33
+ }
34
+
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 }) {
37
+ const config = await loadIsolationConfig(dataRoot, configFile);
38
+ if (!config.enabled) return {
39
+ enabled: false,
40
+ async route(req, res, url) {
41
+ if (url.pathname === '/api/auth/status' && req.method === 'GET') {
42
+ jsonReply(res, { isolation: false, user: null }); return true;
43
+ }
44
+ return false;
45
+ },
46
+ close() {},
47
+ };
48
+ const core = await import('./core-runtime.mjs');
49
+ if (!core.handleWebRequest) throw new Error('隔离模式需要支持 handleWebRequest 的 core,请先构建或更新 core');
50
+ if (process.env.AGENT_SESSION_TRANSCRIPT === '0') throw new Error('隔离模式要求启用会话存储');
51
+ const { installRuntimeRouterIdleCleanup } = await import('./runtime-router-cleanup.mjs');
52
+ installRuntimeRouterIdleCleanup();
53
+ const { createWorkspaceRuntimeManager } = await import('./runtime-workspaces.mjs');
54
+ const { createWebPluginHost } = await import('./plugins.mjs');
55
+ const { createWebPluginSettings } = await import('./plugin-settings.mjs');
56
+ const { createWebToolSettings } = await import('./tool-settings.mjs');
57
+ const { createChunkUploadHandler } = await import('./chunk-uploads.mjs');
58
+ const { workspaceFs, openWorkspaceRead, containerMode } = await import('./execution-backend.mjs');
59
+ const globalPlugins = pluginSettings || await createWebPluginSettings(path.join(dataRoot, 'plugins.json'));
60
+ const globalTools = toolSettings || await createWebToolSettings(path.join(dataRoot, 'tools.json'));
61
+ const startupPlugins = process.env.NEO_WEB_PLUGINS?.trim() || globalPlugins.globalEnabledIds();
62
+ const users = new Map();
63
+ let modelConfigQueue = Promise.resolve();
64
+ const withModelConfigLock = operation => {
65
+ const result = modelConfigQueue.then(operation);
66
+ modelConfigQueue = result.catch(() => {});
67
+ return result;
68
+ };
69
+ const accounts = createIsolationAccounts(config, { dataRoot, onDelete: username => auth.revokeUser(username) });
70
+ const auth = createIsolationAuth(config, accounts);
71
+ const allOwners = () => [...config.users, ...config.retiredUsernames.map(username => ({ username, role: 'user', deleted: true }))];
72
+ async function adminRoute(req, res, url) {
73
+ if (url.pathname === '/api/admin/users' && req.method === 'GET') {
74
+ jsonReply(res, { users: accounts.list() }); return;
75
+ }
76
+ if (url.pathname === '/api/admin/users' && req.method === 'POST') {
77
+ jsonReply(res, { user: await accounts.create(await readBoundedJson(req, 8192)) }, 201); return;
78
+ }
79
+ if (url.pathname === '/api/admin/users/delete' && req.method === 'POST') {
80
+ const body = await readBoundedJson(req, 4096);
81
+ await accounts.remove(body?.username); jsonReply(res, { ok: true }); return;
82
+ }
83
+ if (url.pathname === '/api/admin/sessions' && req.method === 'GET') {
84
+ const groups = [];
85
+ for (const owner of allOwners()) {
86
+ const engine = new core.QueryEngine({ agentId: 'main', cwd: path.join(workspaceRoot, 'users', owner.username), session: { rootDir: path.join(dataRoot, 'isolated-users', owner.username, 'sessions') } });
87
+ groups.push({ user: { ...publicUser(owner), deleted: owner.deleted === true }, sessions: await engine.listSessions(Number.POSITIVE_INFINITY) });
88
+ }
89
+ jsonReply(res, { groups }); return;
90
+ }
91
+ throw httpError(404, '接口不存在');
92
+ }
93
+
94
+ async function createUser(user) {
95
+ const root = path.join(dataRoot, 'isolated-users', user.username);
96
+ const sessionsRoot = path.join(root, 'sessions');
97
+ const uploadsRoot = path.join(root, 'uploads');
98
+ const workRoot = path.join(workspaceRoot, 'users', user.username);
99
+ await fs.mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
100
+ const pluginEnv = { ...process.env };
101
+ for (const key of ['NEO_DOWNLOADS_DIR', 'NEO_VIDEO_SHARE_DIR', 'NEO_XHS_ARTIFACTS_DIR']) delete pluginEnv[key];
102
+ const settings = await createWebPluginSettings(path.join(root, 'plugins.json'));
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()),
107
+ settings: { ...settings, globalEnabledIds: () => globalPlugins.globalEnabledIds(), setGlobalEnabled: ids => globalPlugins.setGlobalEnabled(ids) },
108
+ });
109
+ const manager = createWorkspaceRuntimeManager({
110
+ projectRoot: workRoot, workspaceRoot: workRoot, registryFile: path.join(root, 'workspaces.json'),
111
+ createRuntime: options => core.createWebRuntime({
112
+ ...options, sessionId: options.sessionId || randomUUID(), sessionRootDir: sessionsRoot, resume: !!options.sessionId,
113
+ ...pluginHost.runtimePlugins(options.sessionId),
114
+ globalToolOverrides: globalTools.globalOverrides(),
115
+ resolveGlobalToolOverrides: () => globalTools.globalOverrides(),
116
+ persistGlobalToolOverrides: overrides => globalTools.setGlobalOverrides(overrides),
117
+ sessionToolOverrides: tools.sessionOverrides(options.sessionId),
118
+ persistSessionToolOverrides: (id, overrides) => tools.setSessionOverrides(id, overrides),
119
+ resolveSessionToolOverrides: id => tools.sessionOverrides(id),
120
+ }),
121
+ });
122
+ const router = new core.WebRuntimeRouter({
123
+ createRuntime: options => manager.createRuntime({ ...options, resume: !!options?.sessionId }),
124
+ createRepl(runtime) {
125
+ const repl = manager.createRepl(runtime);
126
+ const snapshot = repl.snapshot.bind(repl);
127
+ repl.snapshot = includeCatalog => sanitizeIsolatedSnapshot(snapshot(includeCatalog));
128
+ // These are public extension methods; no changes to the model/query loop.
129
+ const resume = repl.resumeSession.bind(repl), remove = repl.deleteSession.bind(repl);
130
+ repl.resumeSession = async id => { await assertOwnedSession(sessionsRoot, id); return resume(id); };
131
+ repl.deleteSession = async id => { await assertOwnedSession(sessionsRoot, id); return remove(id); };
132
+ const browse = repl.browseWorkspace.bind(repl);
133
+ repl.browseWorkspace = async value => {
134
+ const result = await browse(value);
135
+ if (!result.ok) return result;
136
+ return { ...result, home: workRoot, parent: result.cwd === workRoot ? undefined : result.parent,
137
+ locations: [{ name: '工作区', path: workRoot }], entries: result.entries.filter(entry => inside(workRoot, entry.path)) };
138
+ };
139
+ return repl;
140
+ },
141
+ });
142
+ const getRuntime = router.get.bind(router);
143
+ router.get = scope => withModelConfigLock(() => getRuntime(scope));
144
+ async function checkedWorkspace(target, allowMissing = false) {
145
+ const absolute = path.resolve(target);
146
+ if (!inside(workRoot, absolute)) throw httpError(403, '工作区访问被拒绝');
147
+ let cursor = absolute;
148
+ while (true) {
149
+ try {
150
+ const resolved = await workspaceFs.realpath(cursor);
151
+ if (!inside(workRoot, resolved)) throw httpError(403, '工作区访问被拒绝');
152
+ return absolute;
153
+ } catch (error) {
154
+ if (!allowMissing || error.code !== 'ENOENT' || cursor === workRoot) throw error;
155
+ cursor = path.dirname(cursor);
156
+ }
157
+ }
158
+ }
159
+ const scopeFor = url => ({ sessionId: url.searchParams.get('sessionId') || undefined, tabId: url.searchParams.get('tabId') || 'default' });
160
+ async function finalize(file, url) {
161
+ if (!containerMode) return file;
162
+ const repl = await router.get(scopeFor(url));
163
+ const cwd = await repl.materializeCurrentWorkspace();
164
+ await checkedWorkspace(cwd);
165
+ const destination = path.join(cwd, file.storedName);
166
+ // Existing upload delivery supports a resolved workspace without an HTTP side channel.
167
+ const { deliverUploadToWorkspace } = await import('./execution-backend.mjs');
168
+ return deliverUploadToWorkspace(file, destination);
169
+ }
170
+ const chunks = createChunkUploadHandler({ uploadsDir: uploadsRoot, baseDir: root, finalize });
171
+ return { root, sessionsRoot, uploadsRoot, workRoot, router, pluginHost, checkedWorkspace, scopeFor, finalize, chunks };
172
+ }
173
+ function userContext(user) {
174
+ if (!users.has(user.username)) {
175
+ const pending = createUser(user);
176
+ users.set(user.username, pending);
177
+ pending.catch(() => users.delete(user.username));
178
+ }
179
+ return users.get(user.username);
180
+ }
181
+ async function serveFile(req, res, filename, workspace = false, image = false) {
182
+ let handle;
183
+ try {
184
+ handle = workspace ? await openWorkspaceRead(filename) : await fs.open(filename, 'r');
185
+ const stat = await handle.stat();
186
+ if (!stat.isFile()) throw httpError(404, '文件不存在');
187
+ const types = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
188
+ const type = image ? types[path.extname(filename).toLowerCase()] : 'application/octet-stream';
189
+ if (!type) throw httpError(404, '图片不存在');
190
+ res.writeHead(200, { 'Content-Type': type, 'Content-Length': stat.size, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...(image ? {} : { 'Content-Disposition': 'attachment' }) });
191
+ if (req.method === 'HEAD') { res.end(); return; }
192
+ for await (const chunk of handle.createReadStream({ autoClose: false })) {
193
+ if (res.destroyed) break;
194
+ if (!res.write(chunk)) await new Promise(resolve => { res.once('drain', resolve); res.once('close', resolve); });
195
+ }
196
+ res.end();
197
+ } finally { await handle?.close(); }
198
+ }
199
+ return {
200
+ enabled: true,
201
+ close: () => auth.close(),
202
+ async route(req, res, url) {
203
+ // Static application assets contain no user data. Every dynamic route is handled here.
204
+ if (!/^\/(api(?:\/|$)|events(?:\/|$)|vendor(?:\/|$))/.test(url.pathname)) return false;
205
+ try {
206
+ // Core images and plugin downloads normally use long-lived caches. Do not
207
+ // let a browser reuse protected content after logout/account switching.
208
+ const writeHead = res.writeHead;
209
+ res.writeHead = function (status, message, headers) {
210
+ const supplied = typeof message === 'string' ? headers : message;
211
+ const clean = { ...supplied };
212
+ for (const name of Object.keys(clean)) if (['cache-control', 'vary', 'x-content-type-options'].includes(name.toLowerCase())) delete clean[name];
213
+ clean['Cache-Control'] = 'private, no-store';
214
+ clean.Vary = 'Cookie';
215
+ clean['X-Content-Type-Options'] = 'nosniff';
216
+ return typeof message === 'string' ? writeHead.call(this, status, message, clean) : writeHead.call(this, status, clean);
217
+ };
218
+ res.setHeader('Cache-Control', 'private, no-store');
219
+ if (await auth.route(req, res, url)) return true;
220
+ const session = auth.authenticate(req);
221
+ if (!session) { jsonReply(res, { errorCode: 'AUTH_REQUIRED', error: '请先登录' }, 401); return true; }
222
+ const isAdmin = session.user.role === 'admin';
223
+ if (url.pathname.startsWith('/api/admin/')) {
224
+ if (!isAdmin) throw httpError(403, '仅超管可管理用户');
225
+ await adminRoute(req, res, url); return true;
226
+ }
227
+ if (url.pathname === '/api/memory' && req.method === 'GET') {
228
+ jsonReply(res, memoryState()); return true;
229
+ }
230
+ if (url.pathname === '/api/cpa-quota' && req.method === 'GET') {
231
+ const state = cpaQuotaMonitor?.getPublicState() || { config: { url: '', hasPassword: false }, quotas: [] };
232
+ jsonReply(res, isAdmin ? state : { quotas: state.quotas }); return true;
233
+ }
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))) {
235
+ if (!isAdmin) throw httpError(403, '仅超管可修改全局配置');
236
+ if (!['GET', 'POST'].includes(req.method)) throw httpError(405, '请求方法无效');
237
+ if (url.pathname === '/api/cpa-config') {
238
+ if (req.method !== 'POST') throw httpError(405, '请求方法无效');
239
+ if (!cpaQuotaMonitor) throw httpError(503, 'CPA 监控未启动');
240
+ const body = await readBoundedJson(req, 65536);
241
+ const current = cpaQuotaMonitor.getPublicState();
242
+ const password = body?.preservePassword && current.config.hasPassword ? undefined : String(body?.password || '');
243
+ const state = await withModelConfigLock(() => cpaQuotaMonitor.updateConfig({ url: body?.url, password, preservePassword: body?.preservePassword }));
244
+ jsonReply(res, { ok: true, ...state }); return true;
245
+ }
246
+ const admin = await userContext(session.user);
247
+ if (url.pathname === '/api/prompt-config') {
248
+ // Global prompt protocol is independent of the selected owner/session.
249
+ const target = new URL(url);
250
+ for (const key of ['ownerUsername', 'sessionId', 'tabId']) target.searchParams.delete(key);
251
+ req.url = target.pathname + target.search;
252
+ await withModelConfigLock(() => core.handleWebRequest(req, res, admin.router)); return true;
253
+ }
254
+ if (url.pathname.startsWith('/api/plugins')) {
255
+ if (!await withModelConfigLock(() => admin.pluginHost.route(req, res, url, { readJsonBody: readBoundedJson, sendJson: jsonReply }))) throw httpError(405, '请求方法无效');
256
+ return true;
257
+ }
258
+ const repl = await admin.router.get({ tabId: 'global-model-config' });
259
+ if (url.pathname === '/api/tools' && req.method === 'GET') {
260
+ jsonReply(res, repl.globalTools()); return true;
261
+ }
262
+ if (url.pathname !== '/api/tools/global' || req.method !== 'POST') throw httpError(405, '请求方法无效');
263
+ const body = await readBoundedJson(req, 65536);
264
+ const result = await withModelConfigLock(async () => {
265
+ const saved = await repl.setGlobalTools(body?.overrides);
266
+ if (!saved.ok) return saved;
267
+ const contexts = await Promise.all([...users.values()]);
268
+ await Promise.all(contexts.map(user => user.router.reloadGlobalTools(globalTools.globalOverrides())));
269
+ return saved;
270
+ });
271
+ jsonReply(res, result); return true;
272
+ }
273
+ if (url.pathname === '/api/login') {
274
+ if (!isAdmin) throw httpError(403, '仅超管可配置模型');
275
+ if (!['GET', 'POST'].includes(req.method)) throw httpError(405, '请求方法无效');
276
+ const admin = await userContext(session.user);
277
+ const repl = await admin.router.get({ tabId: 'global-model-config' });
278
+ if (req.method === 'GET') {
279
+ const form = await withModelConfigLock(() => repl.loginForm(url.searchParams.get('provider') || undefined));
280
+ jsonReply(res, { ...form, envPath: undefined }); return true;
281
+ }
282
+ const body = await readBoundedJson(req, 64 * 1024);
283
+ if (!body || typeof body.provider !== 'string' || !body.values || typeof body.values !== 'object' || Array.isArray(body.values) || Object.values(body.values).some(value => typeof value !== 'string')) throw httpError(400, '模型配置无效');
284
+ const result = await withModelConfigLock(async () => {
285
+ const saved = await repl.saveLogin(body.provider, body.values);
286
+ if (!saved.ok) return saved;
287
+ const contexts = await Promise.all([...users.values()]);
288
+ await Promise.all(contexts.map(user => user.router.reloadModelConfig()));
289
+ return saved;
290
+ });
291
+ jsonReply(res, result); return true;
292
+ }
293
+ const ownerUsernames = url.searchParams.getAll('ownerUsername');
294
+ if (ownerUsernames.length > 1 || (ownerUsernames.length && !ownerUsernames[0])) throw httpError(400, '用户参数无效');
295
+ let owner = session.user;
296
+ if (ownerUsernames.length) {
297
+ if (!isAdmin) throw httpError(403, '不能访问其他用户');
298
+ owner = allOwners().find(user => user.username === ownerUsernames[0]);
299
+ if (!owner) throw httpError(404, '用户不存在');
300
+ }
301
+ url.searchParams.delete('ownerUsername');
302
+ const user = await userContext(owner);
303
+ for (const name of ['sessionId', 'tabId']) {
304
+ const values = url.searchParams.getAll(name);
305
+ if (values.length > 1 || (values.length && (!values[0] || !SESSION_ID.test(values[0])))) throw httpError(400, '会话参数无效');
306
+ }
307
+ if (url.searchParams.has('sessionId')) await assertOwnedSession(user.sessionsRoot, url.searchParams.get('sessionId'));
308
+ if (!url.searchParams.has('sessionId') && !url.searchParams.has('tabId')) url.searchParams.set('tabId', 'default');
309
+ const scope = user.scopeFor(url);
310
+ if (/^\/api\/(?:prompt-library|prompt-config|session-prompt|app-prompt|login|client-reload|cpa-config|tools\/global)(?:\/|$)/.test(url.pathname)) throw httpError(403, '隔离模式不允许访问此接口');
311
+ if (url.pathname.startsWith('/api/plugins') && url.pathname !== '/api/plugins') throw httpError(403, '全局配置不可修改');
312
+ if (await user.pluginHost.route(req, res, url, { readJsonBody: readBoundedJson, sendJson: jsonReply })) return true;
313
+ if (await user.chunks(req, res, url)) return true;
314
+ if (url.pathname.startsWith('/api/uploads/') && ['GET', 'HEAD'].includes(req.method)) {
315
+ const name = decodeURIComponent(url.pathname.slice('/api/uploads/'.length));
316
+ if (!name || path.basename(name) !== name || name.startsWith('.')) throw httpError(404, '文件不存在');
317
+ await serveFile(req, res, path.join(user.uploadsRoot, name)); return true;
318
+ }
319
+ if (url.pathname.startsWith('/api/local-images/') && req.method === 'GET') {
320
+ const encoded = decodeURIComponent(url.pathname.slice('/api/local-images/'.length));
321
+ const filename = Buffer.from(encoded, 'base64url').toString('utf8');
322
+ await user.checkedWorkspace(filename);
323
+ await serveFile(req, res, filename, true, true); return true;
324
+ }
325
+ if (url.pathname === '/api/uploads' && req.method === 'POST') {
326
+ const body = await readBoundedJson(req, 32 * 1024 * 1024);
327
+ const name = path.basename(String(body.name || '').replace(/\\/g, '/')).replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-').slice(0, 180);
328
+ if (!name || name === '.' || name === '..' || typeof body.data !== 'string') throw httpError(400, '文件无效');
329
+ const storedName = `${randomUUID()}-${name}`;
330
+ await fs.mkdir(user.uploadsRoot, { recursive: true });
331
+ const buffer = Buffer.from(body.data, 'base64');
332
+ const absolutePath = path.join(user.uploadsRoot, storedName);
333
+ await fs.writeFile(absolutePath, buffer, { flag: 'wx', mode: 0o600 });
334
+ const file = await user.finalize({ id: randomUUID(), name, storedName, size: buffer.length, mimeType: String(body.mimeType || 'application/octet-stream'), absolutePath, relativePath: absolutePath, url: `/api/uploads/${encodeURIComponent(storedName)}` }, url);
335
+ jsonReply(res, { ok: true, file }); return true;
336
+ }
337
+ const imageRoute = /^\/api\/images\/(?:by-id\/[^/]+|[^/]+\/\d+)$/.test(url.pathname);
338
+ const vendorRoute = /^\/vendor\/(marked.esm.js|highlight.min.js|highlight-theme.css)$/.test(url.pathname);
339
+ if (!(req.method === 'GET' && (GET_ROUTES.has(url.pathname) || imageRoute || vendorRoute)) && !(req.method === 'POST' && POST_ROUTES.has(url.pathname))) throw httpError(403, '隔离模式不允许访问此接口');
340
+ let body;
341
+ if (req.method === 'POST') {
342
+ body = await readBoundedJson(req, 32 * 1024 * 1024);
343
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw httpError(400, '请求无效');
344
+ if (['/api/sessions/resume', '/api/sessions/delete'].includes(url.pathname)) await assertOwnedSession(user.sessionsRoot, body.sessionId);
345
+ if (url.pathname === '/api/submit' || url.pathname === '/api/submit-now') {
346
+ const text = String(body.text || '').trim();
347
+ if (text.startsWith('/') && !SAFE_COMMANDS.has(text.split(/\s/)[0].toLowerCase())) throw httpError(403, '隔离模式不支持此命令');
348
+ for (const attachment of body.attachments || []) if (attachment?.kind === 'file') {
349
+ const filename = path.resolve(String(attachment.absolutePath || ''));
350
+ if (inside(user.uploadsRoot, filename)) {
351
+ const real = await fs.realpath(filename);
352
+ if (!inside(user.uploadsRoot, real)) throw httpError(403, '附件访问被拒绝');
353
+ } else await user.checkedWorkspace(filename);
354
+ }
355
+ }
356
+ }
357
+ if (url.pathname === '/api/cwd' || url.pathname.startsWith('/api/cwd/')) {
358
+ const repl = await user.router.get(scope);
359
+ await workspaceFs.mkdir(user.workRoot, { recursive: true });
360
+ const current = repl.snapshot().cwd;
361
+ const target = path.resolve(current, String(body?.path || url.searchParams.get('path') || current));
362
+ await user.checkedWorkspace(target, true);
363
+ if (body) body.path = target; else url.searchParams.set('path', target);
364
+ }
365
+ let request = req;
366
+ if (body !== undefined) {
367
+ request = Readable.from([Buffer.from(JSON.stringify(body))]);
368
+ Object.assign(request, { method: req.method, headers: req.headers, socket: req.socket });
369
+ }
370
+ request.url = url.pathname + url.search;
371
+ if (url.pathname === '/events') {
372
+ await user.router.get(scope);
373
+ if (!auth.track(session, res)) return true;
374
+ }
375
+ await core.handleWebRequest(request, res, user.router);
376
+ return true;
377
+ } catch (error) {
378
+ if (res.headersSent) { res.end(); return true; }
379
+ const status = error.status || (error.code === 'ENOENT' ? 404 : 500);
380
+ jsonReply(res, { error: status === 500 ? '请求处理失败' : error.message }, status);
381
+ return true;
382
+ }
383
+ },
384
+ };
385
+ }