@gavinc-cn/touchstone-dsh 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +336 -2
- package/README.zh.md +318 -0
- package/agents.py +247 -0
- package/auth.py +86 -0
- package/board.py +4551 -0
- package/builtin_assets.py +808 -0
- package/builtin_prompts/free_style/flow.md +301 -0
- package/builtin_prompts/free_style/load_test.md +114 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +41 -0
- package/builtin_prompts/free_style/templates/case_template.md +23 -0
- package/builtin_prompts/free_style/templates/index_template.md +41 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +104 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +5 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +76 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +49 -0
- package/builtin_prompts/free_style/templates/status_template.md +18 -0
- package/chat.py +616 -0
- package/db.py +1407 -0
- package/dsh-plugin/cordis.patch.yml +21 -0
- package/dsh-plugin/install.sh +79 -0
- package/dsh-plugin/lib/agent-driver.js +2036 -0
- package/dsh-plugin/lib/client.bundle.js +397 -0
- package/dsh-plugin/lib/index.js +443 -0
- package/dsh-plugin/scripts/build.mjs +37 -0
- package/dsh-plugin/scripts/prepack-check.mjs +61 -0
- package/dsh-plugin/scripts/select-bundle.mjs +51 -0
- package/dsh-plugin/src/client.js +384 -0
- package/dshdriver.py +702 -0
- package/dshevents.py +470 -0
- package/export_cases.py +160 -0
- package/extensions/dsh-autocommit/asset.json +18 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +361 -0
- package/extensions/dsh-autocommit/pkg/package.json +10 -0
- package/feishu.py +2160 -0
- package/lib.py +245 -0
- package/lifecycle.py +417 -0
- package/loadcase.py +558 -0
- package/loadgen.py +678 -0
- package/localbus.py +91 -0
- package/migrate_p7b.py +191 -0
- package/package.json +79 -4
- package/platcompat.py +272 -0
- package/prompts.py +666 -0
- package/rag.py +553 -0
- package/requirements.txt +9 -0
- package/runner.py +2482 -0
- package/server.py +5379 -0
- package/sessparse.py +732 -0
- package/touchstone.py +513 -0
- package/waitq.py +1319 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +1 -0
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +473 -0
- package/webui/dist-plugin/index.html +18 -0
- package/worktree.py +393 -0
package/README.zh.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Touchstone(试金石)
|
|
4
|
+
|
|
5
|
+
**AI agent 驱动的自动化测试平台。** 把它指向一个代码仓库,它会读代码、写用例、跑用例,
|
|
6
|
+
把失败沉淀成 bug 报告,修完再复测一遍。
|
|
7
|
+
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://www.npmjs.com/package/@gavinc-cn/touchstone-dsh)
|
|
10
|
+
[](https://www.python.org/)
|
|
11
|
+
[](https://nodejs.org/)
|
|
12
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
13
|
+
|
|
14
|
+
[English](README.md) | 中文
|
|
15
|
+
|
|
16
|
+
<img src="docs/images/board.png" alt="Touchstone 开发看板" width="920">
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
## Touchstone 是什么
|
|
21
|
+
|
|
22
|
+
Touchstone 把「我们真该多测一点」变成一条持续转动的流水线。你登记一个项目——源码目录、
|
|
23
|
+
工作目录、环境标签、以及要求 agent 遵守的项目约定——Touchstone 就会通过
|
|
24
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)驱动 AI agent:
|
|
25
|
+
**生成测试用例 → 执行 → 把失败写成 bug 报告 → 修代码 → 再复测**。所有产物都以可直接审阅的形式
|
|
26
|
+
落在磁盘上:Markdown 写的案例库、`bug_report/` 目录、以及逐轮日志。
|
|
27
|
+
|
|
28
|
+
它不是「跑一遍我的测试」的一次性 CLI。Touchstone 的核心是**多轮常驻 agent 会话**:一个任务在同一
|
|
29
|
+
会话里一轮接一轮地跑,上下文不断档,agent 记得自己试过什么。统一队列保证同一个项目同一时刻只有
|
|
30
|
+
一个写者(不会有两个 agent 同时改同一份检出),并且全流程可观测——逐轮实时日志、可渲染的会话窗口、
|
|
31
|
+
以及基于 SSE 的看板。
|
|
32
|
+
|
|
33
|
+
Touchstone 是**本地运行**的:Python 站点 + SQLite + 文件系统工作区。除了你的 `dsh` 宿主发起的模型
|
|
34
|
+
调用之外,数据不出本机。
|
|
35
|
+
|
|
36
|
+
## 主要特性
|
|
37
|
+
|
|
38
|
+
- **六类任务**覆盖完整测试闭环:探索 / 回归 / 压测 / 修复 / 复测 / 修例,每类都走七阶段生命周期
|
|
39
|
+
(生成用例 → 测试 → 生成报告 → 报告分析 → 问题修复 → 重新部署 → 复测)。
|
|
40
|
+
- **案例库而不是测试日志**。用例是可读的 Markdown(每个用例 `case.md` + `status.md`,每层目录一个
|
|
41
|
+
`INDEX.md`)。agent 跨任务读写同一份案例库;用例写错了,你直接改文件就能修。
|
|
42
|
+
- **bug 报告是一等产物**。失败会写成结构化报告(严重程度、复现步骤、日志证据、关联用例),并可在
|
|
43
|
+
界面上直接派生成修复 / 复测 / 修例任务。
|
|
44
|
+
- **按项目的统一队列**。任务、看板卡片、会话消息共用同一个项目级串行位;排队位次、当前占用者与
|
|
45
|
+
判定证据都可查,不靠猜。
|
|
46
|
+
- **面向 agent 开发的看板**。卡片可以起跑、排队、阻塞、完成各自的 agent 会话;需要时在独立的
|
|
47
|
+
`git worktree` 里跑,探索性改动不碰你的主检出。
|
|
48
|
+
- **会话可观测**。完整渲染任意 agent 会话的消息、思考、工具调用与返回;可续聊、可往当前轮注入、
|
|
49
|
+
可压缩、可 fork、也可通过 fork 回退到更早的节点。
|
|
50
|
+
- **内置压测**。压测任务先让 agent 产出场景与脚本,之后由平台自己发压,一个任务可跑 N 次,每次都有
|
|
51
|
+
指标、图表与 Markdown/JSON 报告。
|
|
52
|
+
- **可选集成**。飞书(Lark)推送通知与卡片双向作答;案例库的可选语义检索(RAG)。
|
|
53
|
+
- **自包含技术栈**。后端:Python 标准库 + 4 个小依赖;前端:React + Vite;存储:SQLite + 普通文件。
|
|
54
|
+
Linux 与 Windows 均可运行。
|
|
55
|
+
|
|
56
|
+
## 界面截图
|
|
57
|
+
|
|
58
|
+
开发看板——五列卡片、任务条目内联展示、项目级排队徽标:
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
测试任务——六类任务、轮次、状态与逐任务操作:
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
会话窗口——从 `dsh` 会话存储渲染出的完整对话(消息、思考、工具调用),带回复、审批与模型控件:
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
卡片详情——描述、定时开工、父任务依赖、绑定会话与评论:
|
|
71
|
+
|
|
72
|
+

|
|
73
|
+
|
|
74
|
+
## 工作原理
|
|
75
|
+
|
|
76
|
+
```mermaid
|
|
77
|
+
flowchart LR
|
|
78
|
+
U[浏览器] -->|REST + SSE| S["Touchstone 站点<br/>Python 标准库 · SQLite · 文件"]
|
|
79
|
+
S -->|"/touchstone-agent<br/>常驻会话"| D["dsh 宿主<br/>DeepSeek Harness"]
|
|
80
|
+
D -->|"读 / 改 / 执行"| R["你的代码仓库"]
|
|
81
|
+
D --> C[("案例库<br/>free_style/")]
|
|
82
|
+
D --> B[("bug 报告<br/>bug_report/")]
|
|
83
|
+
S --- C
|
|
84
|
+
S --- B
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
1. **建项目**:Touchstone 记录仓库路径、工作目录、环境标签、可选的项目能力 skill,以及每轮首轮
|
|
88
|
+
提示词都会注入的自由文本约定。
|
|
89
|
+
2. **建任务**:选类型(例如「探索」)与终点阶段(例如「生成报告」),入该项目的队列。
|
|
90
|
+
3. **多轮会话执行**:Touchstone 经 `dsh` 插件新建或续接一个常驻会话,下发本轮提示词;过程实时回流
|
|
91
|
+
到界面,并写入 `<工作目录>/.web/task_<id>_round_<n>.log`。
|
|
92
|
+
4. **产物落到工作目录**:新用例进 `free_style/`,bug 报告进 `bug_report/`,监控状态进 `.live/`——
|
|
93
|
+
你像审代码一样审它们。
|
|
94
|
+
5. **后段任务闭环**:报告可派生修复任务,修复可派生复测任务,被否决的报告可派生修例任务;当任务的
|
|
95
|
+
终点越过「生成报告」时,平台会自动追加后段阶段任务。
|
|
96
|
+
|
|
97
|
+
### 任务类型
|
|
98
|
+
|
|
99
|
+
| 类型 | agent 做什么 | 常见终点 |
|
|
100
|
+
|------|--------------|----------|
|
|
101
|
+
| 探索 | 读代码并把新用例写进案例库 | 生成报告 |
|
|
102
|
+
| 回归 | 重跑已有用例(可限定提交日期范围) | 生成报告 |
|
|
103
|
+
| 压测 | 产出压测场景 + 驱动脚本,随后由平台发压 | 生成报告 |
|
|
104
|
+
| 修复 | 分析 bug 报告、改代码、部署并复测 | 问题修复 / 重新部署 / 复测 |
|
|
105
|
+
| 复测 | 针对一份 bug 报告复验(仅复测 / 部署后复测 / 仅部署) | 复测 |
|
|
106
|
+
| 修例 | 判定报告对应的用例本身有误,改写用例 | 修订用例 |
|
|
107
|
+
|
|
108
|
+
### 核心概念
|
|
109
|
+
|
|
110
|
+
| 概念 | 含义 |
|
|
111
|
+
|------|------|
|
|
112
|
+
| 项目 | 一个仓库 + 工作目录 + 智能体绑定 + 环境标签 + 项目约定 |
|
|
113
|
+
| 任务 | 一个排队单元:任务类型、阶段范围、停止条件与轮次历史 |
|
|
114
|
+
| 轮次 | 任务会话里的一次「提示词 → agent 执行」循环 |
|
|
115
|
+
| 案例库 | `<工作目录>/free_style/`:Markdown 用例、状态文件与逐层索引 |
|
|
116
|
+
| bug 报告 | `<工作目录>/bug_report/<时间戳>_FS_<标题>/`:一条用例的失败档案 |
|
|
117
|
+
| 卡片 | 看板条目,可起跑、排队并驱动自己的 agent 会话 |
|
|
118
|
+
| 队列 | 任务 / 卡片 / 会话消息共享的项目级串行位 |
|
|
119
|
+
|
|
120
|
+
## 快速开始
|
|
121
|
+
|
|
122
|
+
### 环境要求
|
|
123
|
+
|
|
124
|
+
| 依赖 | 版本 | 用途 |
|
|
125
|
+
|------|------|------|
|
|
126
|
+
| Python | 3.12+ | 运行站点(实测 3.12) |
|
|
127
|
+
| Node.js | 22.19+ 或 24+ | 运行 `dsh` 宿主(仅构建前端时 Node 18+ 即可) |
|
|
128
|
+
| DeepSeek Harness(`dsh`) | 当前版本 | agent 执行——插件形态必需 |
|
|
129
|
+
|
|
130
|
+
### 1. 克隆并安装
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
git clone https://github.com/gavinc-cn/touchstone-dsh.git
|
|
134
|
+
cd touchstone-dsh
|
|
135
|
+
python3 -m pip install -r requirements.txt
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 2. 启动站点(独立形态)
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
./touchstone.sh start # 首次启动会自动构建前端,然后监听 127.0.0.1:4601
|
|
142
|
+
./touchstone.sh status # 查看 PID 与实际端口
|
|
143
|
+
./touchstone.sh stop
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
浏览器打开 <http://127.0.0.1:4601>。
|
|
147
|
+
|
|
148
|
+
首次启动会种子一个 `admin` 账号。若未设置 `TS_ADMIN_PASSWORD`,平台会随机生成一次性初始口令,
|
|
149
|
+
**只在启动横幅打印一次**:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
grep '一次性初始口令' .run/server.log # 或:./touchstone.sh status
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
首次登录会被要求改密。想跳过一次性口令(以及强制改密),在首次启动前设置
|
|
156
|
+
`TS_ADMIN_PASSWORD=<你的口令>` 即可。
|
|
157
|
+
|
|
158
|
+
> 独立形态给的是完整站点——项目、案例库、bug 报告、看板、队列、监控都能用;但**agent 执行需要
|
|
159
|
+
> 插件形态**(见下),因为 agent 跑在 `dsh` 宿主进程里。
|
|
160
|
+
|
|
161
|
+
### 3. 以 `dsh` 插件形态运行(推荐:启用 agent 执行)
|
|
162
|
+
|
|
163
|
+
插件把 Touchstone 嵌进 `dsh` Web 界面(侧栏入口 + 面板),并让 Touchstone 能驱动宿主进程内的常驻
|
|
164
|
+
agent 会话。
|
|
165
|
+
|
|
166
|
+
**方式 A —— 从 npm 安装(自包含包:整个平台 + 预构建前端都在包里):**
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npx @deepseek-ai/dsh web # 1) 先启动一次 dsh 宿主(会创建 ~/.dsh/profiles/web)
|
|
170
|
+
dsh plugin --profile web add @gavinc-cn/touchstone-dsh # 2) 装插件包
|
|
171
|
+
# 3) 把包名选进 profile 清单(等同 Plugins 面板里那一行的开关):
|
|
172
|
+
node ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/dsh-plugin/scripts/select-bundle.mjs \
|
|
173
|
+
~/.dsh/profiles/web/package.json
|
|
174
|
+
# 4) 重启 dsh web,打开 http://127.0.0.1:3080,用侧栏里的 Touchstone 入口(Alt+T 开关面板)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
平台自身的 Python 依赖走 pip,npm 不管:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pip install -r ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/requirements.txt
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**不需要配 `repoDir`**:包里带着 `server.py` 与 `webui/dist-plugin`,插件缺省就用包自身目录。
|
|
184
|
+
|
|
185
|
+
**方式 B —— 从检出安装(开发用):**
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
cd touchstone-dsh
|
|
189
|
+
./dsh-plugin/install.sh # 幂等安装本检出到 profile
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
安装脚本会打印**可选**的 profile 覆盖(指向本机工作副本、或指定解释器);不写就用包自身目录:
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
- id: touchstone
|
|
196
|
+
config: { repoDir: /path/to/touchstone-dsh, pythonPath: /path/to/python3 }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
两种形态默认共用同一个数据库(`~/.touchstone/touchstone.db`),运行时互斥:第二个实例会拒绝启动,
|
|
200
|
+
并打印当前占用者。
|
|
201
|
+
|
|
202
|
+
### Windows
|
|
203
|
+
|
|
204
|
+
跨平台启停器提供同一套命令:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
python touchstone.py start | stop | restart | status | build | test
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 界面里的第一步
|
|
211
|
+
|
|
212
|
+
1. 登录;若用的是一次性口令,先去 **设置 → 修改密码**。
|
|
213
|
+
2. **添加项目**——源码目录、工作目录(默认 `<项目目录>/.touchstone`)、环境标签,以及要求 agent
|
|
214
|
+
遵守的约定。案例库与 bug 报告目录由平台派生并自动创建。
|
|
215
|
+
3. 新建任务(或建一张看板卡),等队列调度。逐轮日志实时刷新,会话窗口随对话增长即时渲染。
|
|
216
|
+
|
|
217
|
+
## 配置
|
|
218
|
+
|
|
219
|
+
| 环境变量 | 默认值 | 作用 |
|
|
220
|
+
|----------|--------|------|
|
|
221
|
+
| `TS_PORT` / `TS_HOST` | `4601` / `127.0.0.1` | 监听地址;`TS_HOST=0.0.0.0` 可对局域网开放 |
|
|
222
|
+
| `TS_PYTHON` | `python3` | 启停器使用的解释器(需装好运行依赖) |
|
|
223
|
+
| `TOUCHSTONE_DB` | `~/.touchstone/touchstone.db` | SQLite 数据库,必须放本地盘——文件锁在 CIFS/网盘上不可用 |
|
|
224
|
+
| `TS_ADMIN_PASSWORD` | *(随机,打印一次)* | `admin` 初始口令;设了就跳过强制改密 |
|
|
225
|
+
| `TS_WEB_DIR` | `webui/dist` | 站点托管的前端静态目录 |
|
|
226
|
+
| `TOUCHSTONE_RUN_DIR` | 仓库内 `.run/` | PID / 日志 / 端口标记文件目录 |
|
|
227
|
+
| `TS_EXT_DIR` | `extensions` | 内置资产清单根目录 |
|
|
228
|
+
| `TS_ARCHIVE_SYNC` | `1` | 看板「已完成」与 `dsh` 会话归档双向同步(置 `0` 关闭) |
|
|
229
|
+
| `TS_DSH_PROFILE` / `TS_DSH_PYTHON` | `web` / 自动探测 | 插件安装脚本使用的 `dsh` profile 与解释器 |
|
|
230
|
+
|
|
231
|
+
项目工作目录下的运行路径:
|
|
232
|
+
|
|
233
|
+
| 路径 | 内容 |
|
|
234
|
+
|------|------|
|
|
235
|
+
| `free_style/` | 案例库(用例、状态文件、逐层索引) |
|
|
236
|
+
| `bug_report/` | bug 报告,一份报告一个目录 |
|
|
237
|
+
| `.web/` | 逐轮日志、对话日志、压测指标与报告 |
|
|
238
|
+
| `.live/live.json` | 监控页数据源(agent 运行状态) |
|
|
239
|
+
| `board_media/` | 粘贴到看板卡片的附件 |
|
|
240
|
+
|
|
241
|
+
## 能力与边界
|
|
242
|
+
|
|
243
|
+
| 领域 | 支持情况 |
|
|
244
|
+
|------|----------|
|
|
245
|
+
| agent 会话 | 新建、续接、回复、往当前轮注入、中断、compact 压缩、fork、按 fork 回退、模型与权限档切换 |
|
|
246
|
+
| 会话可观测 | 完整对话渲染、归档状态、附件预览、实时状态流(零状态轮询) |
|
|
247
|
+
| 任务 | 六类型、七阶段、停止条件、日期范围限定、逐轮日志、重启、继续 |
|
|
248
|
+
| 看板 | 五列卡片、拖拽、排队位次、父任务依赖、定时开工、回收站、独立 worktree |
|
|
249
|
+
| 案例库 | 派生索引、按变更文件做复测粗筛、可选语义检索(RAG) |
|
|
250
|
+
| 压测 | 场景校验、并发发压、秒级指标、图表、每次运行独立的 Markdown/JSON 报告 |
|
|
251
|
+
| 通知 | 飞书推送、入站指令、在聊天卡片上直接作答 |
|
|
252
|
+
| 多用户 | 登录、admin/普通用户、所有按项目进入的接口都做归属校验 |
|
|
253
|
+
|
|
254
|
+
如实说明的已知限制:
|
|
255
|
+
|
|
256
|
+
- **界面目前只有中文。**
|
|
257
|
+
- **agent 执行必须走插件形态**;独立形态是「没有 agent 的站点」。
|
|
258
|
+
- **回退与「压缩并新建」是语义近似**——回退是从更早的节点另起一支(原会话保留),不是原地截断。
|
|
259
|
+
- **模型请求失败没有错误横幅**——`dsh` 没有对应事件通道,会话窗只能显示宿主真实回报的内容。
|
|
260
|
+
- **Windows 启停器已实现,但 `dsh` 在 Windows 上尚未实机验证。**
|
|
261
|
+
- **端到端测试脚本不随本仓分发**(见下),因此 `test-full` 与 `test-ui` 只会提示跳过。
|
|
262
|
+
|
|
263
|
+
## 仓库结构
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
touchstone-dsh/
|
|
267
|
+
├── server.py HTTP 站点:路由、鉴权、REST、SSE、静态前端
|
|
268
|
+
├── runner.py 任务执行引擎与按项目调度
|
|
269
|
+
├── waitq.py 统一队列模型(「下一个谁跑」的唯一权威)
|
|
270
|
+
├── board.py 开发看板:卡片、门禁、agent 会话、归档同步
|
|
271
|
+
├── chat.py 会话对话:排队消息与注入
|
|
272
|
+
├── prompts.py 逐轮提示词模板(按任务类型与阶段)
|
|
273
|
+
├── sessparse.py 读取 dsh 会话存储,还原成可渲染的对话
|
|
274
|
+
├── dshdriver.py dsh 进程内 agent 驱动的客户端
|
|
275
|
+
├── dshevents.py 把 dsh 状态流折进进程内注册表
|
|
276
|
+
├── rag.py 案例库可选语义检索
|
|
277
|
+
├── loadgen.py 内置压测引擎(场景、指标、报告)
|
|
278
|
+
├── feishu.py 飞书集成
|
|
279
|
+
├── db.py SQLite 数据层
|
|
280
|
+
├── builtin_prompts/ 注入首轮提示词的提示词资产
|
|
281
|
+
├── dsh-plugin/ dsh 插件包(Node 薄壳 + 驱动 + 客户端 bundle)
|
|
282
|
+
├── extensions/ 设置页可一键安装的内置资产
|
|
283
|
+
├── webui/ React + Vite 前端
|
|
284
|
+
├── tests/ pytest / vitest 套件与隔离实例夹具
|
|
285
|
+
└── touchstone.sh|py|cmd 启停入口(Linux / 跨平台 / Windows)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## 开发
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
./touchstone.sh build # 安装前端依赖并构建 webui/dist
|
|
292
|
+
./touchstone.sh test # 后端 pytest + 前端 vitest
|
|
293
|
+
./touchstone.sh test-full # 追加隔离实例 e2e(本仓不随附这些脚本)
|
|
294
|
+
./touchstone.sh test-ui # Playwright e2e(需站点在跑;本仓不随附这些脚本)
|
|
295
|
+
cd webui && npm run dev # 前端开发服务器 :5173,/api 代理到 :4601
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
可选的提交门禁:
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
git config core.hooksPath githooks # pre-commit 跑测试快层(SKIP_TS_TESTS=1 单次跳过)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
测试矩阵与前置条件见 [tests/README.md](tests/README.md)。
|
|
305
|
+
|
|
306
|
+
## 安全说明
|
|
307
|
+
|
|
308
|
+
- 站点默认只监听 `127.0.0.1` 并要求登录。用 `TS_HOST=0.0.0.0` 对外暴露等于把它放进你的网络里,
|
|
309
|
+
请有意为之。
|
|
310
|
+
- 所有按项目进入的接口先做归属校验;`admin` 是唯一管理员,不可改名或删除。
|
|
311
|
+
- agent 以你配置的权限档运行,默认是限制最松的一档(免审批)。方便,但也锋利:**把它指向你在意的
|
|
312
|
+
仓库之前,请先看清项目约定与权限档。**
|
|
313
|
+
- 口令存储沿用本项目既定的算法(新口令为无盐 MD5,历史种子账号为 PBKDF2-SHA256)。请把它当作
|
|
314
|
+
本地开发级别的口令存储来对待。
|
|
315
|
+
|
|
316
|
+
## 许可证
|
|
317
|
+
|
|
318
|
+
[MIT](LICENSE) © Touchstone contributors
|
package/agents.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Touchstone 本机 AI CLI 扫描:供项目配置"智能体路径"下拉选择。
|
|
3
|
+
|
|
4
|
+
扫描顺序:PATH(shutil.which)→ 常见安装目录,找到即止;版本探测 --version 2s 超时。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import glob
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
# 支持的 agent 清单: (展示名, 可能的可执行名元组)。
|
|
15
|
+
# 2026-10-03(P7b 退场删除,B1):产品入口收敛为 **dsh 插件形态**,kimi / opencode /
|
|
16
|
+
# claude / hermes 四个 CLI 条目不再扫描(对应族已删除);dsh 可执行仍扫——它是
|
|
17
|
+
# 「插件·进程内」条目的 path/version 来源。
|
|
18
|
+
# 2026-10-04(B7):扫描结果只剩「插件·进程内」一条(见 scan_agents),本表的展示名
|
|
19
|
+
# 自此仅作文档用,实际用于定位 dsh 可执行(PATH → 常见安装目录)。
|
|
20
|
+
AGENT_DEFS = (
|
|
21
|
+
("dsh(DeepSeek Harness)", ("dsh",)),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# 退场族的旧虚拟条目前缀(kimi web / opencode serve 驱动,P7b 删除):扫描不再产出,
|
|
25
|
+
# 仅「存量 agent_path 的归族」仍要认出来 —— 归 RETIRED_FAMILY 由起跑处给明确报错。
|
|
26
|
+
LEGACY_WEB_PREFIXES = ("kimi-web:", "opencode-web:")
|
|
27
|
+
|
|
28
|
+
# 退场族标记(P7b,2026-10-03):agent_path 仍是旧族可执行名或旧虚拟前缀时归此族。
|
|
29
|
+
# 存量库里的绑定由 migrate_p7b.py 改写(`dsh-plugin:<dsh 路径>` 或空串);未改写前
|
|
30
|
+
# 起跑处据此返回 RETIRED_MSG,而不是静默按 dsh 跑(dsh 宿主里没有旧会话)或
|
|
31
|
+
# AttributeError(驱动模块已删)。
|
|
32
|
+
RETIRED_FAMILY = "retired"
|
|
33
|
+
RETIRED_MSG = "该智能体族已下线,请在项目设置里改绑 dsh 插件"
|
|
34
|
+
|
|
35
|
+
# dsh 插件驱动前缀(路线 A):项目 agent_path 存 "dsh-plugin:" 表示走 **dsh 宿主进程内**
|
|
36
|
+
# 的 agent 运行时(dsh-plugin/lib/agent-driver.js 持有的会话池),不再 spawn
|
|
37
|
+
# `dsh --profile headless` 子进程。前缀后可跟任意提示串(如 dsh 可执行路径)但平台不使用:
|
|
38
|
+
# 驱动地址经环境变量 TS_AGENT_DRIVER_URL 下发(见 dshdriver.py),不依赖本机路径。
|
|
39
|
+
DSH_PLUGIN_PREFIX = "dsh-plugin:"
|
|
40
|
+
|
|
41
|
+
# 「插件·进程内」条目展示名(前端「智能体」下拉的唯一选项)。P7b B7(2026-10-04)
|
|
42
|
+
# 起由 scan_agents 直接产出为**唯一一条**,裸 dsh CLI 行不再返回(见 scan_agents docstring)。
|
|
43
|
+
PLUGIN_ENTRY_NAME = "dsh(插件·进程内)"
|
|
44
|
+
|
|
45
|
+
# PATH 之外常出现的安装目录(按已知部署习惯)
|
|
46
|
+
EXTRA_DIRS = [
|
|
47
|
+
"/usr/local/bin",
|
|
48
|
+
os.path.expanduser("~/.local/bin"),
|
|
49
|
+
"/bin/versions/node",
|
|
50
|
+
os.path.expanduser("~/.npm-global/bin"),
|
|
51
|
+
]
|
|
52
|
+
# Windows:npm 全局 shim(kimi.cmd / claude.cmd 等)所在目录
|
|
53
|
+
# (shutil.which 走 PATH 也能命中,此目录兜底 PATH 被裁剪的安装方式)
|
|
54
|
+
if os.name == "nt":
|
|
55
|
+
_appdata = os.environ.get("APPDATA")
|
|
56
|
+
if _appdata:
|
|
57
|
+
EXTRA_DIRS.append(os.path.join(_appdata, "npm"))
|
|
58
|
+
# 递归进 versions 下各 node 版本的 bin/
|
|
59
|
+
VERSIONS_BIN_GLOB = "/bin/versions/node"
|
|
60
|
+
# conda/mamba 各虚拟环境的 bin 目录(glob 模式,覆盖常见安装位置;
|
|
61
|
+
# 如 hermes 常被装进某个 conda 环境的 bin/)
|
|
62
|
+
CONDA_ENV_BIN_GLOBS = [
|
|
63
|
+
"/opt/miniconda3/envs/*/bin",
|
|
64
|
+
"/opt/anaconda3/envs/*/bin",
|
|
65
|
+
os.path.expanduser("~/miniconda3/envs/*/bin"),
|
|
66
|
+
os.path.expanduser("~/anaconda3/envs/*/bin"),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _search_extra(name):
|
|
71
|
+
"""在扩展目录中查找 name 可执行文件,返回路径或 None。"""
|
|
72
|
+
candidates = []
|
|
73
|
+
for d in EXTRA_DIRS:
|
|
74
|
+
if not os.path.isdir(d):
|
|
75
|
+
continue
|
|
76
|
+
if d == VERSIONS_BIN_GLOB:
|
|
77
|
+
for ver in os.listdir(d):
|
|
78
|
+
candidates.append(os.path.join(d, ver, "bin", name))
|
|
79
|
+
else:
|
|
80
|
+
candidates.append(os.path.join(d, name))
|
|
81
|
+
for p in candidates:
|
|
82
|
+
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
83
|
+
return p
|
|
84
|
+
# conda/mamba 虚拟环境 bin:glob 展开各环境目录后逐个匹配
|
|
85
|
+
for pat in CONDA_ENV_BIN_GLOBS:
|
|
86
|
+
for bindir in sorted(glob.glob(pat)):
|
|
87
|
+
p = os.path.join(bindir, name)
|
|
88
|
+
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
89
|
+
return p
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _probe_version(path):
|
|
94
|
+
"""运行 --version(2s 超时),失败返回空串。
|
|
95
|
+
cwd 用系统临时目录(跨平台;原写死 /tmp 在 Windows 不存在会静默失败)。"""
|
|
96
|
+
try:
|
|
97
|
+
out = subprocess.run([path, "--version"], capture_output=True, text=True,
|
|
98
|
+
encoding="utf-8", errors="replace",
|
|
99
|
+
timeout=2, cwd=tempfile.gettempdir())
|
|
100
|
+
text = (out.stdout or out.stderr).strip().splitlines()
|
|
101
|
+
return text[0][:120] if text else ""
|
|
102
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def scan_agents():
|
|
107
|
+
"""扫描本机 dsh 可执行,返回**产品入口单条目** [{name, path, version, found}]。
|
|
108
|
+
|
|
109
|
+
P7b B1 起扫描只认 dsh;**B7(2026-10-04)再收敛为只回一条**「dsh(插件·进程内)」
|
|
110
|
+
(`path = dsh-plugin:<dsh 路径>`,进程内会话池:多轮续接/插话/实时事件流)。
|
|
111
|
+
|
|
112
|
+
为什么仍要定位 dsh 可执行:该条目的 path(前缀后的提示串)与 version 都取自它
|
|
113
|
+
(`--version` 探测);但**裸 dsh CLI 行不再返回给前端**——它属退场族
|
|
114
|
+
(`agent_family` 对 basename == "dsh" 判 `retired`,起跑处必报 `RETIRED_MSG`),
|
|
115
|
+
列进「智能体」下拉就是纯误选陷阱(用户 2026-10-04 报告「同一个 dsh 出现两条」)。
|
|
116
|
+
|
|
117
|
+
dsh 未找到时返回同名的 `found=False` 条目(前端显示「dsh(插件·进程内)未找到」),
|
|
118
|
+
保持「下拉里有且只有这一条」的读侧不变量。
|
|
119
|
+
"""
|
|
120
|
+
path = ""
|
|
121
|
+
for _name, bins in AGENT_DEFS:
|
|
122
|
+
for b in bins:
|
|
123
|
+
path = shutil.which(b) or _search_extra(b)
|
|
124
|
+
if path:
|
|
125
|
+
break
|
|
126
|
+
if path:
|
|
127
|
+
break
|
|
128
|
+
return [{"name": PLUGIN_ENTRY_NAME,
|
|
129
|
+
"path": DSH_PLUGIN_PREFIX + path if path else "",
|
|
130
|
+
"version": _probe_version(path) if path else "",
|
|
131
|
+
"found": bool(path)}]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def agent_family(agent_path):
|
|
135
|
+
"""归族:只可能返回 `"dsh_plugin"`(可跑)或 `RETIRED_FAMILY`(退场族,起跑处报错)。
|
|
136
|
+
|
|
137
|
+
- `dsh-plugin:` 前缀 → `dsh_plugin`:产品唯一入口(dsh 宿主进程内 agent 运行时,
|
|
138
|
+
见 dshdriver.py)。前缀判定必须先于 basename——该串不是有效可执行路径;
|
|
139
|
+
- 空 `agent_path`(db 默认 `''`)与未知可执行名 → `dsh_plugin`:2026-10-03 起
|
|
140
|
+
默认族由 kimi 改为 dsh 插件(产品入口收敛);
|
|
141
|
+
- kimi / opencode / claude / hermes / deepseek(dsh CLI) 旧可执行名,以及
|
|
142
|
+
`kimi-web:` / `opencode-web:` 两个旧虚拟前缀 → `RETIRED_FAMILY`(P7b 删除的
|
|
143
|
+
五族,2026-10-03)。存量项目由 `migrate_p7b.py` 改写 agent_path;未改写的
|
|
144
|
+
项目在起跑处得到 `RETIRED_MSG` 明确提示。
|
|
145
|
+
"""
|
|
146
|
+
ap = (agent_path or "").strip()
|
|
147
|
+
if ap.startswith(DSH_PLUGIN_PREFIX):
|
|
148
|
+
return "dsh_plugin"
|
|
149
|
+
if ap.startswith(LEGACY_WEB_PREFIXES):
|
|
150
|
+
return RETIRED_FAMILY # kimi-web: / opencode-web:(旧虚拟条目)
|
|
151
|
+
name = os.path.basename(ap).lower()
|
|
152
|
+
if any(h in name for h in ("kimi", "opencode", "claude", "hermes", "deepseek")):
|
|
153
|
+
return RETIRED_FAMILY # 旧 CLI 族可执行名
|
|
154
|
+
if name == "dsh":
|
|
155
|
+
# dsh CLI(--profile headless)已退场:只保留插件形态(每轮独立会话、无续接)
|
|
156
|
+
return RETIRED_FAMILY
|
|
157
|
+
# 空 agent_path(db 默认 '')与未知可执行名的默认族:本项目已收敛为「产品入口
|
|
158
|
+
# 只有 dsh 插件形态」(2026-10-03 方案 §七 D1/D2),故默认走 dsh_plugin;
|
|
159
|
+
# 要跑其他族须在项目里显式选。此前默认 "kimi",会让未配置项目静默按 kimi 跑。
|
|
160
|
+
return "dsh_plugin"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# 各 agent 族的 skill 目录约定(2026-08-31 从各 CLI 安装产物与本机磁盘实况验证,勿凭记忆改):
|
|
164
|
+
# 族 -> (用户级目录元组, 项目级相对目录元组),SKILL.md 位于各目录的子目录下。
|
|
165
|
+
# 产品只剩 dsh 插件一族:`dsh-skill-filesystem` 实测扫描 `<project>/.agents/skills`
|
|
166
|
+
# (project-agents)与 `~/.agents/skills`(user-agents),另有 dsh 私有根
|
|
167
|
+
# `<project>/.dsh/skills` 与 `~/.dsh/skills`;四者一并扫,与 dsh 侧技能面板看到的一致。
|
|
168
|
+
# `builtin_assets` 的清单校验与本表判 `family`,落点取各元组首个目录。
|
|
169
|
+
SKILL_DIRS_BY_FAMILY = {
|
|
170
|
+
"dsh_plugin": (("~/.dsh/skills", "~/.agents/skills"),
|
|
171
|
+
(".dsh/skills", ".agents/skills")),
|
|
172
|
+
}
|
|
173
|
+
# SKILL.md 相对扫描根的最大目录深度:2 层(分类/技能名,如 <类>/<名>/SKILL.md)
|
|
174
|
+
SKILL_WALK_MAX_DEPTH = 2
|
|
175
|
+
# SKILL.md frontmatter 的 name/description 行解析(简单 key: value,足够通用格式)
|
|
176
|
+
_SKILL_FRONT_RE = re.compile(r"\A---\s*\n(.*?)\n---", re.S)
|
|
177
|
+
_SKILL_NAME_RE = re.compile(r"^name:[ \t]*(.+)$", re.M)
|
|
178
|
+
_SKILL_DESC_RE = re.compile(r"^description:[ \t]*(.+)$", re.M)
|
|
179
|
+
# description 截断长度(避免超长描述撑爆下拉)
|
|
180
|
+
_SKILL_DESC_LIMIT = 200
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_skill_md(path, fallback_name):
|
|
184
|
+
"""解析一个 SKILL.md 的 frontmatter,返回 (name, description)。
|
|
185
|
+
|
|
186
|
+
name 缺省用所在目录名;description 缺省空串(截断到 _SKILL_DESC_LIMIT);
|
|
187
|
+
文件读失败返回 None,调用方跳过该条。
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
191
|
+
head = f.read(4096)
|
|
192
|
+
except OSError:
|
|
193
|
+
return None
|
|
194
|
+
m = _SKILL_FRONT_RE.match(head)
|
|
195
|
+
body = m.group(1) if m else head # 无 frontmatter 时退化为头部全文(容错)
|
|
196
|
+
nm = _SKILL_NAME_RE.search(body)
|
|
197
|
+
dm = _SKILL_DESC_RE.search(body)
|
|
198
|
+
return (nm.group(1).strip() if nm else fallback_name,
|
|
199
|
+
dm.group(1).strip()[:_SKILL_DESC_LIMIT] if dm else "")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _scan_skill_dir(root, source):
|
|
203
|
+
"""扫描一个 skill 根目录(深度受限),返回 [{name, description, source}]。
|
|
204
|
+
|
|
205
|
+
目录不存在/不可读返回空列表;深度按相对根的目录层数限制
|
|
206
|
+
(SKILL.md 最深位于第 SKILL_WALK_MAX_DEPTH 层)。
|
|
207
|
+
"""
|
|
208
|
+
skills = []
|
|
209
|
+
if not root or not os.path.isdir(root):
|
|
210
|
+
return skills
|
|
211
|
+
base_depth = root.rstrip(os.sep).count(os.sep)
|
|
212
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
213
|
+
rel = dirpath.rstrip(os.sep).count(os.sep) - base_depth
|
|
214
|
+
# 只在深度未达上限前继续下钻(SKILL.md 允许出现在 rel <= MAX_DEPTH 层)
|
|
215
|
+
dirnames[:] = [d for d in dirnames if rel < SKILL_WALK_MAX_DEPTH]
|
|
216
|
+
if "SKILL.md" not in filenames:
|
|
217
|
+
continue
|
|
218
|
+
parsed = _parse_skill_md(os.path.join(dirpath, "SKILL.md"),
|
|
219
|
+
os.path.basename(dirpath.rstrip(os.sep)))
|
|
220
|
+
if parsed:
|
|
221
|
+
skills.append({"name": parsed[0], "description": parsed[1],
|
|
222
|
+
"source": source})
|
|
223
|
+
return skills
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def scan_skills(agent_path, project_dir=""):
|
|
227
|
+
"""列出指定 agent CLI 可用的 skill(用户级 + 项目级),供项目能力下拉。
|
|
228
|
+
|
|
229
|
+
目录约定见 SKILL_DIRS_BY_FAMILY(dsh 插件族四目录);同名 skill 项目级覆盖
|
|
230
|
+
用户级;agent_path 为空(未配置)返回空列表,无目录约定的族返回空。
|
|
231
|
+
返回 [{name, description, source}],按 name 排序;纯文件扫描不做缓存。
|
|
232
|
+
"""
|
|
233
|
+
ap = (agent_path or "").strip()
|
|
234
|
+
if not ap:
|
|
235
|
+
return []
|
|
236
|
+
family = agent_family(ap)
|
|
237
|
+
user_dirs, proj_dirs = SKILL_DIRS_BY_FAMILY.get(family, ((), ()))
|
|
238
|
+
found = {}
|
|
239
|
+
for d in user_dirs:
|
|
240
|
+
for s in _scan_skill_dir(os.path.expanduser(d), "user"):
|
|
241
|
+
found.setdefault(s["name"], s)
|
|
242
|
+
pd = (project_dir or "").strip()
|
|
243
|
+
if pd:
|
|
244
|
+
for d in proj_dirs:
|
|
245
|
+
for s in _scan_skill_dir(os.path.join(pd, d), "project"):
|
|
246
|
+
found[s["name"]] = s # 项目级覆盖用户级
|
|
247
|
+
return sorted(found.values(), key=lambda s: s["name"])
|
package/auth.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Touchstone 认证:PBKDF2 密码哈希、session 签发与校验、登录限流。
|
|
3
|
+
|
|
4
|
+
纯标准库。db 层会话表由 server.py 组装调用(本模块不依赖 db.py)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
PBKDF2_ITER = 100_000
|
|
15
|
+
SESSION_TTL = 7 * 24 * 3600 # 7 天
|
|
16
|
+
COOKIE_NAME = "ts_session"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hash_password(password):
|
|
20
|
+
"""返回 (哈希 hex, 盐 hex)。PBKDF2-HMAC-SHA256,10 万轮,16 字节随机盐。
|
|
21
|
+
|
|
22
|
+
仅用于种子 admin 等存量账号;新写入的密码统一用 md5_password(见下)。
|
|
23
|
+
"""
|
|
24
|
+
salt = os.urandom(16)
|
|
25
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITER)
|
|
26
|
+
return digest.hex(), salt.hex()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def md5_password(password):
|
|
30
|
+
"""返回 (MD5 hex, 盐空串)。
|
|
31
|
+
|
|
32
|
+
按需求约定:注册用户/后台重置/修改密码统一用 MD5 存储(无盐)。仅 32 位 hex。
|
|
33
|
+
"""
|
|
34
|
+
return hashlib.md5(password.encode("utf-8")).hexdigest(), ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify_password(password, pass_hash, salt_hex):
|
|
38
|
+
"""常量时间比对,防时序侧信道。
|
|
39
|
+
|
|
40
|
+
兼容两种存量格式:长度为 32 的 hex 按 MD5 验证(新注册/重置的密码),
|
|
41
|
+
否则按 PBKDF2(种子 admin 等旧账号)。
|
|
42
|
+
密码非字符串(如 JSON null)直接判不匹配,不抛异常。
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(password, str):
|
|
45
|
+
return False
|
|
46
|
+
if len(pass_hash) == 32:
|
|
47
|
+
return hmac.compare_digest(md5_password(password)[0], pass_hash)
|
|
48
|
+
try:
|
|
49
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"),
|
|
50
|
+
bytes.fromhex(salt_hex), PBKDF2_ITER)
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
return False
|
|
53
|
+
return hmac.compare_digest(digest.hex(), pass_hash)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def new_token():
|
|
57
|
+
"""新会话 token(32 字节随机 hex)。"""
|
|
58
|
+
return secrets.token_hex(32)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def session_expiry():
|
|
62
|
+
"""会话过期时间字符串(now + 7 天)。"""
|
|
63
|
+
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() + SESSION_TTL))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LoginLimiter:
|
|
67
|
+
"""每 IP 每分钟最多 10 次登录尝试的滑动窗口限流(内存态即可)。"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, max_attempts=10, window=60):
|
|
70
|
+
self.max_attempts = max_attempts
|
|
71
|
+
self.window = window
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
self._records = {} # ip -> [尝试时间戳]
|
|
74
|
+
|
|
75
|
+
def allow(self, ip):
|
|
76
|
+
"""返回 True 表示本次尝试放行(尝试计数在 check 后须调用 record)。"""
|
|
77
|
+
now = time.time()
|
|
78
|
+
with self._lock:
|
|
79
|
+
records = [t for t in self._records.get(ip, []) if now - t < self.window]
|
|
80
|
+
self._records[ip] = records
|
|
81
|
+
return len(records) < self.max_attempts
|
|
82
|
+
|
|
83
|
+
def record(self, ip):
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._records.setdefault(ip, []).append(time.time())
|
|
86
|
+
|