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:
jocay
2026-10-18 10:23:15 +08:00
parent 63d48ec951
commit 84653bec61
9 changed files with 3083 additions and 752 deletions
+45
View File
@@ -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
View File
@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "bolt" name = "bolt"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"libc", "libc",
"serde_json", "serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "bolt" name = "bolt"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
description = "bolt — 极速单文件 MCP 命令执行服务器 (stdio / Streamable HTTP)" description = "bolt — 极速单文件 MCP 命令执行服务器 (stdio / Streamable HTTP)"
license = "MIT" license = "MIT"
+61 -24
View File
@@ -1,8 +1,8 @@
# bolt — 极速单文件 MCP 命令执行服务器 # 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、无重量级依赖 - **零框架**:手写 JSON-RPC 2.0 + MCP 协议 + Streamable HTTP 服务器,无 async runtime、无重量级依赖
- **双传输**:`stdio`(本地 AI 客户端默认)与 `Streamable HTTP`(远程连接) - **双传输**:`stdio`(本地 AI 客户端默认)与 `Streamable HTTP`(远程连接)
@@ -27,7 +27,7 @@ claude mcp add --transport stdio bolt -- /abs/path/to/bolt
```bash ```bash
bolt --http 127.0.0.1:8080 # 仅本机 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 # 对外 + 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 Code 连接远程
claude mcp add --transport http bolt http://127.0.0.1:8080/mcp 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` 取消 | | `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_write` | 向会话写入输入(自动补换行) |
| `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出) | | `shell_read` | 读取会话新输出,可阻塞等待(`timeout_ms`,0 = 等到会话退出),可被取消 |
| `shell_close` | 关闭并杀掉会话进程树 | | `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>` | | `--token <t>` | 无 | HTTP 要求 `Authorization: Bearer <t>` |
| `--allow <a,b>` | 空 | 命令白名单(命令首词前缀匹配;非空时仅允许列表内命令) | | `--allow <a,b>` | 空 | 命令白名单(按程序名精确匹配;非空时 shell 模式禁止元字符) |
| `--deny <a,b>` | 空 | 命令黑名单(前缀匹配) | | `--deny <a,b>` | 空 | 命令黑名单(按程序名匹配命令中每一段,尽力而为) |
| `--timeout-ms <n>` | 30000 | 命令默认超时(工具参数可覆盖,0 = 无限) | | `--timeout-ms <n>` | 30000 | 命令默认超时(工具参数可覆盖,0 = 无限) |
| `--cwd <dir>` | 当前 | 命令默认工作目录 | | `--cwd <dir>` | 当前 | 命令与相对文件路径的默认工作目录 |
| `--max-output <n>` | 1M | 单流输出捕获上限(支持 K/M/G) | | `--max-output <n>` | 1M | 单流输出捕获上限(支持 K/M/G) |
| `--allow-origin <url>` | localhost/127.0.0.1 | 额外允许的 HTTP Origin(防 DNS rebinding) | | `--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 ```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 bolt --http 127.0.0.1:8080
``` ```
## 协议实现(2025-06-18 规范) ## 协议实现(2025-06-18 规范)
- `initialize`(协议版本协商,支持 2024-11-05 / 2025-03-26 / 2025-06-18)、`ping`、`tools/list`、`tools/call`、`shutdown` - `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 等) - 工具执行错误以 `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`,连带杀掉整棵进程树,无僵尸 - 子进程独占进程组(`setpgid`),超时/取消按进程组 `SIGKILL`,连带杀掉整棵进程树,无僵尸
- 上限:并发工具调用 128、HTTP 连接 512、shell 会话 64(空闲 30 分钟回收)、MCP 会话 1024(空闲 24 小时回收)
- 实测(本机,含 Python 客户端逐字节 I/O 开销): - 实测(本机,含 Python 客户端逐字节 I/O 开销):
- ping 往返吞吐 ≈ **14,700 msg/s** - ping 往返吞吐 ≈ **14,700 msg/s**
- 并发 `echo` 命令平均 **4.7 ms/次**(进程 spawn + 捕获输出) - 并发 `echo` 命令平均 **4.7 ms/次**(进程 spawn + 捕获输出)
- 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`,二进制约 0.7MB - 构建配置:`lto = "fat"`、`codegen-units = 1`、`strip = true`
## 测试 ## 测试
```bash ```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 ```bash
python3 /tmp/bolt_stdio_test.py # stdio 端到端 python3 docs/bolt_stdio_test.py # stdio 端到端
python3 /tmp/bolt_http_test.py # HTTP 端到端 python3 docs/bolt_http_test.py # HTTP 端到端(含 Mcp-Session-Id、/files、签名链接)
``` ```
## 安全说明 ## 安全说明
- 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token` - 默认只接受本机连接(`127.0.0.1`),对外公开需显式 `0.0.0.0` + `--token`(未设置时启动日志会警告)
- 该服务器等于远程 root shell 入口,务必配置白名单(`--allow`)与认证后再暴露公网 - 该服务器等于远程 root shell 入口,务必配置认证后再暴露公网
- Unix 下命令在独立进程组运行,取消/超时会连带杀掉子进程 - 白名单(`--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(完整功能:进程组杀、信号处理) - 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 权限。 一键脚本支持 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 认证令牌(公网必填) | | `--token <t>` | Bearer 认证令牌(公网必填) |
| `--allow <a,b>` / `--deny <a,b>` | 命令白/黑名单 | | `--allow <a,b>` / `--deny <a,b>` | 命令白/黑名单 |
| `--timeout-ms <n>` | 默认命令超时 | | `--timeout-ms <n>` | 默认命令超时 |
| `--file-root <d1,d2>` | 文件功能允许访问的目录(配置 `--allow` 时必填,否则文件功能禁用) |
| `--max-upload <n>` | `PUT /files` 单次上传上限(默认 2G) |
| `--binary <路径或URL>` | 跳过下载,用本地已拷贝的二进制(如 `scp` 过去的) | | `--binary <路径或URL>` | 跳过下载,用本地已拷贝的二进制(如 `scp` 过去的) |
| `--no-start` / `--no-enable` | 只安装不启动 / 不设开机自启 | | `--no-start` / `--no-enable` | 只安装不启动 / 不设开机自启 |
+8 -2
View File
@@ -7,12 +7,18 @@
# 认证令牌(必填,若监听 0.0.0.0 对外暴露) # 认证令牌(必填,若监听 0.0.0.0 对外暴露)
#BOLT_TOKEN=请改成强随机串 #BOLT_TOKEN=请改成强随机串
# 命令白名单(非空时仅允许列出的命令,用逗号分隔) # 命令白名单(按程序名精确匹配,逗号分隔;非空时 shell 模式禁止管道/重定向/命令串联等元字符)
#BOLT_ALLOW=git,docker,curl #BOLT_ALLOW=git,docker,curl
# 命令黑名单(前缀匹配) # 命令黑名单(按程序名精确匹配命令中每一段;尽力而为,不能替代白名单)
#BOLT_DENY=rm,mkfs #BOLT_DENY=rm,mkfs
# 文件工具与 /files 允许访问的目录(逗号分隔;设置了 BOLT_ALLOW 时必须配置,否则文件功能禁用)
#BOLT_FILE_ROOT=/srv/data,/tmp
# PUT /files 单次上传上限(支持 K/M/G,默认 2G)
#BOLT_MAX_UPLOAD=2G
# 默认命令超时毫秒(0 = 无限) # 默认命令超时毫秒(0 = 无限)
#BOLT_TIMEOUT_MS=30000 #BOLT_TIMEOUT_MS=30000
+14
View File
@@ -14,6 +14,8 @@
# --allow <a,b> 命令白名单 # --allow <a,b> 命令白名单
# --deny <a,b> 命令黑名单 # --deny <a,b> 命令黑名单
# --timeout-ms <n> 命令默认超时(默认 30000) # --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 下载) # --binary <src> 二进制来源:本地路径 或 http(s) URL(默认从 Gitea Release 下载)
# --no-start 只安装不启动 # --no-start 只安装不启动
# --no-enable 不设置开机自启 # --no-enable 不设置开机自启
@@ -24,6 +26,8 @@ TOKEN="${BOLT_TOKEN:-}"
ALLOW="" ALLOW=""
DENY="" DENY=""
TIMEOUT_MS="${BOLT_TIMEOUT_MS:-30000}" TIMEOUT_MS="${BOLT_TIMEOUT_MS:-30000}"
FILE_ROOT=""
MAX_UPLOAD=""
BINARY_SRC="" BINARY_SRC=""
NO_START=0 NO_START=0
NO_ENABLE=0 NO_ENABLE=0
@@ -35,6 +39,8 @@ while [ $# -gt 0 ]; do
--allow) ALLOW="${2:-}"; shift 2 ;; --allow) ALLOW="${2:-}"; shift 2 ;;
--deny) DENY="${2:-}"; shift 2 ;; --deny) DENY="${2:-}"; shift 2 ;;
--timeout-ms) TIMEOUT_MS="${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 ;; --binary) BINARY_SRC="${2:-}"; shift 2 ;;
--no-start) NO_START=1; shift ;; --no-start) NO_START=1; shift ;;
--no-enable) NO_ENABLE=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_ALLOW</key><string>$(xml_escape "$ALLOW")</string>
<key>BOLT_DENY</key><string>$(xml_escape "$DENY")</string> <key>BOLT_DENY</key><string>$(xml_escape "$DENY")</string>
<key>BOLT_TIMEOUT_MS</key><string>$(xml_escape "$TIMEOUT_MS")</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> </dict>
<key>WorkingDirectory</key><string>/var/root</string> <key>WorkingDirectory</key><string>/var/root</string>
<key>RunAtLoad</key><true/> <key>RunAtLoad</key><true/>
@@ -200,6 +208,12 @@ fi
if [ -n "$DENY" ]; then if [ -n "$DENY" ]; then
printf 'BOLT_DENY=%s\n' "$DENY" >> "$ENV_FILE" printf 'BOLT_DENY=%s\n' "$DENY" >> "$ENV_FILE"
fi 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" ] && \ [ -z "$TOKEN" ] && [ -z "$ALLOW" ] && [ -z "$DENY" ] && \
printf '# 默认无认证无白名单;监听公网时请设置 BOLT_TOKEN 与 BOLT_ALLOW\n' >> "$ENV_FILE" printf '# 默认无认证无白名单;监听公网时请设置 BOLT_TOKEN 与 BOLT_ALLOW\n' >> "$ENV_FILE"
+140 -40
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""bolt HTTP 模式端到端测试""" """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 PORT = 18080
PORT2 = 18081 # 带 token + --file-root 的第二个实例
TOKEN = "e2e-secret"
passed = failed = 0 passed = failed = 0
def check(name, cond, detail=""): def check(name, cond, detail=""):
global passed, failed global passed, failed
@@ -14,26 +18,50 @@ def check(name, cond, detail=""):
failed += 1 failed += 1
print(f" FAIL {name} {detail}") 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) 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) time.sleep(0.5)
def post(obj, auth=None, origin=None, accept="application/json", path="/mcp"): def request(method, path, body=None, headers=None, port=PORT, timeout=30):
c = http.client.HTTPConnection("127.0.0.1", 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} headers = {"Content-Type": "application/json", "Accept": accept}
if auth: headers["Authorization"] = auth if auth: headers["Authorization"] = auth
if origin: headers["Origin"] = origin if origin: headers["Origin"] = origin
body = json.dumps(obj) if sid: headers["Mcp-Session-Id"] = sid
c.request("POST", path, body=body, headers=headers) st, hdrs, data = request("POST", path, json.dumps(obj), headers, port=port)
r = c.getresponse() return st, hdrs.get("content-type", ""), data
data = r.read()
ct = r.getheader("Content-Type", "")
c.close()
return r.status, ct, data
# 1. initialize(JSON 响应) def post_h(obj, sid=None, accept="application/json", port=PORT, auth=None):
st, ct, data = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}) """返回 (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) 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) # 2. SSE 响应(accept 含 text/event-stream)
st, ct, data = post({"jsonrpc": "2.0", "id": 2, "method": "ping"}, accept="application/json, 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"] sc = m["result"]["structuredContent"]
check("run over http", sc["exit_code"] == 5 and sc["stdout"].strip() == "HTTP_OK", str(sc)) check("run over http", sc["exit_code"] == 5 and sc["stdout"].strip() == "HTTP_OK", str(sc))
# 5. GET SSE 流:开流收心跳,并发命令进度通知广播 # 5. GET SSE 流:开流收心跳,同作用域命令的进度通知推送到流上
got_event = threading.Event()
got_heartbeat = threading.Event() got_heartbeat = threading.Event()
msgs = [] msgs = []
def sse_get(): def sse_get():
@@ -71,6 +98,7 @@ def sse_get():
buf = b"" buf = b""
if msgs and (": ping" in msgs[-1]): if msgs and (": ping" in msgs[-1]):
got_heartbeat.set() got_heartbeat.set()
break
c.close() c.close()
t = threading.Thread(target=sse_get) t = threading.Thread(target=sse_get)
t.start() t.start()
@@ -80,20 +108,29 @@ st, ct, data = post({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params"
m = json.loads(data) m = json.loads(data)
check("long cmd done", m["result"]["structuredContent"]["stdout"].count("h-") == 4, str(m)[:100]) 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) 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) t.join(timeout=25)
saw_hb = got_heartbeat.is_set() check("sse heartbeat", got_heartbeat.is_set(), str(msgs)[:200])
check("sse heartbeat", saw_hb, str(msgs)[:200])
# 6. 取消:连接 A 长命令,连接 B 发 cancelled # 5b. POST + SSE:进度通知与结果在同一个响应流里
st, ct, data = post({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "echo nothing"}}}) # 预热(连接池无关,每次新连接) 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(): def long_cmd():
return post({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "sleep 30"}}}) return post({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "sleep 30"}}})
res = {} res = {}
t2 = threading.Thread(target=lambda: res.update(r=long_cmd())) t2 = threading.Thread(target=lambda: res.update(r=long_cmd()))
t2.start() t2.start()
time.sleep(0.5) 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}}) st, ct, data = post({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 6}})
check("cancel notif 202", st == 202) check("cancel notif 202", st == 202)
t2.join(timeout=10) t2.join(timeout=10)
@@ -112,24 +149,16 @@ check("http concurrent", json.loads(results[10][2])["result"]["structuredContent
# 8. 错误路径 # 8. 错误路径
st, _, _ = post({"jsonrpc": "2.0", "id": 99, "method": "nope"}) st, _, _ = post({"jsonrpc": "2.0", "id": 99, "method": "nope"})
check("unknown method", st == 200) check("unknown method", st == 200)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) st, _, _ = request("POST", "/mcp", b"this is {not json", {"Content-Type": "application/json", "Accept": "application/json"})
c.request("POST", "/mcp", body=b"this is {not json", headers={"Content-Type": "application/json", "Accept": "application/json"}) check("bad json -> 400", st == 400, str(st))
r = c.getresponse(); bd = r.read(); c.close()
check("bad json -> 400", r.status == 400, str(r.status))
st, _, data = post({"jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": {"name": "nope", "arguments": {}}}) 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) check("unknown tool -32602", json.loads(data)["error"]["code"] == -32602)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) st, _, _ = request("GET", "/other", headers={"Accept": "text/event-stream"})
c.request("GET", "/other", headers={"Accept": "text/event-stream"}) check("404 other path", st == 404)
r = c.getresponse(); r.read(); c.close() st, _, _ = request("PUT", "/mcp")
check("404 other path", r.status == 404) check("405 method", st == 405)
c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5) st, hdrs, _ = request("OPTIONS", "/mcp")
c.request("PUT", "/mcp") check("options cors", st == 204 and hdrs.get("access-control-allow-origin") == "*" and "Mcp-Session-Id" in hdrs.get("access-control-allow-headers", ""))
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") == "*")
# 9. origin 校验 # 9. origin 校验
st, ct, data = post({"jsonrpc": "2.0", "id": 50, "method": "ping"}, origin="https://evil.example.com") 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() c.close()
check("get sse default", r.status == 200 and "text/event-stream" in r.getheader("Content-Type")) 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() proc.kill()
proc2.kill()
err = proc.stderr.read().decode() err = proc.stderr.read().decode()
shutil.rmtree(tmpdir, ignore_errors=True)
print(f"\n=== HTTP: {passed} passed, {failed} failed ===") print(f"\n=== HTTP: {passed} passed, {failed} failed ===")
if err.strip(): if failed and err.strip():
print("bolt stderr:", err[:400]) print("bolt stderr:", err[:400])
sys_exit = 1 if failed else 0 sys.exit(1 if failed else 0)
import sys; sys.exit(sys_exit)
+52 -8
View File
@@ -3,8 +3,9 @@
import faulthandler, json, os, subprocess, sys, threading, time import faulthandler, json, os, subprocess, sys, threading, time
BOLT = "/root/bolt/target/release/bolt" # 二进制路径:环境变量 BOLT_BIN 优先,默认为仓库内的 release 产物
proc = subprocess.Popen([BOLT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) 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 = {} sent = {}
next_id = [0] next_id = [0]
@@ -79,7 +80,7 @@ send_notif("notifications/initialized")
i = send("tools/list") i = send("tools/list")
r = wait_id(i) r = wait_id(i)
names = [t["name"] for t in r["result"]["tools"]] 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 基本 # 3. run_command 基本
i = send("tools/call", {"name": "run_command", "arguments": {"command": "echo hello; printf 'oops' >&2; exit 7"}}) 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) r1, r2 = wait_id(i1), wait_id(i2)
check("concurrent", r1["result"]["structuredContent"]["stdout"].strip() == "ONE" and r2["result"]["structuredContent"]["stdout"].strip() == "TWO") 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": {}}) i = send("tools/call", {"name": "shell_open", "arguments": {}})
r = wait_id(i) r = wait_id(i)
sid = r["result"]["session_id"] sid = r["result"]["structuredContent"]["session_id"]
check("shell open", isinstance(sid, int)) 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"}}) send("tools/call", {"name": "shell_write", "arguments": {"session_id": sid, "input": "cd /tmp && echo IN_TMP; echo DONE_$$; exit"}})
# 输出先于退出标志到达,循环读取直到会话退出 # 输出先于退出标志到达,循环读取直到会话退出
all_out = "" all_out = ""
for _ in range(10): for _ in range(10):
i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 3000}}) i = send("tools/call", {"name": "shell_read", "arguments": {"session_id": sid, "timeout_ms": 3000}})
r = wait_id(i) r = wait_id(i)
res = r["result"] res = r["result"]["structuredContent"]
all_out += res["output"] all_out += res["output"]
if res["exited"]: if res["exited"]:
break break
check("shell read output", "IN_TMP" in all_out and res["exited"], f"out={all_out!r}") 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}}) i = send("tools/call", {"name": "shell_close", "arguments": {"session_id": sid}})
r = wait_id(i) 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 关闭 → 进程退出 # 10. stdio 关闭 → 进程退出
send_notif("notifications/exit") send_notif("notifications/exit")
+2685 -600
View File
File diff suppressed because it is too large Load Diff