mingdao-harness 0.6.2 → 0.6.4
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/AUDIT-v0.6.1-/347/254/254/344/270/211/346/226/271/346/212/245/345/221/212/347/231/273/350/256/260.md +300 -32
- package/docs/CONFIG.md +32 -6
- package/docs/DEVELOPER.md +1 -1
- package/docs/PACK-API.md +26 -3
- package/docs/PROVIDERS.md +22 -0
- package/docs/RELEASE-CHECKLIST.md +13 -2
- package/package.json +1 -1
- package/src/agent.js +133 -15
- package/src/atomic-write.js +247 -71
- package/src/audit.js +23 -18
- package/src/cachestats.js +57 -47
- package/src/cli.js +25 -4
- package/src/commands/diagnose.js +3 -0
- package/src/commands/key.js +23 -1
- package/src/commands/ledger.js +41 -3
- package/src/commands/net.js +5 -1
- package/src/commands/pack.js +23 -6
- package/src/commands/repl.js +9 -1
- package/src/commands/skill.js +30 -1
- package/src/commands/sync.js +7 -0
- package/src/commands/workspace.js +3 -3
- package/src/compact.js +15 -1
- package/src/config.js +71 -6
- package/src/constraints.js +55 -6
- package/src/credentials.js +36 -5
- package/src/hooks.js +37 -2
- package/src/ledger.js +3 -3
- package/src/log-writer.js +22 -17
- package/src/mcp.js +19 -1
- package/src/memory.js +8 -8
- package/src/packs.js +57 -8
- package/src/permissions.js +35 -9
- package/src/presets.js +46 -1
- package/src/providers/index.js +133 -7
- package/src/providers/openai-compatible.js +24 -3
- package/src/redact.js +38 -2
- package/src/replay.js +15 -3
- package/src/safe-fetch.js +8 -2
- package/src/schedule.js +22 -4
- package/src/session-index.js +2 -2
- package/src/session.js +2 -2
- package/src/skill-lib.js +43 -14
- package/src/skill-registry.js +31 -2
- package/src/sync-server.js +47 -28
- package/src/sync.js +17 -3
- package/src/task-state.js +10 -1
- package/src/tasks.js +22 -10
- package/src/tools/bash.js +96 -18
- package/src/tools/fetch.js +24 -0
- package/src/web/app.js +7 -7
- package/src/web/attachments.js +7 -1
- package/src/web/index.html +2 -2
- package/src/web/routes/api.js +27 -2
- package/src/web/routes/domains/sessions.js +3 -3
- package/src/web/routes/domains/sync.js +2 -1
- package/src/web/routes/domains/workspace.js +45 -15
- package/src/web/server.js +22 -5
- package/src/workspace.js +31 -18
package/src/skill-registry.js
CHANGED
|
@@ -23,10 +23,37 @@ const DEFAULT_HOSTS = [
|
|
|
23
23
|
const TTL_MS = 60 * 60 * 1000;
|
|
24
24
|
const MAX_FILE = 512 * 1024;
|
|
25
25
|
|
|
26
|
+
// 回环/本机地址:本地开发与内网自建 registry 的常见形态,允许明文 http
|
|
27
|
+
function isLoopbackHost(/** @type {string} */ host) {
|
|
28
|
+
return /^(127\.0\.0\.1|localhost|\[::1\]|::1)(:\d+)?$/i.test(host);
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
function registryBase() {
|
|
27
32
|
const env = process.env.MINGDAO_REGISTRY_URL;
|
|
28
33
|
if (env) {
|
|
29
|
-
const
|
|
34
|
+
const raw = String(env).trim();
|
|
35
|
+
const u = raw.replace(/\/index\.json$/, '').replace(/\/+$/, '');
|
|
36
|
+
// v0.6.3(审计 M-7):自建 registry **拒绝明文 http**(回环除外)。
|
|
37
|
+
//
|
|
38
|
+
// 为什么必须拒:自定义源的内容会直接决定"装进用户机器里的技能正文",
|
|
39
|
+
// 而 registry 索引里的 sha256 是**自证**的(正文与哈希同在索引里,见 M-6)——
|
|
40
|
+
// 明文 http 下内网中间人可同时替换正文与哈希,校验必然通过。
|
|
41
|
+
// 也就是说 TLS 是这条链上**唯一**的外部信任锚,不能默认放弃。
|
|
42
|
+
// 回环地址(127.0.0.1/localhost/[::1])不经过网络,允许明文以便本地开发。
|
|
43
|
+
const scheme = (u.match(/^([a-z][a-z0-9+.-]*):\/\//i) || [])[1];
|
|
44
|
+
if (scheme && scheme.toLowerCase() !== 'https' && scheme.toLowerCase() !== 'http') {
|
|
45
|
+
return { hosts: [], isCustom: true, error: `MINGDAO_REGISTRY_URL 的协议不支持:${scheme}://(只支持 https,或回环地址上的 http)` };
|
|
46
|
+
}
|
|
47
|
+
if (scheme && scheme.toLowerCase() === 'http' && !isLoopbackHost(u.replace(/^https?:\/\//i, ''))) {
|
|
48
|
+
return {
|
|
49
|
+
hosts: [],
|
|
50
|
+
isCustom: true,
|
|
51
|
+
error:
|
|
52
|
+
`MINGDAO_REGISTRY_URL 使用明文 http(${u})已被拒绝:技能正文与该索引里的 sha256 同源,` +
|
|
53
|
+
'明文链路下中间人可同时替换两者,校验必然通过——TLS 是这条链上唯一的外部信任锚。' +
|
|
54
|
+
'请改用 https;若确为本机开发,请用 127.0.0.1/localhost。',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
30
57
|
return { hosts: [u], isCustom: true };
|
|
31
58
|
}
|
|
32
59
|
return { hosts: DEFAULT_HOSTS, isCustom: false };
|
|
@@ -71,7 +98,8 @@ export async function fetchRegistryIndex({ force = false, allowNetwork = true }
|
|
|
71
98
|
return { error: '尚无线上技能库缓存(点击「刷新线上」拉取,或设置 MINGDAO_REGISTRY_URL 指向自建 registry)' };
|
|
72
99
|
}
|
|
73
100
|
}
|
|
74
|
-
const { hosts, isCustom } = registryBase();
|
|
101
|
+
const { hosts, isCustom, error: baseError } = registryBase();
|
|
102
|
+
if (baseError) return { error: baseError };
|
|
75
103
|
let lastErr = null;
|
|
76
104
|
for (const host of hosts) {
|
|
77
105
|
try {
|
|
@@ -123,6 +151,7 @@ export async function installFromRegistry(/** @type {any} */ name) {
|
|
|
123
151
|
// 逐文件下载按镜像回退(审计:国内网络下 raw.githubusercontent 常超时/被断,
|
|
124
152
|
// 此前只试首选主机 → 安装报「This operation was aborted」;gitee/gitcode 国内秒开)
|
|
125
153
|
const base = registryBase();
|
|
154
|
+
if (base.error) return { error: base.error };
|
|
126
155
|
const hosts = [r.host, ...base.hosts.filter((h) => h !== r.host)];
|
|
127
156
|
const allowPrivate = base.isCustom; // 用户显式配置的源(可能是内网自建 registry)
|
|
128
157
|
let verified = false; // 每个文件都必须通过 sha256 校验(缺哈希直接拒绝安装),成功即 true
|
package/src/sync-server.js
CHANGED
|
@@ -408,24 +408,29 @@ function doDelete(username, name) {
|
|
|
408
408
|
* @param {any} username
|
|
409
409
|
* @param {any} body
|
|
410
410
|
*/
|
|
411
|
-
function doChangePassword(username, body) {
|
|
411
|
+
async function doChangePassword(username, body) {
|
|
412
412
|
const oldPassword = String(body.oldPassword || '');
|
|
413
413
|
const newPassword = String(body.newPassword || '');
|
|
414
414
|
if (newPassword.length < 8) return { error: '新密码至少 8 位' };
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
415
|
+
return withWriteLock(() => {
|
|
416
|
+
// v0.6.3(P0-4):改密会 `delete devices[username]`(吊销全部设备 token),而设备表另有
|
|
417
|
+
// 一把 registerLock/写锁——此前这里是**锁外**读改写,于是"吊销"可以被一次并发的 pair
|
|
418
|
+
// 写回覆盖,已吊销的旧 token 复活。改密与设备表写必须互斥。
|
|
419
|
+
const users = readJson(usersFile(), {});
|
|
420
|
+
const u = users[username];
|
|
421
|
+
if (!u) return { unauthorized: '用户不存在' };
|
|
422
|
+
if (!verifyPassword(oldPassword, u.salt, u.hash)) return { unauthorized: '旧密码错误' };
|
|
423
|
+
const salt = crypto.randomBytes(12).toString('hex');
|
|
424
|
+
users[username] = { ...u, salt, hash: hashPassword(newPassword, salt), updatedAt: Date.now() };
|
|
425
|
+
writeJson(usersFile(), users);
|
|
426
|
+
// 改密吊销既有设备 token:密码可能已泄露,旧 token 一律失效(当前设备也需重新登录)
|
|
427
|
+
const devices = readJson(devicesFile(), {});
|
|
428
|
+
delete devices[username];
|
|
429
|
+
writeJson(devicesFile(), devices);
|
|
430
|
+
invalidateDeviceCache();
|
|
431
|
+
log('password-changed', username, '(全部设备已吊销)');
|
|
432
|
+
return { ok: true, note: '密码已修改,所有设备需重新登录' };
|
|
433
|
+
});
|
|
429
434
|
}
|
|
430
435
|
|
|
431
436
|
// ---------- 会话分享 ----------
|
|
@@ -518,14 +523,20 @@ function doShareAccept(username, shareId) {
|
|
|
518
523
|
atomicWriteFileSync(target, content, { mode: 0o600 }); // 质检 H4
|
|
519
524
|
}
|
|
520
525
|
return withWriteLock(() => {
|
|
526
|
+
// v0.6.3(P0-4):**锁内重读**。此前 shares/accepted 是在锁**外**读的,写回的是陈旧快照——
|
|
527
|
+
// 于是"revoke 之后的一次并发 accept"会把已删除的 shareId 写回去(分享复活),
|
|
528
|
+
// 同理也会覆盖并发的 pulls/accepted 更新。这里重新读取并再次校验存在性。
|
|
529
|
+
const shares2 = readJson(sharesFile(), {});
|
|
530
|
+
if (!shares2[shareId]) return { notFound: '分享不存在(可能已被并发撤销)' };
|
|
531
|
+
const accepted2 = readJson(acceptedFile(), {});
|
|
521
532
|
const meta = readJson(metaFile(username), {});
|
|
522
533
|
meta[savedAs] = { mtime: Date.now(), size: Buffer.byteLength(content) };
|
|
523
534
|
writeJson(metaFile(username), meta);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
writeJson(acceptedFile(),
|
|
527
|
-
|
|
528
|
-
writeJson(sharesFile(),
|
|
535
|
+
accepted2[username] = accepted2[username] || {};
|
|
536
|
+
accepted2[username][shareId] = { owner: s.owner, name: s.name, savedAs, acceptedAt: Date.now(), copyHash: sha(content) };
|
|
537
|
+
writeJson(acceptedFile(), accepted2);
|
|
538
|
+
shares2[shareId].pulls = (shares2[shareId].pulls || 0) + 1;
|
|
539
|
+
writeJson(sharesFile(), shares2);
|
|
529
540
|
log('share-accept', username, shareId, savedAs, conflict ? '(conflict-copy)' : '(refresh)');
|
|
530
541
|
return {
|
|
531
542
|
ok: true,
|
|
@@ -583,13 +594,21 @@ async function handle(req, res) {
|
|
|
583
594
|
if (nowSeen - (dev.device.lastSeen || 0) > 60000) {
|
|
584
595
|
dev.device.lastSeen = nowSeen;
|
|
585
596
|
// 质检 A1:与 pair 的 devices 写入互斥(60s 节流只降频,不消除竞态——加锁消除)
|
|
586
|
-
withWriteLock
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
597
|
+
// v0.6.3(P0-4):**必须 await**。withWriteLock 是 async:临界区里任何 I/O 异常
|
|
598
|
+
// (ENOSPC/EACCES/ENOENT)都会让返回的 promise reject,而无人处理 → Node 15+ 默认
|
|
599
|
+
// `--unhandled-rejections=throw` → **整个同步服务进程退出**(所有在线设备一起掉线)。
|
|
600
|
+
try {
|
|
601
|
+
await withWriteLock(() => {
|
|
602
|
+
const devices = readJson(devicesFile(), {});
|
|
603
|
+
if (devices[dev.username]?.[dev.deviceId]) {
|
|
604
|
+
devices[dev.username][dev.deviceId] = dev.device;
|
|
605
|
+
writeJson(devicesFile(), devices);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
} catch (/** @type {any} */ e) {
|
|
609
|
+
// lastSeen 写失败不影响本次请求的语义(它只是"最近在线"标记),但必须留痕而不是崩掉进程
|
|
610
|
+
log('lastSeen-write-failed', dev.username, String(e?.message || e));
|
|
611
|
+
}
|
|
593
612
|
}
|
|
594
613
|
|
|
595
614
|
if (req.method === 'POST' && p === '/api/devices') {
|
|
@@ -627,7 +646,7 @@ async function handle(req, res) {
|
|
|
627
646
|
if (req.method === 'POST' && p === '/api/password') {
|
|
628
647
|
if (rateLimited(req, 10)) return json(res, 429, { error: '尝试过于频繁,请稍后再试' });
|
|
629
648
|
const body = await parseBody(req);
|
|
630
|
-
const r = doChangePassword(dev.username, body);
|
|
649
|
+
const r = await doChangePassword(dev.username, body); // v0.6.3(P0-4):改为持锁执行,必须 await
|
|
631
650
|
if (r.error) return json(res, 400, r);
|
|
632
651
|
if (r.unauthorized) return json(res, 401, r);
|
|
633
652
|
return json(res, 200, r);
|
package/src/sync.js
CHANGED
|
@@ -17,6 +17,7 @@ import { loadCredentials, saveCredentials } from './credentials.js';
|
|
|
17
17
|
import { listSessions, CONFLICT_BACKUP_RE } from './session.js';
|
|
18
18
|
import { atomicWriteFileSync, withFileLockSync } from './atomic-write.js';
|
|
19
19
|
import { decideEgress, currentPolicy } from './net-guard.js';
|
|
20
|
+
import { redactSecrets } from './redact.js';
|
|
20
21
|
|
|
21
22
|
const TIMEOUT_MS = 20000;
|
|
22
23
|
// 自签证书(--insecure)只影响同步请求本身,不再改写进程级 NODE_TLS_REJECT_UNAUTHORIZED
|
|
@@ -287,6 +288,7 @@ export async function syncPush(name) {
|
|
|
287
288
|
/** @type {Record<string, any>} */
|
|
288
289
|
const delta = {}; // 本次真正变更的状态键(T19:末尾按增量合并)
|
|
289
290
|
const pushed = [];
|
|
291
|
+
const redactedSessions = []; // 上传前被脱敏的会话(调用方据此提示用户)
|
|
290
292
|
const conflicts = [];
|
|
291
293
|
const skipped = [];
|
|
292
294
|
for (const s of locals) {
|
|
@@ -303,11 +305,23 @@ export async function syncPush(name) {
|
|
|
303
305
|
skipped.push(s.name);
|
|
304
306
|
continue;
|
|
305
307
|
}
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
308
|
+
const rawContent = fs.readFileSync(s.file, 'utf8');
|
|
309
|
+
if (!rawContent.trim()) {
|
|
308
310
|
skipped.push(s.name); // 空会话文件(刚创建未写消息)不推送
|
|
309
311
|
continue;
|
|
310
312
|
}
|
|
313
|
+
// v0.6.3(审计 H-2):**上传前必须脱敏**。
|
|
314
|
+
//
|
|
315
|
+
// 会话原文会原样记录用户粘贴的 `sk-*`、PEM、JWT、`AWS_SECRET_ACCESS_KEY=…`;
|
|
316
|
+
// 而账本与审计日志都做到了「写入即脱敏」,唯独**信息量最大的会话原文两边都不脱敏**,
|
|
317
|
+
// 且自动同步默认开启——于是用户配置过同步/配对后,这些凭据会以**明文上传到同步服务器**。
|
|
318
|
+
//
|
|
319
|
+
// 本地文件保持原样(用户自己的会话不该被改写),只在**出网前**这一层脱敏:
|
|
320
|
+
// 远端拿到的是掩码版,其它设备拉回来也是掩码版——这正是我们要的语义。
|
|
321
|
+
// 冲突判定仍用脱敏后的文本(与远端同一口径),否则每次同步都会被判成"内容不同"。
|
|
322
|
+
const content = redactSecrets(rawContent);
|
|
323
|
+
const redacted = content !== rawContent;
|
|
324
|
+
if (redacted) redactedSessions.push(s.name);
|
|
311
325
|
if (Buffer.byteLength(content) > 19 * 1024 * 1024) {
|
|
312
326
|
return { error: `${s.name} 超过 20MB 上限,跳过同步` };
|
|
313
327
|
}
|
|
@@ -337,7 +351,7 @@ export async function syncPush(name) {
|
|
|
337
351
|
}
|
|
338
352
|
}
|
|
339
353
|
commitState(delta);
|
|
340
|
-
return { ok: true, pushed, conflicts, skipped };
|
|
354
|
+
return { ok: true, pushed, conflicts, skipped, redacted: redactedSessions };
|
|
341
355
|
}
|
|
342
356
|
|
|
343
357
|
// 拉取单个/全部会话。本地有不同内容时保留本地,远端写入 .remote- 副本。
|
package/src/task-state.js
CHANGED
|
@@ -15,7 +15,16 @@ function taskStateDir() {
|
|
|
15
15
|
|
|
16
16
|
/** @param {any} sessionName */
|
|
17
17
|
export function taskStateFile(sessionName) {
|
|
18
|
-
|
|
18
|
+
const dir = taskStateDir();
|
|
19
|
+
const file = path.join(dir, `${String(sessionName)}.json`);
|
|
20
|
+
// v0.6.3(BUG-067):**包含性检查**。sessionName 直接来自调用方(会话文件名/CLI 参数),
|
|
21
|
+
// 此前不校验:`../../x` 会把检查点写到仓库之外(读写任意 .json)。
|
|
22
|
+
// 判据用 path.relative 而不是"过滤 ../"——后者挡不住绝对路径、Windows 盘符与符号链接归一后的形态。
|
|
23
|
+
const rel = path.relative(dir, file);
|
|
24
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
25
|
+
throw new Error(`会话名非法(会越出检查点目录):${String(sessionName)}`);
|
|
26
|
+
}
|
|
27
|
+
return file;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/** @param {any} sessionName */
|
package/src/tasks.js
CHANGED
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { relativeTime } from './session.js';
|
|
10
|
-
import { atomicWriteFileSync, withFileLockSync } from './atomic-write.js';
|
|
10
|
+
import { atomicWriteFileSync, atomicWritePrivateSync, withFileLockSync } from './atomic-write.js';
|
|
11
11
|
import { procAlive, pidOwnedBy, spawnOpts } from './proc.js';
|
|
12
12
|
|
|
13
13
|
const CLI_PATH = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
@@ -55,10 +55,10 @@ export function readTask(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function writeTask(/** @type {any} */ home, /** @type {any} */ task) {
|
|
58
|
-
fs.mkdirSync(tasksDir(home), { recursive: true });
|
|
58
|
+
fs.mkdirSync(tasksDir(home), { recursive: true, mode: 0o700 });
|
|
59
59
|
// 原子写:先临时文件再改名,避免崩溃留下半截 JSON
|
|
60
60
|
const target = path.join(tasksDir(home), task.id + '.json');
|
|
61
|
-
|
|
61
|
+
atomicWritePrivateSync(target, JSON.stringify(task, null, 2) + '\n'); // 质检 H4:tmp 名含 pid+随机;0600(含提问原文)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function isValidTaskId(/** @type {any} */ id) {
|
|
@@ -247,11 +247,17 @@ export async function flushKillEscalation() {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
export function killTask(/** @type {any} */ home, /** @type {any} */ id) {
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
250
|
+
// v0.6.2(P2-7 阻塞面):进程操作全部移到锁**外**,临界区只剩状态读-改-写。
|
|
251
|
+
//
|
|
252
|
+
// 原实现把 killTaskInner 整个包在锁里,而它内部会:
|
|
253
|
+
// · pidOwnedBy() —— 非 Linux 上回退到**同步** execFileSync('ps');
|
|
254
|
+
// · killTree() —— Windows 上走**同步** spawnSync('taskkill');
|
|
255
|
+
// 于是临界区里夹着外部进程调用,持锁时间从毫秒级变成百毫秒级,**每个等锁的人都被拖住**。
|
|
256
|
+
// 实测(本机)纯状态读-改-写约 0.13ms、最坏的 cache-stats 轮转也只有 6ms——
|
|
257
|
+
// 也就是说,锁被长时间持有完全是自己把慢操作放进去造成的。
|
|
258
|
+
//
|
|
259
|
+
// 语义不变:状态写回仍在锁内(与 worker 的终态写互斥),P3 T20 的「killed 优先」保护
|
|
260
|
+
// 仍在 patchTask 里;把进程操作提前到锁外不影响任何一条竞态路径的最终状态。
|
|
255
261
|
const t = readTask(home, id);
|
|
256
262
|
if (!t) return false;
|
|
257
263
|
if (t.status === 'running' && t.pid) {
|
|
@@ -269,8 +275,14 @@ function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
269
275
|
pendingKill = escalateKill(t.pid);
|
|
270
276
|
}
|
|
271
277
|
}
|
|
272
|
-
|
|
273
|
-
return
|
|
278
|
+
// 状态写回:仍在跨进程锁内(只做读-改-写,毫秒级)
|
|
279
|
+
return killTaskInner(home, id) !== null;
|
|
280
|
+
}
|
|
281
|
+
/** 临界区内的状态写回(进程部分见 killTask,已移到锁外) */
|
|
282
|
+
function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
|
|
283
|
+
const t = readTask(home, id);
|
|
284
|
+
if (!t) return null;
|
|
285
|
+
return patchTask(home, id, { status: 'killed', durationMs: t.durationMs ?? Date.now() - t.startedAt });
|
|
274
286
|
}
|
|
275
287
|
|
|
276
288
|
const MARK = { running: '▶', done: '✓', failed: '✖', killed: '■' };
|
package/src/tools/bash.js
CHANGED
|
@@ -86,9 +86,89 @@ function foldRepeats(/** @type {any} */ s) {
|
|
|
86
86
|
}
|
|
87
87
|
return out.join('\n');
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* 子进程输出解码:**一次**解整段字节,而不是逐块 `toString()`。
|
|
91
|
+
*
|
|
92
|
+
* 两处乱码根因(v0.6.3,桌面版实测):
|
|
93
|
+
* ① 逐块解码:中文 3 字节被管道切成两半 → 两半各自解出 U+FFFD;
|
|
94
|
+
* ② Windows 上本工具走 `cmd.exe /d /s /c`,输出是 OEM 代码页(中文 = GBK/CP936),
|
|
95
|
+
* 按 UTF-8 解就是花屏。故严格 UTF-8 失败时用 GBK 再解一次。
|
|
96
|
+
* 导出的目的是让这两类乱码能被**确定性**测到——②只在 Windows 出现,CI 上跑不出真环境。
|
|
97
|
+
* @param {Buffer} buf
|
|
98
|
+
*/
|
|
99
|
+
export function decodeProcessOutput(buf) {
|
|
100
|
+
if (!buf || !buf.length) return '';
|
|
101
|
+
try {
|
|
102
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
103
|
+
} catch {
|
|
104
|
+
try {
|
|
105
|
+
return new TextDecoder('gbk').decode(buf);
|
|
106
|
+
} catch {
|
|
107
|
+
return buf.toString('utf8');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 子进程输出采集器(**导出以便确定性测试**)。
|
|
114
|
+
*
|
|
115
|
+
* 为什么单独成一个原语:乱码的两个根因都藏在这里,而"端到端跑一个大输出"根本测不准——
|
|
116
|
+
* 输出上限(约 60KB)比一个管道块(64KB)还小,保留的尾部常常落在**单块之内**,
|
|
117
|
+
* 跨块解码的缺陷于是照样通过(第一版回归就是这么假绿的,靠变异验证才发现)。
|
|
118
|
+
* 把 push/text 暴露出来,测试就能**人工把汉字切成两半**喂进去。
|
|
119
|
+
*
|
|
120
|
+
* @param {number} maxBytes 只保留尾部这么多字节(超出丢弃头部)
|
|
121
|
+
*/
|
|
122
|
+
export function createOutputCapture(maxBytes) {
|
|
123
|
+
/** @type {{chunks: Buffer[], bytes: number}} */
|
|
124
|
+
const st = { chunks: [], bytes: 0 };
|
|
125
|
+
const snapBoundary = () => {
|
|
126
|
+
// **对齐到字符边界**:按字节裁会把 UTF-8 从字符中间切断,于是"整段严格解码"必然失败
|
|
127
|
+
// → 误触发 GBK 回退 → 反而解成花屏(本修复第一版的真实缺陷,被单测抓出来的)。
|
|
128
|
+
// UTF-8 的续字节形如 10xxxxxx,丢掉它们即可让缓冲区从**前导字节**开始。
|
|
129
|
+
while (st.chunks.length && st.chunks[0].length && (st.chunks[0][0] & 0xc0) === 0x80) {
|
|
130
|
+
st.chunks[0] = st.chunks[0].subarray(1);
|
|
131
|
+
st.bytes -= 1;
|
|
132
|
+
if (!st.chunks[0].length) st.chunks.shift();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
/** @param {Buffer} d */
|
|
137
|
+
push(d) {
|
|
138
|
+
st.chunks.push(d);
|
|
139
|
+
st.bytes += d.length;
|
|
140
|
+
while (st.bytes > maxBytes && st.chunks.length) {
|
|
141
|
+
const first = st.chunks[0];
|
|
142
|
+
const drop = Math.min(first.length, st.bytes - maxBytes);
|
|
143
|
+
if (drop >= first.length) {
|
|
144
|
+
st.chunks.shift();
|
|
145
|
+
st.bytes -= first.length;
|
|
146
|
+
} else {
|
|
147
|
+
st.chunks[0] = first.subarray(drop);
|
|
148
|
+
st.bytes -= drop;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
snapBoundary();
|
|
152
|
+
},
|
|
153
|
+
/** 结束时**一次**解码整段字节(绝不能逐块 toString:会把汉字劈成 U+FFFD) */
|
|
154
|
+
text() {
|
|
155
|
+
return st.chunks.length ? decodeProcessOutput(Buffer.concat(st.chunks)) : '';
|
|
156
|
+
},
|
|
157
|
+
get bytes() {
|
|
158
|
+
return st.bytes;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
89
163
|
function tail(/** @type {any} */ s, /** @type {any} */ n) {
|
|
90
164
|
const folded = foldRepeats(stripAnsi(s));
|
|
91
|
-
|
|
165
|
+
if (folded.length <= n) return folded;
|
|
166
|
+
let t = folded.slice(-n);
|
|
167
|
+
// 不要把**代理对**劈成两半:BMP 以外的字符(emoji 等)被切一半会渲染成 U+FFFD。
|
|
168
|
+
// 与 context.js 的同类问题同源(登记簿 BUG-079)。
|
|
169
|
+
const c0 = t.charCodeAt(0);
|
|
170
|
+
if (c0 >= 0xdc00 && c0 <= 0xdfff) t = t.slice(1);
|
|
171
|
+
return `…[输出过长,已截断头部]\n${t}`;
|
|
92
172
|
}
|
|
93
173
|
|
|
94
174
|
export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
@@ -140,15 +220,17 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
140
220
|
detached: true, // POSIX:自成进程组,超时/结束可整组清理,孙进程不成孤儿
|
|
141
221
|
...spawnOpts({ piped: true }), // Windows:不 detach + 隐藏控制台(否则每次命令弹一个终端)
|
|
142
222
|
});
|
|
143
|
-
|
|
144
|
-
|
|
223
|
+
// v0.6.3(桌面版 bash 输出乱码,两处根因):
|
|
224
|
+
// ① **不能逐块 `toString()`**:中文 3 字节,被管道切成两半时两半各自解出 U+FFFD(乱码)。
|
|
225
|
+
// 改为按 **Buffer** 累积、结束时**一次**解码。
|
|
226
|
+
// ② **Windows 上这里走的是 `cmd.exe /d /s /c`**,其输出是 OEM 代码页(中文 = GBK/CP936),
|
|
227
|
+
// 按 UTF-8 解就是花屏("回访"→"»Ø·Ã")。故严格 UTF-8 解失败时用 GBK 再解一次。
|
|
228
|
+
// Node 官方构建带 full-icu,`new TextDecoder('gbk')` 可用;不支持时退回非严格 UTF-8。
|
|
229
|
+
const MAX_BYTES = MAX_OUTPUT * 2; // 按字节预留(中文 1 字 ≈ 3 字节)
|
|
230
|
+
const outCap = createOutputCapture(MAX_BYTES);
|
|
231
|
+
const errCap = createOutputCapture(MAX_BYTES);
|
|
145
232
|
let done = false;
|
|
146
233
|
let timedOut = false;
|
|
147
|
-
// 输出增量截断:超长输出只保留尾部,避免内存无限累积
|
|
148
|
-
const cap = (/** @type {any} */ s, /** @type {any} */ d) => {
|
|
149
|
-
const t = s + d;
|
|
150
|
-
return t.length > MAX_OUTPUT * 2 ? t.slice(-MAX_OUTPUT * 2) : t;
|
|
151
|
-
};
|
|
152
234
|
const killGroup = (/** @type {any} */ sig) => {
|
|
153
235
|
try {
|
|
154
236
|
process.kill(-(/** @type {any} */ (child)).pid, sig);
|
|
@@ -175,17 +257,13 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
175
257
|
timedOut,
|
|
176
258
|
sandbox,
|
|
177
259
|
note: timedOut ? '命令超时,已强杀进程组' : '输出管道未释放,已清理子进程组',
|
|
178
|
-
stdout: tail(
|
|
179
|
-
stderr: tail(
|
|
260
|
+
stdout: tail(outCap.text(), MAX_OUTPUT),
|
|
261
|
+
stderr: tail(errCap.text(), MAX_OUTPUT),
|
|
180
262
|
});
|
|
181
263
|
}, timeoutSec * 1000 + 3000);
|
|
182
264
|
|
|
183
|
-
child.stdout.on('data', (d) =>
|
|
184
|
-
|
|
185
|
-
});
|
|
186
|
-
child.stderr.on('data', (d) => {
|
|
187
|
-
err = cap(err, d);
|
|
188
|
-
});
|
|
265
|
+
child.stdout.on('data', (d) => outCap.push(d));
|
|
266
|
+
child.stderr.on('data', (d) => errCap.push(d));
|
|
189
267
|
child.on('error', (e) => {
|
|
190
268
|
if (done) return;
|
|
191
269
|
done = true;
|
|
@@ -204,8 +282,8 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
204
282
|
timedOut,
|
|
205
283
|
sandbox,
|
|
206
284
|
note: note || undefined,
|
|
207
|
-
stdout: tail(
|
|
208
|
-
stderr: tail(
|
|
285
|
+
stdout: tail(outCap.text(), MAX_OUTPUT),
|
|
286
|
+
stderr: tail(errCap.text(), MAX_OUTPUT),
|
|
209
287
|
});
|
|
210
288
|
});
|
|
211
289
|
});
|
package/src/tools/fetch.js
CHANGED
|
@@ -63,6 +63,28 @@ export function isPrivateHost(/** @type {string} */ hostname) {
|
|
|
63
63
|
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);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// v0.6.3(P1-8):**云元数据端点无条件拒绝**——它不属于"内网可信服务"那一类,
|
|
67
|
+
// 任何开关(web.allowPrivateEndpoints / allowPrivate)都不该放行:那里放的是实例凭据。
|
|
68
|
+
// 各家元数据的明文地址(AWS/GCP/Azure/Oracle 169.254.169.254、腾讯 169.254.0.23、
|
|
69
|
+
// 阿里 100.100.100.200、ECS 任务元数据 169.254.170.2、IPv6 fd00:ec2::254)与
|
|
70
|
+
// Google 的 DNS 名。放在这里而不是各调用点,是因为"单一来源"正是这条防线此前的失守方式。
|
|
71
|
+
const METADATA_HOSTS = new Set([
|
|
72
|
+
'169.254.169.254',
|
|
73
|
+
'169.254.0.23',
|
|
74
|
+
'169.254.170.2',
|
|
75
|
+
'100.100.100.200',
|
|
76
|
+
'fd00:ec2::254',
|
|
77
|
+
'metadata.google.internal',
|
|
78
|
+
'metadata.goog',
|
|
79
|
+
]);
|
|
80
|
+
/** @param {string} hostname */
|
|
81
|
+
export function isMetadataHost(hostname) {
|
|
82
|
+
const h = String(hostname || '')
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/^\[|\]$/g, '');
|
|
85
|
+
return METADATA_HOSTS.has(h);
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
// IP 字面量判定(含 IPv6):用于跳过 DNS 复检分支(此前只认点分 IPv4,导致带方括号的 IPv6
|
|
67
89
|
// 字面量被送进 lookup 失败后「放行」)。
|
|
68
90
|
/** @param {string} h */
|
|
@@ -81,6 +103,7 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
|
|
|
81
103
|
}
|
|
82
104
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: '仅支持 http/https 地址。' };
|
|
83
105
|
const host = String(u.hostname || '').toLowerCase();
|
|
106
|
+
if (isMetadataHost(host)) return { ok: false, error: `拒绝访问云元数据端点(${host})——SSRF 防护,此地址无任何放行开关。` };
|
|
84
107
|
let blocked = isPrivateHost(host);
|
|
85
108
|
if (!blocked && host && host !== 'localhost' && !isIpLiteral(host)) {
|
|
86
109
|
try {
|
|
@@ -100,6 +123,7 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
|
|
|
100
123
|
let res = /** @type {any} */ (null);
|
|
101
124
|
for (let hop = 0; hop <= 5; hop++) {
|
|
102
125
|
const ch = String(cur.hostname || '').toLowerCase();
|
|
126
|
+
if (isMetadataHost(ch)) return { ok: false, error: `拒绝访问云元数据端点(${ch})——SSRF 重定向防护。` };
|
|
103
127
|
let hopBlocked = isPrivateHost(ch);
|
|
104
128
|
if (!hopBlocked && ch && ch !== 'localhost' && !isIpLiteral(ch)) {
|
|
105
129
|
try {
|
package/src/web/app.js
CHANGED
|
@@ -388,7 +388,7 @@ async function send(){
|
|
|
388
388
|
else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); reason.dataset.full=''; } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.dataset.full+=ev.delta; const full=reason.dataset.full; reason.textContent=(full.length>9000?full.slice(-9000)+'\n…(思考内容较长,仅显示末尾;已 '+full.length+' 字符)':full); reason.scrollTop=reason.scrollHeight; scroll(); }
|
|
389
389
|
else if(ev.type==='turnStart'){ stepsCount+=1; curSteps+=1; msg._steps=curSteps; curPhase='模型推理中'; msg._traj.push({kind:'turn', t:Date.now()}); showThink(); think.textContent='💭 第 '+msg._steps+' 回合:模型推理中…'; renderWorkStatus(); scroll(); }
|
|
390
390
|
else if(ev.type==='turnEnd'){ curPhase='回合完成,进入下一回合'; showThink(); think.textContent='⏸ 第 '+msg._steps+' 回合完成(累计 '+(ev.toolSteps||0)+' 工具步)· 进入下一回合…'; renderWorkStatus(); }
|
|
391
|
-
else if(ev.type==='toolStart'){ stepsCount+=1; curSteps+=1; curPhase=ev.name==='task'?'
|
|
391
|
+
else if(ev.type==='toolStart'){ stepsCount+=1; curSteps+=1; curPhase=ev.name==='task'?'子 Agent 执行中':'执行工具中'; msg._steps=curSteps; msg._traj.push({kind:'tool', seq:ev.seq, name:ev.name, args:ev.args, t:Date.now(), done:false}); onActivity(); update(); renderToolStartEvent(ev); renderWorkStatus(); }
|
|
392
392
|
else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; insertBeforeActiveMsg(pre); scroll(); }
|
|
393
393
|
else if(ev.type==='tool'){ onActivity(); update(); const pending=runningTools.get(ev.seq); if(pending){ pending.remove(); runningTools.delete(ev.seq); } const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.result=ev.result; tj.durationMs=ev.durationMs; tj.card=pending||null; } if(ev.name==='task'){ sessionSubs.push({seq:ev.seq, question:String(ev.args?.question||ev.args?.prompt||''), result:ev.result, durationMs:ev.durationMs, msg}); renderSubPanel(); } renderToolEvent(ev); }
|
|
394
394
|
else if(ev.type==='toolDenied'){ onActivity(); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.denied=ev.reason||'未授权'; } const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
|
|
@@ -422,7 +422,7 @@ function renderWorkStatus(){
|
|
|
422
422
|
let tip='';
|
|
423
423
|
if(generating){
|
|
424
424
|
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
425
|
-
html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ 第 '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+'
|
|
425
|
+
html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ 第 '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子 Agent':'')+'</span></span>';
|
|
426
426
|
} else if(bgRunning>0){
|
|
427
427
|
// 后台任务 chip(非顶部,位于输入框上方):计数 + 最新任务 + 悬浮详情 tooltip,点击打开详情面板
|
|
428
428
|
const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
|
|
@@ -441,7 +441,7 @@ function renderLiveBar(){
|
|
|
441
441
|
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
442
442
|
el.style.display='flex';
|
|
443
443
|
el.innerHTML='<span class="spinner"></span><span class="lb-phase">正在执行:'+esc(curPhase)+'</span>'+
|
|
444
|
-
'<span class="lb-stat">第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+'
|
|
444
|
+
'<span class="lb-stat">第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子 Agent':'')+'</span>';
|
|
445
445
|
}
|
|
446
446
|
// 轨迹元行:任务完成后附在本轮消息顶部(步数/子代理数,点击打开轨迹面板)
|
|
447
447
|
function attachTrajMeta(msg){
|
|
@@ -450,7 +450,7 @@ function attachTrajMeta(msg){
|
|
|
450
450
|
const subs=tools.filter(x=>x.name==='task');
|
|
451
451
|
const meta=document.createElement('div'); meta.className='msg-meta';
|
|
452
452
|
const b=document.createElement('button'); b.className='trajBtn';
|
|
453
|
-
b.textContent='🧭 轨迹:'+(msg._steps||tools.length)+' 步 · '+subs.length+'
|
|
453
|
+
b.textContent='🧭 轨迹:'+(msg._steps||tools.length)+' 步 · '+subs.length+' 个子 Agent';
|
|
454
454
|
b.onclick=()=>openTraj(msg);
|
|
455
455
|
meta.appendChild(b);
|
|
456
456
|
msg.insertBefore(meta, msg.firstChild);
|
|
@@ -459,7 +459,7 @@ function openTraj(msg){
|
|
|
459
459
|
const list=$('#tjList'); list.innerHTML='';
|
|
460
460
|
const tools=msg._traj.filter(x=>x.kind==='tool');
|
|
461
461
|
const subs=tools.filter(x=>x.name==='task');
|
|
462
|
-
$('#tjSummary').textContent='('+(msg._steps||tools.length)+' 步 · '+subs.length+'
|
|
462
|
+
$('#tjSummary').textContent='('+(msg._steps||tools.length)+' 步 · '+subs.length+' 个子 Agent)';
|
|
463
463
|
let turnN=0;
|
|
464
464
|
for(const e of msg._traj){
|
|
465
465
|
if(e.kind==='turn'){ turnN+=1;
|
|
@@ -470,7 +470,7 @@ function openTraj(msg){
|
|
|
470
470
|
const ico={'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'🤖','skill':'🧩','todo':'☑','undo':'↩'}[e.name]||'🔌';
|
|
471
471
|
const args = e.name==='bash' ? (e.args&&e.args.command) : (e.args&&(e.args.path||e.args.pattern||e.args.name||e.args.question||''))||'';
|
|
472
472
|
const div=document.createElement('div'); div.className='tj-item'+(isSub?' tj-sub':'');
|
|
473
|
-
const subLabel=isSub?'
|
|
473
|
+
const subLabel=isSub?'子 Agent:':'';
|
|
474
474
|
div.innerHTML='<div class="tj-head"><span>'+ico+'</span><span style="white-space:nowrap">'+esc(subLabel+e.name)+'</span><span class="tj-args">'+esc(String(args).slice(0,80))+'</span><span style="margin-left:auto;color:var(--faint)">'+(e.done?(e.denied?'✖ '+esc(e.denied):'✓'+(e.durationMs!=null?' '+e.durationMs+'ms':'')):'执行中…')+'</span></div>';
|
|
475
475
|
const body=document.createElement('div'); body.className='tj-body';
|
|
476
476
|
if(isSub){
|
|
@@ -534,7 +534,7 @@ function renderSubPanel(){
|
|
|
534
534
|
div.onclick=(e)=>{ if(e.target.closest('.sb-body')) return; div.classList.toggle('open'); };
|
|
535
535
|
list.appendChild(div);
|
|
536
536
|
}
|
|
537
|
-
if(!count) list.innerHTML='<div class="empty" style="color:var(--faint);font-size:12px;padding:10px"
|
|
537
|
+
if(!count) list.innerHTML='<div class="empty" style="color:var(--faint);font-size:12px;padding:10px">本会话还没有子 Agent(task 工具会派生子 Agent)</div>';
|
|
538
538
|
}
|
|
539
539
|
const toggleSubPanel=()=>{
|
|
540
540
|
const p=$('#subPanel');
|
package/src/web/attachments.js
CHANGED
|
@@ -23,7 +23,13 @@ export function buildUserContent(/** @type {any} */ message, /** @type {any} */
|
|
|
23
23
|
return { error: `图片过大:${a.name || '未命名'}(单张 ≤5MB)` };
|
|
24
24
|
}
|
|
25
25
|
if (!visionSupported) {
|
|
26
|
-
return {
|
|
26
|
+
return {
|
|
27
|
+
error:
|
|
28
|
+
'当前模型不支持图片输入。三种开启方式:' +
|
|
29
|
+
'① 切换到支持视觉的内置模型(deepseek-v4-flash-vision-exp);' +
|
|
30
|
+
'② 在 config.customModels.<模型名>.vision = true 显式声明(纯能力声明,不会改变请求发往哪里);' +
|
|
31
|
+
'③ 自定义 Provider 模块(~/.mingdao/providers/<provider>.mjs)里 export const supportsVision = true。',
|
|
32
|
+
};
|
|
27
33
|
}
|
|
28
34
|
imageParts.push({ type: 'image_url', image_url: { url: dataUrl } });
|
|
29
35
|
persistParts.push(`[图片:${a.name || '未命名'}]`);
|
package/src/web/index.html
CHANGED
|
@@ -281,10 +281,10 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
281
281
|
</header>
|
|
282
282
|
<div id="trajRail">
|
|
283
283
|
<button class="rail-btn" id="trajRailBtn" title="轨迹(点击查看详情)">🧭</button>
|
|
284
|
-
<button class="rail-btn" id="subRailBtn" title="
|
|
284
|
+
<button class="rail-btn" id="subRailBtn" title="子 Agent">🤖<span class="rail-badge" id="subBadge" style="display:none"></span></button>
|
|
285
285
|
</div>
|
|
286
286
|
<aside id="subPanel" style="display:none">
|
|
287
|
-
<div class="sb-head">🤖
|
|
287
|
+
<div class="sb-head">🤖 子 Agent<span id="sbCount" style="font-size:11.5px;color:var(--faint);font-weight:400"></span><button id="sbClose">关闭</button></div>
|
|
288
288
|
<div id="sbList"></div>
|
|
289
289
|
</aside>
|
|
290
290
|
<aside id="trajPanel" style="display:none">
|
package/src/web/routes/api.js
CHANGED
|
@@ -40,10 +40,33 @@ export function createApiDispatch(deps) {
|
|
|
40
40
|
return json(res, 403, { error: 'Host 校验失败:请通过绑定地址访问(DNS rebinding 防护)' });
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
43
|
+
// v0.6.3(H-5 / H-6):**跨站浏览器请求一律拒绝**。
|
|
44
|
+
//
|
|
45
|
+
// 威胁模型:默认 `mingdao web`(回环且未配置令牌)不校验令牌,靠"本机信任"。但浏览器的
|
|
46
|
+
// 同源策略**不保护本机端口**:任意网页都能用 `<img>` / `fetch(...,{mode:'no-cors'})` 发起
|
|
47
|
+
// 盲打请求(读不到响应,但副作用已经发生),而这类请求不带 Origin、也无法靠 Content-Type
|
|
48
|
+
// 拦截——这正是 H-6 里"读取即删除的 GET /api/draft""带外呼的 GET /api/sync?refresh"的真实入口。
|
|
49
|
+
//
|
|
50
|
+
// `Sec-Fetch-Site` 是浏览器强制附加的**元数据头**,页面脚本无法伪造(属于 forbidden header):
|
|
51
|
+
// · same-origin —— SPA 自己的请求(正常路径)
|
|
52
|
+
// · none —— 地址栏直接访问 / 书签(正常路径)
|
|
53
|
+
// · cross-site —— 其它站点发起的请求,**含 img/no-cors 盲打**
|
|
54
|
+
// · same-site —— 同站但跨源(如子域):同样按跨站处理
|
|
55
|
+
// 因此拒绝 cross-site/same-site 就等于一次性关掉整条浏览器攻击面,且不改任何既有 UX。
|
|
56
|
+
// 老浏览器不发这个头 → 缺失时按既有策略处理(不引入新的硬依赖)。
|
|
57
|
+
const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
|
|
58
|
+
const isStaticShell = p === '/' || p === '/index.html' || p === '/app.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/manifest.webmanifest' || p === '/sw.js';
|
|
59
|
+
const isNavigation = (method === 'GET' || method === 'HEAD') && isStaticShell;
|
|
60
|
+
if ((site === 'cross-site' || site === 'same-site') && !isNavigation) {
|
|
61
|
+
return json(res, 403, { error: '跨站请求被拒绝(Sec-Fetch-Site)。请从本机打开 WebUI 地址,不要从其它网页发起请求。' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// CSRF 防护:Origin 校验覆盖**所有方法**(原先只覆盖非 GET)。同源 GET 不带 Origin,
|
|
65
|
+
// 所以这条扩展不会误伤正常页面;它拦的是"带 Origin 的跨源 GET"(另一种盲打形态)。
|
|
66
|
+
// POST 另需 JSON Content-Type(拦截表单/纯文本跨站盲提交)。
|
|
44
67
|
// v0.4.7(T21):HEAD 是安全方法(无请求体),此前落入「必须带 JSON Content-Type」分支 →
|
|
45
68
|
// 对 /api/state 等做 HEAD 探活会拿到 415,破坏监控/反代脚本。与 GET 同等对待。
|
|
46
|
-
|
|
69
|
+
{
|
|
47
70
|
const origin = req.headers.origin;
|
|
48
71
|
if (origin) {
|
|
49
72
|
try {
|
|
@@ -52,6 +75,8 @@ export function createApiDispatch(deps) {
|
|
|
52
75
|
return json(res, 403, { error: '非法 Origin' });
|
|
53
76
|
}
|
|
54
77
|
}
|
|
78
|
+
}
|
|
79
|
+
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
|
55
80
|
const ct = String(req.headers['content-type'] || '');
|
|
56
81
|
if (!ct.includes('application/json') && !ct.includes('text/plain')) {
|
|
57
82
|
return json(res, 415, { error: '仅接受 JSON 请求体' });
|