leduo-patrol 1.0.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/README.md +217 -0
- package/dist/server/__tests__/access-key.test.js +25 -0
- package/dist/server/__tests__/acp-session.test.js +54 -0
- package/dist/server/__tests__/network.test.js +28 -0
- package/dist/server/__tests__/server-helpers.test.js +18 -0
- package/dist/server/__tests__/session-manager.test.js +152 -0
- package/dist/server/access-key.js +40 -0
- package/dist/server/acp-session.js +300 -0
- package/dist/server/git-diff.js +124 -0
- package/dist/server/index.js +313 -0
- package/dist/server/network.js +62 -0
- package/dist/server/server-helpers.js +23 -0
- package/dist/server/session-manager.js +778 -0
- package/dist/server/shell-session.js +84 -0
- package/dist/web/assets/addon-fit-DX4qG4td.js +1 -0
- package/dist/web/assets/brand-icon.png +0 -0
- package/dist/web/assets/index-BbPJ87hi.js +33 -0
- package/dist/web/assets/index-yhylkmhc.css +1 -0
- package/dist/web/assets/xterm-B-qIQCd3.js +16 -0
- package/dist/web/index.html +14 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# 乐多汪汪队 / leduo-patrol
|
|
2
|
+
|
|
3
|
+
一个部署在服务器上的 Web 控制台,用来通过 ACP 驱动 Claude Code,并在浏览器里接收执行流、工具调用和权限确认。
|
|
4
|
+
|
|
5
|
+
## Showcase
|
|
6
|
+
|
|
7
|
+
### Walkthrough
|
|
8
|
+
|
|
9
|
+
**1. 进入控制台:整体布局**
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
**2. 当前会话 tab(激活态)**
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
**3. 切换到新建会话 tab**
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
**4. 对比两个 tab 状态(session)**
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
**5. 对比两个 tab 状态(create)**
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
**6. 选择会话卡片**
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
**7. 查看新建会话表单**
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
**8. 时间线(SubAgent)展开态**
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
**9. 时间线(SubAgent)折叠态**
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
**10. 右侧审批 + 状态面板**
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
**11. 侧边栏完整视图**
|
|
50
|
+
|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
## 功能列表
|
|
54
|
+
|
|
55
|
+
### 多会话管理
|
|
56
|
+
- **并行多会话**:在同一页面管理多个目录会话,各会话有独立时间线、忙碌状态、权限确认与模式状态
|
|
57
|
+
- **会话切换**:左侧边栏列出所有会话,点击即切换;当前选中会话有明显的左侧边框高亮与背景着色
|
|
58
|
+
- **会话创建**:通过「+ 新建会话」面板指定目录、会话名与默认模式,支持目录浏览器辅助选择路径
|
|
59
|
+
- **会话取消 / 关闭 / 恢复**:支持取消进行中的任务、关闭已完成的会话、在错误态下重连恢复
|
|
60
|
+
- **服务端持久化**:会话状态写入 `~/.leduo-patrol/state.json`,浏览器刷新后自动恢复会话列表与时间线窗口
|
|
61
|
+
|
|
62
|
+
### 指令与模式
|
|
63
|
+
- **多种执行模式**:支持 `default` / `plan` / `acceptEdits` / `dontAsk` / `bypassPermissions`,创建时可设默认模式,发送消息时可临时覆盖
|
|
64
|
+
- **斜杠命令补全**:输入框支持 `/` 前缀的命令名自动补全,列出当前会话可用命令
|
|
65
|
+
- **消息发送队列**:会话忙碌时可继续输入,消息会在当前执行完成后自动发送
|
|
66
|
+
|
|
67
|
+
### 时间线与内容渲染
|
|
68
|
+
- **分页时间线**:按窗口展示时间线条目,支持向上加载历史记录,避免一次性渲染过多数据
|
|
69
|
+
- **SubAgent 树状折叠**:Task / SubAgent 的起止条目自动归组,可折叠/展开子树,并显示子项计数
|
|
70
|
+
- **Markdown 渲染**:Agent 回复与计划详情支持标题、列表、代码块、行内强调与链接
|
|
71
|
+
- **Markdown 表格**:时间线中的 Markdown 表格自动解析并以可读格式呈现
|
|
72
|
+
- **详情弹窗**:工具调用、计划步骤等复杂内容可点击在弹窗中查看完整原始数据
|
|
73
|
+
- **执行计划追踪**:Plan 类型条目解析为步骤列表,每步带完成/待处理状态圆点
|
|
74
|
+
|
|
75
|
+
### 权限确认
|
|
76
|
+
- **右侧审批面板**:待确认的工具调用集中展示在右侧面板,显示工具名与输入预览
|
|
77
|
+
- **批准 / 拒绝 / 附加说明**:可直接在 Web 界面批准或拒绝,也可附上文字说明后拒绝
|
|
78
|
+
- **待处理计数**:顶部导航显示待确认数量,便于多会话时快速定位
|
|
79
|
+
|
|
80
|
+
### 差异与文件
|
|
81
|
+
- **Session Diff 面板**:查看当前会话工作目录的 Git 差异,区分未暂存、已暂存与未跟踪文件
|
|
82
|
+
- **文件内联 Diff**:点击差异文件可在弹窗中查看逐行 diff,支持平铺与按文件两种视图
|
|
83
|
+
- **目录浏览**:创建会话时可在允许根目录范围内浏览子目录,安全限制越权访问
|
|
84
|
+
|
|
85
|
+
### 工具与集成
|
|
86
|
+
- **VS Code Remote SSH**:侧边栏提供快捷按钮,一键在 VS Code 中打开远程目录(需配置环境变量)
|
|
87
|
+
- **内置终端**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY 终端体验(需服务端开启 `LEDUO_ENABLE_SHELL=true`)
|
|
88
|
+
|
|
89
|
+
### 界面与可访问性
|
|
90
|
+
- **访问 Key 认证**:所有请求(HTTP / WebSocket)均需携带 key;浏览器检测到无效 key 时展示输入页
|
|
91
|
+
- **Toast 通知**:后台事件(新会话、错误、会话恢复)以 toast 形式非侵入提示,支持跳转
|
|
92
|
+
- **错误聚合**:全局错误指示器汇总所有应用级错误,点击查看详情
|
|
93
|
+
- **键盘友好**:所有交互元素均有 focus-visible 样式,tab 与会话列表支持完整 ARIA 语义
|
|
94
|
+
- **响应式过渡**:状态切换(tab、会话选中、按钮 hover)均有平滑过渡动画
|
|
95
|
+
|
|
96
|
+
## 环境要求
|
|
97
|
+
|
|
98
|
+
- Node.js 22+
|
|
99
|
+
- 已能正常运行 Claude Code
|
|
100
|
+
- 服务器环境里已配置 `ANTHROPIC_API_KEY`
|
|
101
|
+
|
|
102
|
+
## 启动
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm install
|
|
106
|
+
npm run dev
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
默认情况下:
|
|
110
|
+
|
|
111
|
+
- 前端开发服务运行在自动探测到的可访问内网 IP(优先 `bond* / eth* / ens* / enp*` 网卡)上
|
|
112
|
+
- 后端服务运行在 `PORT`(默认 `3001`,端口冲突时会自动递增寻找可用端口)
|
|
113
|
+
- 启动日志只打印一个推荐访问地址,避免 `br-*`、`veth*` 等虚拟网卡地址干扰
|
|
114
|
+
|
|
115
|
+
- 开发模式下 `Access URL` 会优先打印前端 Web 端口(默认 `5173`,可通过 `LEDUO_PATROL_WEB_PORT` 指定),避免误用 server 端口
|
|
116
|
+
|
|
117
|
+
> 说明:`npm run dev -- --host 0.0.0.0` 不会把参数透传到 `vite`,因为该命令实际启动的是 `concurrently`。本项目会在 `vite.config.ts` 内自动选择一个可访问的内网地址用于开发访问。
|
|
118
|
+
|
|
119
|
+
生产构建:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm run build
|
|
123
|
+
npm start
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
> 开发者向的编程与验证测试技巧请见 `AGENTS.md`。
|
|
127
|
+
|
|
128
|
+
## 可选环境变量
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
PORT=3001
|
|
132
|
+
LEDUO_PATROL_WEB_PORT=5173
|
|
133
|
+
LEDUO_PATROL_APP_NAME=乐多汪汪队
|
|
134
|
+
LEDUO_PATROL_WORKSPACE_PATH=/absolute/workspace/path
|
|
135
|
+
LEDUO_PATROL_ALLOWED_ROOTS=/absolute/workspace/path,/another/allowed/root
|
|
136
|
+
LEDUO_PATROL_SSH_HOST=user@example-host
|
|
137
|
+
LEDUO_PATROL_SSH_PATH=/absolute/workspace/path
|
|
138
|
+
LEDUO_PATROL_VSCODE_URI=vscode://vscode-remote/ssh-remote+user@example-host/absolute/workspace/path
|
|
139
|
+
ANTHROPIC_API_KEY=sk-...
|
|
140
|
+
LEDUO_PATROL_ACCESS_KEY=your-fixed-key
|
|
141
|
+
LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
|
|
142
|
+
LEDUO_ENABLE_SHELL=true
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
如果未设置 `LEDUO_PATROL_VSCODE_URI`,但设置了 `LEDUO_PATROL_SSH_HOST`,服务会自动生成一个 VS Code Remote SSH 链接。
|
|
146
|
+
如果设置了 `LEDUO_PATROL_ALLOWED_ROOTS`,网页中只能连接这些根目录之下的路径;未设置时默认只允许启动命令所在目录。
|
|
147
|
+
如果未设置 `LEDUO_PATROL_WORKSPACE_PATH`,默认工作目录为启动命令所在目录(`process.cwd()`),并在启动日志中提示如何通过环境变量修改。
|
|
148
|
+
如果未设置 `LEDUO_PATROL_ALLOWED_ROOTS`,默认允许根目录同样为启动命令所在目录,并会在启动日志中提示可配置项。
|
|
149
|
+
|
|
150
|
+
## 状态持久化
|
|
151
|
+
|
|
152
|
+
服务会把会话状态写到用户目录下:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
~/.leduo-patrol/state.json
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
其中包含:
|
|
159
|
+
|
|
160
|
+
- 管理中的会话列表
|
|
161
|
+
- 每个会话的工作目录、模式与最近状态
|
|
162
|
+
- 浏览器刷新后用于恢复界面的基础数据
|
|
163
|
+
|
|
164
|
+
## 访问校验 Key
|
|
165
|
+
|
|
166
|
+
服务启动时会自动生成一次性访问 key,并在控制台打印可直接打开的地址。
|
|
167
|
+
|
|
168
|
+
- 开发模式(`npm run dev`)下,`Access URL` 默认指向 Web 端口(默认 `5173`)。
|
|
169
|
+
- 生产模式(`npm start`)下,Web 由同一个 Express 服务静态托管,因此不会出现独立的 Web 监听端口;`Access URL` 会指向 server 端口。若未找到打包后的 `dist/web` 资源,服务会给出错误提示页与启动日志提示。
|
|
170
|
+
|
|
171
|
+
浏览器访问、前端 API 请求和 WebSocket 连接都需要携带这个 `key` 参数;未携带或错误会返回 `401 Unauthorized`。
|
|
172
|
+
|
|
173
|
+
前端页面在检测到 URL 缺少 `key` 或 `key` 失效时,会先展示一个 key 输入页,粘贴后可直接进入控制台。
|
|
174
|
+
|
|
175
|
+
如需固定 key,可设置:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
LEDUO_PATROL_ACCESS_KEY=your-fixed-key
|
|
179
|
+
LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## 已知限制
|
|
183
|
+
|
|
184
|
+
- 当前只实现了 Claude Code
|
|
185
|
+
- 目前终端能力没有暴露给 ACP client,先聚焦网页指令和确认流
|
|
186
|
+
|
|
187
|
+
> SubAgent 树状折叠 demo 的开发说明(含 demo 数据维护、截图流程)请见 `AGENTS.md`。
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
## 打包并发布为 npm 包
|
|
191
|
+
|
|
192
|
+
可以把本项目作为一个可安装的 Node 服务发布:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
npm run build
|
|
196
|
+
npm pack
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`npm pack` 会生成一个 `.tgz` 包,其他人可以这样安装并运行:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
npm install -g ./leduo-patrol-1.0.0.tgz
|
|
203
|
+
leduo-patrol
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
如果要发布到 npm registry:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
npm login
|
|
210
|
+
npm publish
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
建议发布前先检查打包内容:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
npm pack --dry-run
|
|
217
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildAccessCookie, createAccessKey, hasAuthorizedAccessCookie, isAccessKeyAuthorized, } from "../access-key.js";
|
|
4
|
+
test("createAccessKey returns a non-empty hex string", () => {
|
|
5
|
+
const key = createAccessKey();
|
|
6
|
+
assert.match(key, /^[a-f0-9]{48}$/);
|
|
7
|
+
});
|
|
8
|
+
test("isAccessKeyAuthorized validates key in query", () => {
|
|
9
|
+
assert.equal(isAccessKeyAuthorized("/api/state?key=abc", "abc"), true);
|
|
10
|
+
assert.equal(isAccessKeyAuthorized("/api/state?key=wrong", "abc"), false);
|
|
11
|
+
assert.equal(isAccessKeyAuthorized("/api/state", "abc"), false);
|
|
12
|
+
});
|
|
13
|
+
test("isAccessKeyAuthorized allows requests when key enforcement disabled", () => {
|
|
14
|
+
assert.equal(isAccessKeyAuthorized("/api/state", ""), true);
|
|
15
|
+
assert.equal(isAccessKeyAuthorized(undefined, ""), true);
|
|
16
|
+
});
|
|
17
|
+
test("hasAuthorizedAccessCookie validates cookie key", () => {
|
|
18
|
+
assert.equal(hasAuthorizedAccessCookie("leduo_patrol_key=abc", "abc"), true);
|
|
19
|
+
assert.equal(hasAuthorizedAccessCookie("foo=bar; leduo_patrol_key=abc", "abc"), true);
|
|
20
|
+
assert.equal(hasAuthorizedAccessCookie("leduo_patrol_key=wrong", "abc"), false);
|
|
21
|
+
assert.equal(hasAuthorizedAccessCookie(undefined, "abc"), false);
|
|
22
|
+
});
|
|
23
|
+
test("buildAccessCookie creates expected cookie", () => {
|
|
24
|
+
assert.equal(buildAccessCookie("a b"), "leduo_patrol_key=a%20b; Path=/; HttpOnly; SameSite=Lax");
|
|
25
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ClaudeAcpSession } from "../acp-session.js";
|
|
4
|
+
function makeSession() {
|
|
5
|
+
return new ClaudeAcpSession({
|
|
6
|
+
workspacePath: "/tmp/workspace",
|
|
7
|
+
agentBinPath: "claude",
|
|
8
|
+
onEvent: () => undefined,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
test("ClaudeAcpSession.resolveWorkspacePath allows path within workspace", () => {
|
|
12
|
+
const session = makeSession();
|
|
13
|
+
const resolved = session.resolveWorkspacePath("a/b.txt");
|
|
14
|
+
assert.equal(resolved, "/tmp/workspace/a/b.txt");
|
|
15
|
+
});
|
|
16
|
+
test("ClaudeAcpSession.resolveWorkspacePath rejects traversal", () => {
|
|
17
|
+
const session = makeSession();
|
|
18
|
+
assert.throws(() => session.resolveWorkspacePath("../etc/passwd"), /outside workspace/);
|
|
19
|
+
});
|
|
20
|
+
test("ClaudeAcpSession.resolvePermission rejects unknown request", async () => {
|
|
21
|
+
const session = makeSession();
|
|
22
|
+
await assert.rejects(() => session.resolvePermission("missing", "allow"), /not found|already resolved/);
|
|
23
|
+
});
|
|
24
|
+
test("ClaudeAcpSession.cancel is a no-op when no active prompt", async () => {
|
|
25
|
+
const session = makeSession();
|
|
26
|
+
await session.cancel();
|
|
27
|
+
assert.ok(true);
|
|
28
|
+
});
|
|
29
|
+
test("ClaudeAcpSession.shouldIgnoreAgentStderr filters known ACP session/update invalid params noise", () => {
|
|
30
|
+
const session = makeSession();
|
|
31
|
+
const ignored = session.shouldIgnoreAgentStderr(`Error handling notification { method: 'session/update' } { message: 'Invalid params' }`);
|
|
32
|
+
assert.equal(ignored, true);
|
|
33
|
+
});
|
|
34
|
+
test("ClaudeAcpSession.shouldIgnoreAgentStderr keeps non-matching errors", () => {
|
|
35
|
+
const session = makeSession();
|
|
36
|
+
const ignored = session.shouldIgnoreAgentStderr("Error: connection reset");
|
|
37
|
+
assert.equal(ignored, false);
|
|
38
|
+
});
|
|
39
|
+
test("ClaudeAcpSession.resolvePermission forwards optional note via _meta", async () => {
|
|
40
|
+
const session = makeSession();
|
|
41
|
+
const calls = [];
|
|
42
|
+
session.pendingPermissions.set("req-1", {
|
|
43
|
+
resolve: (value) => calls.push(value),
|
|
44
|
+
reject: () => undefined,
|
|
45
|
+
});
|
|
46
|
+
await session.resolvePermission("req-1", "deny", "请先解释影响范围");
|
|
47
|
+
assert.deepEqual(calls[0], {
|
|
48
|
+
outcome: {
|
|
49
|
+
outcome: "selected",
|
|
50
|
+
optionId: "deny",
|
|
51
|
+
_meta: { note: "请先解释影响范围" },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { findAvailablePort, networkTestables } from "../network.js";
|
|
5
|
+
test("network helper skips virtual/bridge interfaces", () => {
|
|
6
|
+
assert.equal(networkTestables.shouldSkipInterface("lo"), true);
|
|
7
|
+
assert.equal(networkTestables.shouldSkipInterface("br-f017ab"), true);
|
|
8
|
+
assert.equal(networkTestables.shouldSkipInterface("vethf0aa"), true);
|
|
9
|
+
assert.equal(networkTestables.shouldSkipInterface("bond0"), false);
|
|
10
|
+
assert.equal(networkTestables.shouldSkipInterface("eth0"), false);
|
|
11
|
+
});
|
|
12
|
+
test("network helper findAvailablePort falls back when preferred port is occupied", async () => {
|
|
13
|
+
const lockedServer = net.createServer();
|
|
14
|
+
await new Promise((resolve) => lockedServer.listen(0, "127.0.0.1", () => resolve()));
|
|
15
|
+
const address = lockedServer.address();
|
|
16
|
+
if (!address || typeof address === "string") {
|
|
17
|
+
throw new Error("Failed to resolve locked server address");
|
|
18
|
+
}
|
|
19
|
+
const fallbackPort = await findAvailablePort(address.port, "127.0.0.1");
|
|
20
|
+
assert.notEqual(fallbackPort, address.port);
|
|
21
|
+
await new Promise((resolve, reject) => lockedServer.close((error) => {
|
|
22
|
+
if (error) {
|
|
23
|
+
reject(error);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
resolve();
|
|
27
|
+
}));
|
|
28
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { formatError, resolveAllowedPath } from "../server-helpers.js";
|
|
5
|
+
test("server helpers formatError handles Error and primitives", () => {
|
|
6
|
+
assert.equal(formatError(new Error("boom")), "boom");
|
|
7
|
+
assert.equal(formatError("plain"), '"plain"');
|
|
8
|
+
assert.equal(formatError(12), "12");
|
|
9
|
+
});
|
|
10
|
+
test("server helpers resolveAllowedPath returns normalized path in root", () => {
|
|
11
|
+
const root = path.resolve("/tmp/repo");
|
|
12
|
+
const resolved = resolveAllowedPath("/tmp/repo/src", [root]);
|
|
13
|
+
assert.equal(resolved, path.resolve("/tmp/repo/src"));
|
|
14
|
+
});
|
|
15
|
+
test("server helpers resolveAllowedPath rejects outside roots", () => {
|
|
16
|
+
const root = path.resolve("/tmp/repo");
|
|
17
|
+
assert.throws(() => resolveAllowedPath("/etc", [root]), /outside allowed roots/);
|
|
18
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { SessionManager, sessionManagerTestables } from "../session-manager.js";
|
|
4
|
+
test("sessionManagerTestables.summarizeToolTitle summarizes from raw input", () => {
|
|
5
|
+
const result = sessionManagerTestables.summarizeToolTitle("tool_exec", { command: "npm test", cwd: "/repo" }, "tool-1");
|
|
6
|
+
assert.equal(result, "npm test · /repo");
|
|
7
|
+
});
|
|
8
|
+
test("sessionManagerTestables.summarizeToolTitle falls back to tool id", () => {
|
|
9
|
+
const result = sessionManagerTestables.summarizeToolTitle("tool_exec", null, "tool-7");
|
|
10
|
+
assert.equal(result, "tool_exec");
|
|
11
|
+
});
|
|
12
|
+
test("sessionManagerTestables.summarizeToolTitle reads subagent description from stringified rawInput", () => {
|
|
13
|
+
const rawInput = JSON.stringify({ rawInput: { description: "探索当前代码库结构" } });
|
|
14
|
+
const result = sessionManagerTestables.summarizeToolTitle("Task", rawInput, "tool-9");
|
|
15
|
+
assert.equal(result, "Task · 探索当前代码库结构");
|
|
16
|
+
});
|
|
17
|
+
test("sessionManagerTestables.labelForMode maps known and unknown modes", () => {
|
|
18
|
+
assert.equal(sessionManagerTestables.labelForMode("plan"), "Plan");
|
|
19
|
+
assert.equal(sessionManagerTestables.labelForMode("custom"), "custom");
|
|
20
|
+
assert.equal(sessionManagerTestables.labelForMode(undefined), "默认模式");
|
|
21
|
+
});
|
|
22
|
+
test("sessionManagerTestables.formatError handles Error and objects", () => {
|
|
23
|
+
assert.equal(sessionManagerTestables.formatError(new Error("boom")), "boom");
|
|
24
|
+
assert.match(sessionManagerTestables.formatError({ code: 1 }), /"code":1/);
|
|
25
|
+
});
|
|
26
|
+
test("sessionManagerTestables.stringifyMaybe and asRecord behave as expected", () => {
|
|
27
|
+
assert.equal(sessionManagerTestables.stringifyMaybe("ok"), "ok");
|
|
28
|
+
assert.equal(sessionManagerTestables.asRecord(["x"]), null);
|
|
29
|
+
assert.deepEqual(sessionManagerTestables.asRecord({ a: 1 }), { a: 1 });
|
|
30
|
+
});
|
|
31
|
+
test("sessionManagerTestables.extractChunkText handles ACP content variants", () => {
|
|
32
|
+
assert.equal(sessionManagerTestables.extractChunkText({ type: "text", text: "hello" }), "hello");
|
|
33
|
+
assert.equal(sessionManagerTestables.extractChunkText({ type: "resource", resource: { text: "from-resource" } }), "from-resource");
|
|
34
|
+
assert.equal(sessionManagerTestables.extractChunkText({ type: "resource_link", uri: "file:///tmp/demo.txt" }), "[resource] file:///tmp/demo.txt");
|
|
35
|
+
assert.equal(sessionManagerTestables.extractChunkText([
|
|
36
|
+
{ type: "text", text: "line-1" },
|
|
37
|
+
{ type: "resource", resource: { text: "line-2" } },
|
|
38
|
+
]), "line-1\nline-2");
|
|
39
|
+
});
|
|
40
|
+
test("SessionManager.getSessionHistory returns bounded page", () => {
|
|
41
|
+
const manager = new SessionManager({ allowedRoots: [process.cwd()], agentBinPath: "claude" });
|
|
42
|
+
const timeline = Array.from({ length: 10 }, (_, index) => ({
|
|
43
|
+
id: String(index),
|
|
44
|
+
kind: "system",
|
|
45
|
+
title: `t-${index}`,
|
|
46
|
+
body: `b-${index}`,
|
|
47
|
+
}));
|
|
48
|
+
manager.sessions.set("s1", {
|
|
49
|
+
snapshot: {
|
|
50
|
+
clientSessionId: "s1",
|
|
51
|
+
title: "demo",
|
|
52
|
+
workspacePath: process.cwd(),
|
|
53
|
+
connectionState: "connected",
|
|
54
|
+
sessionId: "x",
|
|
55
|
+
modes: [],
|
|
56
|
+
defaultModeId: "default",
|
|
57
|
+
currentModeId: "default",
|
|
58
|
+
busy: false,
|
|
59
|
+
timeline: [],
|
|
60
|
+
historyTotal: 0,
|
|
61
|
+
historyStart: 0,
|
|
62
|
+
permissions: [],
|
|
63
|
+
availableCommands: [],
|
|
64
|
+
updatedAt: new Date().toISOString(),
|
|
65
|
+
},
|
|
66
|
+
acpSession: null,
|
|
67
|
+
connectPromise: null,
|
|
68
|
+
fullTimeline: timeline,
|
|
69
|
+
});
|
|
70
|
+
const page = manager.getSessionHistory("s1", 9, 3);
|
|
71
|
+
assert.equal(page.start, 6);
|
|
72
|
+
assert.equal(page.total, 10);
|
|
73
|
+
assert.deepEqual(page.items.map((item) => item.id), ["6", "7", "8"]);
|
|
74
|
+
});
|
|
75
|
+
test("SessionManager.setSessionMode updates default and current mode together", async () => {
|
|
76
|
+
const manager = new SessionManager({ allowedRoots: [process.cwd()], agentBinPath: "claude" });
|
|
77
|
+
const events = [];
|
|
78
|
+
let requestedMode = "";
|
|
79
|
+
manager.subscribe((event) => {
|
|
80
|
+
events.push(event);
|
|
81
|
+
});
|
|
82
|
+
manager.sessions.set("s1", {
|
|
83
|
+
snapshot: {
|
|
84
|
+
clientSessionId: "s1",
|
|
85
|
+
title: "demo",
|
|
86
|
+
workspacePath: process.cwd(),
|
|
87
|
+
connectionState: "connected",
|
|
88
|
+
sessionId: "x",
|
|
89
|
+
modes: ["default", "plan"],
|
|
90
|
+
defaultModeId: "default",
|
|
91
|
+
currentModeId: "default",
|
|
92
|
+
busy: false,
|
|
93
|
+
timeline: [],
|
|
94
|
+
historyTotal: 0,
|
|
95
|
+
historyStart: 0,
|
|
96
|
+
permissions: [],
|
|
97
|
+
availableCommands: [],
|
|
98
|
+
updatedAt: new Date().toISOString(),
|
|
99
|
+
},
|
|
100
|
+
acpSession: {
|
|
101
|
+
setMode: async (modeId) => {
|
|
102
|
+
requestedMode = modeId;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
connectPromise: null,
|
|
106
|
+
fullTimeline: [],
|
|
107
|
+
});
|
|
108
|
+
await manager.setSessionMode("s1", "plan");
|
|
109
|
+
const entry = manager.sessions.get("s1");
|
|
110
|
+
assert.equal(requestedMode, "plan");
|
|
111
|
+
assert.equal(entry.snapshot.defaultModeId, "plan");
|
|
112
|
+
assert.equal(entry.snapshot.currentModeId, "plan");
|
|
113
|
+
assert.deepEqual(events.at(-1), {
|
|
114
|
+
type: "session_mode_changed",
|
|
115
|
+
payload: {
|
|
116
|
+
clientSessionId: "s1",
|
|
117
|
+
defaultModeId: "plan",
|
|
118
|
+
currentModeId: "plan",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
test("sessionManagerTestables.enrichPromptWithToolHints does not append routing hint", () => {
|
|
123
|
+
const untouched = sessionManagerTestables.enrichPromptWithToolHints("请读取配置并写回");
|
|
124
|
+
assert.equal(untouched, "请读取配置并写回");
|
|
125
|
+
const trimmed = sessionManagerTestables.enrichPromptWithToolHints(" 请使用 mcp_acp_Read 读取文件 ");
|
|
126
|
+
assert.equal(trimmed, "请使用 mcp_acp_Read 读取文件");
|
|
127
|
+
});
|
|
128
|
+
test("sessionManagerTestables.formatEditToolChangeMessage summarizes edit diff payload", () => {
|
|
129
|
+
const formatted = sessionManagerTestables.formatEditToolChangeMessage(JSON.stringify([
|
|
130
|
+
{
|
|
131
|
+
oldFileName: "/tmp/a.ts",
|
|
132
|
+
newFileName: "/tmp/a.ts",
|
|
133
|
+
hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 2, lines: ["-x", "+y"] }],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
index: "/tmp/b.ts",
|
|
137
|
+
hunks: [],
|
|
138
|
+
},
|
|
139
|
+
]));
|
|
140
|
+
assert.deepEqual(formatted, {
|
|
141
|
+
title: "Edit 已修改 2 个文件",
|
|
142
|
+
body: "Edit 工具已更新以下文件:\n- /tmp/a.ts(1 处修改)\n- /tmp/b.ts",
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
test("sessionManagerTestables.normalizeAvailableCommandsSnapshot keeps slash names", () => {
|
|
146
|
+
const normalized = sessionManagerTestables.normalizeAvailableCommandsSnapshot([
|
|
147
|
+
{ name: "help", description: "h" },
|
|
148
|
+
{ command: "mcp.list", title: "list" },
|
|
149
|
+
"/help",
|
|
150
|
+
]);
|
|
151
|
+
assert.deepEqual(normalized.map((item) => item.name), ["/help", "/mcp.list"]);
|
|
152
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
export function createAccessKey() {
|
|
3
|
+
return randomBytes(24).toString("hex");
|
|
4
|
+
}
|
|
5
|
+
export function isAccessKeyAuthorized(rawUrl, expectedKey) {
|
|
6
|
+
if (!expectedKey) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (!rawUrl) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const parsedUrl = new URL(rawUrl, "http://localhost");
|
|
13
|
+
return parsedUrl.searchParams.get("key") === expectedKey;
|
|
14
|
+
}
|
|
15
|
+
export function hasAuthorizedAccessCookie(rawCookie, expectedKey) {
|
|
16
|
+
if (!expectedKey) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (!rawCookie) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const segments = rawCookie.split(";");
|
|
23
|
+
for (const segment of segments) {
|
|
24
|
+
const [rawName, ...rawValueParts] = segment.trim().split("=");
|
|
25
|
+
if (rawName !== "leduo_patrol_key") {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const rawValue = rawValueParts.join("=");
|
|
29
|
+
try {
|
|
30
|
+
return decodeURIComponent(rawValue) === expectedKey;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return rawValue === expectedKey;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function buildAccessCookie(expectedKey) {
|
|
39
|
+
return `leduo_patrol_key=${encodeURIComponent(expectedKey)}; Path=/; HttpOnly; SameSite=Lax`;
|
|
40
|
+
}
|