feat: v0.2.0 文件传输(MCP 工具 + HTTP 直传)与安全/正确性修复
文件功能: - 新增 file_read / file_write / file_stat 工具(文本/base64/图片,分块读取,原子替换写入,SHA-256) - 新增 file_link 工具与 GET/PUT /files 端点:HMAC 签名限时链接或 Bearer 认证, 支持 Range、HEAD、chunked、Expect: 100-continue,大文件不经过模型上下文 - 新增 --file-root / --max-upload;配置白名单但未限定目录时文件功能自动禁用 安全: - 白名单改为程序名精确匹配,shell 模式禁止元字符,shell_write 输入逐行受检;黑名单检查每一段 - HTTP 签发 Mcp-Session-Id,按会话隔离 shell 会话与取消,支持 DELETE 终止会话 - HTTP 客户端不能再用 notifications/exit 关停服务;token 常量时间比较 正确性: - 后台进程占用输出管道时 run_command 不再挂起(detached=true) - shell_read 改用绝对偏移,修复缓冲裁剪后错位;被信号杀死的会话能报告退出;阻塞读可取消 - 输出与分块读取不再切断 UTF-8 多字节字符 - shell_* 结果改为标准 content + structuredContent - POST + SSE 的 tools/call 在同一响应流中推送进度与结果 - /mcp 支持 Expect: 100-continue(curl 发送大请求时不再白等 1 秒) - CLI 参数优先于环境变量;SIGTERM 在 stdio 模式下也能清理退出;Windows 支持 Ctrl+C 清理与优雅退出 资源:并发工具调用 / HTTP 连接 / shell 会话数量上限,空闲会话自动回收 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目
|
||||
|
||||
bolt 是一个单文件 MCP 服务器(`src/main.rs`),让 MCP 客户端在主机上执行 shell 命令、读写和传输文件。它手写实现了 JSON-RPC 2.0 + MCP(协议版本 2024-11-05 / 2025-03-26 / 2025-06-18),支持两种传输:stdio(默认)和 Streamable HTTP(`--http <addr>`)。依赖只有 `serde_json`,Unix 下再加 `libc`;base64、SHA-256、HMAC、百分号编码、随机数(`/dev/urandom` / `BCryptGenRandom`)都是手写的。请保持这一点:不引入新 crate,不引入 async runtime,也不引入 HTTP/MCP 框架。代码注释、日志信息和文档都用中文,新增内容请保持一致。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
cargo build --release # 产物 target/release/bolt(release 配置:lto=fat、codegen-units=1、strip)
|
||||
cargo test --release # 单元测试(位于 src/main.rs 末尾的 mod tests)
|
||||
cargo test --release test_policy # 按名称运行单个单元测试
|
||||
cargo clippy --release --all-targets # 当前无警告,请保持
|
||||
cargo check --release --all-targets --target x86_64-pc-windows-gnu # 在 Linux 上检查 Windows 分支能否编译
|
||||
./target/release/bolt --help # 查看 CLI 参数;每个参数都可用对应的 BOLT_* 环境变量作为回退(CLI 优先)
|
||||
./target/release/bolt --http 127.0.0.1:8080 --token t --allow "git,ls" --file-root /tmp
|
||||
```
|
||||
|
||||
- `test_run_command_basic` 和 `test_shell_session` 带 `#[cfg(unix)]`(使用 `sh -c`、`printf`、`sleep`),Windows 上会跳过。Windows 下服务器使用 `cmd /C`。
|
||||
- 端到端测试在 `docs/bolt_stdio_test.py`(stdio)和 `docs/bolt_http_test.py`(HTTP,会额外起一个带 `--token` 和 `--file-root` 的实例)。它们对管道使用 `select`,需要 Unix 主机;二进制路径默认是 `target/release/bolt`,可用 `BOLT_BIN` 覆盖,例如 `BOLT_BIN=/path/bolt python3 docs/bolt_http_test.py`。HTTP 测试占用端口 18080 和 18081。
|
||||
|
||||
## 架构(src/main.rs,自上而下)
|
||||
|
||||
文件用 `// ----` 横幅注释分段:常量 → 配置 → 出站通道 → 应用状态 → JSON-RPC 辅助 → 命令策略 → 编码/摘要/随机数 → run_command 执行引擎 → shell 会话 → 文件 → 工具列表 → MCP 分发 → 客户端作用域 → stdio 传输 → HTTP 传输 → 信号与清理 → main → 测试。
|
||||
|
||||
- **`App` / `Shared = Arc<App>`**:全局状态。包含 `Config`、按配置生成的 `tools`、`inflight` 表(`作用域\n请求id` → `Arc<Killable>`)、`sessions`(shell 会话,带 `owner` 作用域)、`mcp_sessions`(`Mcp-Session-Id` → 最近活动时间)、并发/连接计数,以及 `file_link` 的签名密钥(启动时随机生成)。
|
||||
- **`Ctx`(作用域 + 出口)**:每个请求都带一个 `Ctx { scope, sink, base_url }`。`scope` 用来隔离取消和 shell 会话:stdio 下是 `"stdio"`;HTTP 下是 `Mcp-Session-Id`,不带该头的请求共用匿名作用域 `"-"`。`sink` 决定通知和响应发往哪里:`Sink::Stdio`、`Sink::Hub(hub, scope)`(只推给该作用域的 GET SSE 流)、`Sink::Chan`(当前 POST 的 SSE 响应流)。`base_url` 由 `Host` / `X-Forwarded-*` 推出,只有 `file_link` 用到。
|
||||
- **`process_message(v, app, ctx, async_exec)`**:两种传输共用的唯一入口。通知返回 `None`。`tools/call` 先通过 `CallSlot` 占用一个并发名额(超限时返回 `isError` 结果),再注册 `Killable`。`async_exec` 为 true(stdio)时在 `bolt-tool` 线程中执行,完成后经 `ctx.sink` 发送响应,所以 stdio 响应可能乱序;为 false(HTTP)时同步执行。`notifications/exit` 只在 stdio 作用域生效。
|
||||
- **`exec_tool_call`**:按工具名分发。工具执行失败返回 `Ok(tool_error(..))`(`isError: true`),成功结果用 `tool_ok(v)` 包成 `content` + `structuredContent`。参数错误或未知工具返回 `Err((code, msg))`,转成 JSON-RPC 错误。参数用 `req_str` / `opt_u64` 等辅助函数读取。新增工具时请保持这一区分,并在 `build_tools(cfg)` 里补上 schema。文件工具只有在 `files_enabled` 时才会出现在列表中,`file_link` 只在 HTTP 模式下出现。
|
||||
- **进程生命周期**:Unix 下子进程放进独立进程组(`pre_exec` + `setpgid(0,0)`),`kill_tree` 对 `-pid` 发 `SIGKILL`;Windows 下用 `taskkill /T /F`。`Killable` 处理"pid 注册前就收到取消"的情况。主进程被收割后会把 pid 清零,防止 pid 复用时误杀。主进程退出后,读取线程最多再等 `PIPE_DRAIN_GRACE`;如果后台进程还占着管道,就标记 `detached` 直接返回,读取线程转入后台继续排空但不再保存。
|
||||
- **Shell 会话**:`SessBuf` 里的偏移都是绝对字节偏移(`base` / `read`),裁剪缓冲前部不会让读取位置错位。退出信息也放在同一把锁里,`Condvar` 不会丢唤醒。`shell_read` 会避免切断 UTF-8 字符,每 200ms 检查一次是否被取消。`janitor_loop` 每 30 秒回收空闲会话和过期的 MCP 会话。
|
||||
- **文件**:`resolve_path` 是所有文件访问(工具和 `/files`)唯一的路径入口。已存在的路径用 `canonicalize` 解析符号链接;不存在的路径先规范化最近的已存在祖先,再拼上剩余部分(剩余部分不允许 `.` / `..`),最后检查是否在 `--file-root` 之内。`write_file` 的 overwrite 模式写同目录临时文件后 rename。错误类型是 `FErr = (HTTP 状态码, 信息)`,工具和 HTTP 共用。
|
||||
- **HTTP 传输**:手写 HTTP/1.1,每个连接一个线程(`conn_loop`,受 `MAX_HTTP_CONNS` 限制)。检查顺序:OPTIONS(CORS)→ Origin 白名单 → Bearer token(`ct_eq` 常量时间比较;带 `sig` 的 `/files` 请求改为在 `handle_files` 里校验 HMAC 签名)→ 路由到 `handle_mcp` 或 `handle_files`。读取 body 之前出错一律关闭连接。请求体统一经 `BodyReader` 读取,支持 Content-Length 和 chunked。`handle_*` 对读写端是泛型的,单元测试直接喂内存缓冲。
|
||||
- **退出**:Unix 下屏蔽 SIGINT 和 SIGTERM,由 `sigwait` 线程接收信号,执行 `shutdown_cleanup` 后直接 `process::exit`(stdio 主线程可能阻塞在读 stdin 上)。Windows 下用 `SetConsoleCtrlHandler` 做同样的事。
|
||||
- **命令策略**(`policy_check(cfg, command, shell)`):allow/deny 都按程序名精确匹配(会去掉路径、引号、转义和 `.exe` 等扩展名)。配置了 allow 时,shell 模式禁止元字符;deny 会检查按元字符切分后每一段的程序名。作用范围:`run_command`、`shell_open` 的 shell 名称、`shell_write` 写入的每一行。配置了 allow 但没有 `--file-root` 时文件功能禁用。
|
||||
|
||||
各种上限和默认值是文件顶部的 `const`(`MAX_HTTP_BODY`、`MAX_CONCURRENT_CALLS`、`SESSION_IDLE`、`DEFAULT_READ_LEN`、`DEFAULT_MAX_UPLOAD` 等)。
|
||||
|
||||
## 部署(deploy/)
|
||||
|
||||
- `install.sh` 是需要 root 的 POSIX sh 安装脚本。自动识别 Linux(systemd:`/usr/local/bin/bolt`、`/etc/bolt.env`、`bolt.service`)或 macOS(launchd 标签 `io.jocay.bolt`,plist 放在 `/Library/Application Support/bolt/`,启用开机自启时复制到 `/Library/LaunchDaemons/`)。默认从 Gitea Release 下载 `bolt-<linux|darwin>-<amd64|arm64>`(版本由 `BOLT_VERSION` 指定,默认 `v0.1.0`;可用 `BOLT_DL_URL` 或 `--binary` 覆盖)。`BOLT_DL_TOKEN`(下载认证)与 `--token`(服务器的 Bearer 令牌)相互独立。
|
||||
- `bolt.service` 和 `bolt.env.example` 用于手动 systemd 部署。监听地址写在 unit 文件的 `ExecStart` 里,其余配置来自 `/etc/bolt.env` 中的 `BOLT_*` 变量。
|
||||
- 修改 CLI 参数时,需同步更新 `parse_args`/`print_help`、`install.sh`(参数解析及其生成的 env/plist)、`bolt.env.example` 和 README 中的参数表。
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bolt"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bolt"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "bolt — 极速单文件 MCP 命令执行服务器 (stdio / Streamable HTTP)"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# bolt — 极速单文件 MCP 命令执行服务器
|
||||
|
||||
让 AI(Claude Code / Claude Desktop / Cursor 等 MCP 客户端)通过 **MCP 协议**连接后,在服务器上执行 shell 命令。
|
||||
让 AI(Claude Code / Claude Desktop / Cursor 等 MCP 客户端)通过 **MCP 协议**连接后,在服务器上执行 shell 命令、读写和传输文件。
|
||||
|
||||
- **单文件**:全部代码在 `src/main.rs`(约 1900 行,其中 400 行为单元测试),仅依赖 `serde_json`(Unix 下加 `libc`)
|
||||
- **单文件**:全部代码在 `src/main.rs`,仅依赖 `serde_json`(Unix 下加 `libc`);base64 / SHA-256 / HMAC 均为手写
|
||||
- **零框架**:手写 JSON-RPC 2.0 + MCP 协议 + Streamable HTTP 服务器,无 async runtime、无重量级依赖
|
||||
- **双传输**:`stdio`(本地 AI 客户端默认)与 `Streamable HTTP`(远程连接)
|
||||
|
||||
@@ -27,7 +27,7 @@ claude mcp add --transport stdio bolt -- /abs/path/to/bolt
|
||||
```bash
|
||||
bolt --http 127.0.0.1:8080 # 仅本机
|
||||
bolt --http 0.0.0.0:8080 --token mysecret # 对外 + Bearer 认证
|
||||
bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl"
|
||||
bolt --http 0.0.0.0:8080 --token mysecret --allow "git,docker,curl" --file-root /srv/data
|
||||
|
||||
# Claude Code 连接远程
|
||||
claude mcp add --transport http bolt http://127.0.0.1:8080/mcp
|
||||
@@ -38,12 +38,35 @@ claude mcp add --transport http bolt http://127.0.0.1:8080/mcp
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `run_command` | 执行 shell 命令:完整 shell 语法(管道/重定向/`&&`)、`cwd`、`env`、`timeout_ms`(超时自动杀进程树)、`max_output_bytes`(输出截断防膨胀)、`shell=false` 直接执行二进制;客户端可传 `_meta.progressToken` 接收进度通知,支持 `notifications/cancelled` 取消 |
|
||||
| `shell_open` | 开启持久化交互式 shell 会话(`cd`、环境变量等状态跨次保留),可指定 `shell`(sh/bash/zsh/fish)与 `cwd` |
|
||||
| `shell_open` | 开启持久化交互式 shell 会话(`cd`、环境变量等状态跨次保留),可指定 `shell`(sh/bash/zsh/fish)与 `cwd`;空闲 30 分钟自动回收 |
|
||||
| `shell_write` | 向会话写入输入(自动补换行) |
|
||||
| `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出) |
|
||||
| `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出),可被取消 |
|
||||
| `shell_close` | 关闭并杀掉会话进程树 |
|
||||
| `file_read` | 读取文件:UTF-8 文本按文本返回,图片按 image 返回,其余按 base64;`offset` + `length` 分块读取,不会切断多字节字符 |
|
||||
| `file_write` | 写入文件:`overwrite`(默认,临时文件 + rename 原子替换,保留原权限)/ `create`(已存在则失败)/ `append`;`encoding=base64` 写二进制;`mkdirs` 自动建目录;返回 SHA-256 |
|
||||
| `file_stat` | 文件信息(是否存在、类型、大小、修改时间、权限),可选计算 SHA-256 |
|
||||
| `file_link` | 仅 HTTP 模式:生成带 HMAC 签名、限时有效的 `/files` 直传链接,并给出可在客户端本机执行的 `curl` 命令 |
|
||||
|
||||
`run_command` 返回:`exit_code` / `signal` / `timed_out` / `cancelled` / `duration_ms` / `stdout` / `stderr` / 截断标志等(同时提供 `content` 与 `structuredContent`)。
|
||||
所有工具结果都同时提供 `content` 与 `structuredContent`(`file_read` 为两块 `content`:元数据 JSON + 文件内容)。
|
||||
|
||||
`run_command` 返回:`exit_code` / `signal` / `timed_out` / `cancelled` / `detached` / `duration_ms` / `stdout` / `stderr` / 截断标志等。命令退出后若仍有后台进程占用输出管道(如 `python -m http.server &`),等待 300ms 后返回 `detached=true`,后台进程继续运行。
|
||||
|
||||
## 文件传输
|
||||
|
||||
MCP 工具的参数和结果都要经过模型上下文,所以分两层:
|
||||
|
||||
- **小文件**(配置、脚本、日志片段、截图):直接用 `file_read` / `file_write`,stdio 和 HTTP 都可用。
|
||||
- **大文件**(HTTP 模式):数据走 `/files` 端点,不经过模型。
|
||||
- AI 调用 `file_link` 拿到签名链接,在客户端本机执行返回的 `curl` 命令即可:
|
||||
|
||||
```bash
|
||||
curl -fsSL -o ./backup.tar.gz 'http://host:8080/files?path=/srv/data/backup.tar.gz&exp=...&sig=...' # 下载
|
||||
curl -fsS -T ./dataset.zip 'http://host:8080/files?path=/srv/data/dataset.zip&exp=...&sig=...&mode=overwrite&mkdirs=0' # 上传
|
||||
```
|
||||
|
||||
- 也可以直接带 Bearer 令牌访问:`curl -H "Authorization: Bearer $T" -T f.bin "http://host:8080/files?path=/srv/data/f.bin&mkdirs=1"`。
|
||||
- 支持 `Range` 断点续传(206)、`HEAD`、chunked 上传、`Expect: 100-continue`;上传同样是原子替换,失败不破坏原文件,响应里带 SHA-256。
|
||||
- 签名覆盖方法、路径、写入模式和过期时间,链接只能用于签名时指定的那一个操作;签名密钥在进程启动时随机生成,重启后旧链接失效。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -51,59 +74,71 @@ CLI 参数(环境变量 `BOLT_*` 为回退):
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--http <addr>` | 无(stdio) | 启用 Streamable HTTP 监听地址,如 `127.0.0.1:8080` |
|
||||
| `--http <addr>` | 无(stdio) | 启用 Streamable HTTP 监听地址,如 `127.0.0.1:8080`(`:8080` 等价于 `127.0.0.1:8080`) |
|
||||
| `--token <t>` | 无 | HTTP 要求 `Authorization: Bearer <t>` |
|
||||
| `--allow <a,b>` | 空 | 命令白名单(命令首词前缀匹配;非空时仅允许列表内命令) |
|
||||
| `--deny <a,b>` | 空 | 命令黑名单(前缀匹配) |
|
||||
| `--allow <a,b>` | 空 | 命令白名单(按程序名精确匹配;非空时 shell 模式禁止元字符) |
|
||||
| `--deny <a,b>` | 空 | 命令黑名单(按程序名匹配命令中每一段,尽力而为) |
|
||||
| `--timeout-ms <n>` | 30000 | 命令默认超时(工具参数可覆盖,0 = 无限) |
|
||||
| `--cwd <dir>` | 当前 | 命令默认工作目录 |
|
||||
| `--cwd <dir>` | 当前 | 命令与相对文件路径的默认工作目录 |
|
||||
| `--max-output <n>` | 1M | 单流输出捕获上限(支持 K/M/G) |
|
||||
| `--allow-origin <url>` | localhost/127.0.0.1 | 额外允许的 HTTP Origin(防 DNS rebinding) |
|
||||
| `--file-root <d1,d2>` | 不限 | 文件工具与 `/files` 只能访问这些目录;配置了 `--allow` 时必须设置,否则文件功能禁用 |
|
||||
| `--max-upload <n>` | 2G | `PUT /files` 单次上传上限(支持 K/M/G) |
|
||||
|
||||
```bash
|
||||
export BOLT_TOKEN=xxx BOLT_ALLOW="git,docker" BOLT_TIMEOUT_MS=60000
|
||||
export BOLT_TOKEN=xxx BOLT_ALLOW="git,docker" BOLT_TIMEOUT_MS=60000 BOLT_FILE_ROOT=/srv/data
|
||||
bolt --http 127.0.0.1:8080
|
||||
```
|
||||
|
||||
## 协议实现(2025-06-18 规范)
|
||||
|
||||
- `initialize`(协议版本协商,支持 2024-11-05 / 2025-03-26 / 2025-06-18)、`ping`、`tools/list`、`tools/call`、`shutdown`
|
||||
- `notifications/initialized`、`notifications/cancelled`、`notifications/exit`、`notifications/progress`(进度上报)
|
||||
- `notifications/initialized`、`notifications/cancelled`、`notifications/exit`(仅 stdio 生效)、`notifications/progress`(进度上报)
|
||||
- 工具执行错误以 `isError: true` 返回;协议错误返回标准 JSON-RPC 错误码(-32601/-32602/-32700 等)
|
||||
- Streamable HTTP:POST 请求(通知 → 202;请求 → JSON 或 SSE 响应)、GET 长连接 SSE 流(服务器通知广播 + 10s 心跳)、CORS、Origin 校验、Bearer 认证、无状态(不要求会话 ID)
|
||||
- Streamable HTTP:
|
||||
- `initialize` 时签发 `Mcp-Session-Id`;携带该头的请求按会话隔离 shell 会话与取消,未知/过期 ID 返回 404,`DELETE /mcp` 终止会话并清理其资源;不带该头的客户端共用一个匿名作用域(兼容无状态客户端)
|
||||
- POST:通知 → 202;请求 → JSON 或 SSE。`Accept` 含 `text/event-stream` 的 `tools/call` 会在同一个 SSE 响应里先推进度通知、最后推结果
|
||||
- GET:长连接 SSE 流(接收本会话的服务器通知 + 10s 心跳)
|
||||
- 支持 chunked 请求体、CORS、Origin 校验、Bearer 认证
|
||||
|
||||
## 性能
|
||||
## 性能与资源
|
||||
|
||||
- 单线程事件循环 + 复用缓冲区,无锁热路径;输出计数用原子操作,进度推送不占主循环
|
||||
- 复用缓冲区,输出计数用原子操作,进度推送不占主循环;stdio 每个工具调用一个工作线程,HTTP 每个连接一个线程
|
||||
- 子进程独占进程组(`setpgid`),超时/取消按进程组 `SIGKILL`,连带杀掉整棵进程树,无僵尸
|
||||
- 上限:并发工具调用 128、HTTP 连接 512、shell 会话 64(空闲 30 分钟回收)、MCP 会话 1024(空闲 24 小时回收)
|
||||
- 实测(本机,含 Python 客户端逐字节 I/O 开销):
|
||||
- ping 往返吞吐 ≈ **14,700 msg/s**
|
||||
- 并发 `echo` 命令平均 **4.7 ms/次**(进程 spawn + 捕获输出)
|
||||
- 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`,二进制约 0.7MB
|
||||
- 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
cargo test --release # 8 个单元测试(协议分发/策略/HTTP 解析/执行引擎/会话)
|
||||
cargo test --release # 单元测试(策略/编码与摘要/HTTP 解析/文件读写与目录限制/会话缓冲/执行引擎等)
|
||||
cargo test --release test_files # 只跑某一个
|
||||
```
|
||||
|
||||
集成测试脚本在 `docs/`(stdio 12 项、HTTP 19 项,覆盖超时/取消/进程树/进度/并发/auth/Origin):
|
||||
端到端测试脚本在 `docs/`(需要 Unix 主机;二进制路径默认 `target/release/bolt`,可用 `BOLT_BIN` 指定):
|
||||
|
||||
```bash
|
||||
python3 /tmp/bolt_stdio_test.py # stdio 端到端
|
||||
python3 /tmp/bolt_http_test.py # HTTP 端到端
|
||||
python3 docs/bolt_stdio_test.py # stdio 端到端
|
||||
python3 docs/bolt_http_test.py # HTTP 端到端(含 Mcp-Session-Id、/files、签名链接)
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token`
|
||||
- 该服务器等于远程 root shell 入口,务必配置白名单(`--allow`)与认证后再暴露公网
|
||||
- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程
|
||||
- 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token`(未设置时启动日志会警告)
|
||||
- 该服务器等于远程 root shell 入口,务必配置认证后再暴露公网
|
||||
- 白名单(`--allow`):按程序名精确匹配,shell 模式下禁止 `; & | $ \` < > ( ) { }` 和换行,防止 `git status; rm -rf ~` 这类串联绕过;`shell_write` 写入的每一行同样受检。注意白名单内的程序如果自身能执行任意命令(`sh`、`env`、`docker`、`find -exec`、`git -c ...`),白名单就约束不了它
|
||||
- 黑名单(`--deny`):检查命令每一段的程序名(去掉路径、引号、转义和前置的 `VAR=x`),但仍可能被包装命令绕过,只适合防误操作
|
||||
- 文件功能等同于任意写文件(可写 `authorized_keys`、crontab),所以配置白名单时必须用 `--file-root` 限定目录,否则文件功能自动禁用;路径会解析符号链接并拒绝 `..` 逃逸
|
||||
- 不同 MCP 会话之间的 shell 会话与取消相互隔离;HTTP 客户端无法通过 `notifications/exit` 关停服务
|
||||
- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程;收到 SIGINT/SIGTERM(Windows 为 Ctrl+C)时先清理全部子进程再退出
|
||||
|
||||
## 平台
|
||||
|
||||
- Linux / macOS(完整功能:进程组杀、信号处理)
|
||||
- Windows(基础功能:`cmd /C`、`taskkill /T` 杀进程树;无信号优雅退出)
|
||||
- Windows(基础功能:`cmd /C`、`taskkill /T` 杀进程树、Ctrl+C 清理退出)
|
||||
## 在其他设备上部署(最快方式)
|
||||
|
||||
一键脚本支持 Linux(systemd)和 macOS(launchd),自动识别 amd64 / arm64;Apple Silicon 即使在 Rosetta 终端中也优先选择 arm64。安装需要 root 权限。
|
||||
@@ -164,6 +199,8 @@ macOS 服务的工作目录为 `/var/root`,PATH 包含 `/opt/homebrew/bin` 和 `
|
||||
| `--token <t>` | Bearer 认证令牌(公网必填) |
|
||||
| `--allow <a,b>` / `--deny <a,b>` | 命令白/黑名单 |
|
||||
| `--timeout-ms <n>` | 默认命令超时 |
|
||||
| `--file-root <d1,d2>` | 文件功能允许访问的目录(配置 `--allow` 时必填,否则文件功能禁用) |
|
||||
| `--max-upload <n>` | `PUT /files` 单次上传上限(默认 2G) |
|
||||
| `--binary <路径或URL>` | 跳过下载,用本地已拷贝的二进制(如 `scp` 过去的) |
|
||||
| `--no-start` / `--no-enable` | 只安装不启动 / 不设开机自启 |
|
||||
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
# 认证令牌(必填,若监听 0.0.0.0 对外暴露)
|
||||
#BOLT_TOKEN=请改成强随机串
|
||||
|
||||
# 命令白名单(非空时仅允许列出的命令,用逗号分隔)
|
||||
# 命令白名单(按程序名精确匹配,逗号分隔;非空时 shell 模式禁止管道/重定向/命令串联等元字符)
|
||||
#BOLT_ALLOW=git,docker,curl
|
||||
|
||||
# 命令黑名单(前缀匹配)
|
||||
# 命令黑名单(按程序名精确匹配命令中每一段;尽力而为,不能替代白名单)
|
||||
#BOLT_DENY=rm,mkfs
|
||||
|
||||
# 文件工具与 /files 允许访问的目录(逗号分隔;设置了 BOLT_ALLOW 时必须配置,否则文件功能禁用)
|
||||
#BOLT_FILE_ROOT=/srv/data,/tmp
|
||||
|
||||
# PUT /files 单次上传上限(支持 K/M/G,默认 2G)
|
||||
#BOLT_MAX_UPLOAD=2G
|
||||
|
||||
# 默认命令超时毫秒(0 = 无限)
|
||||
#BOLT_TIMEOUT_MS=30000
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
# --allow <a,b> 命令白名单
|
||||
# --deny <a,b> 命令黑名单
|
||||
# --timeout-ms <n> 命令默认超时(默认 30000)
|
||||
# --file-root <d1,d2> 文件工具与 /files 只能访问这些目录(配置 --allow 时必须设置,否则文件功能禁用)
|
||||
# --max-upload <n> PUT /files 单次上传上限,支持 K/M/G(默认 2G)
|
||||
# --binary <src> 二进制来源:本地路径 或 http(s) URL(默认从 Gitea Release 下载)
|
||||
# --no-start 只安装不启动
|
||||
# --no-enable 不设置开机自启
|
||||
@@ -24,6 +26,8 @@ TOKEN="${BOLT_TOKEN:-}"
|
||||
ALLOW=""
|
||||
DENY=""
|
||||
TIMEOUT_MS="${BOLT_TIMEOUT_MS:-30000}"
|
||||
FILE_ROOT=""
|
||||
MAX_UPLOAD=""
|
||||
BINARY_SRC=""
|
||||
NO_START=0
|
||||
NO_ENABLE=0
|
||||
@@ -35,6 +39,8 @@ while [ $# -gt 0 ]; do
|
||||
--allow) ALLOW="${2:-}"; shift 2 ;;
|
||||
--deny) DENY="${2:-}"; shift 2 ;;
|
||||
--timeout-ms) TIMEOUT_MS="${2:-}"; shift 2 ;;
|
||||
--file-root) FILE_ROOT="${2:-}"; shift 2 ;;
|
||||
--max-upload) MAX_UPLOAD="${2:-}"; shift 2 ;;
|
||||
--binary) BINARY_SRC="${2:-}"; shift 2 ;;
|
||||
--no-start) NO_START=1; shift ;;
|
||||
--no-enable) NO_ENABLE=1; shift ;;
|
||||
@@ -150,6 +156,8 @@ if [ "$OS" = Darwin ]; then
|
||||
<key>BOLT_ALLOW</key><string>$(xml_escape "$ALLOW")</string>
|
||||
<key>BOLT_DENY</key><string>$(xml_escape "$DENY")</string>
|
||||
<key>BOLT_TIMEOUT_MS</key><string>$(xml_escape "$TIMEOUT_MS")</string>
|
||||
<key>BOLT_FILE_ROOT</key><string>$(xml_escape "$FILE_ROOT")</string>
|
||||
<key>BOLT_MAX_UPLOAD</key><string>$(xml_escape "$MAX_UPLOAD")</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key><string>/var/root</string>
|
||||
<key>RunAtLoad</key><true/>
|
||||
@@ -200,6 +208,12 @@ fi
|
||||
if [ -n "$DENY" ]; then
|
||||
printf 'BOLT_DENY=%s\n' "$DENY" >> "$ENV_FILE"
|
||||
fi
|
||||
if [ -n "$FILE_ROOT" ]; then
|
||||
printf 'BOLT_FILE_ROOT=%s\n' "$FILE_ROOT" >> "$ENV_FILE"
|
||||
fi
|
||||
if [ -n "$MAX_UPLOAD" ]; then
|
||||
printf 'BOLT_MAX_UPLOAD=%s\n' "$MAX_UPLOAD" >> "$ENV_FILE"
|
||||
fi
|
||||
[ -z "$TOKEN" ] && [ -z "$ALLOW" ] && [ -z "$DENY" ] && \
|
||||
printf '# 默认无认证无白名单;监听公网时请设置 BOLT_TOKEN 与 BOLT_ALLOW\n' >> "$ENV_FILE"
|
||||
|
||||
|
||||
+140
-40
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bolt HTTP 模式端到端测试"""
|
||||
import http.client, json, subprocess, threading, time
|
||||
import hashlib, http.client, json, os, shutil, subprocess, sys, tempfile, threading, time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
BOLT = "/root/bolt/target/release/bolt"
|
||||
# 二进制路径:环境变量 BOLT_BIN 优先,默认为仓库内的 release 产物
|
||||
BOLT = os.environ.get("BOLT_BIN") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "target", "release", "bolt")
|
||||
PORT = 18080
|
||||
PORT2 = 18081 # 带 token + --file-root 的第二个实例
|
||||
TOKEN = "e2e-secret"
|
||||
passed = failed = 0
|
||||
def check(name, cond, detail=""):
|
||||
global passed, failed
|
||||
@@ -14,26 +18,50 @@ def check(name, cond, detail=""):
|
||||
failed += 1
|
||||
print(f" FAIL {name} {detail}")
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="bolt-http-e2e-")
|
||||
proc = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT}"], stderr=subprocess.PIPE)
|
||||
proc2 = subprocess.Popen([BOLT, "--http", f"127.0.0.1:{PORT2}", "--token", TOKEN, "--file-root", tmpdir], stderr=subprocess.PIPE)
|
||||
time.sleep(0.5)
|
||||
|
||||
def post(obj, auth=None, origin=None, accept="application/json", path="/mcp"):
|
||||
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=30)
|
||||
def request(method, path, body=None, headers=None, port=PORT, timeout=30):
|
||||
c = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
|
||||
c.request(method, path, body=body, headers=headers or {})
|
||||
r = c.getresponse()
|
||||
data = r.read()
|
||||
hdrs = {k.lower(): v for k, v in r.getheaders()}
|
||||
c.close()
|
||||
return r.status, hdrs, data
|
||||
|
||||
def post(obj, auth=None, origin=None, accept="application/json", path="/mcp", sid=None, port=PORT):
|
||||
headers = {"Content-Type": "application/json", "Accept": accept}
|
||||
if auth: headers["Authorization"] = auth
|
||||
if origin: headers["Origin"] = origin
|
||||
body = json.dumps(obj)
|
||||
c.request("POST", path, body=body, headers=headers)
|
||||
r = c.getresponse()
|
||||
data = r.read()
|
||||
ct = r.getheader("Content-Type", "")
|
||||
c.close()
|
||||
return r.status, ct, data
|
||||
if sid: headers["Mcp-Session-Id"] = sid
|
||||
st, hdrs, data = request("POST", path, json.dumps(obj), headers, port=port)
|
||||
return st, hdrs.get("content-type", ""), data
|
||||
|
||||
# 1. initialize(JSON 响应)
|
||||
st, ct, data = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}})
|
||||
def post_h(obj, sid=None, accept="application/json", port=PORT, auth=None):
|
||||
"""返回 (status, headers, data)"""
|
||||
headers = {"Content-Type": "application/json", "Accept": accept}
|
||||
if sid: headers["Mcp-Session-Id"] = sid
|
||||
if auth: headers["Authorization"] = auth
|
||||
return request("POST", "/mcp", json.dumps(obj), headers, port=port)
|
||||
|
||||
def call(name, args, rid=100, sid=None, port=PORT, auth=None):
|
||||
st, _, data = post_h({"jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": {"name": name, "arguments": args}}, sid=sid, port=port, auth=auth)
|
||||
return json.loads(data)
|
||||
|
||||
def sse_events(data):
|
||||
return [json.loads(line[len(b"data: "):]) for line in data.split(b"\r\n") if line.startswith(b"data: ")]
|
||||
|
||||
INIT = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}
|
||||
|
||||
# 1. initialize(JSON 响应 + 签发 Mcp-Session-Id)
|
||||
st, hdrs, data = post_h(INIT)
|
||||
m = json.loads(data)
|
||||
check("init json", st == 200 and "application/json" in ct and m["result"]["serverInfo"]["name"] == "bolt", f"{st} {ct} {data[:100]}")
|
||||
sid_a = hdrs.get("mcp-session-id")
|
||||
check("init json", st == 200 and "application/json" in hdrs.get("content-type", "") and m["result"]["serverInfo"]["name"] == "bolt", f"{st} {data[:100]}")
|
||||
check("init issues session id", bool(sid_a) and len(sid_a) == 32, str(hdrs))
|
||||
|
||||
# 2. SSE 响应(accept 含 text/event-stream)
|
||||
st, ct, data = post({"jsonrpc": "2.0", "id": 2, "method": "ping"}, accept="application/json, text/event-stream")
|
||||
@@ -51,8 +79,7 @@ m = json.loads(data)
|
||||
sc = m["result"]["structuredContent"]
|
||||
check("run over http", sc["exit_code"] == 5 and sc["stdout"].strip() == "HTTP_OK", str(sc))
|
||||
|
||||
# 5. GET SSE 流:开流收心跳,并发命令进度通知广播
|
||||
got_event = threading.Event()
|
||||
# 5. GET SSE 流:开流收心跳,同作用域命令的进度通知推送到流上
|
||||
got_heartbeat = threading.Event()
|
||||
msgs = []
|
||||
def sse_get():
|
||||
@@ -71,6 +98,7 @@ def sse_get():
|
||||
buf = b""
|
||||
if msgs and (": ping" in msgs[-1]):
|
||||
got_heartbeat.set()
|
||||
break
|
||||
c.close()
|
||||
t = threading.Thread(target=sse_get)
|
||||
t.start()
|
||||
@@ -80,20 +108,29 @@ st, ct, data = post({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params"
|
||||
m = json.loads(data)
|
||||
check("long cmd done", m["result"]["structuredContent"]["stdout"].count("h-") == 4, str(m)[:100])
|
||||
saw_progress = any("notifications/progress" in x and "h1" in x for x in msgs)
|
||||
check("sse got progress broadcast", saw_progress, str(msgs)[:200])
|
||||
check("sse got progress on GET stream", saw_progress, str(msgs)[:200])
|
||||
t.join(timeout=25)
|
||||
saw_hb = got_heartbeat.is_set()
|
||||
check("sse heartbeat", saw_hb, str(msgs)[:200])
|
||||
check("sse heartbeat", got_heartbeat.is_set(), str(msgs)[:200])
|
||||
|
||||
# 6. 取消:连接 A 长命令,连接 B 发 cancelled
|
||||
st, ct, data = post({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "echo nothing"}}}) # 预热(连接池无关,每次新连接)
|
||||
# 5b. POST + SSE:进度通知与结果在同一个响应流里
|
||||
st, hdrs, data = post_h({"jsonrpc": "2.0", "id": 41, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "for x in 1 2 3; do echo p-$x; sleep 0.3; done"}, "_meta": {"progressToken": "p1"}}}, accept="application/json, text/event-stream", sid=sid_a)
|
||||
evs = sse_events(data)
|
||||
prog = [e for e in evs if e.get("method") == "notifications/progress" and e["params"]["progressToken"] == "p1"]
|
||||
final = [e for e in evs if e.get("id") == 41]
|
||||
check("post sse progress + result", st == 200 and prog and final and final[0]["result"]["structuredContent"]["stdout"].count("p-") == 3, str(evs)[:300])
|
||||
check("post sse result is last", evs and evs[-1].get("id") == 41, str(evs)[-200:])
|
||||
|
||||
# 6. 取消:连接 A 长命令,连接 B 发 cancelled(同一作用域)
|
||||
def long_cmd():
|
||||
return post({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "sleep 30"}}})
|
||||
res = {}
|
||||
t2 = threading.Thread(target=lambda: res.update(r=long_cmd()))
|
||||
t2.start()
|
||||
time.sleep(0.5)
|
||||
# 其他会话发同 id 的取消不能误杀
|
||||
st, _, _ = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}}, sid=sid_a)
|
||||
time.sleep(0.3)
|
||||
check("cancel from other scope ignored", t2.is_alive())
|
||||
st, ct, data = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}})
|
||||
check("cancel notif 202", st == 202)
|
||||
t2.join(timeout=10)
|
||||
@@ -112,24 +149,16 @@ check("http concurrent", json.loads(results[10][2])["result"]["structuredContent
|
||||
# 8. 错误路径
|
||||
st, _, _ = post({"jsonrpc": "2.0", "id": 99, "method": "nope"})
|
||||
check("unknown method", st == 200)
|
||||
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
|
||||
c.request("POST", "/mcp", body=b"this is {not json", headers={"Content-Type": "application/json", "Accept": "application/json"})
|
||||
r = c.getresponse(); bd = r.read(); c.close()
|
||||
check("bad json -> 400", r.status == 400, str(r.status))
|
||||
st, _, _ = request("POST", "/mcp", b"this is {not json", {"Content-Type": "application/json", "Accept": "application/json"})
|
||||
check("bad json -> 400", st == 400, str(st))
|
||||
st, _, data = post({"jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": {"name": "nope", "arguments": {}}})
|
||||
check("unknown tool -32602", json.loads(data)["error"]["code"] == -32602)
|
||||
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
|
||||
c.request("GET", "/other", headers={"Accept": "text/event-stream"})
|
||||
r = c.getresponse(); r.read(); c.close()
|
||||
check("404 other path", r.status == 404)
|
||||
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
|
||||
c.request("PUT", "/mcp")
|
||||
r = c.getresponse(); r.read(); c.close()
|
||||
check("405 method", r.status == 405)
|
||||
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
|
||||
c.request("OPTIONS", "/mcp")
|
||||
r = c.getresponse(); r.read(); c.close()
|
||||
check("options cors", r.status == 204 and r.getheader("Access-Control-Allow-Origin") == "*")
|
||||
st, _, _ = request("GET", "/other", headers={"Accept": "text/event-stream"})
|
||||
check("404 other path", st == 404)
|
||||
st, _, _ = request("PUT", "/mcp")
|
||||
check("405 method", st == 405)
|
||||
st, hdrs, _ = request("OPTIONS", "/mcp")
|
||||
check("options cors", st == 204 and hdrs.get("access-control-allow-origin") == "*" and "Mcp-Session-Id" in hdrs.get("access-control-allow-headers", ""))
|
||||
|
||||
# 9. origin 校验
|
||||
st, ct, data = post({"jsonrpc": "2.0", "id": 50, "method": "ping"}, origin="https://evil.example.com")
|
||||
@@ -144,10 +173,81 @@ r = c.getresponse()
|
||||
c.close()
|
||||
check("get sse default", r.status == 200 and "text/event-stream" in r.getheader("Content-Type"))
|
||||
|
||||
# 11. MCP 会话:隔离、未知 ID → 404、DELETE 终止
|
||||
st, hdrs, _ = post_h(INIT)
|
||||
sid_b = hdrs.get("mcp-session-id")
|
||||
r = call("shell_open", {}, sid=sid_a)
|
||||
shell_id = r["result"]["structuredContent"]["session_id"]
|
||||
r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_b)
|
||||
check("shell session isolated", r["result"].get("isError") is True, str(r))
|
||||
r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_a)
|
||||
check("shell session owner ok", r["result"].get("isError") is None, str(r))
|
||||
st, _, _ = post_h({"jsonrpc": "2.0", "id": 60, "method": "ping"}, sid="0" * 32)
|
||||
check("unknown session 404", st == 404, str(st))
|
||||
st, _, _ = request("DELETE", "/mcp", headers={"Mcp-Session-Id": sid_a})
|
||||
check("delete session", st == 200, str(st))
|
||||
st, _, _ = post_h({"jsonrpc": "2.0", "id": 61, "method": "ping"}, sid=sid_a)
|
||||
check("deleted session 404", st == 404, str(st))
|
||||
r = call("shell_read", {"session_id": shell_id, "timeout_ms": 10}, sid=sid_b)
|
||||
check("deleted session shells closed", r["result"].get("isError") is True)
|
||||
|
||||
# 12. HTTP 客户端不能用 notifications/exit 关停服务
|
||||
post({"jsonrpc": "2.0", "method": "notifications/exit"})
|
||||
time.sleep(0.4)
|
||||
st, _, _ = post({"jsonrpc": "2.0", "id": 70, "method": "ping"})
|
||||
check("exit notif ignored over http", st == 200 and proc.poll() is None)
|
||||
|
||||
# 13. tools/list 含 file_link
|
||||
st, _, data = post({"jsonrpc": "2.0", "id": 71, "method": "tools/list"})
|
||||
names = [t["name"] for t in json.loads(data)["result"]["tools"]]
|
||||
check("tools/list http", names[-4:] == ["file_read", "file_write", "file_stat", "file_link"], str(names))
|
||||
|
||||
# 14. /files:Bearer 认证、签名链接、Range、chunked 上传、目录限制
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
fpath = os.path.join(tmpdir, "data.bin")
|
||||
payload = os.urandom(300_000)
|
||||
# 未认证请求在读取 body 前即返回 401 并关闭连接,这里用小 body 避免客户端写入时 broken pipe
|
||||
st, _, _ = request("PUT", f"/files?path={fpath}", b"x", port=PORT2)
|
||||
check("files put needs token", st == 401 and not os.path.exists(fpath), str(st))
|
||||
st, _, data = request("PUT", f"/files?path={fpath}", payload, AUTH, port=PORT2)
|
||||
info = json.loads(data)
|
||||
check("files put", st == 201 and info["sha256"] == hashlib.sha256(payload).hexdigest(), f"{st} {data[:200]}")
|
||||
st, hdrs, data = request("GET", f"/files?path={fpath}", headers=AUTH, port=PORT2)
|
||||
check("files get", st == 200 and data == payload and hdrs.get("accept-ranges") == "bytes")
|
||||
st, hdrs, data = request("GET", f"/files?path={fpath}", headers={**AUTH, "Range": "bytes=100-199"}, port=PORT2)
|
||||
check("files range", st == 206 and data == payload[100:200] and hdrs.get("content-range") == f"bytes 100-199/{len(payload)}", f"{st} {hdrs}")
|
||||
st, _, _ = request("GET", "/files?path=/etc/hosts", headers=AUTH, port=PORT2)
|
||||
check("files outside root 403", st == 403, str(st))
|
||||
# chunked 上传(http.client 对迭代器 body 使用 chunked 编码)
|
||||
cpath = os.path.join(tmpdir, "chunked.txt")
|
||||
c = http.client.HTTPConnection("127.0.0.1", PORT2, timeout=10)
|
||||
c.request("PUT", f"/files?path={cpath}&mkdirs=1", body=iter([b"hello ", b"chunked ", b"world"]), headers=AUTH, encode_chunked=True)
|
||||
r = c.getresponse(); r.read(); c.close()
|
||||
check("files chunked put", r.status == 201 and open(cpath, "rb").read() == b"hello chunked world", str(r.status))
|
||||
# 签名链接:不带 token 也能访问,且仅限签名的路径/方法
|
||||
st, _, data = post_h(INIT, port=PORT2, auth=AUTH["Authorization"])
|
||||
r = call("file_link", {"path": fpath}, port=PORT2, auth=AUTH["Authorization"])
|
||||
url = r["result"]["structuredContent"]["url"]
|
||||
u = urlsplit(url)
|
||||
check("file_link url", u.netloc == f"127.0.0.1:{PORT2}" and u.path == "/files" and "curl" in r["result"]["structuredContent"]["curl"], url)
|
||||
st, _, data = request("GET", f"{u.path}?{u.query}", port=PORT2)
|
||||
check("signed get", st == 200 and data == payload, str(st))
|
||||
st, _, _ = request("PUT", f"{u.path}?{u.query}", b"x", port=PORT2)
|
||||
check("signed get cannot put", st == 403, str(st))
|
||||
r = call("file_link", {"path": os.path.join(tmpdir, "up", "new.txt"), "method": "PUT", "mkdirs": True}, port=PORT2, auth=AUTH["Authorization"])
|
||||
u = urlsplit(r["result"]["structuredContent"]["url"])
|
||||
st, _, data = request("PUT", f"{u.path}?{u.query}", b"uploaded via link", {"Expect": "100-continue"}, port=PORT2)
|
||||
check("signed put", st == 201 and open(os.path.join(tmpdir, "up", "new.txt"), "rb").read() == b"uploaded via link", f"{st} {data[:200]}")
|
||||
st, _, _ = request("GET", f"{u.path}?{u.query.replace('sig=', 'sig=0')}", port=PORT2)
|
||||
check("bad signature 403", st == 403, str(st))
|
||||
r = call("file_link", {"path": "/etc/hosts"}, port=PORT2, auth=AUTH["Authorization"])
|
||||
check("file_link outside root", r["result"].get("isError") is True)
|
||||
|
||||
proc.kill()
|
||||
proc2.kill()
|
||||
err = proc.stderr.read().decode()
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
print(f"\n=== HTTP: {passed} passed, {failed} failed ===")
|
||||
if err.strip():
|
||||
if failed and err.strip():
|
||||
print("bolt stderr:", err[:400])
|
||||
sys_exit = 1 if failed else 0
|
||||
import sys; sys.exit(sys_exit)
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
+51
-7
@@ -3,7 +3,8 @@
|
||||
import faulthandler, json, os, subprocess, sys, threading, time
|
||||
|
||||
|
||||
BOLT = "/root/bolt/target/release/bolt"
|
||||
# 二进制路径:环境变量 BOLT_BIN 优先,默认为仓库内的 release 产物
|
||||
BOLT = os.environ.get("BOLT_BIN") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "target", "release", "bolt")
|
||||
proc =subprocess.Popen([BOLT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0)
|
||||
sent = {}
|
||||
next_id = [0]
|
||||
@@ -79,7 +80,7 @@ send_notif("notifications/initialized")
|
||||
i = send("tools/list")
|
||||
r = wait_id(i)
|
||||
names = [t["name"] for t in r["result"]["tools"]]
|
||||
check("tools/list", names == ["run_command", "shell_open", "shell_write", "shell_read", "shell_close"], str(names))
|
||||
check("tools/list", names == ["run_command", "shell_open", "shell_write", "shell_read", "shell_close", "file_read", "file_write", "file_stat"], str(names))
|
||||
|
||||
# 3. run_command 基本
|
||||
i = send("tools/call", {"name": "run_command", "arguments": {"command": "echo hello; printf 'oops' >&2; exit 7"}})
|
||||
@@ -138,25 +139,68 @@ i2 = send("tools/call", {"name": "run_command", "arguments": {"command": "sleep
|
||||
r1, r2 = wait_id(i1), wait_id(i2)
|
||||
check("concurrent", r1["result"]["structuredContent"]["stdout"].strip() == "ONE" and r2["result"]["structuredContent"]["stdout"].strip() == "TWO")
|
||||
|
||||
# 9. shell 会话
|
||||
# 9. shell 会话(结果为标准 MCP 格式:content + structuredContent)
|
||||
i = send("tools/call", {"name": "shell_open", "arguments": {}})
|
||||
r = wait_id(i)
|
||||
sid = r["result"]["session_id"]
|
||||
check("shell open", isinstance(sid, int))
|
||||
sid = r["result"]["structuredContent"]["session_id"]
|
||||
check("shell open", isinstance(sid, int) and isinstance(r["result"]["content"], list), str(r))
|
||||
send("tools/call", {"name": "shell_write", "arguments": {"session_id": sid, "input": "cd /tmp && echo IN_TMP; echo DONE_$$; exit"}})
|
||||
# 输出先于退出标志到达,循环读取直到会话退出
|
||||
all_out = ""
|
||||
for _ in range(10):
|
||||
i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 3000}})
|
||||
r = wait_id(i)
|
||||
res = r["result"]
|
||||
res = r["result"]["structuredContent"]
|
||||
all_out += res["output"]
|
||||
if res["exited"]:
|
||||
break
|
||||
check("shell read output", "IN_TMP" in all_out and res["exited"], f"out={all_out!r}")
|
||||
i = send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}})
|
||||
r = wait_id(i)
|
||||
check("shell close", r["result"]["closed"] is True)
|
||||
check("shell close", r["result"]["structuredContent"]["closed"] is True)
|
||||
|
||||
# 9b. shell_read 阻塞等待时可被取消
|
||||
i = send("tools/call", {"name": "shell_open", "arguments": {}})
|
||||
sid = wait_id(i)["result"]["structuredContent"]["session_id"]
|
||||
i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 0}})
|
||||
time.sleep(0.3)
|
||||
send_notif("notifications/cancelled", {"requestId": i})
|
||||
r = wait_id(i, timeout=5)
|
||||
check("shell_read cancel", r["result"]["structuredContent"]["cancelled"] is True, str(r))
|
||||
wait_id(send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}}))
|
||||
|
||||
# 9c. 后台进程占用输出管道时不阻塞
|
||||
t0 = time.time()
|
||||
i = send("tools/call", {"name": "run_command", "arguments": {"command": "(sleep 5; echo late) & echo now"}})
|
||||
r = wait_id(i)
|
||||
sc = r["result"]["structuredContent"]
|
||||
check("background detach", sc["stdout"] == "now\n" and sc["detached"] and time.time() - t0 < 3, f"{sc} dt={time.time() - t0:.1f}")
|
||||
|
||||
# 9d. 文件读写(文本 / base64 / 分块)
|
||||
import base64, hashlib, tempfile
|
||||
tmpdir = tempfile.mkdtemp(prefix="bolt-e2e-")
|
||||
fp = os.path.join(tmpdir, "sub", "hello.txt")
|
||||
i = send("tools/call", {"name": "file_write", "arguments": {"path": fp, "content": "你好, bolt\n", "mkdirs": True}})
|
||||
sc = wait_id(i)["result"]["structuredContent"]
|
||||
check("file_write text", sc["sha256"] == hashlib.sha256("你好, bolt\n".encode()).hexdigest(), str(sc))
|
||||
i = send("tools/call", {"name": "file_read", "arguments": {"path": fp, "length": 4}})
|
||||
content = wait_id(i)["result"]["content"]
|
||||
meta = json.loads(content[0]["text"])
|
||||
check("file_read chunk utf8", content[1]["text"] == "你" and meta["next_offset"] == 3 and not meta["eof"], str(content))
|
||||
blob = bytes(range(256))
|
||||
bp = os.path.join(tmpdir, "blob.bin")
|
||||
i = send("tools/call", {"name": "file_write", "arguments": {"path": bp, "content": base64.b64encode(blob).decode(), "encoding": "base64"}})
|
||||
wait_id(i)
|
||||
i = send("tools/call", {"name": "file_read", "arguments": {"path": bp}})
|
||||
content = wait_id(i)["result"]["content"]
|
||||
check("file_read binary", json.loads(content[0]["text"])["encoding"] == "base64" and base64.b64decode(content[1]["text"]) == blob)
|
||||
i = send("tools/call", {"name": "file_stat", "arguments": {"path": bp, "sha256": True}})
|
||||
sc = wait_id(i)["result"]["structuredContent"]
|
||||
check("file_stat sha256", sc["size"] == 256 and sc["sha256"] == hashlib.sha256(blob).hexdigest(), str(sc))
|
||||
i = send("tools/call", {"name": "file_link", "arguments": {"path": bp}})
|
||||
r = wait_id(i)
|
||||
check("file_link stdio unavailable", "error" in r and r["error"]["code"] == -32602 or r["result"].get("isError") is True, str(r))
|
||||
import shutil; shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
# 10. stdio 关闭 → 进程退出
|
||||
send_notif("notifications/exit")
|
||||
|
||||
+2686
-601
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user