mingdao-harness 0.2.1 → 0.2.2
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 +53 -0
- package/package.json +1 -1
- package/src/atomic-write.js +5 -5
- package/src/cli.js +20 -29
- package/src/commands/sync.js +17 -7
- package/src/log-writer.js +27 -0
- package/src/notify.js +6 -6
- package/src/sync-server.js +70 -44
- package/src/web/app.js +1 -1
- package/src/web/routes/api.js +31 -19
- package/src/web/server.js +7 -17
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# 下一步迭代规划(v0.2.2 → v0.3.0)
|
|
2
|
+
|
|
3
|
+
基于四路审计剩余清单、六份评估综合路线与近期反馈收敛。原则不变:每期可独立发布、全绿测试门禁、零依赖不动摇。**待确认后开工。**
|
|
4
|
+
|
|
5
|
+
## 待办盘点(当前真实剩余)
|
|
6
|
+
|
|
7
|
+
- 审计遗留(QUALITY-REVIEW 清单中未做的 12 项中低危):sync-server devices/meta 互斥、sync login 密码走参数、fs-browse 基目录约束、readBody 按路由分级、draft 单槽、日志双文件 O(n) 重写、桌面崩溃循环护栏、notify 转义、cli 共享助手去重(offpeak/mcpFacade/参数解析)、会话/任务 misc。
|
|
8
|
+
- 结构渐进(0.2.1 标注的渐进项):routes/api.js 按域细分、cli.js REPL/worker 拆分。
|
|
9
|
+
- strict 棘轮:1584 条隐式 any 待消(每版定削减配额,先安全关键模块)。
|
|
10
|
+
- 省钱路线剩余(EVALUATION-SYNTHESIS 中期 6 项):工具 Schema 按需挂载、Batch 输入侧智能、费用二级分账+看板、护栏 downgrade、评测基准扩充(压缩质量集/路由标注集/V4 词表对拍)。
|
|
11
|
+
- 能力演进:跨平台沙箱补位、记忆语义检索、增量上下文。
|
|
12
|
+
- 工程:覆盖率阈值随版本上调、CHANGELOG 历史回填、GitHub Release 附件自动清理(需 PAT)。
|
|
13
|
+
|
|
14
|
+
## 分期方案
|
|
15
|
+
|
|
16
|
+
### Phase A「正确性收尾」v0.2.2(约 1–1.5 天)
|
|
17
|
+
审计遗留全部落地,零新功能:
|
|
18
|
+
1. sync-server devices/meta 读改写加锁(withFileLockSync 复用)
|
|
19
|
+
2. `mingdao sync login` 密码改隐藏输入/stdin,拒绝位置参数
|
|
20
|
+
3. `/api/fs-browse` 限定基目录集合(工作空间根+显式授权),禁止向上越界、不回传绝对路径
|
|
21
|
+
4. readBody 按路由分级(chat 40MB,其余 1MB)
|
|
22
|
+
5. draft 按会话维度存储(key=session 而非全局单槽)
|
|
23
|
+
6. 日志统一工具函数(append+按天滚动+按行截断),桌面/服务端共用口径
|
|
24
|
+
7. 桌面渲染进程崩溃计数护栏(超 3 次弹窗提示重启)
|
|
25
|
+
8. notify 结构化 argv(去掉启发式转义)
|
|
26
|
+
9. cli.js 共享助手抽取(offpeak defer、mcpFacade 构建、run/run-worker 参数解析合一)
|
|
27
|
+
- 验收:全绿 + 新增 fs-browse 越界与限流回归断言。
|
|
28
|
+
|
|
29
|
+
### Phase B「省钱第二轮」v0.2.3(约 2–3 天)
|
|
30
|
+
1. **工具 Schema 按需挂载**(每请求省 1–2K tokens):只读阶段只发 read/ls/glob/grep/skill + 本回合已用工具;写类工具首次调用时动态注入
|
|
31
|
+
2. **Batch 输入侧**:文本哈希去重(结果按 custom_id 回填全部位置)、单问超窗口预检报错、`--max-cost` 上限
|
|
32
|
+
3. **费用二级分账 + 看板**:cache-stats 增 reasoning/tool 维度与 byTool 累加;WebUI 仪表盘加模型/工具 Top5 与按天折线(零依赖 SVG)
|
|
33
|
+
4. **护栏 downgrade**:`action:'downgrade'` —— 触顶后自动切 flash + 明确提示(默认仍 warn/block 可选)
|
|
34
|
+
5. **评测基准扩充**:压缩质量集(5 组长会话样本→压缩→关键事实追问断言)、路由 100 条标注集入 bench
|
|
35
|
+
- 验收:bench 断言数 38→60+;覆盖率阈值上调至 60%。
|
|
36
|
+
|
|
37
|
+
### Phase C「结构渐进」v0.2.4(约 2 天)
|
|
38
|
+
1. routes/api.js 按域细分(config/sessions/sync/schedule/skills/workspace/misc 7 模块 + 共享中间件),每域配 API 契约测试
|
|
39
|
+
2. cli.js REPL → commands/repl.js、worker → tasks/worker.js(e2e-local 16 项全绿做护栏)
|
|
40
|
+
3. strict 棘轮削 300 条(先 credentials/permissions/config/sync-server/session 五个安全关键模块全量注解)
|
|
41
|
+
- 验收:tsc full 错误数 ≤1284;结构清单在文档登记。
|
|
42
|
+
|
|
43
|
+
### Phase D「能力演进」v0.3.0(约 3–5 天,可按需裁剪)
|
|
44
|
+
1. **跨平台沙箱补位**:Windows Job Object + 降权 token(零依赖原生 API)、macOS seatbelt 规则;敏感命令平台无关拦截清单兜底
|
|
45
|
+
2. **记忆语义检索**:零依赖实现(内存向量 + 余弦/哈希相似度;Node 22 可用 node:sqlite 持久化,Node 18 回退 JSONL 索引),记忆注入从「最近 3 条」升级为「相关 3 条」
|
|
46
|
+
3. **WebUI 看板扩展**:预算燃尽预测、费用导出 CSV
|
|
47
|
+
4. GitHub Release 附件自动清理(如你提供 PAT)+ 发布清单文档化
|
|
48
|
+
|
|
49
|
+
## 需要你拍板的三件事
|
|
50
|
+
|
|
51
|
+
1. **Phase 顺序**:A→B→C→D 直接推进,还是把 B(省钱)提前到 A 之前?
|
|
52
|
+
2. **GitHub PAT**:是否提供(用于 Release 附件自动清理与发版自动化)?
|
|
53
|
+
3. **Phase D 裁剪**:跨平台沙箱与语义检索是否都进 0.3.0,还是只做其一?
|
package/package.json
CHANGED
package/src/atomic-write.js
CHANGED
|
@@ -11,7 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
|
|
14
|
-
export function atomicWriteFileSync(target, data, options = {}) {
|
|
14
|
+
export function atomicWriteFileSync(/** @type {string} */ target, /** @type {string|Buffer} */ data, /** @type {any} */ options = {}) {
|
|
15
15
|
const dir = path.dirname(target);
|
|
16
16
|
const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
17
17
|
try {
|
|
@@ -25,16 +25,16 @@ export function atomicWriteFileSync(target, data, options = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function atomicWriteJsonSync(target, value, { mode = 0o600 } = {}) {
|
|
28
|
+
export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any} */ value, { mode = 0o600 } = {}) {
|
|
29
29
|
atomicWriteFileSync(target, JSON.stringify(value, null, 2) + '\n', { mode });
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步);异常/完成释放。
|
|
33
33
|
// 进程崩溃遗留的陈旧锁(> staleMs 未更新)自动回收,避免永久卡死。
|
|
34
34
|
const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
35
|
-
const sleepMs = (ms) => Atomics.wait(sleepBuf, 0, 0, ms);
|
|
35
|
+
const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
|
|
36
36
|
|
|
37
|
-
export function withFileLockSync(lockPath, fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
|
|
37
|
+
export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
|
|
38
38
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
39
39
|
const t0 = Date.now();
|
|
40
40
|
for (;;) {
|
|
@@ -53,7 +53,7 @@ export function withFileLockSync(lockPath, fn, { timeoutMs = 5000, staleMs = 150
|
|
|
53
53
|
} catch {}
|
|
54
54
|
}
|
|
55
55
|
} catch (err) {
|
|
56
|
-
if (err.code !== 'EEXIST') throw err;
|
|
56
|
+
if (/** @type {any} */ (err).code !== 'EEXIST') throw err;
|
|
57
57
|
try {
|
|
58
58
|
const st = fs.statSync(lockPath);
|
|
59
59
|
if (Date.now() - st.mtimeMs > staleMs) {
|
package/src/cli.js
CHANGED
|
@@ -296,45 +296,36 @@ async function main() {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
let
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const a = opts.prompt[i];
|
|
308
|
-
if (a === '--permission') permission = opts.prompt[++i];
|
|
309
|
-
else if (a === '--model') model = opts.prompt[++i];
|
|
310
|
-
else if (a === '--offpeak') offpeak = true;
|
|
299
|
+
// 质检 A9:run/run-worker 共用同一参数解析(此前两份重复且语义微妙不同)
|
|
300
|
+
const parseRunArgs = (argv, { questionFlag = false } = {}) => {
|
|
301
|
+
const out = { question: '', permission: null, model: null, offpeak: false };
|
|
302
|
+
for (let i = 0; i < argv.length; i++) {
|
|
303
|
+
const a = argv[i];
|
|
304
|
+
if (a === '--permission') out.permission = argv[++i];
|
|
305
|
+
else if (a === '--model') out.model = argv[++i];
|
|
306
|
+
else if (a === '--offpeak') out.offpeak = true;
|
|
311
307
|
else if (a === '--question') {
|
|
312
|
-
// 收集到下一个已知 flag 为止(参数顺序:--question <任务> --permission … --model …)
|
|
313
308
|
const parts = [];
|
|
314
|
-
while (i + 1 <
|
|
315
|
-
parts.push(
|
|
309
|
+
while (i + 1 < argv.length && !['--permission', '--model', '--offpeak'].includes(argv[i + 1])) {
|
|
310
|
+
parts.push(argv[++i]);
|
|
316
311
|
}
|
|
317
|
-
question = parts.join(' ');
|
|
318
|
-
}
|
|
312
|
+
out.question = parts.join(' ');
|
|
313
|
+
} else if (!out.question) out.question = a;
|
|
319
314
|
}
|
|
315
|
+
return out;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// 后台任务 worker(内部入口,由 mingdao run 启动)
|
|
319
|
+
if (opts.prompt[0] === 'run-worker') {
|
|
320
|
+
const id = opts.prompt[1];
|
|
321
|
+
const { question, permission, model, offpeak } = parseRunArgs(opts.prompt.slice(2));
|
|
320
322
|
await runWorkerTask(id, question, { permission, model, offpeak });
|
|
321
323
|
return;
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
// 后台任务启动:mingdao run "<任务>" [--permission auto] [--model x]
|
|
325
327
|
if (opts.prompt[0] === 'run') {
|
|
326
|
-
const
|
|
327
|
-
let question = '';
|
|
328
|
-
let permission = null;
|
|
329
|
-
let model = null;
|
|
330
|
-
let offpeak = false;
|
|
331
|
-
for (let i = 0; i < rest.length; i++) {
|
|
332
|
-
const a = rest[i];
|
|
333
|
-
if (a === '--permission') permission = rest[++i];
|
|
334
|
-
else if (a === '--model') model = rest[++i];
|
|
335
|
-
else if (a === '--offpeak') offpeak = true;
|
|
336
|
-
else if (question === '') question = a;
|
|
337
|
-
}
|
|
328
|
+
const { question, permission, model, offpeak } = parseRunArgs(opts.prompt.slice(1));
|
|
338
329
|
if (!question) {
|
|
339
330
|
console.log('用法:mingdao run "<任务>" [--permission auto|readonly] [--model 模型名] [--offpeak]');
|
|
340
331
|
process.exitCode = 1;
|
package/src/commands/sync.js
CHANGED
|
@@ -35,21 +35,31 @@ export async function handleSync(cmd, args) {
|
|
|
35
35
|
if (sub === 'login') {
|
|
36
36
|
const username = args[1];
|
|
37
37
|
if (!username) {
|
|
38
|
-
console.log('用法:mingdao sync login <用户名> [
|
|
38
|
+
console.log('用法:mingdao sync login <用户名> [服务器地址] [--insecure](密码将隐藏输入,绝不走命令行参数)');
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
// 质检 A2:密码绝不接受位置参数(明文出现在 ps/shell history)——隐藏输入或 stdin 单行
|
|
43
|
+
if (args[2] && !String(args[2]).startsWith('http')) {
|
|
44
|
+
console.log('[错误] 出于安全考虑,密码不再支持命令行参数——运行 mingdao sync login <用户名> <地址> 后按提示隐藏输入。');
|
|
39
45
|
process.exitCode = 1;
|
|
40
46
|
return true;
|
|
41
47
|
}
|
|
42
48
|
const s0 = syncStatus();
|
|
43
|
-
const url = args[
|
|
49
|
+
const url = args[2] && String(args[2]).startsWith('http') ? args[2] : s0.url;
|
|
44
50
|
if (!url) {
|
|
45
|
-
console.log('缺少服务器地址:mingdao sync login <用户名>
|
|
51
|
+
console.log('缺少服务器地址:mingdao sync login <用户名> <http(s)://地址>');
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
const insecureFlag = args.includes('--insecure');
|
|
56
|
+
let password = await askHidden('密码(至少 8 位):');
|
|
57
|
+
if (!password) {
|
|
58
|
+
console.log('[错误] 未输入密码');
|
|
46
59
|
process.exitCode = 1;
|
|
47
60
|
return true;
|
|
48
61
|
}
|
|
49
|
-
|
|
50
|
-
if (!password) password = await askHidden('密码(至少 8 位):');
|
|
51
|
-
const insecureFlag = args[4] === '--insecure' || args[5] === '--insecure';
|
|
52
|
-
const r = await syncLogin({ url, username, password, deviceName: args[4] === '--insecure' ? undefined : args[4], insecure: insecureFlag });
|
|
62
|
+
const r = await syncLogin({ url, username, password, deviceName: undefined, insecure: insecureFlag });
|
|
53
63
|
if (r.error) {
|
|
54
64
|
console.log('[错误] ' + r.error);
|
|
55
65
|
process.exitCode = 1;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// 统一日志写入器(质检 A6):追加写 + 超限按行截断(原子替换,绝不截断在行中间)。
|
|
2
|
+
// 桌面主进程(appLog)与 Web 服务端(srvlog)共用同一实现,消除双文件口径漂移与 O(n) 全量重写。
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1024 } = {}) {
|
|
8
|
+
return (/** @type {string} */ msg) => {
|
|
9
|
+
try {
|
|
10
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
|
+
const line = new Date().toISOString() + ' ' + String(msg) + '\n';
|
|
12
|
+
fs.appendFileSync(file, line);
|
|
13
|
+
const st = fs.statSync(file);
|
|
14
|
+
if (st.size > maxBytes) {
|
|
15
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
16
|
+
const cut = raw.length - maxBytes;
|
|
17
|
+
const nl = raw.indexOf('\n', cut);
|
|
18
|
+
const keep = nl === -1 ? raw.slice(cut) : raw.slice(nl + 1); // 行对齐保留尾部
|
|
19
|
+
const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
|
|
20
|
+
fs.writeFileSync(tmp, keep);
|
|
21
|
+
fs.renameSync(tmp, file);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// 日志失败绝不抛错(best-effort)
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
package/src/notify.js
CHANGED
|
@@ -16,15 +16,15 @@ export function notify(title, message) {
|
|
|
16
16
|
child.on('error', () => {});
|
|
17
17
|
child.unref();
|
|
18
18
|
} else if (process.platform === 'darwin') {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const child = spawn('osascript', ['-e', `display notification "${safe}" with title "${safeT}"`], { detached: true, stdio: 'ignore' });
|
|
19
|
+
// 质检 A8:osascript 逐参数传递(spawn 不经 shell,无需手工引号转义)
|
|
20
|
+
const child = spawn('osascript', ['-e', 'display notification ' + JSON.stringify(text) + ' with title ' + JSON.stringify(t)], { detached: true, stdio: 'ignore' });
|
|
22
21
|
child.on('error', () => {});
|
|
23
22
|
child.unref();
|
|
24
23
|
} else if (process.platform === 'win32') {
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
24
|
+
// 质检 A8:PowerShell 用 -EncodedCommand(Base64 UTF-16LE)传递,彻底消除引号/元字符问题
|
|
25
|
+
const script = `(New-Object -ComObject Wscript.Shell).Popup(${JSON.stringify(text)}, 10, ${JSON.stringify(t)}, 64)`;
|
|
26
|
+
const enc = Buffer.from(script, 'utf16le').toString('base64');
|
|
27
|
+
const child = spawn('powershell', ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { detached: true, stdio: 'ignore' });
|
|
28
28
|
child.on('error', () => {});
|
|
29
29
|
child.unref();
|
|
30
30
|
}
|
package/src/sync-server.js
CHANGED
|
@@ -201,6 +201,21 @@ function findDeviceByToken(token) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
// ---------- 业务 ----------
|
|
204
|
+
// 质检 A1:通用写互斥(注册/pair/lastSeen/meta 更新串行化),防 devices.json/meta.json 读改写竞态
|
|
205
|
+
let writeLock = null;
|
|
206
|
+
async function withWriteLock(fn) {
|
|
207
|
+
const prev = writeLock || Promise.resolve();
|
|
208
|
+
let release;
|
|
209
|
+
writeLock = new Promise((resolve) => {
|
|
210
|
+
release = resolve;
|
|
211
|
+
});
|
|
212
|
+
await prev;
|
|
213
|
+
try {
|
|
214
|
+
return await fn();
|
|
215
|
+
} finally {
|
|
216
|
+
release();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
204
219
|
let registerLock = null;
|
|
205
220
|
async function doRegister(body) {
|
|
206
221
|
const username = String(body.username || '').trim();
|
|
@@ -232,7 +247,7 @@ async function doRegister(body) {
|
|
|
232
247
|
}
|
|
233
248
|
}
|
|
234
249
|
|
|
235
|
-
function doPair(body) {
|
|
250
|
+
async function doPair(body) {
|
|
236
251
|
const username = String(body.username || '').trim();
|
|
237
252
|
const password = String(body.password || '');
|
|
238
253
|
const deviceName = String(body.deviceName || '').trim().slice(0, 60) || '未命名设备';
|
|
@@ -240,15 +255,17 @@ function doPair(body) {
|
|
|
240
255
|
const u = users[username];
|
|
241
256
|
if (!u) return { notFound: '用户不存在(请先注册)' };
|
|
242
257
|
if (!verifyPassword(password, u.salt, u.hash)) return { unauthorized: '密码错误' };
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
258
|
+
return withWriteLock(() => {
|
|
259
|
+
const deviceId = crypto.randomBytes(8).toString('hex');
|
|
260
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
261
|
+
const devices = readJson(devicesFile(), {});
|
|
262
|
+
devices[username] = devices[username] || {};
|
|
263
|
+
devices[username][deviceId] = { name: deviceName, tokenHash: sha(token), createdAt: Date.now(), lastSeen: Date.now() };
|
|
264
|
+
writeJson(devicesFile(), devices);
|
|
265
|
+
invalidateDeviceCache();
|
|
266
|
+
log('pair', username, deviceName, deviceId);
|
|
267
|
+
return { ok: true, username, deviceId, deviceName, token };
|
|
268
|
+
});
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
function doListSessions(username) {
|
|
@@ -280,10 +297,12 @@ function doPush(username, body) {
|
|
|
280
297
|
const tmp = file + '.tmp';
|
|
281
298
|
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
|
282
299
|
fs.renameSync(tmp, file);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
300
|
+
return withWriteLock(() => {
|
|
301
|
+
const meta = readJson(metaFile(username), {});
|
|
302
|
+
meta[name] = { mtime: Date.now(), size: Buffer.byteLength(content) };
|
|
303
|
+
writeJson(metaFile(username), meta);
|
|
304
|
+
return { ok: true, name, mtime: meta[name].mtime, size: meta[name].size };
|
|
305
|
+
});
|
|
287
306
|
}
|
|
288
307
|
|
|
289
308
|
function doPull(username, name) {
|
|
@@ -307,10 +326,12 @@ function doDelete(username, name) {
|
|
|
307
326
|
} catch {
|
|
308
327
|
return { notFound: '会话不存在' };
|
|
309
328
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
329
|
+
return withWriteLock(() => {
|
|
330
|
+
const meta = readJson(metaFile(username), {});
|
|
331
|
+
delete meta[name];
|
|
332
|
+
writeJson(metaFile(username), meta);
|
|
333
|
+
return { ok: true, name };
|
|
334
|
+
});
|
|
314
335
|
}
|
|
315
336
|
|
|
316
337
|
// ---------- 密码修改 ----------
|
|
@@ -410,23 +431,25 @@ function doShareAccept(username, shareId) {
|
|
|
410
431
|
fs.mkdirSync(sessionsDir(username), { recursive: true, mode: 0o700 });
|
|
411
432
|
atomicWriteFileSync(target, content, { mode: 0o600 }); // 质检 H4
|
|
412
433
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
434
|
+
return withWriteLock(() => {
|
|
435
|
+
const meta = readJson(metaFile(username), {});
|
|
436
|
+
meta[savedAs] = { mtime: Date.now(), size: Buffer.byteLength(content) };
|
|
437
|
+
writeJson(metaFile(username), meta);
|
|
438
|
+
accepted[username] = accepted[username] || {};
|
|
439
|
+
accepted[username][shareId] = { owner: s.owner, name: s.name, savedAs, acceptedAt: Date.now(), copyHash: sha(content) };
|
|
440
|
+
writeJson(acceptedFile(), accepted);
|
|
441
|
+
shares[shareId].pulls = (shares[shareId].pulls || 0) + 1;
|
|
442
|
+
writeJson(sharesFile(), shares);
|
|
443
|
+
log('share-accept', username, shareId, savedAs, conflict ? '(conflict-copy)' : '(refresh)');
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
shareId,
|
|
447
|
+
savedAs,
|
|
448
|
+
conflict,
|
|
449
|
+
content,
|
|
450
|
+
mtime: meta[savedAs]?.mtime || Date.now(),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
430
453
|
}
|
|
431
454
|
|
|
432
455
|
// ---------- 路由 ----------
|
|
@@ -457,7 +480,7 @@ async function handle(req, res) {
|
|
|
457
480
|
const body = await parseBody(req);
|
|
458
481
|
if (body.__error) return json(res, 400, { error: 'JSON 解析失败' });
|
|
459
482
|
if (rateLimited(req, 5, String(body.username || '').toLowerCase())) return json(res, 429, { error: '该用户名的尝试过于频繁,请稍后再试' });
|
|
460
|
-
const r = doPair(body);
|
|
483
|
+
const r = await doPair(body);
|
|
461
484
|
if (r.notFound) return json(res, 404, r);
|
|
462
485
|
if (r.unauthorized) return json(res, 401, r);
|
|
463
486
|
return json(res, 200, r);
|
|
@@ -469,11 +492,14 @@ async function handle(req, res) {
|
|
|
469
492
|
const nowSeen = Date.now();
|
|
470
493
|
if (nowSeen - (dev.device.lastSeen || 0) > 60000) {
|
|
471
494
|
dev.device.lastSeen = nowSeen;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
devices
|
|
475
|
-
|
|
476
|
-
|
|
495
|
+
// 质检 A1:与 pair 的 devices 写入互斥(60s 节流只降频,不消除竞态——加锁消除)
|
|
496
|
+
withWriteLock(() => {
|
|
497
|
+
const devices = readJson(devicesFile(), {});
|
|
498
|
+
if (devices[dev.username]?.[dev.deviceId]) {
|
|
499
|
+
devices[dev.username][dev.deviceId] = dev.device;
|
|
500
|
+
writeJson(devicesFile(), devices);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
477
503
|
}
|
|
478
504
|
|
|
479
505
|
if (req.method === 'POST' && p === '/api/devices') {
|
|
@@ -492,7 +518,7 @@ async function handle(req, res) {
|
|
|
492
518
|
if (req.method === 'POST' && p === '/api/sessions/push') {
|
|
493
519
|
const body = await parseBody(req);
|
|
494
520
|
if (body.__error) return json(res, 400, { error: 'JSON 解析失败' });
|
|
495
|
-
const r = doPush(dev.username, body);
|
|
521
|
+
const r = await doPush(dev.username, body);
|
|
496
522
|
if (r.error) return json(res, 400, r);
|
|
497
523
|
return json(res, 200, r);
|
|
498
524
|
}
|
|
@@ -504,7 +530,7 @@ async function handle(req, res) {
|
|
|
504
530
|
}
|
|
505
531
|
if (req.method === 'POST' && p === '/api/sessions/delete') {
|
|
506
532
|
const body = await parseBody(req);
|
|
507
|
-
const r = doDelete(dev.username, String(body.name || '').trim());
|
|
533
|
+
const r = await doDelete(dev.username, String(body.name || '').trim());
|
|
508
534
|
if (r.notFound) return json(res, 404, r);
|
|
509
535
|
return json(res, 200, r);
|
|
510
536
|
}
|
|
@@ -536,7 +562,7 @@ async function handle(req, res) {
|
|
|
536
562
|
if (req.method === 'POST' && p === '/api/share/accept') {
|
|
537
563
|
if (rateLimited(req, 10)) return json(res, 429, { error: '尝试过于频繁,请稍后再试' });
|
|
538
564
|
const body = await parseBody(req);
|
|
539
|
-
const r = doShareAccept(dev.username, String(body.shareId || '').trim());
|
|
565
|
+
const r = await doShareAccept(dev.username, String(body.shareId || '').trim());
|
|
540
566
|
if (r.error) return json(res, 400, r);
|
|
541
567
|
if (r.notFound) return json(res, 404, r);
|
|
542
568
|
return json(res, 200, r);
|
package/src/web/app.js
CHANGED
|
@@ -634,7 +634,7 @@ async function init(){
|
|
|
634
634
|
refreshSessions();
|
|
635
635
|
refreshWsSel();
|
|
636
636
|
try{
|
|
637
|
-
const dr=await fetch('/api/draft',{cache:'no-store'}); const dj=await dr.json();
|
|
637
|
+
const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
|
|
638
638
|
if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
|
|
639
639
|
}catch(e){}
|
|
640
640
|
}
|
package/src/web/routes/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import crypto from 'node:crypto';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../../config.js';
|
|
@@ -49,9 +50,10 @@ import { presetList, buildPreset } from '../../mcp-presets.js';
|
|
|
49
50
|
import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList, maybeAutoSync, syncChangePassword, syncShareCreate, syncShareList, syncShareAccept, syncShareRevoke, listSyncConflicts, resolveSyncConflict } from '../../sync.js';
|
|
50
51
|
|
|
51
52
|
export function createApiDispatch(deps) {
|
|
52
|
-
const { json, srvlog, readBody, cfg, home, providerCache, getProviderFor, tasks, MAX_CONCURRENT, pruneTasks, handleChat, mcpFacade, provider, validateRemoteUrl, isPrivateHost, authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML } = deps;
|
|
53
|
-
const state = deps.state; // { modelName, workingDir
|
|
53
|
+
const { json, srvlog, readBody, cfg, home, providerCache, getProviderFor, tasks, MAX_CONCURRENT, pruneTasks, handleChat, mcpFacade, provider, validateRemoteUrl, isPrivateHost, authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd } = deps;
|
|
54
|
+
const state = deps.state; // { modelName, workingDir }
|
|
54
55
|
const refs = deps.refs; // { inflight }
|
|
56
|
+
const MAX_API_BODY = 1024 * 1024; // 质检 A4:普通 JSON 接口 1MB;chat 保持 readBody 默认 40MB
|
|
55
57
|
const dispatch = async (req, res) => {
|
|
56
58
|
const url = new URL(req.url, 'http://localhost');
|
|
57
59
|
const p = url.pathname;
|
|
@@ -151,7 +153,7 @@ export function createApiDispatch(deps) {
|
|
|
151
153
|
return;
|
|
152
154
|
}
|
|
153
155
|
if (req.method === 'POST' && p === '/api/config') {
|
|
154
|
-
const body = await readBody(req);
|
|
156
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
155
157
|
// 内存 cfg 可能比磁盘旧(如 CLI 改过同步地址):先从磁盘刷新 sync,避免保存时回滚
|
|
156
158
|
cfg.sync = loadConfig()?.sync || cfg.sync;
|
|
157
159
|
const next = { model: state.modelName, permission: cfg.permission ?? 'ask' };
|
|
@@ -258,7 +260,7 @@ export function createApiDispatch(deps) {
|
|
|
258
260
|
return;
|
|
259
261
|
}
|
|
260
262
|
if (req.method === 'POST' && p === '/api/models-config') {
|
|
261
|
-
const body = await readBody(req);
|
|
263
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
262
264
|
const action = body.action;
|
|
263
265
|
// —— 服务商 Key 管理 ——
|
|
264
266
|
if (action === 'setProviderKey') {
|
|
@@ -401,7 +403,7 @@ export function createApiDispatch(deps) {
|
|
|
401
403
|
return;
|
|
402
404
|
}
|
|
403
405
|
if (req.method === 'POST' && p === '/api/session') {
|
|
404
|
-
const body = await readBody(req);
|
|
406
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
405
407
|
const file = path.basename(String(body.file || ''));
|
|
406
408
|
const full = path.join(home, 'sessions', file);
|
|
407
409
|
if (!file || !fs.existsSync(full)) return json(res, 404, { error: '会话不存在' });
|
|
@@ -485,7 +487,7 @@ export function createApiDispatch(deps) {
|
|
|
485
487
|
});
|
|
486
488
|
let body;
|
|
487
489
|
try {
|
|
488
|
-
body = await readBody(req);
|
|
490
|
+
body = await readBody(req, MAX_API_BODY);
|
|
489
491
|
} catch (e) {
|
|
490
492
|
refs.inflight -= 1;
|
|
491
493
|
res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
|
|
@@ -509,7 +511,7 @@ export function createApiDispatch(deps) {
|
|
|
509
511
|
return;
|
|
510
512
|
}
|
|
511
513
|
if (req.method === 'POST' && p === '/api/permission') {
|
|
512
|
-
const body = await readBody(req);
|
|
514
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
513
515
|
const entry = tasks.get(body.taskId);
|
|
514
516
|
if (!entry || !entry.pendingAsk) return json(res, 409, { error: '没有挂起的权限确认' });
|
|
515
517
|
const pa = entry.pendingAsk;
|
|
@@ -525,7 +527,7 @@ export function createApiDispatch(deps) {
|
|
|
525
527
|
return;
|
|
526
528
|
}
|
|
527
529
|
if (req.method === 'POST' && p === '/api/abort') {
|
|
528
|
-
const body = await readBody(req);
|
|
530
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
529
531
|
if (body.taskId) {
|
|
530
532
|
const entry = tasks.get(body.taskId);
|
|
531
533
|
if (entry?.abortHandler) {
|
|
@@ -547,14 +549,17 @@ export function createApiDispatch(deps) {
|
|
|
547
549
|
return;
|
|
548
550
|
}
|
|
549
551
|
if (req.method === 'GET' && p === '/api/draft') {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
+
// 质检 A5:按会话维度读取(无 file 参数用全局槽,兼容旧客户端)
|
|
553
|
+
const key = String(url.searchParams.get('file') || '');
|
|
554
|
+
const text = draftTexts.get(key) || '';
|
|
555
|
+
draftTexts.delete(key); // 读取即清除
|
|
552
556
|
json(res, 200, { ok: true, text });
|
|
553
557
|
return;
|
|
554
558
|
}
|
|
555
559
|
if (req.method === 'POST' && p === '/api/draft') {
|
|
556
|
-
const body = await readBody(req);
|
|
557
|
-
|
|
560
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
561
|
+
const key = String(body.file || '');
|
|
562
|
+
draftTexts.set(key, String(body.text ?? '').slice(0, 200000));
|
|
558
563
|
json(res, 200, { ok: true });
|
|
559
564
|
return;
|
|
560
565
|
}
|
|
@@ -579,7 +584,7 @@ export function createApiDispatch(deps) {
|
|
|
579
584
|
return;
|
|
580
585
|
}
|
|
581
586
|
if (req.method === 'POST' && p === '/api/schedule') {
|
|
582
|
-
const body = await readBody(req);
|
|
587
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
583
588
|
const action = String(body.action || '');
|
|
584
589
|
try {
|
|
585
590
|
if (action === 'add') {
|
|
@@ -619,7 +624,7 @@ export function createApiDispatch(deps) {
|
|
|
619
624
|
return;
|
|
620
625
|
}
|
|
621
626
|
if (req.method === 'POST' && p === '/api/workspaces') {
|
|
622
|
-
const body = await readBody(req);
|
|
627
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
623
628
|
const name = String(body.name || '').trim();
|
|
624
629
|
if (body.action === 'add') {
|
|
625
630
|
if (!name) return json(res, 400, { error: '名称不能为空' });
|
|
@@ -672,6 +677,11 @@ export function createApiDispatch(deps) {
|
|
|
672
677
|
if (req.method === 'GET' && p === '/api/fs-browse') {
|
|
673
678
|
let dir = String(url.searchParams.get('dir') || '').trim();
|
|
674
679
|
if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
|
|
680
|
+
// 质检 A3:目录浏览限定基目录(默认家目录 + config.web.browseRoots 显式授权),
|
|
681
|
+
// 拒绝越界——此前可枚举服务器任意绝对路径(整机目录结构探测面)
|
|
682
|
+
const browseRoots = [os.homedir(), startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => path.resolve(String(r)));
|
|
683
|
+
const inRoot = browseRoots.some((r) => dir === r || dir.startsWith(r + path.sep));
|
|
684
|
+
if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(家目录或 web.browseRoots 授权目录)' });
|
|
675
685
|
try {
|
|
676
686
|
const st = fs.statSync(dir);
|
|
677
687
|
if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
|
|
@@ -681,7 +691,9 @@ export function createApiDispatch(deps) {
|
|
|
681
691
|
.map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
|
|
682
692
|
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
|
683
693
|
.slice(0, 300);
|
|
684
|
-
|
|
694
|
+
let parent = path.dirname(dir);
|
|
695
|
+
// 父目录同样不得越出基目录
|
|
696
|
+
if (!browseRoots.some((r) => parent === r || parent.startsWith(r + path.sep))) parent = null;
|
|
685
697
|
json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
|
|
686
698
|
} catch (err) {
|
|
687
699
|
json(res, 400, { error: String(err?.message || err) });
|
|
@@ -693,7 +705,7 @@ export function createApiDispatch(deps) {
|
|
|
693
705
|
return;
|
|
694
706
|
}
|
|
695
707
|
if (req.method === 'POST' && p === '/api/memory') {
|
|
696
|
-
const body = await readBody(req);
|
|
708
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
697
709
|
if (body.action === 'dedupe') {
|
|
698
710
|
const removed = dedupeMemory();
|
|
699
711
|
return json(res, 200, { ok: true, removed });
|
|
@@ -720,7 +732,7 @@ export function createApiDispatch(deps) {
|
|
|
720
732
|
return;
|
|
721
733
|
}
|
|
722
734
|
if (req.method === 'POST' && p === '/api/mcp-presets') {
|
|
723
|
-
const body = await readBody(req);
|
|
735
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
724
736
|
const name = String(body.name || '').trim();
|
|
725
737
|
const r = buildPreset(name, body.arg, state.workingDir);
|
|
726
738
|
if (r.error) return json(res, 400, { error: r.error });
|
|
@@ -754,7 +766,7 @@ export function createApiDispatch(deps) {
|
|
|
754
766
|
return;
|
|
755
767
|
}
|
|
756
768
|
if (req.method === 'POST' && p === '/api/skills') {
|
|
757
|
-
const body = await readBody(req);
|
|
769
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
758
770
|
if (body.action === 'install') {
|
|
759
771
|
const r = await installSkill(body.arg || body.name || '');
|
|
760
772
|
if (r.error) return json(res, 400, { error: r.error });
|
|
@@ -778,7 +790,7 @@ export function createApiDispatch(deps) {
|
|
|
778
790
|
return;
|
|
779
791
|
}
|
|
780
792
|
if (req.method === 'POST' && p === '/api/sync') {
|
|
781
|
-
const body = await readBody(req);
|
|
793
|
+
const body = await readBody(req, MAX_API_BODY);
|
|
782
794
|
if (body.action === 'login') {
|
|
783
795
|
const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
|
|
784
796
|
if (vurl.error) return json(res, 400, { error: vurl.error });
|
package/src/web/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import fs from 'node:fs';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import crypto from 'node:crypto';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { createLogWriter } from '../log-writer.js';
|
|
19
20
|
import { createApiDispatch } from './routes/api.js';
|
|
20
21
|
import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
|
|
21
22
|
import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
|
|
@@ -163,6 +164,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
let workingDir = process.cwd(); // 工作空间切换时随之更新(后续会话/工具都跟随新目录)
|
|
167
|
+
const startupCwd = workingDir; // 质检 A3:启动工作目录恒为合法浏览根(工作空间切换后仍可浏览原位)
|
|
166
168
|
// Provider 按模型懒加载缓存:界面切换模型后即时生效
|
|
167
169
|
const providerCache = new Map();
|
|
168
170
|
async function getProviderFor(m) {
|
|
@@ -206,23 +208,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
206
208
|
sessionLocks.set(file, next.then(() => sessionLocks.delete(file), () => sessionLocks.delete(file)));
|
|
207
209
|
return next;
|
|
208
210
|
}
|
|
209
|
-
|
|
211
|
+
const draftTexts = new Map(); // 质检 A5:草稿按会话维度存储(此前单槽多客户端互相覆盖)
|
|
210
212
|
|
|
211
213
|
// —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
|
|
212
214
|
// 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const dir = path.join(mingdaoHome(), 'logs');
|
|
216
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
217
|
-
const f = path.join(dir, 'web-server.log');
|
|
218
|
-
let prev = '';
|
|
219
|
-
try {
|
|
220
|
-
prev = fs.readFileSync(f, 'utf8');
|
|
221
|
-
} catch {}
|
|
222
|
-
if (prev.length > 2 * 1024 * 1024) prev = prev.slice(-1024 * 1024);
|
|
223
|
-
fs.writeFileSync(f, prev + new Date().toISOString() + ' ' + msg + '\n');
|
|
224
|
-
} catch {}
|
|
225
|
-
}
|
|
215
|
+
// 质检 A6:统一日志写入器(append + 按行截断 + 原子替换),与桌面 appLog 同款实现
|
|
216
|
+
const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
|
|
226
217
|
|
|
227
218
|
// —— SSRF 防护(质检 S1):远端地址校验 ——
|
|
228
219
|
function isPrivateHost(hostname) {
|
|
@@ -506,10 +497,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
506
497
|
providerCache, getProviderFor, tasks,
|
|
507
498
|
MAX_CONCURRENT, pruneTasks, handleChat,
|
|
508
499
|
mcpFacade, provider, validateRemoteUrl, isPrivateHost,
|
|
509
|
-
authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML,
|
|
500
|
+
authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd,
|
|
510
501
|
state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
|
|
511
|
-
get workingDir() { return workingDir; }, set workingDir(v) { workingDir = v; },
|
|
512
|
-
get draftText() { return draftText; }, set draftText(v) { draftText = v; } },
|
|
502
|
+
get workingDir() { return workingDir; }, set workingDir(v) { workingDir = v; } },
|
|
513
503
|
refs: { get inflight() { return inflight; }, set inflight(v) { inflight = v; } },
|
|
514
504
|
});
|
|
515
505
|
const server = http.createServer((req, res) => {
|