@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.0
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/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +30 -1
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +0 -0
- package/dsh-plugin/lib/index.js +299 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +0 -0
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node 后端认证基元(`auth.py` 的对应物,M1 地基)。
|
|
3
|
+
*
|
|
4
|
+
* 口径是**需求约定,不得自创**(与 auth.py 逐字对齐):
|
|
5
|
+
* - 新写入的密码:无盐 MD5(32 位小写 hex)——注册/后台重置/改密都走它;
|
|
6
|
+
* - 存量/种子账号:PBKDF2-HMAC-SHA256,10 万轮、16 字节随机盐(盐以 **hex 字符串**存库,
|
|
7
|
+
* 哈希为 32 字节 → 64 位 hex);
|
|
8
|
+
* - `verifyPassword` 按**哈希长度**双格式兼容(32 位 = MD5,否则 PBKDF2),并做常量时间比对。
|
|
9
|
+
*
|
|
10
|
+
* 为什么返回形态与 Python 的元组不同:JS 没有元组,`hashPassword` 返回 `{ hash, salt }`
|
|
11
|
+
* 对象(Python 是 `(hash, salt)` 二元组);`md5Password` 只返回哈希串(Python 的
|
|
12
|
+
* `md5_password` 返回 `(hash, "")`,空盐是常量,单独返回没有信息量)。
|
|
13
|
+
*
|
|
14
|
+
* M6.9 追加会话面四件:`SESSION_TTL` / `COOKIE_NAME` / `sessionExpiry` / `LoginLimiter`
|
|
15
|
+
* (见文件末尾;cookie 头的解析与 Set-Cookie 生成在 `cookie.js`)。
|
|
16
|
+
*
|
|
17
|
+
* 零 npm 依赖:只用 node:crypto。本模块不依赖 db.js(模块图与 Python 侧一致:
|
|
18
|
+
* db.py 单向 import auth)。
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, pbkdf2Sync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
/** PBKDF2 轮数(对齐 auth.py:PBKDF2_ITER) */
|
|
23
|
+
export const PBKDF2_ITER = 100000;
|
|
24
|
+
/** 随机盐字节数(对齐 auth.py:os.urandom(16) → 库里存 32 位 hex) */
|
|
25
|
+
export const SALT_BYTES = 16;
|
|
26
|
+
/** PBKDF2 派生结果字节数(sha256 摘要长度;hex 后 64 位,即「非 MD5 格式」的判据) */
|
|
27
|
+
export const PBKDF2_KEYLEN = 32;
|
|
28
|
+
/** 会话 token 字节数(对齐 auth.py:new_token 的 secrets.token_hex(32) → 64 位 hex) */
|
|
29
|
+
export const TOKEN_BYTES = 32;
|
|
30
|
+
/** 会话有效期(秒):7 天。对齐 auth.py:SESSION_TTL —— 也是 Set-Cookie 的 Max-Age */
|
|
31
|
+
export const SESSION_TTL = 7 * 24 * 3600;
|
|
32
|
+
/** 会话 cookie 名。对齐 auth.py:COOKIE_NAME(cookie **名**比较是精确的,见 cookie.js) */
|
|
33
|
+
export const COOKIE_NAME = 'ts_session';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 无盐 MD5 密码哈希(32 位小写 hex)。
|
|
37
|
+
* 对齐 `auth.md5_password(password)[0]`:`hashlib.md5(password.encode("utf-8")).hexdigest()`。
|
|
38
|
+
* @param {string} password 明文口令(UTF-8)
|
|
39
|
+
* @returns {string} 32 位小写 hex
|
|
40
|
+
*/
|
|
41
|
+
export function md5Password(password) {
|
|
42
|
+
if (typeof password !== 'string') {
|
|
43
|
+
// Python 侧对非 str 会 AttributeError;这里显式抛错,绝不把 null 静默哈希成 "null"
|
|
44
|
+
throw new TypeError('md5Password: password 必须是字符串');
|
|
45
|
+
}
|
|
46
|
+
return createHash('md5').update(password, 'utf8').digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PBKDF2-HMAC-SHA256 派生(HMAC 口令 + 盐 → hex)。私有:只经 hashPassword/verifyPassword 使用。
|
|
51
|
+
* @param {string} password 明文口令(UTF-8)
|
|
52
|
+
* @param {Buffer} salt 盐字节
|
|
53
|
+
* @returns {string} 64 位小写 hex
|
|
54
|
+
*/
|
|
55
|
+
function pbkdf2Hex(password, salt) {
|
|
56
|
+
return pbkdf2Sync(Buffer.from(password, 'utf8'), salt, PBKDF2_ITER, PBKDF2_KEYLEN, 'sha256')
|
|
57
|
+
.toString('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 常量时间比较两个 hex 字符串(长度不等直接 false,防 timingSafeEqual 抛错)。
|
|
62
|
+
* 对齐 `hmac.compare_digest` 的语义(长度不同返回 False 而非异常)。
|
|
63
|
+
* @param {string} aHex
|
|
64
|
+
* @param {string} bHex
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
function constantTimeEqualHex(aHex, bHex) {
|
|
68
|
+
const a = Buffer.from(aHex, 'utf8');
|
|
69
|
+
const b = Buffer.from(bHex, 'utf8');
|
|
70
|
+
if (a.length !== b.length) return false;
|
|
71
|
+
return timingSafeEqual(a, b);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 生成 PBKDF2 密码哈希 + 随机盐(仅用于种子 admin 等存量账号)。
|
|
76
|
+
* 对齐 `auth.hash_password(password)`:`(digest.hex(), os.urandom(16).hex())`。
|
|
77
|
+
* @param {string} password 明文口令(UTF-8)
|
|
78
|
+
* @returns {{hash: string, salt: string}} hash=64 位 hex,salt=32 位 hex
|
|
79
|
+
*/
|
|
80
|
+
export function hashPassword(password) {
|
|
81
|
+
if (typeof password !== 'string') {
|
|
82
|
+
throw new TypeError('hashPassword: password 必须是字符串');
|
|
83
|
+
}
|
|
84
|
+
const salt = randomBytes(SALT_BYTES);
|
|
85
|
+
return { hash: pbkdf2Hex(password, salt), salt: salt.toString('hex') };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 校验口令(双格式兼容 + 常量时间比对)。对齐 `auth.verify_password`:
|
|
90
|
+
* - 口令非字符串(如 JSON null)→ false,不抛异常;
|
|
91
|
+
* - 哈希长度 32 → 按 MD5 验证;否则按 PBKDF2(盐 hex 解码失败/缺盐 → false)。
|
|
92
|
+
* 盐必须是字符串:Python 侧 `bytes.fromhex(None)` 抛 TypeError 被吞掉判 False,这里同判据。
|
|
93
|
+
* @param {string} password 明文口令
|
|
94
|
+
* @param {string} passHash 库里的哈希(32 位 hex = MD5,64 位 hex = PBKDF2)
|
|
95
|
+
* @param {string} saltHex 库里的盐(hex;MD5 格式为空串)
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function verifyPassword(password, passHash, saltHex) {
|
|
99
|
+
if (typeof password !== 'string') return false;
|
|
100
|
+
if (typeof passHash !== 'string') return false;
|
|
101
|
+
if (passHash.length === 32) {
|
|
102
|
+
return constantTimeEqualHex(md5Password(password), passHash);
|
|
103
|
+
}
|
|
104
|
+
if (typeof saltHex !== 'string') return false;
|
|
105
|
+
let digestHex;
|
|
106
|
+
try {
|
|
107
|
+
digestHex = pbkdf2Hex(password, Buffer.from(saltHex, 'hex'));
|
|
108
|
+
} catch {
|
|
109
|
+
// 盐不可解码等异常一律判不匹配(对齐 Python 的 ValueError/TypeError 分支)
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return constantTimeEqualHex(digestHex, passHash);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 新会话 token(64 位小写 hex)。对齐 `auth.new_token`:`secrets.token_hex(32)`。
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function newToken() {
|
|
120
|
+
return randomBytes(TOKEN_BYTES).toString('hex');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 会话过期时间字符串(now + SESSION_TTL)。对齐 `auth.session_expiry`:
|
|
125
|
+
* `time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() + SESSION_TTL))`。
|
|
126
|
+
*
|
|
127
|
+
* 口径要点:
|
|
128
|
+
* - **本地时区**(`time.localtime`),不是 UTC、不是 ISO —— `toISOString()` 在这里是错的;
|
|
129
|
+
* - 定长 `YYYY-MM-DD HH:MM:SS`,词法序即时间序(db 层 `expires_at < now_str()` 直接按
|
|
130
|
+
* 字符串比大小,见 db.getSession);
|
|
131
|
+
* - 与 `db.nowStr()` **同口径**(同样的本地字段手拼)。为什么不直接 `import { nowStr }`:
|
|
132
|
+
* `db.js` 单向 `import` 本模块(seedAdmin 用 hashPassword),反向 import 会成环 ——
|
|
133
|
+
* 模块图与 Python 侧一致(db.py → auth.py 单向)。
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function sessionExpiry() {
|
|
137
|
+
const d = new Date(Date.now() + SESSION_TTL * 1000);
|
|
138
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
139
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
|
|
140
|
+
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 每 IP 每分钟最多 10 次登录尝试的滑动窗口限流(内存态)。对齐 `auth.LoginLimiter`。
|
|
145
|
+
*
|
|
146
|
+
* 调用协议是**两步**,顺序不能动(`server.py:_api_login`):
|
|
147
|
+
* `allow(ip)` → 查库/校验口令 → `record(ip)`
|
|
148
|
+
* 且**无论成败都 record** ⇒ 失败尝试同样计数(这正是限流的意义)。
|
|
149
|
+
*
|
|
150
|
+
* 三个易错语义(照抄 Python,别"改进"):
|
|
151
|
+
* 1. `allow` **不写计数**,只「剪掉窗口外的旧时间戳 + 判数」;只调 `allow` 不调 `record`
|
|
152
|
+
* 时永远放行。计数只在 `record` 里追加(追加的是 `record` 当下的时刻,不是 `allow`
|
|
153
|
+
* 时捕获的 now);
|
|
154
|
+
* 2. `allow` **有副作用**:剪枝结果会写回该 ip 的记录(内存不涨、过期即归零);
|
|
155
|
+
* 3. 判据是**严格小于**:`now - t < window`,恰好等于窗口宽度的记录算过期。
|
|
156
|
+
*
|
|
157
|
+
* 与 Python 的差异(有意):Python 用 `threading.Lock` 保护 `_records`,Node 单线程事件循环
|
|
158
|
+
* 内同步执行、无并发交错 ⇒ 不加锁,语义等价。Python 是**进程级单例**
|
|
159
|
+
* (`server.Handler.limiter = auth.LoginLimiter()`);Node 侧本类只导出,单例由 server.js 持有
|
|
160
|
+
* —— 本模块不建单例,避免单测与真实服务器共享状态。
|
|
161
|
+
*/
|
|
162
|
+
export class LoginLimiter {
|
|
163
|
+
/**
|
|
164
|
+
* @param {number} [maxAttempts=10] 窗口内允许的尝试次数(对齐 Python 缺省 10)
|
|
165
|
+
* @param {number} [window=60] 滑动窗口宽度(秒;对齐 Python 缺省 60)
|
|
166
|
+
*/
|
|
167
|
+
constructor(maxAttempts = 10, window = 60) {
|
|
168
|
+
this.maxAttempts = maxAttempts;
|
|
169
|
+
this.window = window;
|
|
170
|
+
/** ip → 尝试时间戳(浮点秒);对齐 Python `self._records` */
|
|
171
|
+
this._records = new Map();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 本次尝试是否放行(**会剪枝并写回**,不计数)。
|
|
176
|
+
* @param {string} ip
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
allow(ip) {
|
|
180
|
+
const now = Date.now() / 1000;
|
|
181
|
+
const records = (this._records.get(ip) || []).filter((t) => now - t < this.window);
|
|
182
|
+
this._records.set(ip, records);
|
|
183
|
+
return records.length < this.maxAttempts;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 记一次尝试(追加当前时刻)。对齐 Python:`setdefault(ip, []).append(time.time())`。
|
|
188
|
+
* @param {string} ip
|
|
189
|
+
*/
|
|
190
|
+
record(ip) {
|
|
191
|
+
const records = this._records.get(ip);
|
|
192
|
+
if (records) records.push(Date.now() / 1000);
|
|
193
|
+
else this._records.set(ip, [Date.now() / 1000]);
|
|
194
|
+
}
|
|
195
|
+
}
|