mingdao-harness 0.2.4 → 0.2.6
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/EVALUATION-ROUND2.md +58 -0
- package/docs/ROADMAP-NEXT.md +5 -5
- package/package.json +2 -2
- package/src/agent.js +74 -36
- package/src/atomic-write.js +8 -2
- package/src/audit.js +2 -2
- package/src/batch.js +7 -7
- package/src/cachestats.js +9 -9
- package/src/cli.js +42 -688
- 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 +37 -21
- package/src/cost-guard.js +14 -6
- 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 +13 -11
- package/src/routing.js +8 -8
- package/src/schedule.js +30 -30
- 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 +14 -12
- package/src/sync-server.js +86 -3
- package/src/sync.js +53 -20
- package/src/tasks/worker.js +126 -0
- package/src/tasks.js +14 -14
- package/src/titles.js +5 -5
- package/src/tokenizer.js +23 -5
- 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/app.js +16 -5
- package/src/web/attachments.js +1 -1
- package/src/web/index.html +21 -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 +129 -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 +119 -0
- package/src/web/server.js +73 -31
- package/src/web/web-io.js +54 -12
- package/src/workspace.js +14 -14
package/src/skill-lib.js
CHANGED
|
@@ -22,8 +22,13 @@ const LIB_DIR = fileURLToPath(new URL('../skills-lib', import.meta.url));
|
|
|
22
22
|
// ---------- 完整性(P3-3):技能目录联合哈希 ----------
|
|
23
23
|
// 对目录内全部文件(排除 .mingdao-source.json 自身)按相对路径排序,
|
|
24
24
|
// 逐文件 sha256 后拼成 "rel:hash\n" 再取一次 sha256,作为目录内容指纹。
|
|
25
|
+
/**
|
|
26
|
+
* @param {any} dir
|
|
27
|
+
*/
|
|
25
28
|
export function skillDirHash(dir) {
|
|
29
|
+
/** @type {any[]} */
|
|
26
30
|
const out = [];
|
|
31
|
+
/** @type {(d: any, base: any) => void} */
|
|
27
32
|
const walk = (d, base) => {
|
|
28
33
|
let entries;
|
|
29
34
|
try {
|
|
@@ -47,6 +52,9 @@ export function skillDirHash(dir) {
|
|
|
47
52
|
.digest('hex');
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
/**
|
|
56
|
+
* @param {any} dir
|
|
57
|
+
*/
|
|
50
58
|
export function readSourceMeta(dir) {
|
|
51
59
|
try {
|
|
52
60
|
return JSON.parse(fs.readFileSync(path.join(dir, '.mingdao-source.json'), 'utf8'));
|
|
@@ -55,11 +63,18 @@ export function readSourceMeta(dir) {
|
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @param {any} dir
|
|
68
|
+
* @param {any} meta
|
|
69
|
+
*/
|
|
58
70
|
export function writeSourceMeta(dir, meta) {
|
|
59
71
|
fs.writeFileSync(path.join(dir, '.mingdao-source.json'), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 });
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
// 显式信任当前内容(用户改过 registry/library 安装的技能后重新记录指纹)
|
|
75
|
+
/**
|
|
76
|
+
* @param {any} name
|
|
77
|
+
*/
|
|
63
78
|
export function trustSkill(name) {
|
|
64
79
|
const target = path.join(userSkillsDir(), name);
|
|
65
80
|
const meta = readSourceMeta(target);
|
|
@@ -79,6 +94,10 @@ export function userSkillsDir() {
|
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
// 从技能目录读取 name / description(frontmatter 优先,回退标题/目录名)
|
|
97
|
+
/**
|
|
98
|
+
* @param {any} dir
|
|
99
|
+
* @returns {any}
|
|
100
|
+
*/
|
|
82
101
|
function readSkillMeta(dir) {
|
|
83
102
|
try {
|
|
84
103
|
const text = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8');
|
|
@@ -116,6 +135,7 @@ export function installedUserSkillNames() {
|
|
|
116
135
|
|
|
117
136
|
export function libraryList() {
|
|
118
137
|
const installed = installedUserSkillNames();
|
|
138
|
+
/** @type {any[]} */
|
|
119
139
|
const out = [];
|
|
120
140
|
let entries;
|
|
121
141
|
try {
|
|
@@ -132,6 +152,9 @@ export function libraryList() {
|
|
|
132
152
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
133
153
|
}
|
|
134
154
|
|
|
155
|
+
/**
|
|
156
|
+
* @param {any} kw
|
|
157
|
+
*/
|
|
135
158
|
export function searchLibrary(kw) {
|
|
136
159
|
const k = String(kw || '').trim().toLowerCase();
|
|
137
160
|
return libraryList().filter(
|
|
@@ -144,6 +167,10 @@ export function searchLibrary(kw) {
|
|
|
144
167
|
// - name:字母/数字/连字符/点/下划线,1–64 位
|
|
145
168
|
// - description:非空且 ≤ 200 字
|
|
146
169
|
// 校验失败返回具体错误,绝不装入技能目录。
|
|
170
|
+
/**
|
|
171
|
+
* @param {any} text
|
|
172
|
+
* @param {any} hint
|
|
173
|
+
*/
|
|
147
174
|
export function validateSkillMarkdown(text, hint) {
|
|
148
175
|
const src = hint ? `(${hint})` : '';
|
|
149
176
|
if (!text || !String(text).trim()) return { error: `SKILL.md 为空${src}` };
|
|
@@ -159,6 +186,10 @@ export function validateSkillMarkdown(text, hint) {
|
|
|
159
186
|
return { ok: true, name, description: desc };
|
|
160
187
|
}
|
|
161
188
|
|
|
189
|
+
/**
|
|
190
|
+
* @param {any} dir
|
|
191
|
+
* @param {any} hint
|
|
192
|
+
*/
|
|
162
193
|
export function validateSkillDir(dir, hint) {
|
|
163
194
|
const skillMd = path.join(dir, 'SKILL.md');
|
|
164
195
|
let text;
|
|
@@ -170,6 +201,12 @@ export function validateSkillDir(dir, hint) {
|
|
|
170
201
|
return validateSkillMarkdown(text, hint);
|
|
171
202
|
}
|
|
172
203
|
|
|
204
|
+
/**
|
|
205
|
+
* @param {any} dir
|
|
206
|
+
* @param {any} name
|
|
207
|
+
* @param {any} source
|
|
208
|
+
* @param {any} extra
|
|
209
|
+
*/
|
|
173
210
|
function copySkillIntoUser(dir, name, source, extra) {
|
|
174
211
|
ensureHome();
|
|
175
212
|
const target = path.join(userSkillsDir(), name);
|
|
@@ -188,12 +225,18 @@ function copySkillIntoUser(dir, name, source, extra) {
|
|
|
188
225
|
return { name, dir: target };
|
|
189
226
|
}
|
|
190
227
|
|
|
228
|
+
/**
|
|
229
|
+
* @param {any} name
|
|
230
|
+
*/
|
|
191
231
|
export function installFromLibrary(name) {
|
|
192
232
|
const found = libraryList().find((s) => s.name === name);
|
|
193
233
|
if (!found) return { error: `技能库中没有 ${name}(mingdao skill search 查看全部)` };
|
|
194
234
|
return copySkillIntoUser(found.dir, found.name, 'library', {});
|
|
195
235
|
}
|
|
196
236
|
|
|
237
|
+
/**
|
|
238
|
+
* @param {any} dir
|
|
239
|
+
*/
|
|
197
240
|
export function installFromDir(dir) {
|
|
198
241
|
const abs = path.resolve(dir);
|
|
199
242
|
if (!fs.existsSync(path.join(abs, 'SKILL.md'))) {
|
|
@@ -204,6 +247,9 @@ export function installFromDir(dir) {
|
|
|
204
247
|
return copySkillIntoUser(abs, meta.name, 'dir', { from: abs });
|
|
205
248
|
}
|
|
206
249
|
|
|
250
|
+
/**
|
|
251
|
+
* @param {any} url
|
|
252
|
+
*/
|
|
207
253
|
export async function installFromUrl(url) {
|
|
208
254
|
let u;
|
|
209
255
|
try {
|
|
@@ -222,7 +268,7 @@ export async function installFromUrl(url) {
|
|
|
222
268
|
if (!res.ok) return { error: `下载失败:HTTP ${res.status}` };
|
|
223
269
|
text = await res.text();
|
|
224
270
|
if (text.length > 512 * 1024) return { error: 'SKILL.md 超过 512KB 上限' };
|
|
225
|
-
} catch (e) {
|
|
271
|
+
} catch (/** @type {any} */ e) {
|
|
226
272
|
return { error: `下载失败:${e.name === 'AbortError' ? '30 秒超时' : e.message}` };
|
|
227
273
|
} finally {
|
|
228
274
|
clearTimeout(timer);
|
|
@@ -244,6 +290,9 @@ export async function installFromUrl(url) {
|
|
|
244
290
|
return r;
|
|
245
291
|
}
|
|
246
292
|
|
|
293
|
+
/**
|
|
294
|
+
* @param {any} gitUrl
|
|
295
|
+
*/
|
|
247
296
|
export function installFromGit(gitUrl) {
|
|
248
297
|
if (typeof gitUrl !== 'string' || gitUrl.trim().startsWith('-')) {
|
|
249
298
|
return { error: 'git 地址不能以 - 开头(防选项注入)' };
|
|
@@ -262,7 +311,8 @@ export function installFromGit(gitUrl) {
|
|
|
262
311
|
const found = [];
|
|
263
312
|
const stack = [tmp];
|
|
264
313
|
while (stack.length && found.length < 20) {
|
|
265
|
-
const dir = stack.pop();
|
|
314
|
+
const dir = /** @type {any} */ (stack.pop());
|
|
315
|
+
/** @type {any[]} */
|
|
266
316
|
let entries = [];
|
|
267
317
|
try {
|
|
268
318
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -297,6 +347,9 @@ export function installFromGit(gitUrl) {
|
|
|
297
347
|
return { names: installed.map((i) => i.name), dirs: installed.map((i) => i.dir), skipped };
|
|
298
348
|
}
|
|
299
349
|
|
|
350
|
+
/**
|
|
351
|
+
* @param {any} name
|
|
352
|
+
*/
|
|
300
353
|
export function uninstallSkill(name) {
|
|
301
354
|
const key = String(name).trim();
|
|
302
355
|
// '.'/'..' 会拼出上级目录,必须硬拒绝(正则 [A-Za-z0-9_.-]+ 会放行纯点号名)
|
|
@@ -310,9 +363,12 @@ export function uninstallSkill(name) {
|
|
|
310
363
|
}
|
|
311
364
|
|
|
312
365
|
// 按元数据来源重新安装(库:重新复制;url:重新下载;git:重新克隆)
|
|
366
|
+
/**
|
|
367
|
+
* @param {any} name
|
|
368
|
+
*/
|
|
313
369
|
export async function reinstallSkill(name) {
|
|
314
370
|
const target = path.join(userSkillsDir(), name);
|
|
315
|
-
let meta = {};
|
|
371
|
+
let meta = /** @type {any} */ ({});
|
|
316
372
|
try {
|
|
317
373
|
meta = JSON.parse(fs.readFileSync(path.join(target, '.mingdao-source.json'), 'utf8'));
|
|
318
374
|
} catch {
|
|
@@ -330,6 +386,9 @@ export async function reinstallSkill(name) {
|
|
|
330
386
|
}
|
|
331
387
|
|
|
332
388
|
// 统一入口:自动识别 库名 | 本地目录 | SKILL.md URL | git 仓库
|
|
389
|
+
/**
|
|
390
|
+
* @param {any} arg
|
|
391
|
+
*/
|
|
333
392
|
export async function installSkill(arg) {
|
|
334
393
|
const a = String(arg || '').trim();
|
|
335
394
|
if (!a) return { error: '缺少参数:mingdao skill install <库名|目录|SKILL.md URL|git 仓库地址>' };
|
package/src/skill-registry.js
CHANGED
|
@@ -42,14 +42,14 @@ function loadCache() {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function saveCache(data, host) {
|
|
45
|
+
function saveCache(/** @type {any} */ data, /** @type {any} */ host) {
|
|
46
46
|
try {
|
|
47
47
|
ensureHome();
|
|
48
48
|
fs.writeFileSync(cacheFile(), JSON.stringify({ fetchedAt: Date.now(), host, data }, null, 2) + '\n', { mode: 0o600 });
|
|
49
49
|
} catch {}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async function fetchText(url, timeoutMs = 20000, maxBytes = 2 * 1024 * 1024) {
|
|
52
|
+
async function fetchText(/** @type {any} */ url, timeoutMs = 20000, maxBytes = 2 * 1024 * 1024) {
|
|
53
53
|
const ctrl = new AbortController();
|
|
54
54
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
55
55
|
try {
|
|
@@ -91,26 +91,26 @@ export async function fetchRegistryIndex({ force = false, allowNetwork = true }
|
|
|
91
91
|
}
|
|
92
92
|
// 网络失败时回退旧缓存(过期也可用)
|
|
93
93
|
if (cached?.data?.skills) return { data: cached.data, host: cached.host, fromCache: true, stale: true };
|
|
94
|
-
return { error: `无法获取线上技能库:${lastErr?.message || '网络不可达'}(可用 MINGDAO_REGISTRY_URL 指向自建 registry)` };
|
|
94
|
+
return { error: `无法获取线上技能库:${(/** @type {any} */ (lastErr))?.message || '网络不可达'}(可用 MINGDAO_REGISTRY_URL 指向自建 registry)` };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
// 远端搜索(合并展示:name/description/source/installed)
|
|
98
|
-
export async function searchRegistry(kw, { force = false, allowNetwork = true } = {}) {
|
|
98
|
+
export async function searchRegistry(/** @type {any} */ kw, { force = false, allowNetwork = true } = {}) {
|
|
99
99
|
const r = await fetchRegistryIndex({ force, allowNetwork });
|
|
100
100
|
if (r.error) return { error: r.error };
|
|
101
101
|
const k = String(kw || '').trim().toLowerCase();
|
|
102
102
|
const installed = installedUserSkillNames();
|
|
103
103
|
const skills = r.data.skills
|
|
104
|
-
.filter((s) => !k || s.name.toLowerCase().includes(k) || (s.description || '').toLowerCase().includes(k))
|
|
105
|
-
.map((s) => ({ name: s.name, description: s.description, source: 'registry', installed: installed.has(s.name) }));
|
|
104
|
+
.filter((/** @type {any} */ s) => !k || s.name.toLowerCase().includes(k) || (s.description || '').toLowerCase().includes(k))
|
|
105
|
+
.map((/** @type {any} */ s) => ({ name: s.name, description: s.description, source: 'registry', installed: installed.has(s.name) }));
|
|
106
106
|
return { skills, host: r.host, updatedAt: r.data.updatedAt, fromCache: r.fromCache, stale: r.stale || false };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
// 按索引安装(逐文件下载 + dry-run 校验)
|
|
110
|
-
export async function installFromRegistry(name) {
|
|
110
|
+
export async function installFromRegistry(/** @type {any} */ name) {
|
|
111
111
|
const r = await fetchRegistryIndex();
|
|
112
112
|
if (r.error) return { error: r.error };
|
|
113
|
-
const entry = r.data.skills.find((s) => s.name === name);
|
|
113
|
+
const entry = r.data.skills.find((/** @type {any} */ s) => s.name === name);
|
|
114
114
|
if (!entry) return { error: `线上技能库中没有 ${name}(mingdao skill search 查看)` };
|
|
115
115
|
if (!Array.isArray(entry.files) || !entry.files.length) return { error: `技能 ${name} 的索引缺少文件清单` };
|
|
116
116
|
|
|
@@ -134,7 +134,8 @@ export async function installFromRegistry(name) {
|
|
|
134
134
|
text = await fetchText(`${host}/skills-lib/${encodeURI(name)}/${rel.split('/').map(encodeURIComponent).join('/')}`, 30000, MAX_FILE);
|
|
135
135
|
break;
|
|
136
136
|
} catch (e) {
|
|
137
|
-
|
|
137
|
+
const ee = /** @type {any} */ (e);
|
|
138
|
+
lastErr = ee?.name === 'AbortError' ? '下载超时' : String(ee?.message || ee);
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
if (text === null) return { error: `下载 ${name}/${rel} 失败:${lastErr}(已尝试全部镜像)` };
|
package/src/skills.js
CHANGED
|
@@ -15,7 +15,7 @@ import { skillDirHash, readSourceMeta } from './skill-lib.js';
|
|
|
15
15
|
|
|
16
16
|
const BUILTIN_DIR = fileURLToPath(new URL('../skills', import.meta.url));
|
|
17
17
|
|
|
18
|
-
export function skillDirs(workingDir) {
|
|
18
|
+
export function skillDirs(/** @type {any} */ workingDir) {
|
|
19
19
|
return [
|
|
20
20
|
{ dir: path.join(mingdaoHome(), 'skills'), source: 'user' },
|
|
21
21
|
{ dir: path.join(workingDir, '.mingdao', 'skills'), source: 'project' },
|
|
@@ -23,7 +23,7 @@ export function skillDirs(workingDir) {
|
|
|
23
23
|
];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function readDescription(skillMd) {
|
|
26
|
+
function readDescription(/** @type {any} */ skillMd) {
|
|
27
27
|
try {
|
|
28
28
|
const text = fs.readFileSync(skillMd, 'utf8');
|
|
29
29
|
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
@@ -39,7 +39,7 @@ function readDescription(skillMd) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// 完整性校验(P3-3):带指纹(sha256)来源记录的技能,内容与安装时不一致 → 拒绝加载
|
|
42
|
-
function isTampered(dir) {
|
|
42
|
+
function isTampered(/** @type {any} */ dir) {
|
|
43
43
|
const meta = readSourceMeta(dir);
|
|
44
44
|
if (!meta?.sha256) return false; // 旧版安装(无指纹)不拦截
|
|
45
45
|
try {
|
|
@@ -50,14 +50,14 @@ function isTampered(dir) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// 被篡改的用户级技能清单(CLI/WebUI 提示用;这些技能已被 listSkills 排除加载)
|
|
53
|
-
export function tamperedSkillNames(workingDir) {
|
|
53
|
+
export function tamperedSkillNames(/** @type {any} */ workingDir) {
|
|
54
54
|
const dir = path.join(mingdaoHome(), 'skills');
|
|
55
|
-
const out = [];
|
|
55
|
+
const /** @type {any} */ out = [];
|
|
56
56
|
let entries;
|
|
57
57
|
try {
|
|
58
58
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
59
|
} catch {
|
|
60
|
-
return out;
|
|
60
|
+
return /** @type {any} */ out;
|
|
61
61
|
}
|
|
62
62
|
for (const e of entries) {
|
|
63
63
|
if (!e.isDirectory()) continue;
|
|
@@ -75,7 +75,7 @@ export function tamperedSkillNames(workingDir) {
|
|
|
75
75
|
return out;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function listSkills(workingDir) {
|
|
78
|
+
export function listSkills(/** @type {any} */ workingDir) {
|
|
79
79
|
const seen = new Set();
|
|
80
80
|
const out = [];
|
|
81
81
|
for (const { dir, source } of skillDirs(workingDir)) {
|
|
@@ -94,8 +94,10 @@ export function listSkills(workingDir) {
|
|
|
94
94
|
continue;
|
|
95
95
|
}
|
|
96
96
|
seen.add(e.name); // 优先级:user > project > builtin,先出现的生效
|
|
97
|
-
// P3-3
|
|
98
|
-
|
|
97
|
+
// P3-3:技能指纹不符 → 拒绝加载(绝不静默执行被篡改的提示词)。
|
|
98
|
+
// project 级(.mingdao/skills/)同样校验(CodeBuddy 报告:仓库投毒可直通提示词注入);
|
|
99
|
+
// 无 .source.json 指纹的旧仓库/手写技能不受影响(isTampered 返回 false)。
|
|
100
|
+
if ((source === 'user' || source === 'project') && isTampered(path.join(dir, e.name))) continue;
|
|
99
101
|
out.push({
|
|
100
102
|
name: e.name,
|
|
101
103
|
dir: path.join(dir, e.name),
|
|
@@ -108,7 +110,7 @@ export function listSkills(workingDir) {
|
|
|
108
110
|
return out;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
export function loadSkill(workingDir, name) {
|
|
113
|
+
export function loadSkill(/** @type {any} */ workingDir, /** @type {any} */ name) {
|
|
112
114
|
const s = listSkills(workingDir).find((x) => x.name === name);
|
|
113
115
|
if (!s) return null;
|
|
114
116
|
try {
|
|
@@ -118,14 +120,14 @@ export function loadSkill(workingDir, name) {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
function sourceLabel(source) {
|
|
123
|
+
function sourceLabel(/** @type {any} */ source) {
|
|
122
124
|
if (source === 'user') return '(用户级)';
|
|
123
125
|
if (source === 'builtin') return '(内置)';
|
|
124
126
|
return '';
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
// 注入系统提示的技能清单(仅名称+描述)
|
|
128
|
-
export function skillsRegistryBlock(workingDir) {
|
|
130
|
+
export function skillsRegistryBlock(/** @type {any} */ workingDir) {
|
|
129
131
|
const skills = listSkills(workingDir);
|
|
130
132
|
if (!skills.length) return '';
|
|
131
133
|
return (
|
package/src/sync-server.js
CHANGED
|
@@ -39,9 +39,14 @@ const INVITE_CODES = new Set(
|
|
|
39
39
|
.filter(Boolean)
|
|
40
40
|
);
|
|
41
41
|
|
|
42
|
+
/** @type {(...a: any[]) => void} */
|
|
42
43
|
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
|
43
44
|
|
|
44
45
|
// ---------- 存储 ----------
|
|
46
|
+
/**
|
|
47
|
+
* @param {any} file
|
|
48
|
+
* @param {any} fallback
|
|
49
|
+
*/
|
|
45
50
|
function readJson(file, fallback) {
|
|
46
51
|
try {
|
|
47
52
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -49,6 +54,10 @@ function readJson(file, fallback) {
|
|
|
49
54
|
return fallback;
|
|
50
55
|
}
|
|
51
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* @param {any} file
|
|
59
|
+
* @param {any} obj
|
|
60
|
+
*/
|
|
52
61
|
function writeJson(file, obj) {
|
|
53
62
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
54
63
|
atomicWriteFileSync(file, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 }); // 质检 H4
|
|
@@ -59,12 +68,15 @@ function usersFile() {
|
|
|
59
68
|
function devicesFile() {
|
|
60
69
|
return path.join(ACTIVE_DIR, 'devices.json');
|
|
61
70
|
}
|
|
71
|
+
/** @param {any} username */
|
|
62
72
|
function userDir(username) {
|
|
63
73
|
return path.join(ACTIVE_DIR, 'data', username);
|
|
64
74
|
}
|
|
75
|
+
/** @param {any} username */
|
|
65
76
|
function sessionsDir(username) {
|
|
66
77
|
return path.join(userDir(username), 'sessions');
|
|
67
78
|
}
|
|
79
|
+
/** @param {any} username */
|
|
68
80
|
function metaFile(username) {
|
|
69
81
|
return path.join(userDir(username), 'meta.json');
|
|
70
82
|
}
|
|
@@ -75,8 +87,13 @@ function acceptedFile() {
|
|
|
75
87
|
return path.join(ACTIVE_DIR, 'accepted.json');
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
/** @type {(s: any) => string} */
|
|
78
91
|
const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
79
92
|
// 常量时间比较(P3-10):token/密码哈希等值判断防时序侧信道
|
|
93
|
+
/**
|
|
94
|
+
* @param {any} a
|
|
95
|
+
* @param {any} b
|
|
96
|
+
*/
|
|
80
97
|
function safeEqualHex(a, b) {
|
|
81
98
|
try {
|
|
82
99
|
const ba = Buffer.from(String(a), 'hex');
|
|
@@ -87,9 +104,18 @@ function safeEqualHex(a, b) {
|
|
|
87
104
|
}
|
|
88
105
|
}
|
|
89
106
|
// 密码存储用 scrypt(带盐 KDF);旧数据兼容:verify 时 sha256 回退
|
|
107
|
+
/**
|
|
108
|
+
* @param {any} password
|
|
109
|
+
* @param {any} salt
|
|
110
|
+
*/
|
|
90
111
|
function hashPassword(password, salt) {
|
|
91
112
|
return crypto.scryptSync(String(password), String(salt), 32).toString('hex');
|
|
92
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* @param {any} password
|
|
116
|
+
* @param {any} salt
|
|
117
|
+
* @param {any} hash
|
|
118
|
+
*/
|
|
93
119
|
function verifyPassword(password, salt, hash) {
|
|
94
120
|
if (!hash) return false;
|
|
95
121
|
try {
|
|
@@ -98,9 +124,11 @@ function verifyPassword(password, salt, hash) {
|
|
|
98
124
|
// 兼容早期 sha256 存储
|
|
99
125
|
return safeEqualHex(hash, sha(`${salt}:${password}`));
|
|
100
126
|
}
|
|
127
|
+
/** @param {any} u */
|
|
101
128
|
function isValidUsername(u) {
|
|
102
129
|
return typeof u === 'string' && /^[A-Za-z0-9_.-]{2,32}$/.test(u) && u !== '.' && u !== '..';
|
|
103
130
|
}
|
|
131
|
+
/** @param {any} n */
|
|
104
132
|
function isValidSessionName(n) {
|
|
105
133
|
return typeof n === 'string' && /^[\w\u4e00-\u9fa5.-]{1,140}\.jsonl$/.test(n) && !n.includes('..');
|
|
106
134
|
}
|
|
@@ -108,6 +136,7 @@ function isValidSessionName(n) {
|
|
|
108
136
|
// ---------- 限速(防爆破/枚举;内存表,进程级) ----------
|
|
109
137
|
const TRUST_PROXY = process.env.SYNC_TRUST_PROXY === '1'; // 仅部署在可信反代后时开启
|
|
110
138
|
const rateBuckets = new Map();
|
|
139
|
+
/** @param {any} req */
|
|
111
140
|
function clientKey(req) {
|
|
112
141
|
let ip = req.socket?.remoteAddress || 'x';
|
|
113
142
|
if (TRUST_PROXY) {
|
|
@@ -116,6 +145,7 @@ function clientKey(req) {
|
|
|
116
145
|
}
|
|
117
146
|
return ip;
|
|
118
147
|
}
|
|
148
|
+
/** @param {any} req */
|
|
119
149
|
function rateLimited(req, limit = 20, extra = '') {
|
|
120
150
|
// 质检 M5:键 = IP + 路由 + 用户名——单账号爆破按用户名限(分布式 IP 无法绕过);
|
|
121
151
|
// 表满按最旧淘汰而非整表 clear(整表 clear 可被攻击者重置全员限额)
|
|
@@ -140,11 +170,13 @@ function rateLimited(req, limit = 20, extra = '') {
|
|
|
140
170
|
}
|
|
141
171
|
|
|
142
172
|
// ---------- HTTP 工具 ----------
|
|
173
|
+
/** @param {any} req */
|
|
143
174
|
function readBody(req, limit = MAX_BODY) {
|
|
144
175
|
return new Promise((resolve, reject) => {
|
|
176
|
+
/** @type {any[]} */
|
|
145
177
|
const chunks = [];
|
|
146
178
|
let size = 0;
|
|
147
|
-
req.on('data', (d) => {
|
|
179
|
+
req.on('data', (/** @type {any} */ d) => {
|
|
148
180
|
size += d.length;
|
|
149
181
|
if (size > limit) {
|
|
150
182
|
reject(new Error('body 超限'));
|
|
@@ -157,20 +189,27 @@ function readBody(req, limit = MAX_BODY) {
|
|
|
157
189
|
req.on('error', reject);
|
|
158
190
|
});
|
|
159
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* @param {any} res
|
|
194
|
+
* @param {any} code
|
|
195
|
+
* @param {any} obj
|
|
196
|
+
*/
|
|
160
197
|
function json(res, code, obj) {
|
|
161
198
|
const body = JSON.stringify(obj);
|
|
162
199
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
|
|
163
200
|
res.end(body);
|
|
164
201
|
}
|
|
202
|
+
/** @param {any} req */
|
|
165
203
|
async function parseBody(req) {
|
|
166
204
|
try {
|
|
167
205
|
return JSON.parse(await readBody(req) || '{}');
|
|
168
206
|
} catch (e) {
|
|
169
|
-
return { __error: e.message };
|
|
207
|
+
return { __error: /** @type {any} */ (e).message };
|
|
170
208
|
}
|
|
171
209
|
}
|
|
172
210
|
|
|
173
211
|
// ---------- 认证 ----------
|
|
212
|
+
/** @param {any} req */
|
|
174
213
|
function tokenOf(req) {
|
|
175
214
|
const h = req.headers.authorization || '';
|
|
176
215
|
const m = h.match(/^Bearer\s+(.+)$/i);
|
|
@@ -181,6 +220,7 @@ const deviceTokenCache = new Map();
|
|
|
181
220
|
function invalidateDeviceCache() {
|
|
182
221
|
deviceTokenCache.clear();
|
|
183
222
|
}
|
|
223
|
+
/** @param {any} token */
|
|
184
224
|
function findDeviceByToken(token) {
|
|
185
225
|
if (!token) return null;
|
|
186
226
|
const tokenHash = sha(token);
|
|
@@ -202,9 +242,12 @@ function findDeviceByToken(token) {
|
|
|
202
242
|
|
|
203
243
|
// ---------- 业务 ----------
|
|
204
244
|
// 质检 A1:通用写互斥(注册/pair/lastSeen/meta 更新串行化),防 devices.json/meta.json 读改写竞态
|
|
245
|
+
/** @type {any} */
|
|
205
246
|
let writeLock = null;
|
|
247
|
+
/** @param {any} fn */
|
|
206
248
|
async function withWriteLock(fn) {
|
|
207
249
|
const prev = writeLock || Promise.resolve();
|
|
250
|
+
/** @type {any} */
|
|
208
251
|
let release;
|
|
209
252
|
writeLock = new Promise((resolve) => {
|
|
210
253
|
release = resolve;
|
|
@@ -216,7 +259,9 @@ async function withWriteLock(fn) {
|
|
|
216
259
|
release();
|
|
217
260
|
}
|
|
218
261
|
}
|
|
262
|
+
/** @type {any} */
|
|
219
263
|
let registerLock = null;
|
|
264
|
+
/** @param {any} body */
|
|
220
265
|
async function doRegister(body) {
|
|
221
266
|
const username = String(body.username || '').trim();
|
|
222
267
|
const password = String(body.password || '');
|
|
@@ -229,6 +274,7 @@ async function doRegister(body) {
|
|
|
229
274
|
});
|
|
230
275
|
}
|
|
231
276
|
const prev = registerLock;
|
|
277
|
+
/** @type {any} */
|
|
232
278
|
let release;
|
|
233
279
|
registerLock = new Promise((resolve) => {
|
|
234
280
|
release = resolve;
|
|
@@ -247,6 +293,7 @@ async function doRegister(body) {
|
|
|
247
293
|
}
|
|
248
294
|
}
|
|
249
295
|
|
|
296
|
+
/** @param {any} body */
|
|
250
297
|
async function doPair(body) {
|
|
251
298
|
const username = String(body.username || '').trim();
|
|
252
299
|
const password = String(body.password || '');
|
|
@@ -268,10 +315,12 @@ async function doPair(body) {
|
|
|
268
315
|
});
|
|
269
316
|
}
|
|
270
317
|
|
|
318
|
+
/** @param {any} username */
|
|
271
319
|
function doListSessions(username) {
|
|
272
320
|
const dir = sessionsDir(username);
|
|
273
321
|
const meta = readJson(metaFile(username), {});
|
|
274
322
|
const out = [];
|
|
323
|
+
/** @type {any[]} */
|
|
275
324
|
let entries = [];
|
|
276
325
|
try {
|
|
277
326
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -285,6 +334,10 @@ function doListSessions(username) {
|
|
|
285
334
|
return out;
|
|
286
335
|
}
|
|
287
336
|
|
|
337
|
+
/**
|
|
338
|
+
* @param {any} username
|
|
339
|
+
* @param {any} body
|
|
340
|
+
*/
|
|
288
341
|
function doPush(username, body) {
|
|
289
342
|
const name = String(body.name || '').trim();
|
|
290
343
|
const content = typeof body.content === 'string' ? body.content : '';
|
|
@@ -305,6 +358,10 @@ function doPush(username, body) {
|
|
|
305
358
|
});
|
|
306
359
|
}
|
|
307
360
|
|
|
361
|
+
/**
|
|
362
|
+
* @param {any} username
|
|
363
|
+
* @param {any} name
|
|
364
|
+
*/
|
|
308
365
|
function doPull(username, name) {
|
|
309
366
|
if (!isValidSessionName(name)) return { notFound: '会话不存在' };
|
|
310
367
|
const file = path.join(sessionsDir(username), name);
|
|
@@ -317,6 +374,10 @@ function doPull(username, name) {
|
|
|
317
374
|
}
|
|
318
375
|
}
|
|
319
376
|
|
|
377
|
+
/**
|
|
378
|
+
* @param {any} username
|
|
379
|
+
* @param {any} name
|
|
380
|
+
*/
|
|
320
381
|
function doDelete(username, name) {
|
|
321
382
|
if (!isValidSessionName(name)) return { notFound: '会话不存在' };
|
|
322
383
|
const dir = sessionsDir(username);
|
|
@@ -335,6 +396,10 @@ function doDelete(username, name) {
|
|
|
335
396
|
}
|
|
336
397
|
|
|
337
398
|
// ---------- 密码修改 ----------
|
|
399
|
+
/**
|
|
400
|
+
* @param {any} username
|
|
401
|
+
* @param {any} body
|
|
402
|
+
*/
|
|
338
403
|
function doChangePassword(username, body) {
|
|
339
404
|
const oldPassword = String(body.oldPassword || '');
|
|
340
405
|
const newPassword = String(body.newPassword || '');
|
|
@@ -356,6 +421,10 @@ function doChangePassword(username, body) {
|
|
|
356
421
|
}
|
|
357
422
|
|
|
358
423
|
// ---------- 会话分享 ----------
|
|
424
|
+
/**
|
|
425
|
+
* @param {any} username
|
|
426
|
+
* @param {any} name
|
|
427
|
+
*/
|
|
359
428
|
function doShareCreate(username, name) {
|
|
360
429
|
if (!isValidSessionName(name)) return { error: `会话名非法:${name}` };
|
|
361
430
|
if (!fs.existsSync(path.join(sessionsDir(username), name))) return { notFound: '你还没有这个会话' };
|
|
@@ -367,6 +436,7 @@ function doShareCreate(username, name) {
|
|
|
367
436
|
return { ok: true, shareId, name };
|
|
368
437
|
}
|
|
369
438
|
|
|
439
|
+
/** @param {any} username */
|
|
370
440
|
function doShareList(username) {
|
|
371
441
|
const shares = readJson(sharesFile(), {});
|
|
372
442
|
const accepted = readJson(acceptedFile(), {})[username] || {};
|
|
@@ -383,6 +453,10 @@ function doShareList(username) {
|
|
|
383
453
|
return { ok: true, mine, accepted: mineAccepted };
|
|
384
454
|
}
|
|
385
455
|
|
|
456
|
+
/**
|
|
457
|
+
* @param {any} username
|
|
458
|
+
* @param {any} shareId
|
|
459
|
+
*/
|
|
386
460
|
function doShareRevoke(username, shareId) {
|
|
387
461
|
const shares = readJson(sharesFile(), {});
|
|
388
462
|
const s = shares[shareId];
|
|
@@ -396,6 +470,10 @@ function doShareRevoke(username, shareId) {
|
|
|
396
470
|
// 接受/刷新分享:克隆(或更新)到接受者自己的会话列表。
|
|
397
471
|
// - 首次接受 → 写入同名文件(同名已有不同内容则另存 .shared- 副本,绝不覆盖)
|
|
398
472
|
// - 再次接受:接受者未修改过副本 → 就地刷新;已修改 → 另存新副本(冲突保留双方)
|
|
473
|
+
/**
|
|
474
|
+
* @param {any} username
|
|
475
|
+
* @param {any} shareId
|
|
476
|
+
*/
|
|
399
477
|
function doShareAccept(username, shareId) {
|
|
400
478
|
const shares = readJson(sharesFile(), {});
|
|
401
479
|
const s = shares[shareId];
|
|
@@ -453,6 +531,10 @@ function doShareAccept(username, shareId) {
|
|
|
453
531
|
}
|
|
454
532
|
|
|
455
533
|
// ---------- 路由 ----------
|
|
534
|
+
/**
|
|
535
|
+
* @param {any} req
|
|
536
|
+
* @param {any} res
|
|
537
|
+
*/
|
|
456
538
|
async function handle(req, res) {
|
|
457
539
|
const u = new URL(req.url, 'http://x');
|
|
458
540
|
const p = u.pathname;
|
|
@@ -569,7 +651,7 @@ async function handle(req, res) {
|
|
|
569
651
|
}
|
|
570
652
|
return json(res, 404, { error: '未知接口' });
|
|
571
653
|
} catch (e) {
|
|
572
|
-
log('error', p, e.message);
|
|
654
|
+
log('error', p, /** @type {any} */ (e).message);
|
|
573
655
|
return json(res, 500, { error: '服务器内部错误' });
|
|
574
656
|
}
|
|
575
657
|
}
|
|
@@ -584,6 +666,7 @@ export function runSyncServer({ port, host, dataDir, cert, key } = {}) {
|
|
|
584
666
|
const certFile = cert || CERT;
|
|
585
667
|
const keyFile = key || KEY;
|
|
586
668
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
669
|
+
/** @type {(req: any, res: any) => void} */
|
|
587
670
|
const handler = (req, res) => {
|
|
588
671
|
handle(req, res).catch(() => {});
|
|
589
672
|
};
|