最后更新:2026-03-26 状态:中书省 brainstorm 6 轮 + 门下省 2 轮审核 分支:
refactor/sandbox-tool-provider前置方案:V3(已废弃)
V3 方案设计了 Sandbox 接口(Exec/ReadFile/WriteFile),但在以下方面存在根本性问题:
- Offload 退化:remote 模式下 offload hash 计算"自动退化"(
os.ReadFile失败 → 跳过 stale 检测),这是偷懒不是设计 - 配置路径错误:个人 skill/agent 路径用了
.xbot/skills和.xbot/agents,实际应为/workspace/skills和/workspace/agents - Remote 语义不清:没有明确 “remote = 所有东西跑在用户本机,server 只是 WebSocket 协调器”
| # | 约束 | 说明 |
|---|---|---|
| H1 | Offload 不可退化 | hash 计算、内容读写必须全部通过 Sandbox.ReadFile/WriteFile 完成 |
| H2 | 配置路径 /workspace/skills + /workspace/agents | 不是 .xbot/skills / .xbot/agents |
| H3 | Remote = 用户本机 | ShellTool、ReadFile/WriteFile、skill/agent、offload、MCP stdio 全在用户本机 |
| H4 | 彻底重构(方案 A) | 不渐进迁移,直接重写,不怕重写历史文件 |
Remote Sandbox 的本质:
Server = WebSocket 协调器(不接触用户文件)
Runner = 用户本机代理(执行所有文件 I/O 和命令)
Sandbox 接口 = 统一抽象(所有模式共享同一套 API)
| 维度 | none | docker | remote (V4) |
|---|---|---|---|
| Shell 执行 | os/exec | docker exec | WebSocket → Runner os/exec |
| 文件读写 | os.ReadFile | docker exec cat/tee | WebSocket/HTTP → Runner os.ReadFile |
| Skill/Agent 发现 | 本地 /workspace/skills | 容器内 /workspace/skills | 用户本机 /workspace/skills |
| Offload hash | os.ReadFile | docker exec cat | Sandbox.ReadFile(穿越到用户本机) |
| MCP stdio | 本地启动 | 容器内启动 | 用户本机启动 |
| Server 是否接触用户文件 | ✅ 直接读写 | ✅ 通过 docker API | ❌ 不接触,只做 WebSocket 协调 |
| ReadOnlyRoots | ✅ 支持 | ✅ 支持 | ❌ 不支持 |
| # | 决策 | 理由 |
|---|---|---|
| D1 | 单一 Sandbox 接口(不拆 FileIO) | FileIO 永远由 Sandbox 提供,拆分增加复杂度但收益不大 |
| D2 | Sandbox.ReadFile/WriteFile 要求绝对路径 | 相对路径解析依赖 session 级 Cwd,Sandbox 是多 session 共享 |
| D3 | Offload JSON 存 server 端(os.*),hash 走 Sandbox | offload 是 agent 内部状态,消费者在 server 端 |
| D4 | OffloadStore.MaybeOffload 接收 Sandbox 参数(方案 X2) | 显式依赖,OffloadStore 保持纯数据存储 |
| D5 | Skill 发现用 Server 缓存 + TTL 5min + 主动失效 | 简单可靠,避免 runner 侧 file watch 的跨平台复杂度 |
| D6 | skill_sync.go 在 remote 模式下跳过 | 全局 skill 由 SkillStore 在 server 端注入 system prompt |
| D7 | 废除 __FEISHU_FILE__:: 协议 | 工具层直接 Sandbox.ReadFile + Feishu API 上传 |
| D8 | 删除 SandboxToHostPath / HostToSandboxPath | 路径语义统一为 sandbox 路径,转换是 Sandbox 实现内部细节 |
| D9 | Glob/Grep 统一走 Sandbox.Exec(删除 executeLocal) | 消除双路径分歧,Sandbox.Exec 在 none 模式下就是 os/exec |
| D10 | Sandbox.WriteFile 不自动 MkdirAll | 遵循最小意外原则,显式调用 Sandbox.MkdirAll |
| D11 | copyDir 跨 Sandbox 时展开 symlink | Sandbox 无 Symlink 方法,skill/agent 不应依赖 symlink 语义 |
| D12 | filepath.WalkDir 用递归 ReadDir 替代 | Sandbox 接口没有 Walk 方法 |
package tools
// MaxSandboxFileSize is the maximum file size for ReadFile/WriteFile (500MB).
const MaxSandboxFileSize = 500 * 1024 * 1024
// ExecSpec defines the parameters for a sandbox command execution.
type ExecSpec struct {
Command string // executable or shell command
Args []string // arguments (ignored when Shell=true)
Shell bool // use shell for execution (sh -c)
Dir string // working directory (absolute path in sandbox)
Env []string // environment variables
Stdin string // stdin input
Timeout time.Duration // execution timeout
Workspace string // workspace root (for sandbox setup)
UserID string // user identity (for sandbox routing)
}
// ExecResult holds the result of a sandbox command execution.
type ExecResult struct {
Stdout string // standard output
Stderr string // standard error
ExitCode int // exit code (-1 if timed out)
TimedOut bool // whether execution timed out
}
// SandboxFileInfo is the sandbox equivalent of os.FileInfo.
// Does NOT include Sys() — cross-process metadata is meaningless.
type SandboxFileInfo struct {
Name string // base name
Size int64 // length in bytes
Mode os.FileMode // file mode bits
ModTime time.Time // modification time
IsDir bool // is directory
}
// DirEntry represents a directory entry from ReadDir.
type DirEntry struct {
Name string
IsDir bool
Size int64
}
// Sandbox defines the unified interface for all sandbox modes.
// All file path parameters must be absolute paths in sandbox format.
// Path conversion (sandbox↔host) is an internal concern of each implementation.
type Sandbox interface {
// === Command Execution ===
Exec(ctx context.Context, spec ExecSpec) (*ExecResult, error)
// === File I/O ===
// ReadFile reads the entire file at path. Path must be absolute.
// Returns os.ErrNotExist if file does not exist.
ReadFile(ctx context.Context, path string, userID string) ([]byte, error)
// WriteFile writes data to path. Path must be absolute.
// Does NOT auto-create parent directories — call MkdirAll first.
WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode, userID string) error
// Stat returns file info. Path must be absolute.
// Returns os.ErrNotExist if file does not exist.
Stat(ctx context.Context, path string, userID string) (*SandboxFileInfo, error)
// ReadDir lists directory entries. Path must be absolute.
ReadDir(ctx context.Context, path string, userID string) ([]DirEntry, error)
// MkdirAll creates directory tree. Path must be absolute.
MkdirAll(ctx context.Context, path string, perm os.FileMode, userID string) error
// Remove removes a file. Path must be absolute.
Remove(ctx context.Context, path string, userID string) error
// RemoveAll removes a directory tree. Path must be absolute.
RemoveAll(ctx context.Context, path string, userID string) error
// === Shell Configuration ===
// GetShell returns the preferred shell command for the user/workspace.
GetShell(userID string, workspace string) (string, error)
// === Lifecycle ===
Name() string
Close() error
CloseForUser(userID string) error
// === Export/Import (docker-specific) ===
IsExporting(userID string) bool
ExportAndImport(userID string) error
}
// NoneSandbox — all operations are direct os.* calls
type NoneSandbox struct { /* no state */ }
// DockerSandbox — all operations go through docker exec / docker cp
type DockerSandbox struct {
client *client.Client
image string
containers sync.Map // userID → containerID
}
// RemoteSandbox — all operations go through WebSocket or HTTP to Runner
type RemoteSandbox struct {
connections sync.Map // userID → *runnerConnection
wsServer *ws.Server
httpServer *http.Server
}
Sandbox 路径 = 绝对路径,以 SandboxWorkDir 为前缀
示例(SandboxWorkDir = /workspace):
/workspace/src/main.go ← 绝对路径,所有模式通用
/workspace/skills/my-skill/ ← skill 目录
/workspace/agents/my-agent.md ← agent 文件
| 模式 | Sandbox 路径 → 实际文件系统 |
|---|---|
| none | /workspace/src/main.go → 宿主机 /workspace/src/main.go(或 SandboxWorkDir 对应目录) |
| docker | /workspace/src/main.go → 容器内 /workspace/src/main.go(DockerSandbox 内部转换为宿主机路径) |
| remote | /workspace/src/main.go → 用户本机 /workspace/src/main.go(通过 WebSocket/HTTP 传输给 Runner) |
┌─────────────────────────────────────────────────────┐
│ xbot Server │
│ │
│ ┌──────────┐ WebSocket ┌──────────────────┐ │
│ │ Agent │◄───────────────►│ RemoteSandbox │ │
│ │ Engine │ (text/binary) │ (Sandbox impl) │ │
│ └──────────┘ └──────┬───────────┘ │
│ │ │
│ ┌──────────┐ HTTP API ┌──────┴───────────┐ │
│ │ Feishu │◄───────────────►│ Runner HTTP │ │
│ │ Upload │ (multipart) │ Server │ │
│ └──────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────┘
│
WebSocket / HTTP
│
┌─────────────────────────────────────────────────────┐
│ User's Machine (Runner) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Runner CLI (main.go) │ │
│ │ ├── WebSocket client → Server │ │
│ │ ├── HTTP server (file upload/download) │ │
│ │ ├── Exec handler → os/exec │ │
│ │ ├── ReadFile handler → os.ReadFile │ │
│ │ ├── WriteFile handler → os.WriteFile │ │
│ │ ├── Stat handler → os.Stat │ │
│ │ ├── ReadDir handler → os.ReadDir │ │
│ │ ├── MkdirAll handler → os.MkdirAll │ │
│ │ ├── Remove/RemoveAll → os.Remove/os.RemoveAll│ │
│ │ └── Path guard → validate path safety │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ /workspace/ ← user's workspace │
│ /workspace/skills/ ← user skills │
│ /workspace/agents/ ← user agents │
└─────────────────────────────────────────────────────┘
RemoteSandbox 内部对大文件使用 HTTP 通道,对调用方完全透明。
ReadFile ≤4MB → WebSocket: {type:"read_file", path:"...", user_id:"..."}
ReadFile >4MB → HTTP GET http://runner:PORT/api/v1/files?path=...&user_id=...
WriteFile ≤4MB → WebSocket: {type:"write_file", path:"...", data:"<base64>", perm:0644, user_id:"..."}
WriteFile >4MB → HTTP POST http://runner:PORT/api/v1/files (multipart)
| 参数 | 默认值 | 说明 |
|---|---|---|
| HTTP 阈值 | 4MB | 超过此大小走 HTTP 通道 |
| 最大文件大小 | 500MB | 超过直接报错,防止 OOM |
实现细节:
- RemoteSandbox.ReadFile/WriteFile 内部判断
len(data) > 4*1024*1024 - WebSocket 消息用 base64 编码(二进制 WebSocket 帧在某些代理下不安全)
- HTTP 通道用 multipart/form-data(支持流式传输)
- Runner HTTP Server 监听
127.0.0.1:{随机端口}(启动时上报给 Server)
// Server → Runner (request)
{"id":"req_001","type":"exec","command":"ls","args":["-la"],"shell":true,"dir":"/workspace","env":[],"stdin":"","timeout":30,"user_id":"ou_xxx"}
{"id":"req_002","type":"read_file","path":"/workspace/src/main.go","user_id":"ou_xxx"}
{"id":"req_003","type":"write_file","path":"/workspace/src/main.go","data":"<base64>","perm":384,"user_id":"ou_xxx"}
{"id":"req_004","type":"stat","path":"/workspace/src/main.go","user_id":"ou_xxx"}
{"id":"req_005","type":"read_dir","path":"/workspace/skills","user_id":"ou_xxx"}
{"id":"req_006","type":"mkdir_all","path":"/workspace/skills/new","perm":493,"user_id":"ou_xxx"}
{"id":"req_007","type":"remove","path":"/workspace/skills/old","user_id":"ou_xxx"}
{"id":"req_008","type":"remove_all","path":"/workspace/skills/old-dir","user_id":"ou_xxx"}
// Runner → Server (response)
{"id":"req_001","type":"exec_result","stdout":"...","stderr":"...","exit_code":0,"timed_out":false}
{"id":"req_002","type":"file_content","data":"<base64>"}
{"id":"req_002","type":"error","message":"file not found","code":"ENOENT"}
{"id":"req_004","type":"file_info","name":"main.go","size":1234,"mode":420,"mod_time":"2026-03-26T00:00:00Z","is_dir":false}
{"id":"req_005","type":"dir_entries","entries":[{"name":"my-skill","is_dir":true,"size":4096},{"name":"readme.md","is_dir":false,"size":256}]}
runner/
├── main.go # CLI entry point
├── client.go # WebSocket client (connects to server)
├── handler.go # Request handlers (exec, read_file, etc.)
├── server.go # HTTP server (large file transfer)
├── pathguard.go # Path safety validation
├── auth.go # Authentication (token-based)
└── go.mod
Runner CLI 用法:
xbot-runner --server ws://server:8080/ws --token <auth-token> --workspace /workspace
Path Guard:
- Runner 侧执行路径安全检查
- 拒绝路径逃逸:
/workspace/../../etc/passwd→ 拒绝 - 只允许访问 workspace 目录及其子目录
- 使用
filepath.Clean+strings.HasPrefix验证
| 文件 | 行号 | 操作 | 改为 |
|---|---|---|---|
agent/offload.go | 215 | os.ReadFile(hostPath) — hash 计算 | Sandbox.ReadFile(ctx, readPath, userID) |
agent/offload.go | 350 | os.ReadFile(resolvedPath) — InvalidateStaleReads | Sandbox.ReadFile(ctx, readPath, userID) |
注意:offload JSON 自身的存储(行 188,237,242,311)保持 os.* 不变——offload JSON 是 server 内部状态。
| 文件 | 行号 | 操作 | 改为 |
|---|---|---|---|
tools/read.go | 185 | os.ReadFile | Sandbox.ReadFile |
tools/edit.go | 337 | os.ReadFile | Sandbox.ReadFile |
tools/edit.go | 376 | os.MkdirAll(dir, 0755) — 创建新文件前 | Sandbox.MkdirAll |
tools/edit.go | 382 | os.WriteFile — doCreate | Sandbox.WriteFile |
tools/edit.go | 364 | os.WriteFile — doReplace | Sandbox.WriteFile |
tools/glob.go | 198,217,259 | os.Stat/filepath.Glob/filepath.WalkDir | 删除 executeLocal,统一走 Sandbox.Exec |
tools/grep.go | 388,509,536 | os.Open/os.Stat/filepath.WalkDir | 删除 executeLocal,统一走 Sandbox.Exec |
tools/cd.go | 263 | os.ReadDir(dir) — buildDirectoryTree | Sandbox.ReadDir |
| 文件 | 行号 | 操作 | 数据域 | 改为 |
|---|---|---|---|---|
agent/skills.go | 53 | os.ReadDir(dir) — 扫描 skill 目录 | 用户 skill /workspace/skills | Sandbox.ReadDir(通过缓存层) |
agent/skills.go | 67 | os.Stat(skillFile) — 检查 SKILL.md | 用户 skill | Sandbox.Stat(通过缓存层) |
agent/skills.go | 120 | os.ReadFile(target) — 读 SKILL.md | 用户 skill | Sandbox.ReadFile(通过缓存层) |
agent/skills.go | 153 | os.ReadFile(path) — 全局 skill | 全局 skill(server 目录) | 不改,os.* |
agent/agents.go | 41 | os.Stat(dir) — 检查 agents 目录 | 用户 agent /workspace/agents | Sandbox.Stat(通过缓存层) |
tools/skill.go | 94 | os.Stat(dir) — 检查 skill 目录 | 用户 skill | Sandbox.Stat |
tools/skill.go | 101 | os.ReadDir(c.hostRoot) — resolveSkill 扫描 | 用户 skill | Sandbox.ReadDir |
tools/skill.go | 120 | os.ReadFile(target) — 加载 SKILL.md | 用户 skill | Sandbox.ReadFile |
tools/skill.go | 136 | filepath.Walk(hostDir, ...) — doListFiles | 用户 skill | 递归 Sandbox.ReadDir(见 5.1.8) |
tools/subagent_loader.go | 14 | os.ReadDir(dir) — LoadAgentRoles 扫描 | 用户 agent 目录时 | Sandbox.ReadDir(用户目录分支) |
tools/subagent_loader.go | 37 | os.ReadFile — 加载 agent .md | 用户 agent | Sandbox.ReadFile |
tools/subagent_roles.go | 58 | os.Stat(dir) — 检查 agent 目录 | 用户 agent | Sandbox.Stat |
注意:tools/skill.go 的 resolveSkill 同时处理全局和用户目录,需区分:全局目录用 os.ReadDir,用户目录用 Sandbox.ReadDir。tools/subagent_loader.go 的 LoadAgentRoles 同理。
install(cache → 用户 workspace):
| 文件 | 行号 | 操作 | 改为 |
|---|---|---|---|
agent/registry.go | 159 | os.Stat(destDir) — 检查已安装 | Sandbox.Stat |
agent/registry.go | 164 | copyDir(src, dst) — 递归复制含 symlink | 拆分为 server 读 + Sandbox 写(见 6.3) |
agent/registry.go | 180 | os.MkdirAll(agentsDir, 0o755) — installAgent | Sandbox.MkdirAll |
agent/registry.go | 196 | os.Stat(destFile) — 检查已安装 | Sandbox.Stat |
agent/registry.go | 200 | os.ReadFile(srcPath) — 读 cache | 不改(server 端读 cache) |
agent/registry.go | 204 | os.WriteFile(destFile, data) — 写到用户目录 | Sandbox.WriteFile |
agent/registry.go | 230-234 | os.Stat + os.RemoveAll — uninstallSkill | Sandbox.Stat + Sandbox.RemoveAll |
agent/registry.go | 243-248 | os.Stat + os.Remove — uninstallAgent | Sandbox.Stat + Sandbox.Remove |
publish(用户 workspace → server cache):
| 文件 | 行号 | 操作 | 改为 |
|---|---|---|---|
agent/registry.go | 70 | snapshotDirToCache(skillDir, cacheDir) — skillDir 在用户本机 | 源端走 Sandbox 递归 ReadFile,目标端 os.WriteFile(见 6.3) |
agent/registry.go | 55-72 | publishSkill — findSkillDirForUser 找用户 skill | 用户目录分支 os.Stat → Sandbox.Stat(见下) |
agent/registry.go | 88-95 | publishAgent — findAgentFile 找用户 agent | 用户目录分支 os.Stat → Sandbox.Stat(见下) |
search/List(扫描用户 workspace 下的 skill/agent):
| 文件 | 行号 | 操作 | 改为 |
|---|---|---|---|
agent/registry.go | 304-316 | scanSkillDir(dir) — os.ReadDir | Sandbox.ReadDir(当 dir 是用户目录时) |
agent/registry.go | 325 | scanAgentDir(dir) — os.ReadDir | Sandbox.ReadDir(当 dir 是用户目录时) |
agent/registry.go | 389-396 | findSkillDirForUser — os.Stat | Sandbox.Stat(用户目录分支) |
agent/registry.go | 406-414 | findAgentFile — os.Stat | Sandbox.Stat(用户目录分支) |
| 文件 | 行号 | 操作 | 改为 |
|---|---|---|---|
tools/download.go | 151 | os.MkdirAll(filepath.Dir(outputPath), 0755) | Sandbox.MkdirAll |
tools/download.go | 152+ | os.Create + io.Copy | Sandbox.WriteFile |
tools/feishu_mcp/download.go | 79,82 | os.MkdirAll + os.Create + io.Copy | Sandbox.MkdirAll + Sandbox.WriteFile |
tools/feishu_mcp/file.go | 77 | os.Open | Sandbox.ReadFile |
tools/feishu_mcp/file.go | 386 | os.Stat(检查文件大小) | Sandbox.Stat |
tools/feishu_mcp/file.go | 391+ | __FEISHU_FILE__::hostPath 协议 | 废除,改为 Sandbox.ReadFile + Feishu API 直接上传 |
| 文件 | 行号 | 操作 | 改为 |
|---|---|---|---|
agent/bang_command.go | 49 | os.MkdirAll(workspaceRoot, 0o755) — 创建用户工作目录 | Sandbox.MkdirAll |
agent/bang_command.go | 169 | os.WriteFile — 写 bang 输出 | Sandbox.WriteFile |
agent/prompt_handler.go | 63 | os.WriteFile — 写 prompt 文件 | Sandbox.WriteFile |
以下 5 处 os.MkdirAll(workspaceRoot) 在消息处理入口处创建用户工作目录。remote 模式下 workspaceRoot 指向用户本机,必须走 Sandbox:
| 文件 | 行号 | 场景 | 改为 |
|---|---|---|---|
agent/agent.go | 1395 | cron 回调创建用户工作目录 | Sandbox.MkdirAll |
agent/agent.go | 1441 | 正常消息处理创建用户工作目录 | Sandbox.MkdirAll |
agent/bang_command.go | 49 | bang 命令创建工作目录 | Sandbox.MkdirAll(同 5.1.6) |
agent/interactive.go | 346 | SubAgent 构建父 ToolContext | Sandbox.MkdirAll |
agent/engine_wire.go | 534 | wire 层确保用户工作目录 | Sandbox.MkdirAll |
实现方式:Agent 持有 Sandbox 引用,在 workspaceRoot 创建处统一调用 Sandbox.MkdirAll(ctx, workspaceRoot, 0o755, senderID)。
tools/skill.go:136 的 doListFiles 和 agent/registry.go:427 的 copyDir/snapshotDirToCache 使用 filepath.WalkDir 递归遍历目录。Sandbox 接口没有 Walk 方法,需要替代方案:
递归 ReadDir 工具函数:
// WalkSandboxDir 递归遍历 Sandbox 目录,等价于 filepath.WalkDir。
// fn 回调接收相对路径和 DirEntry。只回调文件(跳过目录本身)。
func WalkSandboxDir(ctx context.Context, sb Sandbox, root, userID string, fn func(relPath string, entry DirEntry) error) error {
return walkSandboxDir(ctx, sb, root, "", userID, fn)
}
func walkSandboxDir(ctx context.Context, sb Sandbox, dir, relBase, userID string, fn func(string, DirEntry) error) error {
entries, err := sb.ReadDir(ctx, dir, userID)
if err != nil {
return err // 目录不存在 → 返回错误
}
for _, e := range entries {
relPath := filepath.Join(relBase, e.Name)
if e.IsDir {
if err := walkSandboxDir(ctx, sb, filepath.Join(dir, e.Name), relPath, userID, fn); err != nil {
return err
}
} else {
if err := fn(relPath, e); err != nil {
return err
}
}
}
return nil
}
当前 copyDir(registry.go:427)使用 os.Lstat + os.Readlink + os.Symlink 处理符号链接。Sandbox 接口没有 Symlink 方法。
策略:copyDir 跨 Sandbox 复制时展开 symlink(follow symlinks)
- publish(用户本机 → server cache):使用
WalkSandboxDir+Sandbox.ReadFile读取目标内容(自动 follow symlink) - install(server cache → 用户本机):使用
os.WalkDir+os.ReadFile读 cache,Sandbox.WriteFile写目标(递归创建目录,不创建 symlink) - 结果:所有 symlink 被展平为普通文件。这是可接受的——skill/agent 内部不应依赖 symlink 语义
| 文件 | 原因 |
|---|---|
agent/offload.go:188,237,242,311 | offload JSON 自身存储(server 内部状态) |
agent/context.go:66,71,94 | server prompt 模板(.xbot/prompts/) |
agent/skills.go:153 | 全局 skill 定义(server 目录) |
agent/registry.go:144,185,356,370,374 | server 缓存(.xbot/registry/) |
agent/registry.go:200 | 读 server cache(publish/install 的源端读 cache) |
config/* | server 配置 |
storage/* | 数据库迁移和操作 |
logger/* | 日志系统 |
channel/* | channel handler 本地文件 |
tools/manage_tools.go:265 | MCP 配置读取(server 本地) |
tools/session_mcp.go:358,377 | MCP 配置读取(server 本地) |
tools/mcp_common.go:248,499 | MCP bin 目录检查(server 本地) |
tools/logs.go:126 | 日志目录扫描(.xbot/logs/,server 内部) |
| 文件 | 原因 |
|---|---|
tools/skill_sync.go | 全局 skill 同步到 workspace → remote 不需要(server 端直接注入 system prompt) |
| 文件 | 原因 |
|---|---|
tools/workspace_scope.go 中的 SandboxToHostPath/HostToSandboxPath | 路径语义统一为 sandbox 路径,转换是 Sandbox 实现内部细节 |
tools/glob.go 中的 executeLocal | 统一走 Sandbox.Exec |
tools/grep.go 中的 executeLocal | 统一走 Sandbox.Exec |
tools/feishu_mcp/file.go 中的 __FEISHU_FILE__:: 协议 | 工具层直接读+上传 |
问题:OffloadStore 当前用 os.ReadFile 计算 hash 和检测 stale,remote 模式下文件在用户本机。
方案:
// OffloadStore 不持有 Sandbox — 通过参数传入(方案 X2)
type OffloadStore struct {
config OffloadConfig
sessions sync.Map
}
// MaybeOffload: 新签名
func (s *OffloadStore) MaybeOffload(
ctx context.Context,
sessionKey, toolName, args, result string,
resolvedReadPath string, // 由 engine.go 预解析的绝对路径
fs tools.Sandbox, // Sandbox 实例
) (OffloadedResult, bool)
// InvalidateStaleReads: 新签名
func (s *OffloadStore) InvalidateStaleReads(
ctx context.Context,
sessionKey string,
fs tools.Sandbox,
) []string
engine.go 调用改造:
// engine.go (改造后)
resolvedReadPath := ""
if tc.Name == "Read" {
if p := extractJSONStringField(tc.Arguments, "path"); p != "" {
cwd := cfg.Session.GetCurrentDir()
if cwd == "" {
cwd = cfg.SandboxWorkDir
}
if filepath.IsAbs(p) {
resolvedReadPath = p
} else {
resolvedReadPath = filepath.Join(cwd, p)
}
}
}
offloaded, wasOffloaded := cfg.OffloadStore.MaybeOffload(
ctx, offloadSessionKey, tc.Name, tc.Arguments, offloadContent,
resolvedReadPath, cfg.Sandbox,
)
offload.go 内部改造:
// 新代码:hash 计算
if resolvedReadPath != "" && fs != nil {
if rawData, err := fs.ReadFile(ctx, resolvedReadPath, userID); err == nil {
entry.ContentHash = fmt.Sprintf("%x", sha256.Sum256(rawData))
}
// hash 失败 → 不设置 ContentHash → stale 检测不生效
// 这是正确行为:文件不可读时无法判断是否 stale
}
问题:remote 模式下用户 skill/agent 在用户本机 /workspace/skills 和 /workspace/agents。
方案:SkillStore 缓存 + TTL
type SkillStore struct {
workDir string
globalDirs []string
sandbox tools.Sandbox
catalog *skillCatalog
catalogTime time.Time
mu sync.RWMutex
}
func (s *SkillStore) GetCatalog(ctx context.Context, senderID string) string {
s.mu.RLock()
if s.catalog != nil && time.Since(s.catalogTime) < 5*time.Minute {
cat := s.catalog
s.mu.RUnlock()
return cat.render(senderID)
}
s.mu.RUnlock()
return s.refreshCatalog(ctx, senderID)
}
func (s *SkillStore) refreshCatalog(ctx context.Context, senderID string) string {
var entries []skillEntry
// 1. 全局 skill → server 本地 os.ReadDir + os.ReadFile
for _, dir := range s.globalDirs {
entries = append(entries, s.scanGlobalSkills(dir)...)
}
// 2. 用户 skill → Sandbox.ReadDir + Sandbox.ReadFile
if s.sandbox != nil {
userSkillsDir := "/workspace/skills"
if dirEntries, err := s.sandbox.ReadDir(ctx, userSkillsDir, senderID); err == nil {
for _, de := range dirEntries {
if de.IsDir {
entry := s.readSkillDefViaSandbox(ctx, filepath.Join(userSkillsDir, de.Name), senderID)
entries = append(entries, entry)
}
}
}
}
s.mu.Lock()
s.catalog = buildCatalog(entries)
s.catalogTime = time.Now()
s.mu.Unlock()
return s.catalog.render(senderID)
}
func (s *SkillStore) InvalidateCache() {
s.mu.Lock()
s.catalog = nil
s.mu.Unlock()
}
问题:install 从 server cache 复制到用户 workspace,publish 从用户 workspace 复制到 server cache,uninstall 从用户 workspace 删除。涉及跨 Sandbox 文件复制。
方案:RegistryManager 接收 Sandbox 引用。
type RegistryManager struct {
workDir string
sharedStore *sqlite.SharedStore
sandbox tools.Sandbox // 新增
}
installSkill — server cache (os.) → user workspace (Sandbox.):
func (rm *RegistryManager) installSkill(ctx context.Context, entry *sqlite.SharedEntry, senderID string) error {
destDir := filepath.Join("/workspace/skills", entry.Name)
// 检查目标:Sandbox
if _, err := rm.sandbox.Stat(ctx, destDir, senderID); err == nil {
return fmt.Errorf("skill %q already installed", entry.Name)
}
// 读源:server cache (os.WalkDir 递归读)
var files []struct{ RelPath string; Data []byte }
err := filepath.WalkDir(entry.SourcePath, func(path string, d fs.DirEntry, err error) error {
if err != nil { return err }
if d.IsDir() { return nil }
rel, _ := filepath.Rel(entry.SourcePath, path)
data, err := os.ReadFile(path) // follow symlink automatically
if err != nil { return err }
files = append(files, struct{ RelPath string; Data []byte }{rel, data})
return nil
})
if err != nil { return fmt.Errorf("read cache: %w", err) }
// 写目标:Sandbox(递归创建目录 + 写文件)
for _, f := range files {
dstPath := filepath.Join(destDir, f.RelPath)
if err := rm.sandbox.MkdirAll(ctx, filepath.Dir(dstPath), 0o755, senderID); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
if err := rm.sandbox.WriteFile(ctx, dstPath, f.Data, 0o644, senderID); err != nil {
return fmt.Errorf("write: %w", err)
}
}
return nil
}
publishSkill — user workspace (Sandbox.) → server cache (os.):
func (rm *RegistryManager) publishSkill(ctx context.Context, name, author string) error {
skillDir := rm.findSkillDirForUser(ctx, name, author)
if skillDir == "" {
return fmt.Errorf("skill %q not found", name)
}
cacheDir := rm.registryCacheDir("skill", name)
// 读源:Sandbox 递归读(WalkSandboxDir)
var files []struct{ RelPath string; Data []byte }
err := WalkSandboxDir(ctx, rm.sandbox, skillDir, author, func(relPath string, entry DirEntry) error {
fullPath := filepath.Join(skillDir, relPath)
data, err := rm.sandbox.ReadFile(ctx, fullPath, author)
if err != nil { return err }
files = append(files, struct{ RelPath string; Data []byte }{relPath, data})
return nil
})
if err != nil { return fmt.Errorf("read skill: %w", err) }
// 写目标:server cache (os.*)
if err := os.RemoveAll(cacheDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("clean cache: %w", err)
}
for _, f := range files {
dstPath := filepath.Join(cacheDir, f.RelPath)
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return fmt.Errorf("mkdir cache: %w", err)
}
if err := os.WriteFile(dstPath, f.Data, 0o644); err != nil {
return fmt.Errorf("write cache: %w", err)
}
}
// ... publish to shared store
}
findSkillDirForUser — 用户目录分支走 Sandbox:
func (rm *RegistryManager) findSkillDirForUser(ctx context.Context, name, senderID string) string {
// 先查全局(server 本地)
if dir := rm.findSkillDir(name); dir != "" {
return dir
}
// 再查用户目录(Sandbox)
if senderID != "" && rm.sandbox != nil {
path := filepath.Join("/workspace/skills", name)
if _, err := rm.sandbox.Stat(ctx, filepath.Join(path, "SKILL.md"), senderID); err == nil {
return path
}
}
return ""
}
方案:废除 __FEISHU_FILE__:: 协议,工具层直接读+上传。
// DownloadFile (Feishu → 用户本机)
func downloadAndSave(ctx context.Context, sandbox tools.Sandbox, userID, filePath string, feishuData []byte) error {
if err := sandbox.MkdirAll(ctx, filepath.Dir(filePath), 0o755, userID); err != nil {
return err
}
return sandbox.WriteFile(ctx, filePath, feishuData, 0o644, userID)
}
// UploadFile/SendFile (用户本机 → Feishu)
func uploadFile(ctx context.Context, sandbox tools.Sandbox, userID, filePath string) (string, error) {
data, err := sandbox.ReadFile(ctx, filePath, userID)
if err != nil { return "", err }
return feishuClient.UploadFile(data, filepath.Base(filePath))
}
双跳不可避免:server ↔ Feishu 是第一跳(需要 token),server ↔ Runner 是第二跳(Sandbox API)。这是 remote 模式的固有代价。
type RunConfig struct {
// ... 现有字段 ...
Sandbox tools.Sandbox // Sandbox 实例引用
SandboxMode string // "none", "docker", "remote"(配置时决策)
}
type ToolContext struct {
// ... 现有字段 ...
Sandbox tools.Sandbox // V4 新增
// SandboxMode 不加 — 用 Sandbox.Name() 代替
}
// 旧代码:所有模式都做 workspaceRoot → sandboxWorkDir 转换
if cwd != "" && cfg.SandboxEnabled && cfg.WorkspaceRoot != "" && cfg.SandboxWorkDir != "" {
if strings.HasPrefix(cwd, cfg.WorkspaceRoot) {
cwd = cfg.SandboxWorkDir + cwd[len(cfg.WorkspaceRoot):]
}
}
// 新代码:remote 模式下跳过转换
// 原因:remote 模式下 CWD 由 Cd 工具设置,已经是 sandbox 格式的绝对路径(/workspace/...)。
// Cd 工具在所有模式下统一使用 Sandbox.Stat 验证路径,返回的路径已经是 sandbox 路径。
// docker 模式仍需转换:docker 模式下 Cd 工具返回的是 host 路径(因为 path_guard 基于 WorkspaceRoot)。
if cwd != "" && cfg.SandboxMode != "remote" && cfg.WorkspaceRoot != "" && cfg.SandboxWorkDir != "" {
if strings.HasPrefix(cwd, cfg.WorkspaceRoot) {
cwd = cfg.SandboxWorkDir + cwd[len(cfg.WorkspaceRoot):]
}
}
删除 executeLocal,所有模式走 Sandbox.Exec:
// 旧代码
func (t *GlobTool) Execute(ctx *ToolContext, args ...) {
if ctx.SandboxEnabled { return t.executeInSandbox(ctx, args) }
return t.executeLocal(ctx, args)
}
// 新代码
func (t *GlobTool) Execute(ctx *ToolContext, args ...) {
return t.executeViaSandbox(ctx, args)
}
// 旧代码:base64 hack
func sandboxWriteFile(ctx *ToolContext, path, content string) {
encoded := base64.StdEncoding.EncodeToString([]byte(content))
executeInSandboxRaw(ctx, fmt.Sprintf("mkdir -p %s", dir))
executeInSandboxRaw(ctx, fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, path))
}
// 新代码
func sandboxWriteFile(ctx context.Context, sandbox tools.Sandbox, userID, path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := sandbox.MkdirAll(ctx, dir, 0o755, userID); err != nil {
return err
}
return sandbox.WriteFile(ctx, path, data, perm, userID)
}
- 定义
Sandbox接口和所有类型 - 实现
NoneSandbox(所有方法直接调 os.*) RunConfig/ToolContext加Sandbox+SandboxModemain.go初始化 Sandbox 并注入 Agent- 改造 5 处 workspaceRoot 创建(5.1.7)→
Sandbox.MkdirAll
目标:不破坏现有功能,建立新抽象层。workspaceRoot 创建统一走 Sandbox。
tools/edit.go:删除 base64 hack,改用 Sandbox.ReadFile/WriteFiletools/read.go:os.ReadFile → Sandbox.ReadFile- ToolContext 传递 Sandbox 引用
- 删除
executeLocal函数 - 所有模式走
Sandbox.Exec(find/grep命令) tools/cd.go:263buildDirectoryTree → Sandbox.ReadDir
- 修改 MaybeOffload/InvalidateStaleReads 签名
- engine.go 解析 Read 路径并传入绝对路径
- offload hash 计算走 Sandbox.ReadFile
- SkillStore 加缓存 + TTL
- AgentStore 同理
- registry install/uninstall/publish 穿越 Sandbox(含 findSkillDirForUser/findAgentFile)
- scanSkillDir/scanAgentDir 穿越 Sandbox
- skill_sync.go 加 remote 模式跳过守卫
- tools/skill.go resolveSkill/doListFiles 穿越 Sandbox
- subagent_loader.go LoadAgentRoles 穿越 Sandbox
- 废除
__FEISHU_FILE__::协议 - download.go/feishu_mcp/download.go → Sandbox.MkdirAll + Sandbox.WriteFile
- feishu_mcp/file.go → Sandbox.ReadFile + Feishu API
- bang_command.go/prompt_handler.go → Sandbox.WriteFile
- 删除 SandboxToHostPath/HostToSandboxPath
- 基于 docker exec 实现所有 Sandbox 方法
- 文件操作:
docker exec cat/docker exec -i tee - 目录操作:
docker exec mkdir/docker exec rm
- WebSocket server + client
- Runner CLI(main.go + handler.go)
- 双通道文件传输
- Path guard
- Runner HTTP Server
- 删除旧 Wrap 方法
- 删除 executeLocal 函数
- 删除
__FEISHU_FILE__::协议 .xbot路径引用加注释标记- 测试覆盖
Phase 依赖关系:
Phase 1 → Phase 2/3 (可并行) → Phase 4 (依赖 2) → Phase 5/6 (可并行) → Phase 7/8 (可并行) → Phase 9
| # | 条目 | 决策 |
|---|---|---|
| 1 | Sandbox 接口方法 | Exec + ReadFile + WriteFile + Stat + ReadDir + MkdirAll + Remove + RemoveAll |
| 2 | ReadFile/WriteFile 路径要求 | 绝对路径,内部处理 sandbox→host 转换 |
| 3 | Offload JSON 存储 | 存 server(os.*),hash 走 Sandbox.ReadFile |
| 4 | OffloadStore 依赖注入 | 接收 Sandbox 参数(方案 X2) |
| 5 | readPath 解析 | engine.go 用 cfg.Session.GetCurrentDir() 解析,传绝对路径 |
| 6 | Skill 发现机制 | Server 缓存 + TTL 5min + 主动失效 |
| 7 | skill_sync.go | remote 模式跳过 |
| 8 | MCP 配置 | 不动,server 本地 |
| 9 | 路径转换函数 | 删除 SandboxToHostPath/HostToSandboxPath |
| 10 | EditTool | 统一走 Sandbox.ReadFile/WriteFile |
| 11 | bang_command/prompt_handler | 传 Sandbox 引用,走 Sandbox.WriteFile |
| 12 | Feishu 文件操作 | 废除 __FEISHU_FILE__:: 协议 |
| 13 | ReadOnlyRoots | remote 模式不支持 |
| 14 | 大文件传输 | 一次性 []byte,≤4MB WebSocket,>4MB HTTP |
| 15 | SandboxFileInfo | 替代 os.FileInfo,不含 Sys() |
| 16 | RunConfig | 加 Sandbox + SandboxMode |
| 17 | ToolContext | 加 Sandbox(不加 SandboxMode,用 Sandbox.Name()) |
| 18 | Glob/Grep | 删除 executeLocal,统一走 Sandbox.Exec |
| 19 | MkdirAll | Sandbox 接口增加 MkdirAll 方法 |