Skip to main content
xbot
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage
Edit page

Remote Sandbox 方案 V4

最后更新:2026-03-26 状态:中书省 brainstorm 6 轮 + 门下省 2 轮审核 分支:refactor/sandbox-tool-provider 前置方案:V3(已废弃)


1. 背景与动机

1.1 V3 方案的致命缺陷

V3 方案设计了 Sandbox 接口(Exec/ReadFile/WriteFile),但在以下方面存在根本性问题:

  1. Offload 退化:remote 模式下 offload hash 计算"自动退化"(os.ReadFile 失败 → 跳过 stale 检测),这是偷懒不是设计
  2. 配置路径错误:个人 skill/agent 路径用了 .xbot/skills.xbot/agents,实际应为 /workspace/skills/workspace/agents
  3. Remote 语义不清:没有明确 “remote = 所有东西跑在用户本机,server 只是 WebSocket 协调器”

1.2 V4 硬约束(不可违反)

#约束说明
H1Offload 不可退化hash 计算、内容读写必须全部通过 Sandbox.ReadFile/WriteFile 完成
H2配置路径 /workspace/skills + /workspace/agents不是 .xbot/skills / .xbot/agents
H3Remote = 用户本机ShellTool、ReadFile/WriteFile、skill/agent、offload、MCP stdio 全在用户本机
H4彻底重构(方案 A)不渐进迁移,直接重写,不怕重写历史文件

2. 架构总览

2.1 核心原则

Remote Sandbox 的本质:
  Server = WebSocket 协调器(不接触用户文件)
  Runner = 用户本机代理(执行所有文件 I/O 和命令)
  Sandbox 接口 = 统一抽象(所有模式共享同一套 API)

2.2 三种模式对比

维度nonedockerremote (V4)
Shell 执行os/execdocker execWebSocket → Runner os/exec
文件读写os.ReadFiledocker exec cat/teeWebSocket/HTTP → Runner os.ReadFile
Skill/Agent 发现本地 /workspace/skills容器内 /workspace/skills用户本机 /workspace/skills
Offload hashos.ReadFiledocker exec catSandbox.ReadFile(穿越到用户本机)
MCP stdio本地启动容器内启动用户本机启动
Server 是否接触用户文件✅ 直接读写✅ 通过 docker API❌ 不接触,只做 WebSocket 协调
ReadOnlyRoots✅ 支持✅ 支持❌ 不支持

2.3 关键设计决策

#决策理由
D1单一 Sandbox 接口(不拆 FileIO)FileIO 永远由 Sandbox 提供,拆分增加复杂度但收益不大
D2Sandbox.ReadFile/WriteFile 要求绝对路径相对路径解析依赖 session 级 Cwd,Sandbox 是多 session 共享
D3Offload JSON 存 server 端(os.*),hash 走 Sandboxoffload 是 agent 内部状态,消费者在 server 端
D4OffloadStore.MaybeOffload 接收 Sandbox 参数(方案 X2)显式依赖,OffloadStore 保持纯数据存储
D5Skill 发现用 Server 缓存 + TTL 5min + 主动失效简单可靠,避免 runner 侧 file watch 的跨平台复杂度
D6skill_sync.go 在 remote 模式下跳过全局 skill 由 SkillStore 在 server 端注入 system prompt
D7废除 __FEISHU_FILE__:: 协议工具层直接 Sandbox.ReadFile + Feishu API 上传
D8删除 SandboxToHostPath / HostToSandboxPath路径语义统一为 sandbox 路径,转换是 Sandbox 实现内部细节
D9Glob/Grep 统一走 Sandbox.Exec(删除 executeLocal)消除双路径分歧,Sandbox.Exec 在 none 模式下就是 os/exec
D10Sandbox.WriteFile 不自动 MkdirAll遵循最小意外原则,显式调用 Sandbox.MkdirAll
D11copyDir 跨 Sandbox 时展开 symlinkSandbox 无 Symlink 方法,skill/agent 不应依赖 symlink 语义
D12filepath.WalkDir 用递归 ReadDir 替代Sandbox 接口没有 Walk 方法

3. Sandbox 接口定义

3.1 核心接口

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
}

3.2 三种实现

// 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
}

3.3 路径语义

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)

4. RemoteSandbox 详细设计

4.1 架构

┌─────────────────────────────────────────────────────┐
│                    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                 │
└─────────────────────────────────────────────────────┘

4.2 双通道文件传输

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)

4.3 WebSocket 协议

// 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}]}

4.4 Runner 设计

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 验证

5. os.* 调用全景分类

5.1 必须改走 Sandbox(涉及用户文件)

5.1.1 Offload hash 计算(2 处)

文件行号操作改为
agent/offload.go215os.ReadFile(hostPath) — hash 计算Sandbox.ReadFile(ctx, readPath, userID)
agent/offload.go350os.ReadFile(resolvedPath) — InvalidateStaleReadsSandbox.ReadFile(ctx, readPath, userID)

注意:offload JSON 自身的存储(行 188,237,242,311)保持 os.* 不变——offload JSON 是 server 内部状态。

5.1.2 文件工具(Read/Edit/Glob/Grep)

文件行号操作改为
tools/read.go185os.ReadFileSandbox.ReadFile
tools/edit.go337os.ReadFileSandbox.ReadFile
tools/edit.go376os.MkdirAll(dir, 0755) — 创建新文件前Sandbox.MkdirAll
tools/edit.go382os.WriteFile — doCreateSandbox.WriteFile
tools/edit.go364os.WriteFile — doReplaceSandbox.WriteFile
tools/glob.go198,217,259os.Stat/filepath.Glob/filepath.WalkDir删除 executeLocal,统一走 Sandbox.Exec
tools/grep.go388,509,536os.Open/os.Stat/filepath.WalkDir删除 executeLocal,统一走 Sandbox.Exec
tools/cd.go263os.ReadDir(dir) — buildDirectoryTreeSandbox.ReadDir

5.1.3 Skill/Agent 发现与加载

文件行号操作数据域改为
agent/skills.go53os.ReadDir(dir) — 扫描 skill 目录用户 skill /workspace/skillsSandbox.ReadDir(通过缓存层)
agent/skills.go67os.Stat(skillFile) — 检查 SKILL.md用户 skillSandbox.Stat(通过缓存层)
agent/skills.go120os.ReadFile(target) — 读 SKILL.md用户 skillSandbox.ReadFile(通过缓存层)
agent/skills.go153os.ReadFile(path) — 全局 skill全局 skill(server 目录)不改,os.*
agent/agents.go41os.Stat(dir) — 检查 agents 目录用户 agent /workspace/agentsSandbox.Stat(通过缓存层)
tools/skill.go94os.Stat(dir) — 检查 skill 目录用户 skillSandbox.Stat
tools/skill.go101os.ReadDir(c.hostRoot) — resolveSkill 扫描用户 skillSandbox.ReadDir
tools/skill.go120os.ReadFile(target) — 加载 SKILL.md用户 skillSandbox.ReadFile
tools/skill.go136filepath.Walk(hostDir, ...) — doListFiles用户 skill递归 Sandbox.ReadDir(见 5.1.8)
tools/subagent_loader.go14os.ReadDir(dir) — LoadAgentRoles 扫描用户 agent 目录时Sandbox.ReadDir(用户目录分支)
tools/subagent_loader.go37os.ReadFile — 加载 agent .md用户 agentSandbox.ReadFile
tools/subagent_roles.go58os.Stat(dir) — 检查 agent 目录用户 agentSandbox.Stat

注意tools/skill.goresolveSkill 同时处理全局和用户目录,需区分:全局目录用 os.ReadDir,用户目录用 Sandbox.ReadDir。tools/subagent_loader.goLoadAgentRoles 同理。

5.1.4 Registry install/uninstall/publish(用户 workspace 端)

install(cache → 用户 workspace)

文件行号操作改为
agent/registry.go159os.Stat(destDir) — 检查已安装Sandbox.Stat
agent/registry.go164copyDir(src, dst) — 递归复制含 symlink拆分为 server 读 + Sandbox 写(见 6.3)
agent/registry.go180os.MkdirAll(agentsDir, 0o755) — installAgentSandbox.MkdirAll
agent/registry.go196os.Stat(destFile) — 检查已安装Sandbox.Stat
agent/registry.go200os.ReadFile(srcPath) — 读 cache不改(server 端读 cache)
agent/registry.go204os.WriteFile(destFile, data) — 写到用户目录Sandbox.WriteFile
agent/registry.go230-234os.Stat + os.RemoveAll — uninstallSkillSandbox.Stat + Sandbox.RemoveAll
agent/registry.go243-248os.Stat + os.Remove — uninstallAgentSandbox.Stat + Sandbox.Remove

publish(用户 workspace → server cache)

文件行号操作改为
agent/registry.go70snapshotDirToCache(skillDir, cacheDir) — skillDir 在用户本机源端走 Sandbox 递归 ReadFile,目标端 os.WriteFile(见 6.3)
agent/registry.go55-72publishSkill — findSkillDirForUser 找用户 skill用户目录分支 os.Stat → Sandbox.Stat(见下)
agent/registry.go88-95publishAgent — findAgentFile 找用户 agent用户目录分支 os.Stat → Sandbox.Stat(见下)

search/List(扫描用户 workspace 下的 skill/agent)

文件行号操作改为
agent/registry.go304-316scanSkillDir(dir)os.ReadDirSandbox.ReadDir(当 dir 是用户目录时)
agent/registry.go325scanAgentDir(dir)os.ReadDirSandbox.ReadDir(当 dir 是用户目录时)
agent/registry.go389-396findSkillDirForUseros.StatSandbox.Stat(用户目录分支)
agent/registry.go406-414findAgentFileos.StatSandbox.Stat(用户目录分支)

5.1.5 Feishu 文件操作

文件行号操作改为
tools/download.go151os.MkdirAll(filepath.Dir(outputPath), 0755)Sandbox.MkdirAll
tools/download.go152+os.Create + io.CopySandbox.WriteFile
tools/feishu_mcp/download.go79,82os.MkdirAll + os.Create + io.CopySandbox.MkdirAll + Sandbox.WriteFile
tools/feishu_mcp/file.go77os.OpenSandbox.ReadFile
tools/feishu_mcp/file.go386os.Stat(检查文件大小)Sandbox.Stat
tools/feishu_mcp/file.go391+__FEISHU_FILE__::hostPath 协议废除,改为 Sandbox.ReadFile + Feishu API 直接上传

5.1.6 Agent 层写操作

文件行号操作改为
agent/bang_command.go49os.MkdirAll(workspaceRoot, 0o755) — 创建用户工作目录Sandbox.MkdirAll
agent/bang_command.go169os.WriteFile — 写 bang 输出Sandbox.WriteFile
agent/prompt_handler.go63os.WriteFile — 写 prompt 文件Sandbox.WriteFile

5.1.7 用户工作目录创建

以下 5 处 os.MkdirAll(workspaceRoot) 在消息处理入口处创建用户工作目录。remote 模式下 workspaceRoot 指向用户本机,必须走 Sandbox:

文件行号场景改为
agent/agent.go1395cron 回调创建用户工作目录Sandbox.MkdirAll
agent/agent.go1441正常消息处理创建用户工作目录Sandbox.MkdirAll
agent/bang_command.go49bang 命令创建工作目录Sandbox.MkdirAll(同 5.1.6)
agent/interactive.go346SubAgent 构建父 ToolContextSandbox.MkdirAll
agent/engine_wire.go534wire 层确保用户工作目录Sandbox.MkdirAll

实现方式:Agent 持有 Sandbox 引用,在 workspaceRoot 创建处统一调用 Sandbox.MkdirAll(ctx, workspaceRoot, 0o755, senderID)

5.1.8 filepath.WalkDir 替代方案

tools/skill.go:136doListFilesagent/registry.go:427copyDir/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 语义

5.2 保持 server 本地(不改)

文件原因
agent/offload.go:188,237,242,311offload JSON 自身存储(server 内部状态)
agent/context.go:66,71,94server prompt 模板(.xbot/prompts/
agent/skills.go:153全局 skill 定义(server 目录)
agent/registry.go:144,185,356,370,374server 缓存(.xbot/registry/
agent/registry.go:200读 server cache(publish/install 的源端读 cache)
config/*server 配置
storage/*数据库迁移和操作
logger/*日志系统
channel/*channel handler 本地文件
tools/manage_tools.go:265MCP 配置读取(server 本地)
tools/session_mcp.go:358,377MCP 配置读取(server 本地)
tools/mcp_common.go:248,499MCP bin 目录检查(server 本地)
tools/logs.go:126日志目录扫描(.xbot/logs/,server 内部)

5.3 Remote 模式下跳过

文件原因
tools/skill_sync.go全局 skill 同步到 workspace → remote 不需要(server 端直接注入 system prompt)

5.4 删除

文件原因
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__:: 协议工具层直接读+上传

6. 关键子系统改造

6.1 Offload 穿越 Sandbox

问题: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
}

6.2 Skill/Agent 发现

问题: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()
}

6.3 Registry install/uninstall/publish

问题: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 ""
}

6.4 Feishu 文件操作

方案:废除 __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 模式的固有代价。

6.5 RunConfig/ToolContext 改造

type RunConfig struct {
    // ... 现有字段 ...
    Sandbox    tools.Sandbox  // Sandbox 实例引用
    SandboxMode string        // "none", "docker", "remote"(配置时决策)
}

type ToolContext struct {
    // ... 现有字段 ...
    Sandbox tools.Sandbox  // V4 新增
    // SandboxMode 不加 — 用 Sandbox.Name() 代替
}

6.6 SubAgent CWD 处理

// 旧代码:所有模式都做 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):]
    }
}

6.7 Glob/Grep 统一

删除 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)
}

6.8 EditTool 统一

// 旧代码: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)
}

7. 实施计划

Phase 1:Sandbox 接口 + None 实现 + workspaceRoot 创建改造

  1. 定义 Sandbox 接口和所有类型
  2. 实现 NoneSandbox(所有方法直接调 os.*)
  3. RunConfig/ToolContextSandbox + SandboxMode
  4. main.go 初始化 Sandbox 并注入 Agent
  5. 改造 5 处 workspaceRoot 创建(5.1.7)→ Sandbox.MkdirAll

目标:不破坏现有功能,建立新抽象层。workspaceRoot 创建统一走 Sandbox。

Phase 2:EditTool/ReadTool 统一走 Sandbox

  1. tools/edit.go:删除 base64 hack,改用 Sandbox.ReadFile/WriteFile
  2. tools/read.go:os.ReadFile → Sandbox.ReadFile
  3. ToolContext 传递 Sandbox 引用

Phase 3:Glob/Grep 统一

  1. 删除 executeLocal 函数
  2. 所有模式走 Sandbox.Execfind/grep 命令)
  3. tools/cd.go:263 buildDirectoryTree → Sandbox.ReadDir

Phase 4:Offload 穿越 Sandbox

  1. 修改 MaybeOffload/InvalidateStaleReads 签名
  2. engine.go 解析 Read 路径并传入绝对路径
  3. offload hash 计算走 Sandbox.ReadFile

Phase 5:Skill/Agent 发现改造

  1. SkillStore 加缓存 + TTL
  2. AgentStore 同理
  3. registry install/uninstall/publish 穿越 Sandbox(含 findSkillDirForUser/findAgentFile)
  4. scanSkillDir/scanAgentDir 穿越 Sandbox
  5. skill_sync.go 加 remote 模式跳过守卫
  6. tools/skill.go resolveSkill/doListFiles 穿越 Sandbox
  7. subagent_loader.go LoadAgentRoles 穿越 Sandbox

Phase 6:Feishu/Download/Agent 写操作改造

  1. 废除 __FEISHU_FILE__:: 协议
  2. download.go/feishu_mcp/download.go → Sandbox.MkdirAll + Sandbox.WriteFile
  3. feishu_mcp/file.go → Sandbox.ReadFile + Feishu API
  4. bang_command.go/prompt_handler.go → Sandbox.WriteFile
  5. 删除 SandboxToHostPath/HostToSandboxPath

Phase 7:DockerSandbox 实现

  1. 基于 docker exec 实现所有 Sandbox 方法
  2. 文件操作:docker exec cat/docker exec -i tee
  3. 目录操作:docker exec mkdir/docker exec rm

Phase 8:RemoteSandbox + Runner 实现

  1. WebSocket server + client
  2. Runner CLI(main.go + handler.go)
  3. 双通道文件传输
  4. Path guard
  5. Runner HTTP Server

Phase 9:清理

  1. 删除旧 Wrap 方法
  2. 删除 executeLocal 函数
  3. 删除 __FEISHU_FILE__:: 协议
  4. .xbot 路径引用加注释标记
  5. 测试覆盖

Phase 依赖关系

Phase 1 → Phase 2/3 (可并行) → Phase 4 (依赖 2) → Phase 5/6 (可并行) → Phase 7/8 (可并行) → Phase 9

8. Brainstorm 共识清单(19 条)

#条目决策
1Sandbox 接口方法Exec + ReadFile + WriteFile + Stat + ReadDir + MkdirAll + Remove + RemoveAll
2ReadFile/WriteFile 路径要求绝对路径,内部处理 sandbox→host 转换
3Offload JSON 存储存 server(os.*),hash 走 Sandbox.ReadFile
4OffloadStore 依赖注入接收 Sandbox 参数(方案 X2)
5readPath 解析engine.go 用 cfg.Session.GetCurrentDir() 解析,传绝对路径
6Skill 发现机制Server 缓存 + TTL 5min + 主动失效
7skill_sync.goremote 模式跳过
8MCP 配置不动,server 本地
9路径转换函数删除 SandboxToHostPath/HostToSandboxPath
10EditTool统一走 Sandbox.ReadFile/WriteFile
11bang_command/prompt_handler传 Sandbox 引用,走 Sandbox.WriteFile
12Feishu 文件操作废除 __FEISHU_FILE__:: 协议
13ReadOnlyRootsremote 模式不支持
14大文件传输一次性 []byte,≤4MB WebSocket,>4MB HTTP
15SandboxFileInfo替代 os.FileInfo,不含 Sys()
16RunConfig加 Sandbox + SandboxMode
17ToolContext加 Sandbox(不加 SandboxMode,用 Sandbox.Name())
18Glob/Grep删除 executeLocal,统一走 Sandbox.Exec
19MkdirAllSandbox 接口增加 MkdirAll 方法