mingdao-harness 0.1.54

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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
package/src/skills.js ADDED
@@ -0,0 +1,135 @@
1
+ // Skills 技能系统(借鉴 OpenAI Codex CLI 的渐进式披露与 DeepSeek-Harness 的 SKILL.md 格式):
2
+ // - 三级来源(同名覆盖优先级从高到低):
3
+ // user <mingdao-home>/skills/ 用户级,本机全局
4
+ // project <项目>/.mingdao/skills/ 项目级,随仓库共享
5
+ // builtin <安装包>/skills/ 内置技能,随 MingDao 发布
6
+ // - 每个技能一个目录,内含 SKILL.md(frontmatter 可含 description)
7
+ // - 技能清单(名称+描述)自动注入系统提示;模型按需调用 skill 工具加载全文
8
+ // - 避免把全部技能内容塞进上下文,节省 token(渐进式披露)
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { mingdaoHome } from './config.js';
14
+ import { skillDirHash, readSourceMeta } from './skill-lib.js';
15
+
16
+ const BUILTIN_DIR = fileURLToPath(new URL('../skills', import.meta.url));
17
+
18
+ export function skillDirs(workingDir) {
19
+ return [
20
+ { dir: path.join(mingdaoHome(), 'skills'), source: 'user' },
21
+ { dir: path.join(workingDir, '.mingdao', 'skills'), source: 'project' },
22
+ { dir: BUILTIN_DIR, source: 'builtin' },
23
+ ];
24
+ }
25
+
26
+ function readDescription(skillMd) {
27
+ try {
28
+ const text = fs.readFileSync(skillMd, 'utf8');
29
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
30
+ if (fm) {
31
+ const d = fm[1].match(/^description:\s*(.+)$/m);
32
+ if (d) return d[1].trim();
33
+ }
34
+ const h = text.match(/^#\s+(.+)$/m);
35
+ return h ? h[1].trim() : '';
36
+ } catch {
37
+ return '';
38
+ }
39
+ }
40
+
41
+ // 完整性校验(P3-3):带指纹(sha256)来源记录的技能,内容与安装时不一致 → 拒绝加载
42
+ function isTampered(dir) {
43
+ const meta = readSourceMeta(dir);
44
+ if (!meta?.sha256) return false; // 旧版安装(无指纹)不拦截
45
+ try {
46
+ return skillDirHash(dir) !== meta.sha256;
47
+ } catch {
48
+ return true;
49
+ }
50
+ }
51
+
52
+ // 被篡改的用户级技能清单(CLI/WebUI 提示用;这些技能已被 listSkills 排除加载)
53
+ export function tamperedSkillNames(workingDir) {
54
+ const dir = path.join(mingdaoHome(), 'skills');
55
+ const out = [];
56
+ let entries;
57
+ try {
58
+ entries = fs.readdirSync(dir, { withFileTypes: true });
59
+ } catch {
60
+ return out;
61
+ }
62
+ for (const e of entries) {
63
+ if (!e.isDirectory()) continue;
64
+ const skillDir = path.join(dir, e.name);
65
+ try {
66
+ if (!fs.statSync(path.join(skillDir, 'SKILL.md')).isFile()) continue;
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (isTampered(skillDir)) {
71
+ const meta = readSourceMeta(skillDir);
72
+ out.push({ name: e.name, source: meta?.source || 'user', sha256: meta?.sha256?.slice(0, 16) || null });
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ export function listSkills(workingDir) {
79
+ const seen = new Set();
80
+ const out = [];
81
+ for (const { dir, source } of skillDirs(workingDir)) {
82
+ let entries;
83
+ try {
84
+ entries = fs.readdirSync(dir, { withFileTypes: true });
85
+ } catch {
86
+ continue;
87
+ }
88
+ for (const e of entries) {
89
+ if (!e.isDirectory() || seen.has(e.name)) continue;
90
+ const skillMd = path.join(dir, e.name, 'SKILL.md');
91
+ try {
92
+ if (!fs.statSync(skillMd).isFile()) continue;
93
+ } catch {
94
+ continue;
95
+ }
96
+ seen.add(e.name); // 优先级:user > project > builtin,先出现的生效
97
+ // P3-3:用户级技能指纹不符 → 拒绝加载(绝不静默执行被篡改的提示词)
98
+ if (source === 'user' && isTampered(path.join(dir, e.name))) continue;
99
+ out.push({
100
+ name: e.name,
101
+ dir: path.join(dir, e.name),
102
+ path: skillMd,
103
+ description: readDescription(skillMd),
104
+ source,
105
+ });
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ export function loadSkill(workingDir, name) {
112
+ const s = listSkills(workingDir).find((x) => x.name === name);
113
+ if (!s) return null;
114
+ try {
115
+ return { ...s, content: fs.readFileSync(s.path, 'utf8') };
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function sourceLabel(source) {
122
+ if (source === 'user') return '(用户级)';
123
+ if (source === 'builtin') return '(内置)';
124
+ return '';
125
+ }
126
+
127
+ // 注入系统提示的技能清单(仅名称+描述)
128
+ export function skillsRegistryBlock(workingDir) {
129
+ const skills = listSkills(workingDir);
130
+ if (!skills.length) return '';
131
+ return (
132
+ '\n\n## 可用技能(Skills)\n当任务与某技能相关时,先调用 skill 工具加载对应 SKILL.md 再动手:\n' +
133
+ skills.map((s) => `- ${s.name}${sourceLabel(s.source)}:${s.description || '(无描述)'}`).join('\n')
134
+ );
135
+ }
@@ -0,0 +1,564 @@
1
+ // MingDao 云同步服务端(零依赖,仅 node:http/https/fs/path/crypto)。
2
+ // 部署:node sync-server.js [端口](默认 443),或 `mingdao sync-server [端口]`
3
+ // 环境变量:
4
+ // SYNC_DATA_DIR 数据目录(默认 /var/lib/mingdao-sync)
5
+ // SYNC_HOST 监听地址(默认 0.0.0.0)
6
+ // SYNC_CERT / SYNC_KEY 提供后走 HTTPS(Let's Encrypt 证书路径)
7
+ // MINGDAO_SYNC_REGISTRATION 注册开关:open(默认)| invite(需邀请码)| closed
8
+ // MINGDAO_SYNC_INVITE_CODES 邀请码列表(逗号分隔,仅 invite 模式生效)
9
+ // 数据布局:
10
+ // <DATA_DIR>/users.json {用户名: {salt, hash, createdAt}}
11
+ // <DATA_DIR>/devices.json {用户名: {设备ID: {name, tokenHash, createdAt, lastSeen}}}
12
+ // <DATA_DIR>/data/<用户名>/sessions/ 各设备会话 JSONL
13
+ // <DATA_DIR>/data/<用户名>/meta.json {会话名: {mtime, size}}
14
+ // 安全:密码 sha256(salt:password) 存储;设备 token 48 位随机数,服务端只存哈希;
15
+ // 会话名白名单校验;body ≤ 20MB;推荐 HTTPS 部署(明文 HTTP 仅限内网/过渡)。
16
+
17
+ import http from 'node:http';
18
+ import https from 'node:https';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+
23
+ const DEFAULT_DATA_DIR = process.env.SYNC_DATA_DIR || '/var/lib/mingdao-sync';
24
+ let ACTIVE_DIR = DEFAULT_DATA_DIR;
25
+ const HOST = process.env.SYNC_HOST || '0.0.0.0';
26
+ const PORT = Number(process.env.SYNC_PORT || process.argv[2] || 443);
27
+ const CERT = process.env.SYNC_CERT;
28
+ const KEY = process.env.SYNC_KEY;
29
+ const MAX_BODY = 20 * 1024 * 1024;
30
+ const MAX_FILE = 20 * 1024 * 1024;
31
+ const startedAt = Date.now();
32
+ // 注册开关(P3-10):公网自建时用 invite/closed 限制自助注册
33
+ const REGISTRATION = String(process.env.MINGDAO_SYNC_REGISTRATION || 'open').toLowerCase();
34
+ const INVITE_CODES = new Set(
35
+ String(process.env.MINGDAO_SYNC_INVITE_CODES || '')
36
+ .split(',')
37
+ .map((s) => s.trim())
38
+ .filter(Boolean)
39
+ );
40
+
41
+ const log = (...a) => console.log(new Date().toISOString(), ...a);
42
+
43
+ // ---------- 存储 ----------
44
+ function readJson(file, fallback) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
47
+ } catch {
48
+ return fallback;
49
+ }
50
+ }
51
+ function writeJson(file, obj) {
52
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
53
+ const tmp = file + '.tmp';
54
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
55
+ fs.renameSync(tmp, file);
56
+ }
57
+ function usersFile() {
58
+ return path.join(ACTIVE_DIR, 'users.json');
59
+ }
60
+ function devicesFile() {
61
+ return path.join(ACTIVE_DIR, 'devices.json');
62
+ }
63
+ function userDir(username) {
64
+ return path.join(ACTIVE_DIR, 'data', username);
65
+ }
66
+ function sessionsDir(username) {
67
+ return path.join(userDir(username), 'sessions');
68
+ }
69
+ function metaFile(username) {
70
+ return path.join(userDir(username), 'meta.json');
71
+ }
72
+ function sharesFile() {
73
+ return path.join(ACTIVE_DIR, 'shares.json');
74
+ }
75
+ function acceptedFile() {
76
+ return path.join(ACTIVE_DIR, 'accepted.json');
77
+ }
78
+
79
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
80
+ // 常量时间比较(P3-10):token/密码哈希等值判断防时序侧信道
81
+ function safeEqualHex(a, b) {
82
+ try {
83
+ const ba = Buffer.from(String(a), 'hex');
84
+ const bb = Buffer.from(String(b), 'hex');
85
+ return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ // 密码存储用 scrypt(带盐 KDF);旧数据兼容:verify 时 sha256 回退
91
+ function hashPassword(password, salt) {
92
+ return crypto.scryptSync(String(password), String(salt), 32).toString('hex');
93
+ }
94
+ function verifyPassword(password, salt, hash) {
95
+ if (!hash) return false;
96
+ try {
97
+ if (safeEqualHex(hash, crypto.scryptSync(String(password), String(salt), 32).toString('hex'))) return true;
98
+ } catch {}
99
+ // 兼容早期 sha256 存储
100
+ return safeEqualHex(hash, sha(`${salt}:${password}`));
101
+ }
102
+ function isValidUsername(u) {
103
+ return typeof u === 'string' && /^[A-Za-z0-9_.-]{2,32}$/.test(u) && u !== '.' && u !== '..';
104
+ }
105
+ function isValidSessionName(n) {
106
+ return typeof n === 'string' && /^[\w\u4e00-\u9fa5.-]{1,140}\.jsonl$/.test(n) && !n.includes('..');
107
+ }
108
+
109
+ // ---------- 限速(防爆破/枚举;内存表,进程级) ----------
110
+ const rateBuckets = new Map();
111
+ function rateLimited(req, limit = 20) {
112
+ const key = (req.socket?.remoteAddress || 'x') + '|' + req.url;
113
+ const now = Date.now();
114
+ const b = rateBuckets.get(key);
115
+ if (b && now - b.t0 < 60000) {
116
+ b.n += 1;
117
+ if (b.n > limit) return true;
118
+ } else {
119
+ rateBuckets.set(key, { t0: now, n: 1 });
120
+ if (rateBuckets.size > 5000) rateBuckets.clear();
121
+ }
122
+ return false;
123
+ }
124
+
125
+ // ---------- HTTP 工具 ----------
126
+ function readBody(req, limit = MAX_BODY) {
127
+ return new Promise((resolve, reject) => {
128
+ const chunks = [];
129
+ let size = 0;
130
+ req.on('data', (d) => {
131
+ size += d.length;
132
+ if (size > limit) {
133
+ reject(new Error('body 超限'));
134
+ req.destroy();
135
+ return;
136
+ }
137
+ chunks.push(d);
138
+ });
139
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
140
+ req.on('error', reject);
141
+ });
142
+ }
143
+ function json(res, code, obj) {
144
+ const body = JSON.stringify(obj);
145
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
146
+ res.end(body);
147
+ }
148
+ async function parseBody(req) {
149
+ try {
150
+ return JSON.parse(await readBody(req) || '{}');
151
+ } catch (e) {
152
+ return { __error: e.message };
153
+ }
154
+ }
155
+
156
+ // ---------- 认证 ----------
157
+ function tokenOf(req) {
158
+ const h = req.headers.authorization || '';
159
+ const m = h.match(/^Bearer\s+(.+)$/i);
160
+ return m ? m[1].trim() : '';
161
+ }
162
+ // 审计 P2-8:tokenHash → 设备的内存缓存(避免每请求全表扫描 + 频繁读盘)
163
+ const deviceTokenCache = new Map();
164
+ function invalidateDeviceCache() {
165
+ deviceTokenCache.clear();
166
+ }
167
+ function findDeviceByToken(token) {
168
+ if (!token) return null;
169
+ const tokenHash = sha(token);
170
+ const hit = deviceTokenCache.get(tokenHash);
171
+ if (hit) return hit;
172
+ const devices = readJson(devicesFile(), {});
173
+ for (const [username, devs] of Object.entries(devices)) {
174
+ for (const [deviceId, d] of Object.entries(devs)) {
175
+ if (safeEqualHex(d.tokenHash, tokenHash)) {
176
+ const out = { username, deviceId, device: d };
177
+ deviceTokenCache.set(tokenHash, out);
178
+ if (deviceTokenCache.size > 5000) deviceTokenCache.clear();
179
+ return out;
180
+ }
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ // ---------- 业务 ----------
187
+ let registerLock = null;
188
+ async function doRegister(body) {
189
+ const username = String(body.username || '').trim();
190
+ const password = String(body.password || '');
191
+ if (!isValidUsername(username)) return { error: '用户名需 2–32 位字母/数字/._-' };
192
+ if (password.length < 8) return { error: '密码至少 8 位' };
193
+ // 审计 P2-10:注册用进程内互斥,避免并发同名注册双双成功(后写覆盖)
194
+ if (!registerLock) {
195
+ registerLock = new Promise((resolve) => {
196
+ queueMicrotask(resolve);
197
+ });
198
+ }
199
+ const prev = registerLock;
200
+ let release;
201
+ registerLock = new Promise((resolve) => {
202
+ release = resolve;
203
+ });
204
+ await prev;
205
+ try {
206
+ const users = readJson(usersFile(), {});
207
+ if (users[username]) return { conflict: '用户名已存在' };
208
+ const salt = crypto.randomBytes(12).toString('hex');
209
+ users[username] = { salt, hash: hashPassword(password, salt), createdAt: Date.now() };
210
+ writeJson(usersFile(), users);
211
+ log('register', username);
212
+ return { ok: true, username };
213
+ } finally {
214
+ release();
215
+ }
216
+ }
217
+
218
+ function doPair(body) {
219
+ const username = String(body.username || '').trim();
220
+ const password = String(body.password || '');
221
+ const deviceName = String(body.deviceName || '').trim().slice(0, 60) || '未命名设备';
222
+ const users = readJson(usersFile(), {});
223
+ const u = users[username];
224
+ if (!u) return { notFound: '用户不存在(请先注册)' };
225
+ if (!verifyPassword(password, u.salt, u.hash)) return { unauthorized: '密码错误' };
226
+ const deviceId = crypto.randomBytes(8).toString('hex');
227
+ const token = crypto.randomBytes(24).toString('hex');
228
+ const devices = readJson(devicesFile(), {});
229
+ devices[username] = devices[username] || {};
230
+ devices[username][deviceId] = { name: deviceName, tokenHash: sha(token), createdAt: Date.now(), lastSeen: Date.now() };
231
+ writeJson(devicesFile(), devices);
232
+ invalidateDeviceCache();
233
+ log('pair', username, deviceName, deviceId);
234
+ return { ok: true, username, deviceId, deviceName, token };
235
+ }
236
+
237
+ function doListSessions(username) {
238
+ const dir = sessionsDir(username);
239
+ const meta = readJson(metaFile(username), {});
240
+ const out = [];
241
+ let entries = [];
242
+ try {
243
+ entries = fs.readdirSync(dir, { withFileTypes: true });
244
+ } catch {}
245
+ for (const e of entries) {
246
+ if (!e.isFile() || !isValidSessionName(e.name)) continue;
247
+ const st = fs.statSync(path.join(dir, e.name));
248
+ out.push({ name: e.name, mtime: meta[e.name]?.mtime || Math.floor(st.mtimeMs), size: st.size });
249
+ }
250
+ out.sort((a, b) => b.mtime - a.mtime);
251
+ return out;
252
+ }
253
+
254
+ function doPush(username, body) {
255
+ const name = String(body.name || '').trim();
256
+ const content = typeof body.content === 'string' ? body.content : '';
257
+ if (!isValidSessionName(name)) return { error: `会话名非法:${name}` };
258
+ if (!content) return { error: 'content 不能为空' };
259
+ if (Buffer.byteLength(content) > MAX_FILE) return { error: '会话文件超过 20MB 上限' };
260
+ const dir = sessionsDir(username);
261
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
262
+ const file = path.join(dir, name);
263
+ const tmp = file + '.tmp';
264
+ fs.writeFileSync(tmp, content, { mode: 0o600 });
265
+ fs.renameSync(tmp, file);
266
+ const meta = readJson(metaFile(username), {});
267
+ meta[name] = { mtime: Date.now(), size: Buffer.byteLength(content) };
268
+ writeJson(metaFile(username), meta);
269
+ return { ok: true, name, mtime: meta[name].mtime, size: meta[name].size };
270
+ }
271
+
272
+ function doPull(username, name) {
273
+ if (!isValidSessionName(name)) return { notFound: '会话不存在' };
274
+ const file = path.join(sessionsDir(username), name);
275
+ try {
276
+ const content = fs.readFileSync(file, 'utf8');
277
+ const meta = readJson(metaFile(username), {});
278
+ return { ok: true, name, content, mtime: meta[name]?.mtime || Math.floor(fs.statSync(file).mtimeMs), size: Buffer.byteLength(content) };
279
+ } catch {
280
+ return { notFound: '会话不存在' };
281
+ }
282
+ }
283
+
284
+ function doDelete(username, name) {
285
+ if (!isValidSessionName(name)) return { notFound: '会话不存在' };
286
+ const dir = sessionsDir(username);
287
+ const file = path.join(dir, name);
288
+ try {
289
+ fs.unlinkSync(file);
290
+ } catch {
291
+ return { notFound: '会话不存在' };
292
+ }
293
+ const meta = readJson(metaFile(username), {});
294
+ delete meta[name];
295
+ writeJson(metaFile(username), meta);
296
+ return { ok: true, name };
297
+ }
298
+
299
+ // ---------- 密码修改 ----------
300
+ function doChangePassword(username, body) {
301
+ const oldPassword = String(body.oldPassword || '');
302
+ const newPassword = String(body.newPassword || '');
303
+ if (newPassword.length < 8) return { error: '新密码至少 8 位' };
304
+ const users = readJson(usersFile(), {});
305
+ const u = users[username];
306
+ if (!u) return { unauthorized: '用户不存在' };
307
+ if (!verifyPassword(oldPassword, u.salt, u.hash)) return { unauthorized: '旧密码错误' };
308
+ const salt = crypto.randomBytes(12).toString('hex');
309
+ users[username] = { ...u, salt, hash: hashPassword(newPassword, salt), updatedAt: Date.now() };
310
+ writeJson(usersFile(), users);
311
+ // 改密吊销既有设备 token:密码可能已泄露,旧 token 一律失效(当前设备也需重新登录)
312
+ const devices = readJson(devicesFile(), {});
313
+ delete devices[username];
314
+ writeJson(devicesFile(), devices);
315
+ invalidateDeviceCache();
316
+ log('password-changed', username, '(全部设备已吊销)');
317
+ return { ok: true, note: '密码已修改,所有设备需重新登录' };
318
+ }
319
+
320
+ // ---------- 会话分享 ----------
321
+ function doShareCreate(username, name) {
322
+ if (!isValidSessionName(name)) return { error: `会话名非法:${name}` };
323
+ if (!fs.existsSync(path.join(sessionsDir(username), name))) return { notFound: '你还没有这个会话' };
324
+ const shares = readJson(sharesFile(), {});
325
+ const shareId = crypto.randomBytes(8).toString('hex'); // 16 位分享码(64bit,抗在线爆破;审计 P2-11 修正注释)
326
+ shares[shareId] = { owner: username, name, createdAt: Date.now(), pulls: 0 };
327
+ writeJson(sharesFile(), shares);
328
+ log('share-create', username, name, shareId);
329
+ return { ok: true, shareId, name };
330
+ }
331
+
332
+ function doShareList(username) {
333
+ const shares = readJson(sharesFile(), {});
334
+ const accepted = readJson(acceptedFile(), {})[username] || {};
335
+ const mine = Object.entries(shares)
336
+ .filter(([, s]) => s.owner === username)
337
+ .map(([shareId, s]) => ({ shareId, name: s.name, createdAt: s.createdAt, pulls: s.pulls || 0 }));
338
+ const mineAccepted = Object.entries(accepted).map(([shareId, a]) => ({
339
+ shareId,
340
+ owner: a.owner,
341
+ name: a.name,
342
+ savedAs: a.savedAs,
343
+ acceptedAt: a.acceptedAt,
344
+ }));
345
+ return { ok: true, mine, accepted: mineAccepted };
346
+ }
347
+
348
+ function doShareRevoke(username, shareId) {
349
+ const shares = readJson(sharesFile(), {});
350
+ const s = shares[shareId];
351
+ if (!s) return { notFound: '分享不存在' };
352
+ if (s.owner !== username) return { forbidden: '只能撤销自己的分享' };
353
+ delete shares[shareId];
354
+ writeJson(sharesFile(), shares);
355
+ return { ok: true, shareId };
356
+ }
357
+
358
+ // 接受/刷新分享:克隆(或更新)到接受者自己的会话列表。
359
+ // - 首次接受 → 写入同名文件(同名已有不同内容则另存 .shared- 副本,绝不覆盖)
360
+ // - 再次接受:接受者未修改过副本 → 就地刷新;已修改 → 另存新副本(冲突保留双方)
361
+ function doShareAccept(username, shareId) {
362
+ const shares = readJson(sharesFile(), {});
363
+ const s = shares[shareId];
364
+ if (!s) return { notFound: '分享不存在(可能已撤销)' };
365
+ if (s.owner === username) return { error: '不能接受自己的分享' };
366
+ const srcFile = path.join(sessionsDir(s.owner), s.name);
367
+ let content;
368
+ try {
369
+ content = fs.readFileSync(srcFile, 'utf8');
370
+ } catch {
371
+ return { notFound: '分享的会话已被删除' };
372
+ }
373
+ const accepted = readJson(acceptedFile(), {});
374
+ const prev = (accepted[username] || {})[shareId];
375
+ const prevName = prev?.savedAs || s.name;
376
+ const target = path.join(sessionsDir(username), prevName);
377
+ let existing = null;
378
+ try {
379
+ existing = fs.readFileSync(target, 'utf8');
380
+ } catch {}
381
+ let savedAs = prevName;
382
+ let conflict = false;
383
+ if (prev && existing !== null && sha(existing) === prev.copyHash && existing !== content) {
384
+ // 接受者未修改副本:就地刷新到最新
385
+ fs.writeFileSync(target, content, { mode: 0o600 });
386
+ } else if (existing !== null && existing !== content) {
387
+ // 目标名已有不同内容:另存时间戳副本(绝不覆盖)
388
+ savedAs = prevName.replace(/\.jsonl$/, `.shared-${Date.now()}.jsonl`);
389
+ fs.mkdirSync(sessionsDir(username), { recursive: true, mode: 0o700 });
390
+ fs.writeFileSync(path.join(sessionsDir(username), savedAs), content, { mode: 0o600 });
391
+ conflict = true;
392
+ } else {
393
+ fs.mkdirSync(sessionsDir(username), { recursive: true, mode: 0o700 });
394
+ fs.writeFileSync(target, content, { mode: 0o600 });
395
+ }
396
+ const meta = readJson(metaFile(username), {});
397
+ meta[savedAs] = { mtime: Date.now(), size: Buffer.byteLength(content) };
398
+ writeJson(metaFile(username), meta);
399
+ accepted[username] = accepted[username] || {};
400
+ accepted[username][shareId] = { owner: s.owner, name: s.name, savedAs, acceptedAt: Date.now(), copyHash: sha(content) };
401
+ writeJson(acceptedFile(), accepted);
402
+ shares[shareId].pulls = (shares[shareId].pulls || 0) + 1;
403
+ writeJson(sharesFile(), shares);
404
+ log('share-accept', username, shareId, savedAs, conflict ? '(conflict-copy)' : '(refresh)');
405
+ return {
406
+ ok: true,
407
+ shareId,
408
+ savedAs,
409
+ conflict,
410
+ content,
411
+ mtime: meta[savedAs]?.mtime || Date.now(),
412
+ };
413
+ }
414
+
415
+ // ---------- 路由 ----------
416
+ async function handle(req, res) {
417
+ const u = new URL(req.url, 'http://x');
418
+ const p = u.pathname;
419
+ try {
420
+ if (req.method === 'GET' && p === '/healthz') {
421
+ return json(res, 200, { ok: true, service: 'mingdao-sync', version: 1, uptimeSec: Math.floor((Date.now() - startedAt) / 1000) });
422
+ }
423
+ if (req.method === 'POST' && p === '/api/register') {
424
+ if (rateLimited(req, 10)) return json(res, 429, { error: '尝试过于频繁,请稍后再试' });
425
+ const body = await parseBody(req);
426
+ if (body.__error) return json(res, 400, { error: 'JSON 解析失败' });
427
+ if (REGISTRATION === 'closed') return json(res, 403, { error: '注册已关闭(管理员已禁用自助注册)' });
428
+ if (REGISTRATION === 'invite') {
429
+ const code = String(body.inviteCode || '').trim();
430
+ if (!code || !INVITE_CODES.has(code)) return json(res, 403, { error: '需要有效邀请码才能注册' });
431
+ }
432
+ const r = await doRegister(body);
433
+ if (r.error) return json(res, 400, r);
434
+ if (r.conflict) return json(res, 409, r);
435
+ return json(res, 200, r);
436
+ }
437
+ if (req.method === 'POST' && p === '/api/pair') {
438
+ if (rateLimited(req, 10)) return json(res, 429, { error: '尝试过于频繁,请稍后再试' });
439
+ const body = await parseBody(req);
440
+ if (body.__error) return json(res, 400, { error: 'JSON 解析失败' });
441
+ const r = doPair(body);
442
+ if (r.notFound) return json(res, 404, r);
443
+ if (r.unauthorized) return json(res, 401, r);
444
+ return json(res, 200, r);
445
+ }
446
+ // —— 以下均需设备 token ——
447
+ const dev = findDeviceByToken(tokenOf(req));
448
+ if (!dev) return json(res, 401, { error: '未认证:请先 mingdao sync login' });
449
+ // lastSeen 节流写盘:60s 一次,避免高频整表读改写与并发丢失(/api/pair 新增设备被覆盖)
450
+ const nowSeen = Date.now();
451
+ if (nowSeen - (dev.device.lastSeen || 0) > 60000) {
452
+ dev.device.lastSeen = nowSeen;
453
+ const devices = readJson(devicesFile(), {});
454
+ if (devices[dev.username]?.[dev.deviceId]) {
455
+ devices[dev.username][dev.deviceId] = dev.device;
456
+ writeJson(devicesFile(), devices);
457
+ }
458
+ }
459
+
460
+ if (req.method === 'POST' && p === '/api/devices') {
461
+ const list = Object.entries(readJson(devicesFile(), {})[dev.username] || {}).map(([id, d]) => ({
462
+ id,
463
+ name: d.name,
464
+ createdAt: d.createdAt,
465
+ lastSeen: d.lastSeen,
466
+ current: id === dev.deviceId,
467
+ }));
468
+ return json(res, 200, { ok: true, username: dev.username, devices: list });
469
+ }
470
+ if (req.method === 'POST' && p === '/api/sessions/list') {
471
+ return json(res, 200, { ok: true, sessions: doListSessions(dev.username) });
472
+ }
473
+ if (req.method === 'POST' && p === '/api/sessions/push') {
474
+ const body = await parseBody(req);
475
+ if (body.__error) return json(res, 400, { error: 'JSON 解析失败' });
476
+ const r = doPush(dev.username, body);
477
+ if (r.error) return json(res, 400, r);
478
+ return json(res, 200, r);
479
+ }
480
+ if (req.method === 'POST' && p === '/api/sessions/pull') {
481
+ const body = await parseBody(req);
482
+ const r = doPull(dev.username, String(body.name || '').trim());
483
+ if (r.notFound) return json(res, 404, r);
484
+ return json(res, 200, r);
485
+ }
486
+ if (req.method === 'POST' && p === '/api/sessions/delete') {
487
+ const body = await parseBody(req);
488
+ const r = doDelete(dev.username, String(body.name || '').trim());
489
+ if (r.notFound) return json(res, 404, r);
490
+ return json(res, 200, r);
491
+ }
492
+ if (req.method === 'POST' && p === '/api/password') {
493
+ if (rateLimited(req, 10)) return json(res, 429, { error: '尝试过于频繁,请稍后再试' });
494
+ const body = await parseBody(req);
495
+ const r = doChangePassword(dev.username, body);
496
+ if (r.error) return json(res, 400, r);
497
+ if (r.unauthorized) return json(res, 401, r);
498
+ return json(res, 200, r);
499
+ }
500
+ if (req.method === 'POST' && p === '/api/share/create') {
501
+ const body = await parseBody(req);
502
+ const r = doShareCreate(dev.username, String(body.name || '').trim());
503
+ if (r.error) return json(res, 400, r);
504
+ if (r.notFound) return json(res, 404, r);
505
+ return json(res, 200, r);
506
+ }
507
+ if (req.method === 'POST' && p === '/api/share/list') {
508
+ return json(res, 200, doShareList(dev.username));
509
+ }
510
+ if (req.method === 'POST' && p === '/api/share/revoke') {
511
+ const body = await parseBody(req);
512
+ const r = doShareRevoke(dev.username, String(body.shareId || '').trim());
513
+ if (r.notFound) return json(res, 404, r);
514
+ if (r.forbidden) return json(res, 403, r);
515
+ return json(res, 200, r);
516
+ }
517
+ if (req.method === 'POST' && p === '/api/share/accept') {
518
+ if (rateLimited(req, 10)) return json(res, 429, { error: '尝试过于频繁,请稍后再试' });
519
+ const body = await parseBody(req);
520
+ const r = doShareAccept(dev.username, String(body.shareId || '').trim());
521
+ if (r.error) return json(res, 400, r);
522
+ if (r.notFound) return json(res, 404, r);
523
+ return json(res, 200, r);
524
+ }
525
+ return json(res, 404, { error: '未知接口' });
526
+ } catch (e) {
527
+ log('error', p, e.message);
528
+ return json(res, 500, { error: '服务器内部错误' });
529
+ }
530
+ }
531
+
532
+ // ---------- 启动 ----------
533
+ export function runSyncServer({ port, host, dataDir, cert, key } = {}) {
534
+ const dir = dataDir || DEFAULT_DATA_DIR;
535
+ ACTIVE_DIR = dir;
536
+ const listenPort = Number(port ?? PORT);
537
+ const listenHost = host || HOST;
538
+ const certFile = cert || CERT;
539
+ const keyFile = key || KEY;
540
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
541
+ const handler = (req, res) => {
542
+ handle(req, res).catch(() => {});
543
+ };
544
+ let server;
545
+ if (certFile && keyFile) {
546
+ server = https.createServer({ cert: fs.readFileSync(certFile), key: fs.readFileSync(keyFile) }, handler);
547
+ console.log(new Date().toISOString(), 'HTTPS 模式(证书 ' + certFile + ')');
548
+ } else {
549
+ server = http.createServer(handler);
550
+ console.log(new Date().toISOString(), '警告:HTTP 明文模式(仅限内网/过渡,公网请配置 SYNC_CERT/SYNC_KEY)');
551
+ }
552
+ server.listen(listenPort, listenHost, () => {
553
+ console.log(
554
+ new Date().toISOString(),
555
+ `MingDao 同步服务已启动 http${certFile ? 's' : ''}://${listenHost}:${listenPort} · 数据目录 ${dir}`
556
+ );
557
+ });
558
+ return server;
559
+ }
560
+
561
+ // 直接运行:node src/sync-server.js [端口]
562
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
563
+ runSyncServer({ port: Number(process.argv[2]) || undefined });
564
+ }