mingdao-harness 0.2.4 → 0.2.5
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/docs/ROADMAP-NEXT.md +5 -5
- package/package.json +1 -1
- package/src/agent.js +19 -19
- package/src/audit.js +2 -2
- package/src/batch.js +7 -7
- package/src/cachestats.js +9 -9
- package/src/cli.js +29 -679
- package/src/commands/desktop.js +1 -1
- package/src/commands/key.js +1 -1
- package/src/commands/repl.js +711 -0
- package/src/commands/schedule.js +7 -7
- package/src/commands/skill.js +18 -16
- package/src/commands/sync.js +5 -5
- package/src/commands/update.js +7 -6
- package/src/commands/workspace.js +2 -2
- package/src/compact.js +6 -6
- package/src/config.js +7 -4
- package/src/context.js +11 -11
- package/src/credentials.js +6 -0
- package/src/hooks.js +11 -11
- package/src/mcp-presets.js +5 -5
- package/src/mcp.js +19 -19
- package/src/memory.js +16 -16
- package/src/model-discovery.js +16 -15
- package/src/models.js +4 -4
- package/src/notify.js +2 -2
- package/src/permissions.js +7 -3
- package/src/pricing.js +35 -1
- package/src/prompts.js +1 -1
- package/src/providers/index.js +7 -7
- package/src/providers/openai-compatible.js +8 -8
- package/src/routing.js +5 -5
- package/src/schedule.js +28 -28
- package/src/session-index.js +5 -1
- package/src/session.js +11 -2
- package/src/skill-lib.js +62 -3
- package/src/skill-registry.js +10 -9
- package/src/skills.js +10 -10
- package/src/sync-server.js +86 -3
- package/src/sync.js +53 -20
- package/src/tasks/worker.js +126 -0
- package/src/tasks.js +13 -13
- package/src/titles.js +5 -5
- package/src/tokenizer.js +22 -4
- package/src/tools/bash.js +12 -12
- package/src/tools/fs-tools.js +66 -9
- package/src/tools/index.js +7 -7
- package/src/ui.js +83 -6
- package/src/update.js +8 -8
- package/src/web/attachments.js +1 -1
- package/src/web/routes/api.js +38 -786
- package/src/web/routes/domains/config.js +280 -0
- package/src/web/routes/domains/misc.js +128 -0
- package/src/web/routes/domains/schedule.js +127 -0
- package/src/web/routes/domains/sessions.js +105 -0
- package/src/web/routes/domains/skills.js +79 -0
- package/src/web/routes/domains/sync.js +101 -0
- package/src/web/routes/domains/workspace.js +113 -0
- package/src/web/server.js +29 -14
- package/src/web/web-io.js +54 -12
- package/src/workspace.js +14 -14
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 技能域(Phase C C1):/api/skills /api/skill-library /api/mcp-presets
|
|
2
|
+
// 技能列表/安装/卸载、内置技能库 + 线上注册表搜索、MCP 生态预设接入。
|
|
3
|
+
import { listSkills, tamperedSkillNames } from '../../../skills.js';
|
|
4
|
+
import { libraryList, installSkill, uninstallSkill, installedUserSkillNames } from '../../../skill-lib.js';
|
|
5
|
+
import { presetList, buildPreset } from '../../../mcp-presets.js';
|
|
6
|
+
import { saveConfig } from '../../../config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 技能域路由。命中返回 true,未命中返回 false。
|
|
10
|
+
* @param {{req:any,res:any,method:any,p:any,url:any}} ctx
|
|
11
|
+
* @param {any} deps
|
|
12
|
+
* @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
|
|
13
|
+
*/
|
|
14
|
+
export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
15
|
+
const { json, readBody, MAX_API_BODY } = shared;
|
|
16
|
+
const { cfg, state } = deps;
|
|
17
|
+
|
|
18
|
+
if (method === 'GET' && p === '/api/skills') {
|
|
19
|
+
json(res, 200, { ok: true, skills: listSkills(state.workingDir), tampered: tamperedSkillNames(state.workingDir) });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (method === 'GET' && p === '/api/mcp-presets') {
|
|
24
|
+
json(res, 200, { ok: true, presets: presetList() });
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (method === 'POST' && p === '/api/mcp-presets') {
|
|
29
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
30
|
+
const name = String(body.name || '').trim();
|
|
31
|
+
const r = buildPreset(name, body.arg, state.workingDir);
|
|
32
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
33
|
+
cfg.mcpServers = cfg.mcpServers || {};
|
|
34
|
+
cfg.mcpServers[name] = r.config;
|
|
35
|
+
saveConfig(cfg);
|
|
36
|
+
json(res, 200, { ok: true, name, note: '重启 mingdao web 后生效(/mcp 查看状态)' });
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (method === 'GET' && p === '/api/skill-library') {
|
|
41
|
+
const q = url.searchParams.get('q') || '';
|
|
42
|
+
const force = url.searchParams.get('refresh') === '1';
|
|
43
|
+
const local = q
|
|
44
|
+
? libraryList().filter((s) => s.name.includes(q) || (s.description || '').includes(q))
|
|
45
|
+
: libraryList();
|
|
46
|
+
const { searchRegistry } = await import('../../../skill-registry.js');
|
|
47
|
+
const remote = await searchRegistry(q || '', { force, allowNetwork: force });
|
|
48
|
+
const localNames = new Set(local.map((s) => s.name));
|
|
49
|
+
const registryEntries = remote.skills
|
|
50
|
+
? remote.skills.filter((/** @type {any} */ s) => !localNames.has(s.name)).map((/** @type {any} */ s) => ({ ...s, dir: null }))
|
|
51
|
+
: [];
|
|
52
|
+
json(res, 200, {
|
|
53
|
+
ok: true,
|
|
54
|
+
library: local.map((s) => ({ name: s.name, description: s.description, source: 'builtin-lib', installed: s.installed })).concat(
|
|
55
|
+
registryEntries.map((/** @type {any} */ s) => ({ name: s.name, description: s.description, source: 'registry', installed: s.installed }))
|
|
56
|
+
),
|
|
57
|
+
installed: [...installedUserSkillNames()],
|
|
58
|
+
registry: remote.error ? { error: remote.error } : { host: remote.host, updatedAt: remote.updatedAt, stale: remote.stale || false },
|
|
59
|
+
});
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (method === 'POST' && p === '/api/skills') {
|
|
64
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
65
|
+
if (body.action === 'install') {
|
|
66
|
+
const r = /** @type {any} */ (await installSkill(body.arg || body.name || ''));
|
|
67
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
68
|
+
return json(res, 200, { ok: true, name: r.name, names: r.names });
|
|
69
|
+
}
|
|
70
|
+
if (body.action === 'uninstall') {
|
|
71
|
+
const r = uninstallSkill(body.name);
|
|
72
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
73
|
+
return json(res, 200, { ok: true, name: r.name });
|
|
74
|
+
}
|
|
75
|
+
return json(res, 400, { error: '未知操作:install|uninstall' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// 同步域(Phase C C1):/api/sync /api/sync-conflicts
|
|
2
|
+
// 云同步登录/登出/推拉/改密/分享与冲突处理。
|
|
3
|
+
import {
|
|
4
|
+
syncStatus,
|
|
5
|
+
syncLogin,
|
|
6
|
+
syncLogout,
|
|
7
|
+
syncPush,
|
|
8
|
+
syncPull,
|
|
9
|
+
syncRemoteList,
|
|
10
|
+
syncChangePassword,
|
|
11
|
+
syncShareCreate,
|
|
12
|
+
syncShareList,
|
|
13
|
+
syncShareAccept,
|
|
14
|
+
syncShareRevoke,
|
|
15
|
+
listSyncConflicts,
|
|
16
|
+
resolveSyncConflict,
|
|
17
|
+
} from '../../../sync.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 同步域路由。命中返回 true,未命中返回 false。
|
|
21
|
+
* @param {{req:any,res:any,method:any,p:any,url:any}} ctx
|
|
22
|
+
* @param {any} deps
|
|
23
|
+
* @param {{json:any,readBody:any,MAX_API_BODY:any,validateRemoteUrl:any}} shared
|
|
24
|
+
*/
|
|
25
|
+
export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
26
|
+
const { json, readBody, MAX_API_BODY, validateRemoteUrl } = shared;
|
|
27
|
+
|
|
28
|
+
if (method === 'GET' && p === '/api/sync') {
|
|
29
|
+
const st = syncStatus();
|
|
30
|
+
let remote = null;
|
|
31
|
+
if (st.loggedIn) {
|
|
32
|
+
const r = await syncRemoteList();
|
|
33
|
+
remote = r.ok ? { sessions: r.sessions.slice(0, 50) } : { error: r.error };
|
|
34
|
+
}
|
|
35
|
+
json(res, 200, { ok: true, ...st, remote });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (method === 'POST' && p === '/api/sync') {
|
|
40
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
41
|
+
if (body.action === 'login') {
|
|
42
|
+
const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
|
|
43
|
+
if (vurl.error) return json(res, 400, { error: vurl.error });
|
|
44
|
+
const r = await syncLogin({ url: body.url, username: body.username, password: body.password, deviceName: body.deviceName });
|
|
45
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
46
|
+
return json(res, 200, { ok: true, ...r });
|
|
47
|
+
}
|
|
48
|
+
if (body.action === 'logout') {
|
|
49
|
+
syncLogout();
|
|
50
|
+
return json(res, 200, { ok: true });
|
|
51
|
+
}
|
|
52
|
+
if (body.action === 'push') {
|
|
53
|
+
const r = /** @type {any} */ (await syncPush(body.name));
|
|
54
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
55
|
+
return json(res, 200, { ok: true, pushed: r.pushed.length, conflicts: r.conflicts });
|
|
56
|
+
}
|
|
57
|
+
if (body.action === 'pull') {
|
|
58
|
+
const r = /** @type {any} */ (await syncPull(body.name));
|
|
59
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
60
|
+
return json(res, 200, { ok: true, pulled: r.pulled.length, conflicts: r.conflicts });
|
|
61
|
+
}
|
|
62
|
+
if (body.action === 'passwd') {
|
|
63
|
+
const r = await syncChangePassword({ oldPassword: body.oldPassword, newPassword: body.newPassword });
|
|
64
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
65
|
+
return json(res, 200, { ok: true });
|
|
66
|
+
}
|
|
67
|
+
if (body.action === 'share') {
|
|
68
|
+
const r = await syncShareCreate(body.name);
|
|
69
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
70
|
+
return json(res, 200, { ok: true, shareId: r.shareId, name: r.name });
|
|
71
|
+
}
|
|
72
|
+
if (body.action === 'shares') {
|
|
73
|
+
const r = await syncShareList();
|
|
74
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
75
|
+
return json(res, 200, { ok: true, mine: r.mine, accepted: r.accepted });
|
|
76
|
+
}
|
|
77
|
+
if (body.action === 'accept') {
|
|
78
|
+
const r = await syncShareAccept(body.shareId);
|
|
79
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
80
|
+
return json(res, 200, { ok: true, ...r });
|
|
81
|
+
}
|
|
82
|
+
if (body.action === 'unshare') {
|
|
83
|
+
const r = await syncShareRevoke(body.shareId);
|
|
84
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
85
|
+
return json(res, 200, { ok: true });
|
|
86
|
+
}
|
|
87
|
+
if (body.action === 'resolveConflict') {
|
|
88
|
+
const r = resolveSyncConflict(body.base, body.choice);
|
|
89
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
90
|
+
return json(res, 200, { ok: true, ...r });
|
|
91
|
+
}
|
|
92
|
+
return json(res, 400, { error: '未知操作:login|logout|push|pull|passwd|share|shares|accept|unshare|resolveConflict' });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (method === 'GET' && p === '/api/sync-conflicts') {
|
|
96
|
+
json(res, 200, { ok: true, conflicts: listSyncConflicts() });
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// 工作空间域(Phase C C1):/api/workspaces /api/fs-browse
|
|
2
|
+
// 工作空间登记/切换/重命名/删除;受限目录浏览(基目录白名单)。
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
currentWorkspace,
|
|
8
|
+
listWorkspaces,
|
|
9
|
+
addWorkspace,
|
|
10
|
+
removeWorkspace,
|
|
11
|
+
renameWorkspace,
|
|
12
|
+
setWorkspaceDir,
|
|
13
|
+
workspacePath,
|
|
14
|
+
touchWorkspace,
|
|
15
|
+
setSessionWorkspace,
|
|
16
|
+
} from '../../../workspace.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 工作空间域路由。命中返回 true,未命中返回 false。
|
|
20
|
+
* @param {{req:any,res:any,method:any,p:any,url:any}} ctx
|
|
21
|
+
* @param {any} deps
|
|
22
|
+
* @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
|
|
23
|
+
*/
|
|
24
|
+
export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
25
|
+
const { json, readBody, MAX_API_BODY } = shared;
|
|
26
|
+
const { cfg, state, startupCwd } = deps;
|
|
27
|
+
|
|
28
|
+
if (method === 'GET' && p === '/api/workspaces') {
|
|
29
|
+
json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(state.workingDir)?.name || null, cwd: state.workingDir });
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (method === 'POST' && p === '/api/workspaces') {
|
|
34
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
35
|
+
const name = String(body.name || '').trim();
|
|
36
|
+
if (body.action === 'add') {
|
|
37
|
+
if (!name) return json(res, 400, { error: '名称不能为空' });
|
|
38
|
+
// 目录为空/不存在时自动新建(默认开,create:false 关闭)
|
|
39
|
+
const target = path.resolve(body.dir || state.workingDir);
|
|
40
|
+
if (body.create !== false) {
|
|
41
|
+
try {
|
|
42
|
+
fs.mkdirSync(target, { recursive: true });
|
|
43
|
+
} catch (/** @type {any} */ e) {
|
|
44
|
+
return json(res, 400, { error: `无法创建目录:${target}(${e.message})` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const r = addWorkspace(name, target);
|
|
48
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
49
|
+
return json(res, 200, { ok: true, name: r.name, dir: r.dir, created: body.create !== false });
|
|
50
|
+
}
|
|
51
|
+
if (body.action === 'rename') {
|
|
52
|
+
const r = renameWorkspace(name, body.newName);
|
|
53
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
54
|
+
return json(res, 200, { ok: true, name: r.name });
|
|
55
|
+
}
|
|
56
|
+
if (body.action === 'set') {
|
|
57
|
+
// 切换全局工作空间(新会话默认目录;可带 dir 修改目录);目录缺失自动重建。
|
|
58
|
+
// 携带 file 时同时把当前会话的工作空间切过去(P3-4:会话跟随显式切换)。
|
|
59
|
+
if (body.dir) {
|
|
60
|
+
const r = setWorkspaceDir(name, body.dir);
|
|
61
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
62
|
+
}
|
|
63
|
+
const dir = workspacePath(name);
|
|
64
|
+
if (!dir) return json(res, 400, { error: `工作空间 ${name} 不存在` });
|
|
65
|
+
try {
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
} catch (/** @type {any} */ e) {
|
|
68
|
+
return json(res, 400, { error: `无法创建目录:${dir}(${e.message})` });
|
|
69
|
+
}
|
|
70
|
+
touchWorkspace(name);
|
|
71
|
+
state.workingDir = dir;
|
|
72
|
+
// 不再 process.chdir:运行中任务的 cwd 在创建时已固定,全局切换只影响新会话
|
|
73
|
+
if (body.file) setSessionWorkspace(String(body.file), dir, /** @type {any} */ (name));
|
|
74
|
+
return json(res, 200, { ok: true, name, dir, current: name });
|
|
75
|
+
}
|
|
76
|
+
if (body.action === 'remove') {
|
|
77
|
+
if (!name) return json(res, 400, { error: '缺少名称' });
|
|
78
|
+
return json(res, 200, { ok: removeWorkspace(name) });
|
|
79
|
+
}
|
|
80
|
+
return json(res, 400, { error: '未知操作:add|rename|set|remove' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 目录浏览器(「新建工作空间」选择电脑磁盘目录用):本机运行时即用户电脑的目录树;
|
|
84
|
+
// 只列子目录(不含隐藏目录),供前端逐级导航选择
|
|
85
|
+
if (method === 'GET' && p === '/api/fs-browse') {
|
|
86
|
+
let dir = String(url.searchParams.get('dir') || '').trim();
|
|
87
|
+
if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
|
|
88
|
+
// 质检 A3:目录浏览限定基目录(默认家目录 + config.web.browseRoots 显式授权),
|
|
89
|
+
// 拒绝越界——此前可枚举服务器任意绝对路径(整机目录结构探测面)
|
|
90
|
+
const browseRoots = [os.homedir(), startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => path.resolve(String(r)));
|
|
91
|
+
const inRoot = browseRoots.some((r) => dir === r || dir.startsWith(r + path.sep));
|
|
92
|
+
if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(家目录或 web.browseRoots 授权目录)' });
|
|
93
|
+
try {
|
|
94
|
+
const st = fs.statSync(dir);
|
|
95
|
+
if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
|
|
96
|
+
const entries = fs
|
|
97
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
98
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
99
|
+
.map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
|
|
100
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
|
101
|
+
.slice(0, 300);
|
|
102
|
+
let parent = path.dirname(dir);
|
|
103
|
+
// 父目录同样不得越出基目录
|
|
104
|
+
if (!browseRoots.some((r) => parent === r || parent.startsWith(r + path.sep))) parent = /** @type {any} */ (null);
|
|
105
|
+
json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
|
|
106
|
+
} catch (/** @type {any} */ err) {
|
|
107
|
+
json(res, 400, { error: String(err?.message || err) });
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return false;
|
|
113
|
+
}
|
package/src/web/server.js
CHANGED
|
@@ -64,14 +64,17 @@ import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList,
|
|
|
64
64
|
|
|
65
65
|
const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html');
|
|
66
66
|
|
|
67
|
+
/** @param {any} res @param {any} code @param {any} obj */
|
|
67
68
|
function json(res, code, obj) {
|
|
68
69
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
69
70
|
res.end(JSON.stringify(obj));
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** @param {any} req */
|
|
72
74
|
function readBody(req) {
|
|
73
75
|
const MAX_BODY = 40 * 1024 * 1024; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
|
|
74
76
|
return new Promise((resolve, reject) => {
|
|
77
|
+
/** @type {any[]} */
|
|
75
78
|
const chunks = [];
|
|
76
79
|
let size = 0;
|
|
77
80
|
// 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
|
|
@@ -83,7 +86,7 @@ function readBody(req) {
|
|
|
83
86
|
}, 60000);
|
|
84
87
|
req.on('end', () => clearTimeout(slowTimer));
|
|
85
88
|
req.on('close', () => clearTimeout(slowTimer));
|
|
86
|
-
req.on('data', (d) => {
|
|
89
|
+
req.on('data', (/** @type {any} */ d) => {
|
|
87
90
|
size += d.length;
|
|
88
91
|
if (size > MAX_BODY) {
|
|
89
92
|
const err = /** @type {Error & { status?: number }} */ (new Error('请求体过大(>40MB)'));
|
|
@@ -117,6 +120,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
117
120
|
}
|
|
118
121
|
// 访问令牌(P1-3):非回环绑定时强制——配置的 token / 启动参数 / 环境变量优先,
|
|
119
122
|
// 都没有则本次随机生成并打印带 token 的访问地址。回环绑定且未配置时保持无认证(本机信任)。
|
|
123
|
+
/** @param {any} h */
|
|
120
124
|
const isLoopbackHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '::1';
|
|
121
125
|
let webToken = String(authToken || cfg?.web?.token || '').trim() || null;
|
|
122
126
|
let tokenGenerated = false;
|
|
@@ -125,6 +129,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
125
129
|
tokenGenerated = true;
|
|
126
130
|
}
|
|
127
131
|
const authEnabled = Boolean(webToken);
|
|
132
|
+
/** @param {any} got */
|
|
128
133
|
const tokenMatches = (got) => {
|
|
129
134
|
if (!webToken) return true;
|
|
130
135
|
if (!got) return false;
|
|
@@ -132,6 +137,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
132
137
|
const b = Buffer.from(webToken);
|
|
133
138
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
134
139
|
};
|
|
140
|
+
/** @param {any} req @param {any} url */
|
|
135
141
|
const requestToken = (req, url) => {
|
|
136
142
|
const q = url.searchParams.get('token');
|
|
137
143
|
if (q) return q;
|
|
@@ -143,6 +149,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
143
149
|
// Host 白名单(P1-4,防 DNS rebinding):只接受回环名 + 绑定地址;
|
|
144
150
|
// 绑定 0.0.0.0 且启用 token 时放行任意 Host(此时 token 才是访问边界,攻击者页面拿不到)。
|
|
145
151
|
let boundPort = port;
|
|
152
|
+
/** @param {any} reqHost */
|
|
146
153
|
const trustedHost = (reqHost) => {
|
|
147
154
|
const h = String(reqHost || '').toLowerCase();
|
|
148
155
|
const names = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
@@ -167,6 +174,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
167
174
|
const startupCwd = workingDir; // 质检 A3:启动工作目录恒为合法浏览根(工作空间切换后仍可浏览原位)
|
|
168
175
|
// Provider 按模型懒加载缓存:界面切换模型后即时生效
|
|
169
176
|
const providerCache = new Map();
|
|
177
|
+
/** @param {any} m */
|
|
170
178
|
async function getProviderFor(m) {
|
|
171
179
|
if (!providerCache.has(m)) {
|
|
172
180
|
providerCache.set(m, await createProvider(cfg, m));
|
|
@@ -177,6 +185,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
177
185
|
const undoStore = { backups: new Map() };
|
|
178
186
|
|
|
179
187
|
// MCP:后台启动,就绪后工具并入后续轮次
|
|
188
|
+
/** @type {any} */
|
|
180
189
|
let mcpManager = null;
|
|
181
190
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
182
191
|
startMcpServers(cfg.mcpServers, workingDir)
|
|
@@ -187,8 +196,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
187
196
|
}
|
|
188
197
|
const mcpFacade = {
|
|
189
198
|
toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
|
|
190
|
-
call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
|
|
191
|
-
isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
|
|
199
|
+
call: (/** @type {any} */ n, /** @type {any} */ a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
|
|
200
|
+
isReadonly: (/** @type {any} */ n) => (mcpManager ? mcpManager.isReadonly(n) : false),
|
|
192
201
|
status: () => (mcpManager ? mcpManager.status() : []),
|
|
193
202
|
stop: () => {
|
|
194
203
|
if (mcpManager) mcpManager.stop();
|
|
@@ -202,6 +211,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
202
211
|
let taskSeq = 0;
|
|
203
212
|
// 会话文件互斥(质检 C1/M3):同一 session 文件的 append 与 compact 整文件重写必须串行
|
|
204
213
|
const sessionLocks = new Map();
|
|
214
|
+
/** @param {any} file @param {any} fn */
|
|
205
215
|
function withSessionLock(file, fn) {
|
|
206
216
|
const prev = sessionLocks.get(file) || Promise.resolve();
|
|
207
217
|
const next = prev.then(fn, fn);
|
|
@@ -216,6 +226,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
216
226
|
const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
|
|
217
227
|
|
|
218
228
|
// —— SSRF 防护(质检 S1):远端地址校验 ——
|
|
229
|
+
/** @param {any} hostname */
|
|
219
230
|
function isPrivateHost(hostname) {
|
|
220
231
|
const h = String(hostname || '').toLowerCase();
|
|
221
232
|
if (!h) return true;
|
|
@@ -226,6 +237,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
226
237
|
const b = Number(m[2]);
|
|
227
238
|
return a === 10 || a === 127 || a === 0 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
|
|
228
239
|
}
|
|
240
|
+
/** @param {any} raw */
|
|
229
241
|
function validateRemoteUrl(raw) {
|
|
230
242
|
let u;
|
|
231
243
|
try {
|
|
@@ -261,11 +273,13 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
261
273
|
}
|
|
262
274
|
}
|
|
263
275
|
|
|
276
|
+
/** @param {any} res @param {any} body */
|
|
264
277
|
async function handleChat(res, body) {
|
|
265
278
|
const taskId = `t${++taskSeq}`; // 服务端生成:客户端自选 taskId 可能覆盖他人任务
|
|
266
|
-
const entry = { res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 };
|
|
279
|
+
const entry = /** @type {any} */ ({ res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 });
|
|
267
280
|
srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
|
|
268
281
|
tasks.set(taskId, entry);
|
|
282
|
+
/** @param {any} obj */
|
|
269
283
|
const send = (obj) => {
|
|
270
284
|
try {
|
|
271
285
|
res.write(`data: ${JSON.stringify({ ...obj, taskId })}\n\n`);
|
|
@@ -301,6 +315,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
301
315
|
return;
|
|
302
316
|
}
|
|
303
317
|
|
|
318
|
+
/** @type {any} */
|
|
304
319
|
let session = null;
|
|
305
320
|
const isNew = !body.file;
|
|
306
321
|
if (body.file) {
|
|
@@ -355,7 +370,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
355
370
|
let persistedBefore = messages.length;
|
|
356
371
|
|
|
357
372
|
// 权限/选择类交互:发 ask 事件(带 taskId),等待 POST /api/permission 应答
|
|
358
|
-
const askHandler = ({ question, hidden, options, label, confirm }) =>
|
|
373
|
+
const askHandler = (/** @type {any} */ { question, hidden, options, label, confirm }) =>
|
|
359
374
|
new Promise((resolve) => {
|
|
360
375
|
const id = Math.random().toString(36).slice(2);
|
|
361
376
|
entry.pendingAsk = { id, resolve };
|
|
@@ -363,19 +378,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
363
378
|
type: 'ask',
|
|
364
379
|
id,
|
|
365
380
|
question: question ?? label ?? '',
|
|
366
|
-
options: options ? options.map((o) => ({ value: o.value, label: o.label })) : null,
|
|
381
|
+
options: options ? options.map((/** @type {any} */ o) => ({ value: o.value, label: o.label })) : null,
|
|
367
382
|
confirm: Boolean(confirm ?? !options),
|
|
368
383
|
hidden: Boolean(hidden),
|
|
369
384
|
});
|
|
370
385
|
});
|
|
371
386
|
|
|
372
|
-
const io = createWebIO({
|
|
387
|
+
const io = /** @type {any} */ (createWebIO({
|
|
373
388
|
send,
|
|
374
389
|
askHandler,
|
|
375
|
-
setAbortHandler: (fn) => {
|
|
390
|
+
setAbortHandler: (/** @type {any} */ fn) => {
|
|
376
391
|
entry.abortHandler = fn;
|
|
377
392
|
},
|
|
378
|
-
});
|
|
393
|
+
}));
|
|
379
394
|
const permission = createPermission(cfg.permission ?? 'ask', io);
|
|
380
395
|
let providerNow;
|
|
381
396
|
try {
|
|
@@ -384,7 +399,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
384
399
|
throw new Error(`模型 ${runModel} 尚未配置 API Key:请点击右上角 ⚙ 设置 →「模型与 API Key」填入密钥。`);
|
|
385
400
|
}
|
|
386
401
|
providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
|
|
387
|
-
} catch (err) {
|
|
402
|
+
} catch (/** @type {any} */ err) {
|
|
388
403
|
entry.status = 'failed';
|
|
389
404
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
390
405
|
send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
|
|
@@ -403,7 +418,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
403
418
|
undoStore,
|
|
404
419
|
mcp: mcpFacade,
|
|
405
420
|
// 自动压缩后重写会话文件(否则每次加载历史都会重新触发压缩),并同步落盘游标
|
|
406
|
-
onCompact: (msgs) => {
|
|
421
|
+
onCompact: (/** @type {any} */ msgs) => {
|
|
407
422
|
withSessionLock(session.file, () => rewriteSession(session.file, msgs)).catch(() => {}); // onCompact 为同步回调:入队串行即可
|
|
408
423
|
persistedBefore = msgs.length;
|
|
409
424
|
},
|
|
@@ -435,7 +450,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
435
450
|
session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
|
|
436
451
|
await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
|
|
437
452
|
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
438
|
-
recordUsage(r.perf?.usedModel || runModel, r.usage, r.perf);
|
|
453
|
+
recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
|
|
439
454
|
maybeAutoSync().catch(() => {});
|
|
440
455
|
// 新会话自动标题(可配置关闭)
|
|
441
456
|
srvlog('chat 标题生成前 ' + taskId);
|
|
@@ -460,7 +475,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
460
475
|
const { makeTokenCounter } = await import('../tokenizer.js');
|
|
461
476
|
const { messageTokens } = await import('../context.js');
|
|
462
477
|
const counter = makeTokenCounter(runModel);
|
|
463
|
-
const used = messages.reduce((sum, m) => sum + messageTokens(m, counter), 0);
|
|
478
|
+
const used = messages.reduce((/** @type {any} */ sum, /** @type {any} */ m) => sum + messageTokens(m, counter), 0);
|
|
464
479
|
budgetInfo = { used, total: cfg.contextBudget || modelPreset(runModel)?.budgetTokens || 128000 };
|
|
465
480
|
} catch {}
|
|
466
481
|
send({
|
|
@@ -476,7 +491,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
476
491
|
stats: io.stats(),
|
|
477
492
|
session: path.basename(session.file),
|
|
478
493
|
});
|
|
479
|
-
} catch (err) {
|
|
494
|
+
} catch (/** @type {any} */ err) {
|
|
480
495
|
entry.status = 'failed';
|
|
481
496
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
482
497
|
srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));
|
package/src/web/web-io.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { estimateCostLabel } from '../pricing.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* @param {{ send: any, askHandler: any, setAbortHandler: any }} param0
|
|
8
|
+
*/
|
|
6
9
|
export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
7
10
|
const io = {
|
|
8
11
|
isTTY: false,
|
|
@@ -10,12 +13,13 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
10
13
|
_pending: null,
|
|
11
14
|
_toolCount: 0,
|
|
12
15
|
_taskCount: 0, // 子代理(task 工具)次数:进度心跳与轨迹面板用
|
|
13
|
-
_deliverables: [],
|
|
14
|
-
_activeTools: [], // {seq, key}:并行批次下 toolStart↔tool 按 name+args 配对(审计 P2-7)
|
|
16
|
+
_deliverables: /** @type {any[]} */ ([]),
|
|
17
|
+
_activeTools: /** @type {any[]} */ ([]), // {seq, key}:并行批次下 toolStart↔tool 按 name+args 配对(审计 P2-7)
|
|
15
18
|
// 交付物与步数统计:写/编辑成功的文件路径(去重)
|
|
16
19
|
stats() {
|
|
17
20
|
return { toolCount: io._toolCount, taskCount: io._taskCount, deliverables: [...new Set(io._deliverables)] };
|
|
18
21
|
},
|
|
22
|
+
/** @param {any} v */
|
|
19
23
|
setShowReasoning(v) {
|
|
20
24
|
io.showReasoning = !!v;
|
|
21
25
|
},
|
|
@@ -26,6 +30,10 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
26
30
|
print(text = '') {
|
|
27
31
|
send({ type: 'banner', text });
|
|
28
32
|
},
|
|
33
|
+
/**
|
|
34
|
+
* @param {any} title
|
|
35
|
+
* @param {any} lines
|
|
36
|
+
*/
|
|
29
37
|
box(title, lines) {
|
|
30
38
|
send({ type: 'banner', title, lines });
|
|
31
39
|
},
|
|
@@ -35,27 +43,39 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
35
43
|
beginTurn() {},
|
|
36
44
|
endTurn() {},
|
|
37
45
|
startSpinner() {
|
|
38
|
-
if (
|
|
39
|
-
io._turnActive = true;
|
|
46
|
+
if (!/** @type {any} */ (io)._turnActive) {
|
|
47
|
+
/** @type {any} */ (io)._turnActive = true;
|
|
40
48
|
send({ type: 'turnStart', toolSteps: io._toolCount });
|
|
41
49
|
}
|
|
42
50
|
},
|
|
43
51
|
stopSpinner() {
|
|
44
|
-
if (io._turnActive) {
|
|
45
|
-
io._turnActive = false;
|
|
52
|
+
if (/** @type {any} */ (io)._turnActive) {
|
|
53
|
+
/** @type {any} */ (io)._turnActive = false;
|
|
46
54
|
send({ type: 'turnEnd', toolSteps: io._toolCount });
|
|
47
55
|
}
|
|
48
56
|
},
|
|
57
|
+
/** @param {any} t */
|
|
49
58
|
writeText(t) {
|
|
50
59
|
send({ type: 'text', delta: String(t) });
|
|
51
60
|
},
|
|
61
|
+
/** @param {any} t */
|
|
52
62
|
writeReasoning(t) {
|
|
53
63
|
if (!io.showReasoning) return;
|
|
54
64
|
send({ type: 'reasoning', delta: String(t) });
|
|
55
65
|
},
|
|
66
|
+
/**
|
|
67
|
+
* @param {any} code
|
|
68
|
+
* @param {any} lang
|
|
69
|
+
*/
|
|
56
70
|
printCodeBlock(code, lang) {
|
|
57
71
|
send({ type: 'code', code, lang });
|
|
58
72
|
},
|
|
73
|
+
/**
|
|
74
|
+
* @param {any} name
|
|
75
|
+
* @param {any} args
|
|
76
|
+
* @param {any} result
|
|
77
|
+
* @param {any} durationMs
|
|
78
|
+
*/
|
|
59
79
|
renderTool(name, args, result, durationMs) {
|
|
60
80
|
io._toolCount += 1;
|
|
61
81
|
if (name === 'task') io._taskCount += 1;
|
|
@@ -64,16 +84,25 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
64
84
|
}
|
|
65
85
|
// 审计 P2-7:按 name+args 找回 toolStart 的 seq(并行批次下各卡片配对正确)
|
|
66
86
|
const key = name + ':' + JSON.stringify(args || {});
|
|
67
|
-
const idx = io._activeTools.findIndex((a) => a.key === key);
|
|
68
|
-
const seq = idx !== -1 ? io._activeTools.splice(idx, 1)[0].seq : (io._toolSeq || 0) + 1;
|
|
87
|
+
const idx = io._activeTools.findIndex((/** @type {any} */ a) => a.key === key);
|
|
88
|
+
const seq = idx !== -1 ? io._activeTools.splice(idx, 1)[0].seq : (/** @type {any} */ (io)._toolSeq || 0) + 1;
|
|
69
89
|
send({ type: 'tool', name, args, result, durationMs, seq });
|
|
70
90
|
},
|
|
71
91
|
// 工具开始执行:前端先渲染带旋转状态的卡片,完成后原地更新(seq 配对)
|
|
92
|
+
/**
|
|
93
|
+
* @param {any} name
|
|
94
|
+
* @param {any} args
|
|
95
|
+
*/
|
|
72
96
|
renderToolStart(name, args) {
|
|
73
|
-
io._toolSeq = (io._toolSeq || 0) + 1;
|
|
74
|
-
io._activeTools.push({ seq: io._toolSeq, key: name + ':' + JSON.stringify(args || {}) });
|
|
75
|
-
send({ type: 'toolStart', name, args, seq: io._toolSeq });
|
|
76
|
-
},
|
|
97
|
+
/** @type {any} */ (io)._toolSeq = (/** @type {any} */ (io)._toolSeq || 0) + 1;
|
|
98
|
+
io._activeTools.push({ seq: /** @type {any} */ (io)._toolSeq, key: name + ':' + JSON.stringify(args || {}) });
|
|
99
|
+
send({ type: 'toolStart', name, args, seq: /** @type {any} */ (io)._toolSeq });
|
|
100
|
+
},
|
|
101
|
+
/**
|
|
102
|
+
* @param {any} name
|
|
103
|
+
* @param {any} args
|
|
104
|
+
* @param {any} reason
|
|
105
|
+
*/
|
|
77
106
|
renderToolDenied(name, args, reason) {
|
|
78
107
|
// 质检 L2:被拒的 toolStart 也要从 _activeTools 移除——否则条目无界增长、
|
|
79
108
|
// 且后续同名同参工具可能 findIndex 命中陈旧 seq 导致卡片配对错位
|
|
@@ -82,9 +111,11 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
82
111
|
if (idx !== -1) io._activeTools.splice(idx, 1);
|
|
83
112
|
send({ type: 'toolDenied', name, args, reason: reason || '未授权' });
|
|
84
113
|
},
|
|
114
|
+
/** @param {any} todos */
|
|
85
115
|
renderTodo(todos) {
|
|
86
116
|
send({ type: 'todo', todos });
|
|
87
117
|
},
|
|
118
|
+
/** @param {{ modelName: any, usage: any, durationMs: any }} param0 */
|
|
88
119
|
printUsageLine({ modelName, usage, durationMs }) {
|
|
89
120
|
send({
|
|
90
121
|
type: 'usage',
|
|
@@ -94,22 +125,33 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
94
125
|
cost: estimateCostLabel(modelName, usage?.prompt_tokens ?? 0, usage?.completion_tokens ?? 0, usage),
|
|
95
126
|
});
|
|
96
127
|
},
|
|
128
|
+
/** @param {any} fn */
|
|
97
129
|
onSigint(fn) {
|
|
98
130
|
if (setAbortHandler) setAbortHandler(fn);
|
|
99
131
|
return () => {
|
|
100
132
|
if (setAbortHandler) setAbortHandler(null);
|
|
101
133
|
};
|
|
102
134
|
},
|
|
135
|
+
/**
|
|
136
|
+
* @param {any} question
|
|
137
|
+
* @param {any} opts
|
|
138
|
+
*/
|
|
103
139
|
ask(question, opts = {}) {
|
|
104
140
|
return askHandler({ question, hidden: Boolean(opts.hidden) });
|
|
105
141
|
},
|
|
142
|
+
/** @param {any} prompt */
|
|
106
143
|
async askMultiline(prompt) {
|
|
107
144
|
return askHandler({ question: prompt });
|
|
108
145
|
},
|
|
146
|
+
/** @param {any} question */
|
|
109
147
|
async confirm(question) {
|
|
110
148
|
const a = await askHandler({ question: question + ' ', confirm: true });
|
|
111
149
|
return /^y(es)?$/i.test(String(a));
|
|
112
150
|
},
|
|
151
|
+
/**
|
|
152
|
+
* @param {any} label
|
|
153
|
+
* @param {any} options
|
|
154
|
+
*/
|
|
113
155
|
async choose(label, options) {
|
|
114
156
|
return askHandler({ label, options });
|
|
115
157
|
},
|