一. 相关结构

1. ContainerJobManager

/// Manages the lifecycle of Docker containers for sandboxed job execution.
pub struct ContainerJobManager {
    config: ContainerJobConfig,
  1. config: ContainerJobConfig
  类型: ContainerJobConfig(同文件,行 73-130)
  作用: 创建容器时所有"形状参数"的不可变配置。
  - 镜像 image:默认 ironclaw-worker:latest,被 docker create_container 当 image 字段用。
  - 资源限制 memory_limit_mb / cpu_shares:worker 模式默认 2048 MB + 1024 shares;claude_code_memory_limit_mb / acp_memory_limit_mb 各自更高。
  - orchestrator_port: u16:orchestrator 内部 HTTP API 监听端口(默认 50051),worker 容器用 http://host.docker.internal:50051 回连。
  - Claude Code 鉴权材料 claude_code_api_key / claude_code_oauth_token:二选一注入到 JobMode::ClaudeCode 容器的 ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN 环境变量。
  - Claude Code 行为参数 claude_code_model / claude_code_max_turns / claude_code_allowed_tools:拼到 claude-bridge 子命令的 CLI 参数里。
  - ACP 行为参数 acp_memory_limit_mb / acp_timeout_secs。
  - 功能开关 mcp_per_job_enabled / claude_code_enabled / acp_enabled:is_mode_enabled(...) 用这两个开关拒绝在配置禁用时创建对应模式容器。
************

    token_store: TokenStore,
第一个 Map 存的是「谁可以用什么 token 调我」,第二个 Map 存的是「这个任务被允许用哪些凭据」。 两者按 job_id 关联生命周期,撤销时一起清。
*************************
    pub(crate) containers: Arc<RwLock<HashMap<Uuid, ContainerHandle>>>,
  ▎ containers 是 job_id → ContainerHandle 的进程内映射表,存的是每个 job 的运行时元数据快照——容器 ID、状态、模式、迭代数、完成结果。故意不存 bearer token(那个在 TokenStore 里),故意在 complete_job 
  ▎ 之后保留 handle 直到调用方读走结果——cleanup_job 才真正移除。
********************
    /// Cached Docker connection (created on first use).
    docker: Arc<RwLock<Option<bollard::Docker>>>,
作用: 到本地 Docker daemon 的 bollard 客户端连接,懒加载并缓存。
}

2. ContainerHandle

/// Handle to a running container job.
#[derive(Debug, Clone)]
pub struct ContainerHandle {
    pub job_id: Uuid,
    pub container_id: String,
    pub state: ContainerState,
    pub mode: JobMode,
    pub created_at: DateTime<Utc>,
    pub project_dir: Option<PathBuf>,
    pub task_description: String,
    /// Last status message reported by the worker (iteration count, progress, etc.).
    pub last_worker_status: Option<String>,
    /// Which iteration the worker is on (updated via status reports).
    pub worker_iteration: u32,
    /// Completion result from the worker (set when the worker reports done).
    pub completion_result: Option<CompletionResult>,
    // NOTE: auth_token is intentionally NOT in this struct.
    // It lives only in the TokenStore (never logged, serialized, or persisted).
}

3. tokenStore

/// A credential grant that maps a secret (stored in SecretsStore) to an
/// environment variable name the container worker expects.
///
/// For example: `{ secret_name: "github_token", env_var: "GITHUB_TOKEN" }`
/// means "decrypt the secret named `github_token` and provide it as the
/// env var `GITHUB_TOKEN` to the container".
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialGrant {
    pub secret_name: String,
    pub env_var: String,
}

/// In-memory store for per-job authentication tokens and credential grants.
#[derive(Clone)]
pub struct TokenStore {
    /// Maps job_id -> bearer token. Never logged or persisted.
    tokens: Arc<RwLock<HashMap<Uuid, String>>>,
    /// Maps job_id -> granted credentials. Revoked alongside the token.
    credential_grants: Arc<RwLock<HashMap<Uuid, Vec<CredentialGrant>>>>,
}
第一个 Map 存的是「谁可以用什么 token 调我」,第二个 Map 存的是「这个任务被允许用哪些凭据」。 两者按 job_id 关联生命周期,撤销时一起清。

二. 链路

1作用

  一、先纠正一个关键误解

  prompt_queue 不是给"LLM 多轮对话"用的——LLM 多轮在主进程里通过会话历史自己管理,根本不需要 prompt_queue。
  
  prompt_queue 是给沙箱容器里那个独立的 agent 进程用的。

  主进程里跑的 IronClaw agent 是 LLM 多轮的"主对话";沙箱容器里跑的 worker 是另一个独立的 agentic 循环实例,主进程通过 prompt_queue 跨进程地驱动它。

  二、为什么需要容器级别的 worker

  看 src/worker/container.rs 和 src/worker/job.rs,IronClaw 的沙箱 worker 有两种模式:

  - ContainerDelegate:完整容器模式,跑完整工具栈(shell、file、apply_patch 等)。
  - JobDelegate:轻量 job 模式,跑受限工具集。

  它们都跑一个共享的 agentic loop——也就是说,容器里的 worker 自己就是一个 agent,不是简单的"执行单条命令然后退"。

  那为什么不让主进程直接调 LLM、非容器工具也走主进程?因为有几类工具必须沙箱隔离:

  ┌────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────────┐
  │                                  工具                                  │              为什么必须沙箱              │
  ├────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
  │ shell                                                                  │ 能跑任意命令,主进程直接调等于裸奔       │
  ├────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
  │ file_write / apply_patch / file_read / list_dir(针对 /project/ 路径) │ 写文件系统需要隔离的工作区               │
  ├────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
  │ 网络出站(HTTP 工具)                                                  │ 需要 allowlist 注入凭据、走 sandbox 代理 │
  └────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────┘

  CLAUDE.md 里 "Engine v2 Per-Project Sandbox" 那节明确说:

  ▎ When SANDBOX_ENABLED=true, engine v2 routes the five filesystem/shell tools for /project/ paths through a per-project Docker container instead of the host filesystem.

  所以沙箱是 per-project 的,不是全局的——不同的 project 各跑各的容器。

  三、那 prompt_queue 解决的是什么问题

  考虑这个用户场景:

  用户:  "帮我把 /project 里的 README.md 改一下标题"
  [round 1] 主进程 agent 分析 → 决定调 create_job
            ↓
            container_job_manager.create_container(job_1)
            ↓
            容器 worker 启动,自己进入 agentic loop
            ↓
            worker 读 README → file_write 改标题 → 完成
            ↓
            容器 worker 进入 idle,等下一条 prompt

  用户:  "顺便把 LICENSE 文件也加一段版权声明"
  [round 2] 主进程收到新消息
            ↓
            问题是:这个新消息是发给容器 worker 的(因为 /project/ 工具还在它手里)
            还是主进程自己处理?
            ↓
            答案是:发给容器 worker——它才是 /project/ 路径的"主人"
            ↓
            prompt_queue[job_1].send("顺便把 LICENSE 文件也加一段版权声明")
            ↓
            容器 worker 醒过来,继续 round 2 的 agentic loop
            ↓
            完成后继续 idle

  用户:  "好,搞定"
  [round 3] 容器 worker 完成 → container_job_manager.stop_container(job_1)

  关键点: 主进程自己不会去操作 /project/ 下的文件,它只负责"调度和路由";真正的文件操作 worker 在容器里。所以"用户的第 N 条消息"必须能跨进程回到这个 worker 手里继续跑。

  四、和"LLM 多轮"的区别

  ┌──────────────────┬───────────────────────────────┬───────────────────────────────────────────────────────┐
  │       维度       │   主进程 agent 的 LLM 多轮    │                  容器 worker 的多轮                   │
  ├──────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────┤
  │ 状态存在哪       │ 主进程内存 + 会话存储         │ 容器内存                                              │
  ├──────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────┤
  │ 谁驱动 LLM 调用  │ 主进程的 LlmProvider          │ worker 通过 orchestrator 的 LLM proxy(proxy_llm.rs) │
  ├──────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────┤
  │ 历史怎么延续     │ 主进程 session manager 自维护 │ worker 自己 session manager 自维护                    │
  ├──────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────┤
  │ 用户新消息怎么到 │ 直接进主进程消息流            │ 必须经 prompt_queue 跨进程投递给 worker               │
  ├──────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────┤
  │ 何时结束         │ 用户结束会话 / agent 退出     │ worker 完成最后一个工具调用后 idle,等 prompt_queue   │
  └──────────────────┴───────────────────────────────┴───────────────────────────────────────────────────────┘

  注意 worker 自己也跑 session——也就是说容器里的 worker 内部就是 LLM 多轮的,主进程这一层不参与它的多轮,只负责"投喂新输入"和"回收事件"。

  五、再回头看 job_event_tx 的角色

  顺着这个图就更清楚了——job_event_tx 是 worker → 主进程的反向通道:

  container worker ──(NDJSON/HTTP)──► orchestrator API
                                         │
                                         ▼
                                job_event_tx.broadcast((job_id, user_id, event))
                                         │
                                         ▼
                                main.rs:1024-1042 那个 tokio::spawn
                                         │
                                         ▼
                                dispatch_status_event → gateway SSE → 用户 UI

  也就是说:

  - prompt_queue = 主进程 → 容器的后续指令通道(用户后续消息)
  - job_event_tx = 容器 → 主进程的事件回流通道(工具调用、进度、结果)

  两者合起来就是 worker 的"远程控制双向链路"。

  六、为什么 container_job_manager 是必须的

  回到你最初的疑问——"容器本身不是工具执行吗"——确实,单次工具调用在容器内完成没问题,但 IronClaw 沙箱的设计目标是:

  1. 承载完整 agentic loop,而不是单条 shell 命令;
  2. 跨多轮用户消息保持状态(worker 内存里的 session、临时文件、cwd);
  3. 生命周期由编排器统一管控(孤儿清理、token 注入、停止/重启)。

  container_job_manager 就是这一切的"房东"——它不管工具执行,只管这个"住在 Docker 里的 agent"什么时候搬进来、什么时候搬走、什么时候被强制赶出去。

  一句话总结

  ▎ 沙箱容器里跑的不是"工具执行器",而是一个独立的 agent 进程。container_job_manager 负责它生灭,prompt_queue 是主进程给它喂后续用户消息的信箱,job_event_tx 
  ▎ 是它给主进程汇报进度的回执。这三个东西合起来才构成完整的"远程代理沙箱",而不是简单的"工具调用容器化"。

2. 两类沙箱

● 这两个对应 IronClaw 里的两种沙箱执行模式,分别针对不同风险等级的工具。看 src/worker/container.rs 和 src/worker/job.rs 就很清楚。

  一、完整容器:ContainerDelegate

  对应: full_job routine,或者任何需要 shell / 文件系统写权限的工具调用。

  架构:

  ┌─────────────────────────── host ───────────────────────────┐
  │  main ironclaw process                                     │
  │    └── orchestrator (HTTP API @ :50051)                    │
  │          ▲                                                 │
  │          │ NDJSON over `docker exec -i`                    │
  │          │                                                 │
  │          ▼                                                 │
  │  ┌──── Docker container ────────────────────────────────┐  │
  │  │  sandbox_daemon binary                                │  │
  │  │    └── ContainerDelegate                              │  │
  │  │          └── 完整 agentic loop (复用主进程那段代码)    │  │
  │  │                ├── LLM (via orchestrator LLM proxy)    │  │
  │  │                ├── 全部工具 (shell, file, apply_patch)│  │
  │  │                ├── WASM 工具                          │  │
  │  │                └── 沙箱网络代理                        │  │
  │  │                                                        │  │
  │  │  bind mount: ~/.ironclaw/projects/<user>/<project>/    │  │
  │  │              → /project/                              │  │
  │  └────────────────────────────────────────────────────────┘  │
  └────────────────────────────────────────────────────────────┘

  特点:

  - 跑完整的 agentic loop,能用 shell、file_write、apply_patch 之类的高权限工具。
  - ~/.ironclaw/projects/<user>/<project>/ 通过 bind mount 挂到容器里的 /project/,sandbox_daemon 通过 NDJSON over docker exec -i 跟主进程对话。
  - 网络出站走 src/sandbox/proxy/ 那个代理——它做三件事:
    - 域名 allowlist 过滤;
    - 凭据注入(OAuth bearer 之类);
    - 流量走 CONNECT 隧道。
  - 启动慢、吃资源,但是真正干活的。

  二、轻量 Job:JobDelegate

  对应: 不需要完整 shell 隔离、但仍然希望异步、不阻塞主对话执行的"长任务"。

  架构:

  ┌──────────────── host ────────────────────┐
  │  main ironclaw process                  │
  │    └── JobScheduler                      │
  │          │                               │
  │          ▼                               │
  │  ┌──── JobWorker task (in-process) ───┐  │
  │  │  JobDelegate                       │  │
  │  │    └── 复用同一段 agentic loop      │  │
  │  │          ├── LLM                    │  │
  │  │          ├── 受限工具集             │  │
  │  │          └── 没有 shell/file_write  │  │
  │  └────────────────────────────────────┘  │
  │                                          │
  │  (没有 Docker,没有 /project/ 挂载)      │
  └──────────────────────────────────────────┘

  特点:

  - 跑在主进程内的 tokio task 里,不是独立 Docker 容器。
  - 同样跑共享的 agentic loop——也就是说它也是一个独立 agent 实例,有它自己的 LLM 多轮、自己的 session。
  - 工具集受限:通常不能调 shell、不能 file_write 到 /project/、不能直接网络出站(要走主进程策略)。
  - 启动快、资源轻,适合"发个 webhook 出去然后等回调"这类 IO 密集但风险低的活。

  三、为什么共享 agentic loop 但分两个 delegate

  src/worker/container.rs 和 src/worker/job.rs 里都有这一段(或者类似的):

  // 两个 delegate 都跑同一份 agentic loop 实现
  async fn run_agentic_loop<D: Delegate>(delegate: D, ...) {
      // LLM call → tool dispatch → tool execution → next round
  }

  delegate 模式的好处:

  - agentic loop 的核心逻辑(消息流、工具调度、LLM 调用)只写一份。
  - delegate 只负责"我是谁":
    - 我能用什么工具?(delegate.allowed_tools())
    - 我的文件系统视图是什么?(host 还是 /project/ bind mount)
    - 我的网络出口在哪?(直连 vs sandbox proxy)
    - 我的事件往哪发?(主进程 SSE vs 容器 NDJSON)
    - 我怎么挂掉?(tokio task 退出 vs Docker stop)

  所以"完整"和"轻量"的差异完全在 delegate 这一层,loop 本身是共享的。这点 CLAUDE.md 里 "Worker" 那节也提到:

  ▎ src/worker/container.rs — Container worker runtime (ContainerDelegate + shared agentic loop)
  ▎ src/worker/job.rs — Background job worker (JobDelegate + shared agentic loop)

  四、和前面讲的"prompt_queue + container_job_manager"怎么对上

  ┌───────────────────────┬──────────────────────────────┬────────────────────────────────────────────────┐
  │         组件          │     ContainerDelegate 用     │                 JobDelegate 用                 │
  ├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────┤
  │ container_job_manager │ ✅  拉起 / 杀掉 / 清理 Docker │ ❌  不需要                                      │
  ├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────┤
  │ prompt_queue          │ ✅  跨进程投递给容器 worker   │ ⚠️ 同进程直接用 channel,不是这个 prompt_queue │
  ├───────────────────────┼──────────────────────────────┼────────────────────────────────────────────────┤
  │ job_event_tx          │ ✅  容器 → 主进程事件回流     │ ✅  in-process 直接 broadcast                   │
  └───────────────────────┴──────────────────────────────┴────────────────────────────────────────────────┘

  也就是说——

  - JobDelegate 不走 prompt_queue——它和主进程同进程,直接用 tokio::sync::mpsc 之类的 in-process 通道就够了。prompt_queue 是为了跨进程投递这个特定场景才存在的数据结构。
  - ContainerDelegate 才用 prompt_queue——因为容器 worker 是另一个 OS 进程。

  main.rs:867-880 那段 register_job_tools 里 prompt_queue 只在 config.sandbox.enabled && container_job_manager.is_some() 时才注入,也是这个原因——只有"完整容器"模式才需要跨进程的 prompt_queue。

  五、什么时候用哪个

  ┌──────────────────────────────────────────────────────────┬───────────────────────────────┐
  │                           场景                           │            选哪个             │
  ├──────────────────────────────────────────────────────────┼───────────────────────────────┤
  │ 改 /project/ 下的代码、写文件、跑 shell                  │ ContainerDelegate(full_job) │
  ├──────────────────────────────────────────────────────────┼───────────────────────────────┤
  │ 长时间跑一个外部 webhook(比如定时轮询、监听第三方回调) │ JobDelegate                   │
  ├──────────────────────────────────────────────────────────┼───────────────────────────────┤
  │ 网络抓取 + LLM 总结,但不需要写文件                      │ 通常 JobDelegate 就够         │
  ├──────────────────────────────────────────────────────────┼───────────────────────────────┤
  │ 用户说"完整地实现这个 feature,改一堆文件"               │ ContainerDelegate,多轮交互   │
  ├──────────────────────────────────────────────────────────┼───────────────────────────────┤
  │ 后台定期清理任务(heartbeat 类)                         │ JobDelegate                   │
  └──────────────────────────────────────────────────────────┴───────────────────────────────┘

  一句话总结

  ▎ ContainerDelegate = 跑在 Docker 里的完整 agent 实例,能用 shell / 文件系统写等高权限工具;JobDelegate = 跑在主进程 tokio task 里的轻量 agent 实例,工具受限但启动快、共享主进程内存。两者共用同一段
  ▎ agentic loop 代码,只在 delegate 这一层区分能力边界。prompt_queue 和 container_job_manager 是为 ContainerDelegate 这种跨进程场景设计的——JobDelegate 在主进程内自己走 mpsc,根本不需要 prompt_queue
  ▎ 这个数据结构。

3. 与wasm_tool

一、先把三层的安全模型摆清楚

  ┌─────────────────────────────────────────────────────────────┐
  │  Trust boundary #1: WASM tool sandbox                       │
  │  ├── 威胁模型: 第三方/不可信代码 (用户从 registry 装的工具)    │
  │  ├── 隔离手段: wasmtime fuel metering + 线性内存限制         │
  │  ├── 网络:    强制走 allowlist (src/tools/wasm/allowlist.rs)  │
  │  ├── 凭据:    credential_injector 只注入白名单凭据            │
  │  └── 隔离单位: 单个工具调用                                   │
  └─────────────────────────────────────────────────────────────┘
                              ▼
  ┌─────────────────────────────────────────────────────────────┐
  │  Trust boundary #2: ContainerDelegate (full_job)            │
  │  ├── 威胁模型: 用户自己写的代码 / LLM 生成的代码              │
  │  ├── 隔离手段: Docker container + bind mount + seccomp       │
  │  ├── 网络:    sandbox proxy 做域名 allowlist + CONNECT 隧道  │
  │  ├── 凭据:    proxy 注入 OAuth bearer,容器本身看不到         │
  │  └── 隔离单位: 一个完整的 agentic loop 实例                   │
  └─────────────────────────────────────────────────────────────┘
                              ▼
  ┌─────────────────────────────────────────────────────────────┐
  │  Trust boundary #3: JobDelegate (in-process)                │
  │  ├── 威胁模型: 用户主对话里的"我不放心让它阻塞我"的副作用      │
  │  ├── 隔离手段: tokio task + 受限工具集 (无 shell / file_w)   │
  │  ├── 网络:    走主进程策略 (没有额外隔离)                      │
  │  ├── 凭据:    复用主进程凭据                                  │
  │  └── 隔离单位: 一个完整的 agentic loop 实例                   │
  └─────────────────────────────────────────────────────────────┘

  关键:每个边界假设的"最坏情况"不同。

  二、WASM tool 解决的是什么

  WASM tool 的威胁模型非常具体——registry 安装的第三方扩展:

  ▎ CLAUDE.md → "Skills System":
  ▎ - Trusted (用户放在 ~/.ironclaw/skills/ 或 workspace skills/, 完整工具权限)
  ▎ - Installed (registry 安装, 只读工具)

  也就是说已安装的扩展默认不可信。WASM 提供的是:

  - 内存隔离——线性内存,fuel 计量,OOM 直接 trap,不能访问 host 内存;
  - 能力裁剪——host function 必须显式声明(host.rs 里那批函数就是白名单);
  - 网络 allowlist——wasm 工具想出网必须先注册域名;
  - 凭据注入——credential_injector 决定哪些 secret 能流进 wasm 工具;
  - rate limiter——每个工具独立限流。

  所以 WASM 工具的本质是: "我信任你(开发者)写的逻辑,但不信任你能跳出沙箱"。

  三、那为什么还需要 ContainerDelegate

  WASM 隔离的是单个工具调用。当你做的事情超出"调一个工具"的范畴——比如:

  1. LLM 写代码、写文件、再读、再改……多个回合——这不是 WASM 工具的抽象层。WASM 工具只能被动被调一次;
  2. agentic loop 本身——主循环怎么调度 LLM、怎么管上下文、怎么跨工具——这些根本不在 WASM 沙箱里,它们跑在主进程(或容器)里;
  3. 原生工具(shell、file_write、apply_patch)——这些不是 WASM 工具,是 Rust 直接实现的内建工具。src/tools/builtin/ 里那一堆就是。它们完全没有 WASM 隔离。

  看 CLAUDE.md → "Engine v2 Per-Project Sandbox":

  ▎ engine v2 routes the five filesystem/shell tools (file_read, file_write, list_dir, apply_patch, shell) for /project/ paths through a per-project Docker container instead of the host filesystem.

  注意是 for /project/ paths——也就是说:

  - 这些内建 Rust 工具本身没有沙箱;
  - 沙箱是作用在调用路径上的:路由层(MountBackend vs container)决定实际执行环境;
  - 没有 Docker 时降级到 MountBackend,行为不变(CLAUDE.md 里明确写了这个降级路径)。

  所以 ContainerDelegate 解决的是: "我信任 agentic loop 的逻辑,但 LLM 在容器里跑一堆 shell/文件工具时,得有个真·OS 级隔离兜底"。

  四、为什么三层并存(而不是只用 WASM)

  假设只有 WASM 这一层,会出现这些空洞:

  ┌─────────────────────────────────────┬─────────────────────────────────────────────────────────────┬─────────────────────────────────────┐
  │                需求                 │                        WASM 能不能做                        │               谁来做                │
  ├─────────────────────────────────────┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
  │ 第三方扩展工具                      │ ✅  这是 WASM 的设计目标                                     │ WASM tool                           │
  ├─────────────────────────────────────┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
  │ 用户主对话里调 shell "echo hi"      │ ❌  内建 Rust 工具,不走 WASM                                │ 路由层 → host 直接执行 或 container │
  ├─────────────────────────────────────┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
  │ 用户让 LLM 改一个真实项目的一堆文件 │ ❌  不是单次工具调用,需要多轮回合 + 文件系统视图 + 网络出站 │ ContainerDelegate                   │
  ├─────────────────────────────────────┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
  │ 后台跑一个 5 分钟的轮询任务         │ ❌  阻塞主对话 + 没现成抽象                                  │ JobDelegate                         │
  ├─────────────────────────────────────┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
  │ 用户信任的、本机放的 SKILL.md       │ ✅  Trusted 路径,给完整工具权限                             │ WASM tool with elevated trust       │
  └─────────────────────────────────────┴─────────────────────────────────────────────────────────────┴─────────────────────────────────────┘

  三层各有明确的威胁模型,不会互相覆盖:

  - WASM tool:隔离不可信代码的"调用颗粒";
  - ContainerDelegate:隔离 LLM 在真文件系统上"做项目"的工作流颗粒;
  - JobDelegate:隔离"我不想被它阻塞"的长任务颗粒。

  五、WASM tool 在容器里会怎样

  这是最反直觉但最关键的一点:

  WASM tool 可以跑在 ContainerDelegate 里面。看代码 src/worker/container.rs 里的 delegate 构造:

  ContainerDelegate 的工具集 = 内建工具 (受限)  ∪  WASM 工具 (按 trust 配置)

  也就是说:

  - WASM 工具的"不可信"隔离仍然生效(wasmtime + fuel + 内存);
  - 之外还多一层 Docker 隔离(系统调用、文件系统、网络);
  - 这是纵深防御,不是替代。

  CLAUDE.md 里"defense in depth"那条原则在这里就体现了——任何一层失守,下一层还能兜住。

  六、对应到 routines / tool 视角

  Routines (full_job / scheduled)
      │
      ▼
  [ Orchestrator picks a delegate ]
      │
      ├── full_job + SANDBOX_ENABLED=true    → ContainerDelegate (Docker)
      │                                          └── tools = builtin (host 文件系统代理) ∪ wasm
      │
      ├── full_job + SANDBOX_ENABLED=false   → JobDelegate (in-process)
      │                                          └── tools = builtin (host 直接) ∪ wasm (受限)
      │
      └── background poll / webhook          → JobDelegate
                                                 └── tools = builtin (受限) ∪ wasm (受限)

  WASM 工具始终存在,但内建工具的执行环境按沙箱开关切换 host / container。

  七、一句话总结

  ▎ WASM 隔离的是"单个工具调用",解决"不可信第三方代码"的威胁模型;ContainerDelegate 隔离的是"整个 agentic loop 在真文件系统上做项目",解决"LLM 在容器里跑 shell/写文件"的威胁模型;JobDelegate
  ▎ 隔离的是"我不想被它阻塞的主对话",解决"长任务"的威胁模型。它们是纵深防御的三层,不是替代——容器里的 WASM 工具仍然受 wasmtime 隔离,WASM
  ▎ 工具也照样能跑在内建工具的容器化路由后面。每层都有自己的威胁模型假设,单独任何一层都不能覆盖所有场景。

4. 凭据隔离

一、容器里跑的是什么?

  不是独立 daemon、不是 NDJSON 协议。容器里跑的是 同一个 ironclaw 主二进制 + 不同子命令,长驻运行直到任务结束。

  src/orchestrator/job_manager.rs:543-570 根据 JobMode 决定启动哪个子命令,三选一:

  ┌────────────────┬──────────────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────┐
  │    JobMode     │                                容器内 PID 1 跑的进程                                 │                                  镜像                                  │
  ├────────────────┼──────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
  │ Worker(默认) │ ironclaw worker --job-id <uuid> --orchestrator-url http://host.docker.internal:50051 │ ironclaw-worker:latest(来自 SANDBOX_IMAGE,src/config/sandbox.rs:48) │
  ├────────────────┼──────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
  │ ClaudeCode     │ ironclaw claude-bridge ...(实际会再 spawn 一个 claude CLI 子进程)                  │ 同上                                                                   │
  ├────────────────┼──────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────┤
  │ Acp            │ ironclaw acp-bridge ...(通过 stdio 与 ACP agent 通信)                              │ 同上                                                                   │
  └────────────────┴──────────────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────┘

  容器 ↔ orchestrator 的通信是 HTTP + bearer token,不是 NDJSON:
  - Worker 进程从环境变量 IRONCLAW_WORKER_TOKEN 取 token(src/worker/api.rs:125-139)
  - 任务拉取:GET /worker/{job_id}/job
  - 凭证拉取:GET /worker/{job_id}/credentials(用 owner-scoped 解密,src/orchestrator/api.rs:498-579)
  - LLM 调用:POST /worker/{job_id}/llm/complete[_with_tools](src/worker/proxy_llm.rs,由 orchestrator 持真实 API key 完成)
  - 事件回流:POST /worker/{job_id}/event(src/orchestrator/api.rs:317-463)

  镜像构建见仓库根目录的 Dockerfile.worker(注意:不是 crates/Dockerfile.sandbox —— 那个是 v2 计划用的 tini + sandbox_daemon 镜像,v1 不走)。

  容器创建流程(src/orchestrator/job_manager.rs:409-632)

  1. bollard::Docker 连 Docker daemon
  2. 挂载项目目录:~/.ironclaw/projects/<job_id>/:/workspace:rw(必须经过 validate_bind_mount_path 校验防 TOCTOU)
  3. 可选挂载 per-job MCP 配置
  4. 设置 HostConfig(行 526-541):
    - 内存:Worker 2048MB / ClaudeCode 4096MB / Acp 4096MB
    - network_mode: bridge(标准 Docker bridge,没有隔离网络)
    - cap_drop: ALL,cap_add: CHOWN,security_opt: no-new-privileges:true
    - /tmp 512M tmpfs
    - 没有设置 readonly_rootfs(容器内 /usr 等可写)
    - 没有挂载 HTTP proxy / 没有注入 http_proxy 环境变量

  二、容器里支持哪些工具?

  模式 A:JobMode::Worker(默认)

  src/tools/registry.rs:527-529 register_container_tools() 只注册 8 个 builtin Rust 工具:

  ┌─────────────┬────────────────────────────────────────────────────────────────────────────┐
  │    工具     │                                    说明                                    │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ shell       │ 任意命令(带 env scrubbing,denylist 见 src/tools/builtin/job.rs:697-724) │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ read_file   │ 受 read_state 追踪                                                         │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ write_file  │ 带 file_history 支持 undo                                                  │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ list_dir    │ -                                                                          │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ apply_patch │ 代码补丁                                                                   │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ glob        │ -                                                                          │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ grep        │ -                                                                          │
  ├─────────────┼────────────────────────────────────────────────────────────────────────────┤
  │ file_undo   │ undo 之前的写操作                                                          │
  └─────────────┴────────────────────────────────────────────────────────────────────────────┘

  工具在容器进程内 inline 执行(src/worker/container.rs:570-577 execute_tool_simple),不通过 daemon/HTTP 暴露。没有 WASM 工具、没有 MCP、没有 memory/job/routine 工具。

  模式 B:JobMode::ClaudeCode

  工具集由 claude CLI 自己提供,受 /workspace/.claude/settings.json 白名单限制(src/worker/claude_bridge.rs:155-172)。默认允许列表在 src/config/sandbox.rs:192-211:

  Read(*), Write(*), Edit(*), Glob(*), Grep(*), NotebookEdit(*),
  Bash(*), Task(*), WebFetch(*), WebSearch(*)

  容器是非交互的,未在白名单的工具会超时。

  模式 C:JobMode::Acp

  工具集由外部 ACP agent(Goose / Codex / Gemini CLI 等)提供,由 ACP_AGENT_COMMAND 决定。

  网络隔离

  v1 worker 容器没有 network proxy。network_mode: bridge + host.docker.internal:host-gateway 解析,完全开放的出网:
  - 容器能直接打任意外网(虽然 LLM 调用被 ProxyLlmProvider 强制走 orchestrator)
  - 没有像 src/sandbox/proxy/allowlist.rs(per-command sandbox 那条路径用的)那种 domain allowlist

  ⚠️ src/sandbox/manager.rs + src/sandbox/proxy/* 是另一套系统(per-command sandbox,给 shell/file_* 单次执行用),它才有 HTTP proxy + domain allowlist。v1 的 worker 容器不走那条路径。

  文件系统 / 凭证

  - 容器以 uid 1000:1000 非 root 运行
  - bind mount 只暴露 ~/.ironclaw/projects/<job_id>/:/workspace
  - 容器进程从不持有 secrets 解密密钥(master key 在 host OS keychain)
  - 凭证通过 /worker/{job_id}/credentials 按 owner-scoped 解密后注入子进程 Command::envs()(src/worker/container.rs:565-568),不写全局 env

  三、关键文件行号速查

  ┌──────────────────────────────────────┬─────────────────────────────────────────────────────────────┬───────────────────┐
  │                 主题                 │                            文件                             │       行号        │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ ContainerJobManager 容器创建         │ src/orchestrator/job_manager.rs                             │ 338-405           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ image/cmd/env/mount 完整流程         │ src/orchestrator/job_manager.rs                             │ 409-632           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ 三种模式的子命令                     │ src/orchestrator/job_manager.rs                             │ 543-570           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ HostConfig(cap_drop/network/tmpfs) │ src/orchestrator/job_manager.rs                             │ 526-541           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ Project dir 校验防 TOCTOU            │ src/orchestrator/job_manager.rs                             │ 193-253           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ Worker 凭据 owner-scoped 解密        │ src/orchestrator/api.rs                                     │ 498-579           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ WorkerRuntime 主循环                 │ src/worker/container.rs                                     │ 107-316           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ Worker 工具 inline 执行              │ src/worker/container.rs                                     │ 570-577           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ 容器工具注册                         │ src/tools/registry.rs                                       │ 527-529 + 582-606 │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ ClaudeCode 工具白名单                │ src/config/sandbox.rs                                       │ 192-211           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ create_job 工具入口                  │ src/tools/builtin/job.rs                                    │ 982-1096          │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ 危险 env var denylist                │ src/tools/builtin/job.rs                                    │ 697-755           │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ v1 镜像配置                          │ src/config/sandbox.rs                                       │ 38-56             │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ v1 镜像 Dockerfile                   │ Dockerfile.worker(仓库根目录)                             │ -                 │
  ├──────────────────────────────────────┼─────────────────────────────────────────────────────────────┼───────────────────┤
  │ v2 计划(不在 v1 跑)                │ crates/Dockerfile.sandbox + src/bridge/sandbox/lifecycle.rs │ -                 │
  └──────────────────────────────────────┴─────────────────────────────────────────────────────────────┴───────────────────┘

  一句话总结

  v1 容器里跑的是 ironclaw worker / ironclaw claude-bridge / ironclaw acp-bridge 同一个二进制的长驻子命令进程,通过 HTTP+bearer token 调回 orchestrator。Worker 模式只 inline 执行 8 个 builtin Rust
  工具(shell/read/write/list_dir/apply_patch/glob/grep/file_undo),网络完全开放(仅靠 capability drop 限制,没有 domain allowlist —— 那是 v2 per-project sandbox 的计划),文件系统靠 bind mount 隔离到
  ~/.ironclaw/projects/<job_id>/:/workspace,凭证按 owner-scoped 解密注入到子进程 env,master key 永不进容器。
● 在 IronClaw 这个项目里,容器内没有 wasmtime,也没有 WASM 工具

  证据:
  - src/worker/container.rs:94 里 WorkerRuntime::new 只调 register_container_tools(),而 register_container_tools 实际只注册 register_dev_tools() 的 8 个 Rust
  内建工具(shell、read_file、write_file、list_dir、apply_patch、glob、grep、file_undo),没有任何 WASM 注册调用。
  - src/worker/ 全目录 grep wasm 零命中。
  - Dockerfile.worker 镜像里没有 wasmtime 二进制,只有 rust/node/python/gh/claude code CLI。
  - Cargo.toml 里 wasmtime 依赖只在顶层 src/ 和 crates/ironclaw_wasm* 里持有,src/worker/ 不引。

  WASM 工具实际跑在哪里

  WASM 工具只在 host 进程内(带 wasmtime 的那个 IronClaw 主进程)跑。加载入口在 src/tools/wasm/runtime.rs 和 src/tools/wasm/loader.rs,通过 registry.register_wasm(...) 注册。

  容器 worker 是另一个独立的 ironclaw binary,不会加载 wasm 模块,也不会持有 wasmtime 实例。两个进程只在 localhost:50051 这条 NDJSON/HTTP 通道上对话。

  所以"两层嵌套的凭据注入"在这个仓库里根本不存在

  之前讨论的 "WASM tool 跑在容器内 → host function 是 stub → 代理回 host 注入凭据" 这个场景是按纵深防御原则脑补的。当前实现把 WASM 工具和容器工具完全正交化:
  - WASM 工具 ↔ host 进程 + credential_injector 注入白名单 header
  - 容器 worker ↔ 独立 binary + ProxyLlmProvider(无 LLM 凭据)+ fetch_credentials() 拿到的 child process env

  容器 worker 的真实凭据流

  容器内接触不到任何架构层的明文凭据:
  - LLM API key:ProxyLlmProvider 不持有 key,所有 LLM 请求走 HTTP 到 host 的 orchestrator(:50051),由 orchestrator 持有真实 key。src/worker/proxy_llm.rs:1-4 注释明确写了这一点。
  - child process 凭据:worker 通过 IRONCLAW_WORKER_TOKEN 鉴权后调 client.fetch_credentials()(src/worker/container.rs:121-134),从 orchestrator 拿到凭据列表,只注入到 Command::envs() 
  启动的子进程(避免污染全局 env),不是 wasm 模块能看到的形式。
  - 架构层没有 "容器内的 wasm host function 发请求 → stub 代理回 host" 这条链路,因为容器内没有 wasm。

  一句话

  WASM 工具和容器 worker 在当前实现里是两个互不重叠的进程:WASM 工具只在 host 进程跑,凭据由 host 上的 credential_injector 注入;容器 worker 是独立 binary,没有 wasmtime,不持有 LLM 凭据,所有 LLM
  调用走 orchestrator proxy。两条线不交叉,所以也不存在"凭据穿越两层边界"的路径。
所以容器内部只有8个自建工具?那容器的agent_loop需要外部工具咋搞?比如web_search这些                                                                                                                    

● 对,容器 worker 默认只能调那 8 个内建工具。需要 web_search 这种外部能力时,容器不会自己去实现或调用这些工具——而是由 host 进程的 agentic loop 在主对话里处理,根本不会通过 worker 的 agentic loop。

  关键架构事实:容器 worker 不是"完整 agent"

  容器 worker 是为 full_job 这种特定长任务设计的,它跑的是一个受限的 agentic loop:
  
  - ✅  适合:在一个项目目录里改文件、跑 build、跑 test、应用 patch、读 glob 结果、grep 代码
  - ❌  不适合:网页搜索、查数据库、调 LLM 用 web 工具、和外部服务对话

  为什么?代码里写得很清楚(src/tools/registry.rs:513-521):

  ▎ register_orchestrator_tools — Register only orchestrator-domain tools (safe for the main process).
  ▎ This registers tools that don't touch the filesystem or run shell commands: echo, time, json, http.

  而 register_container_tools 是反过来:

  ▎ Register container-domain tools (filesystem, shell, code). These tools are intended to run inside sandboxed Docker containers.

  容器 worker 拿到的工具集是按 domain 切片的——它故意没有 web 类工具。register_container_tools() 注释自己写明(src/tools/registry.rs:527):

  ▎ Call this in the worker process, not the orchestrator (unless allow_local_tools = true).

  web_search 这类工具实际跑在哪里

  在 host 进程的 main agentic loop 里。web_search、memory_search、memory_write、http、web_fetch 这些工具注册在 host 上(通过 components.tools.register_* 那批调用,main.rs 启动时),由 host 的
  dispatcher 调度。

  典型流:

  用户问 "网上 React 19 有什么新特性?"
      ↓
  host 进程 main agent loop
      ↓ LLM 决定调 web_search
      ↓
  ToolDispatcher::dispatch("web_search", ...)
      ↓
  host 进程内执行 web_search → 拿结果
      ↓
  LLM 看结果,回答用户

  整个对话从头到尾在 host 进程内完成,容器 worker 根本不会介入。

  什么时候才会触发容器 worker

  只有当 host 的 LLM 决定"这个任务是个完整项目级别的活"时——通过 create_job / start_full_job 这种工具。代码主线是 src/tools/builtin/job_tools + src/orchestrator/job_manager.rs:

  host agent loop
      ↓ LLM 决定 "这个活需要容器隔离"
      ↓ 调 create_job({ ..., tools: ["file_read", "file_write", ...] })
      ↓
  orchestrator
      ↓ container_job_manager.create_container(job_id)
      ↓ 容器 worker 启动
      ↓
  worker 跑 agentic loop(只能在那 8 个工具里挑)
      ↓
  结果回传 orchestrator → main.rs:1024 那个 tokio::spawn 投到 SSE
      ↓
  host agent 把 job 结果拼回主对话

  所以"web_search + 容器 worker"是什么组合

  两种合法组合:

  组合 A:web_search 在 host 上做,容器 worker 只做"项目级"子任务

  host 主对话
    ├─ LLM 调 web_search → host 出网 → 拿结果
    └─ LLM 决定:"够了,现在我去改项目代码"
         └─ create_job("把 README 改成最新 API 文档的格式")
              └─ 容器 worker: 用 read_file + write_file + apply_patch 改 README
                   (不会上网,不会调 web_search)

  组合 B:job 描述里就预先收集好外部信息,再启动容器

  host 主对话
    ├─ LLM 调 web_search → 拿结果 → 拼成一段 context
    └─ LLM 调 create_job({
          title: "改 README",
          description: "按下面这个 API 文档改:<前序 web_search 摘录>...",
          ...
       })
       └─ 容器 worker: description 里已经包含所有外部信息

  容器 worker 收到的 job.description 是 host 在 dispatch 时就准备好的"自包含任务说明"。worker 不需要再上网查东西。

  设计意图:让容器 worker 不可信

  这个切片的根本目的,注释里也写到了(src/tools/registry.rs:526):

  ▎ Call this in the worker process, not the orchestrator (unless allow_local_tools = true).

  以及 src/config/agent.rs 里:

  pub allow_local_tools: bool,  // ALLOW_LOCAL_TOOLS env

  allow_local_tools = true 时,host 也跑内建 dev 工具;默认 false,dev 工具只能跑在容器里。这是单向隔离——容器只能用内建工具,host 可以额外有 web_search 等。

  容器 worker 在工具层面故意比 host 弱——因为:
  1. 容器里跑的是 LLM 写的代码(可能不靠谱)
  2. 容器网络是受限的(默认可能无外网或走 host proxy)
  3. 容器持久的只是 extra_env 里那批 host 显式下发的凭据
  4. worker 的 LLM 调用本身经过 ProxyLlmProvider,不能直接调模型

  给容器 worker 加 web_search = 打破这个隔离设计。架构上不会让这发生。

  一句话总结

  ▎ 容器 worker 拿到的 8 个工具不是"残缺版",而是"为那个特定 domain 故意切片的版本"。web_search、memory_*、http、web_fetch 这类外部交互工具根本不会出现在容器 worker 的工具列表里——它们是 host agentic
  ▎ loop 的工具,由 host 的 dispatcher 调度。需要外部信息的 full_job,host 在 dispatch 时把信息塞进 job.description 里作为任务上下文下发,容器 worker 是个"自包含任务执行器",不需要再上网。所以**"容器内
  ▎ agent_loop 需要外部工具"这个场景在当前架构里不会发生**——如果真的需要,那是设计层面的 bug 而不是缺失功能。

5. worker模式

● Worker 模式下容器里跑的是完整 Agent Loop 吗?怎么和主进程(orchestrator)交互?

  是的,Worker 模式下容器里跑的是完整 AgenticLoop,不是一个简单的命令执行器。它和 orchestrator 之间是 HTTP + bearer token 的双向 RPC,orchestrator 充当 "大脑",container worker 充当 "手脚 +
  工具执行器"。

  下面分两块讲:容器内 Agent Loop 长什么样、它和 orchestrator 之间来回传什么。
  
  ---
  一、容器内:WorkerRuntime + AgenticLoop + ContainerDelegate

  启动入口:src/worker/container.rs:107-134 WorkerRuntime::run:

  pub async fn run(self, cancel: CancellationToken) -> Result<()> {
      let tools = Arc::new(ToolRegistry::new());
      tools.register_container_tools();   // 只注册 8 个 builtin dev tools

      let llm = Arc::new(ProxyLlmProvider::new(self.worker_http.clone()));

      let delegate = ContainerDelegate::new(
          self.job_id,
          self.worker_http.clone(),
          tools.clone(),
          self.event_tx.clone(),
      );

      let agentic_loop = AgenticLoop::new(llm, tools, delegate);
      agentic_loop.run(messages, cancel).await
  }

  关键三件套

  ┌───────────────────┬───────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────┐
  │       组件        │                               职责                                │                                         位置                                          │
  ├───────────────────┼───────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
  │ ProxyLlmProvider  │ 不持 API key,把每次 LLM 调用 POST 给 orchestrator                │ src/worker/proxy_llm.rs:44-66                                                         │
  ├───────────────────┼───────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
  │ ToolRegistry      │ 只注册 8 个 builtin 工具(register_container_tools)              │ src/tools/registry.rs:527-529                                                         │
  ├───────────────────┼───────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
  │ ContainerDelegate │ 实现 Delegate trait,把 AgenticLoop 的事件流 POST 回 orchestrator │ src/worker/container.rs:91-104                                                        │
  ├───────────────────┼───────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
  │ AgenticLoop       │ 和主进程用同一份 agentic loop 代码                                │ crates/ironclaw_engine/src/agentic_loop.rs(shared agentic loop,项目结构里明确标注) │
  └───────────────────┴───────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────┘

  所以 worker 容器内的执行流程是:

  AgenticLoop
    ├── 调 LLM(实际是 ProxyLlmProvider → POST /worker/{job_id}/llm/complete)
    │       └── orchestrator 用真实 API key 完成后返回
    ├── LLM 决定调工具 → 在容器内 inline 执行 8 个 builtin tools
    ├── 通过 Delegate 把每一步事件 POST 回 orchestrator → SSE → Web UI
    ├── LLM 决定调更多工具 → 循环
    └── 直到 LLM 输出 final answer

  注意:容器进程持有 AgenticLoop 的完整状态机,但没有持久化。如果容器崩溃,state 丢失(CLAUDE.md 里 "LLM data is never deleted" 的不变量在主进程的 SSE/事件流里有备份)。

  ---
  二、容器 ↔ Orchestrator 的 HTTP API

  src/orchestrator/api.rs:121-142 列出所有 worker 端点(路由前缀 /worker/{job_id}),bearer token 鉴权(src/orchestrator/auth.rs 的 TokenStore):

  ┌───────────────┬───────────────────────────────────────────────┬──────────────────────┬─────────────────────────────────────────────┐
  │     方向      │                     端点                      │        谁调谁        │                   传什么                    │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ worker → orch │ POST /worker/{job_id}/job/init                │ 启动时上报           │ {container_id, started_at}                  │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ worker → orch │ GET /worker/{job_id}/job                      │ 拉任务               │ 返回 messages(初始 user prompt、history)  │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ worker → orch │ GET /worker/{job_id}/credentials              │ 拉凭证               │ orch 按 owner 解密后返回 {env_var, value}[] │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ worker → orch │ POST /worker/{job_id}/llm/complete            │ LLM 代理             │ orch 持真实 API key 完成                    │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ worker → orch │ POST /worker/{job_id}/llm/complete_with_tools │ 同上(tool calling) │ 同上                                        │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ worker → orch │ POST /worker/{job_id}/event                   │ 事件回流             │ {type, payload} —— 工具调用、思考、消息     │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ worker → orch │ POST /worker/{job_id}/job/complete            │ 结束                 │ {status, result/error}                      │
  ├───────────────┼───────────────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
  │ orch → worker │ DELETE /worker/{job_id}                       │ stop job             │ 触发 SIGTERM(docker.stop)                 │
  └───────────────┴───────────────────────────────────────────────┴──────────────────────┴─────────────────────────────────────────────┘

  三个最关键的双向交互

  1. LLM 代理(src/worker/proxy_llm.rs)

  pub async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
      self.http.post(&format!("/worker/{job_id}/llm/complete"), &request).await
  }

  容器完全没有 LLM API key。每次 AgenticLoop 走到 LLM 调用,都是:
  1. 容器 POST 完整 request(含 messages、tools schema)给 orchestrator
  2. orchestrator src/orchestrator/api.rs:201-262 拿 job 的 user_id → 取该用户配置的 LLM provider → 持真实 key 调官方 SDK → 返回响应
  3. 容器收到响应后继续 AgenticLoop 下一步

  这意味着:容器可以跑任何用户的 LLM,但容器进程从不持有任何 LLM 凭据。停掉容器 = 失忆式安全。

  2. 凭证注入(src/orchestrator/api.rs:498-579)

  async fn get_credentials_handler(...) -> Result<...> {
      let owner = resolve_job_owner(job_id).await?;  // 缓存 + DB fallback
      for secret_name in &requested {
          let value = secrets_store.get_decrypted(owner, secret_name)?;
          ...
      }
  }

  Worker 在工具执行前 GET /worker/{job_id}/credentials,拿到 secrets 后不写全局 env,而是通过 JobContext.extra_env: Arc<HashMap<String, String>> 传给
  Command::envs(extra_env)(src/worker/container.rs:565-568),仅注入到子进程环境。

  3. 事件回流 → SSE → Web UI(src/orchestrator/api.rs:317-463)

  容器每产生一个 step(LLM token、tool call、tool output、reflection、error)就 POST /worker/{job_id}/event。Orchestrator 收下后做两件事:
  - 转发到 SSE 广播 → web UI 实时显示
  - 入库保存(满足 "LLM data is never deleted")

  ---
  三、容器和主进程的职责切分

  ┌────────────────────────────────────────────────┬────────────────────────────────────────────────┐
  │                      职责                      │                     在哪跑                     │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ AgenticLoop 状态机                             │ 容器                                           │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ LLM API key                                    │ Orchestrator(容器只有 proxy client)          │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ Tools(8 个 builtin)执行                      │ 容器内 inline(不调回 orchestrator)           │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ ToolRegistry 中其他工具(WASM/MCP/memory/job) │ 不在容器——容器 worker 只 register 8 个         │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ SSE 事件广播到 web UI                          │ Orchestrator                                   │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ Secrets 解密                                   │ Orchestrator(master key 在 host OS keychain) │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ 容器生命周期(create/stop/cleanup)            │ Orchestrator(ContainerJobManager)            │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ Docker 连接                                    │ Orchestrator(bollard)                        │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ 用户身份 / 配额 / 多租户隔离                   │ Orchestrator(resolve_job_owner + DB)         │
  ├────────────────────────────────────────────────┼────────────────────────────────────────────────┤
  │ Reflection / heartbeat / routines              │ 不在 worker 容器——这些跑在主进程               │
  └────────────────────────────────────────────────┴────────────────────────────────────────────────┘

  ---
  四、和主进程 / Web UI 的完整时序

  Web UI (chat message)
    → Web gateway → ToolDispatcher → create_job tool
    → src/tools/builtin/job.rs:982-1096
    → JobManager::create (src/orchestrator/job_manager.rs:338)
    → bollard.create_container (image=ironclaw-worker:latest)
    → 启动容器内 ironclaw worker
         │
         ├─ POST /worker/{job}/init           (上报 container_id)
         ├─ GET  /worker/{job}/job            (拉 initial messages)
         ├─ GET  /worker/{job}/credentials    (拉 secrets, owner-scoped)
         │
         └─ AgenticLoop 开始循环:
              ├─ POST /worker/{job}/llm/complete[_with_tools]
              │      └─ orchestrator 持真实 API key 调 LLM SDK
              │
              ├─ LLM 返回 tool_calls → 容器内 inline execute_tool
              │
              ├─ POST /worker/{job}/event     (每个 step)
              │      └─ orchestrator → SSE → Web UI 实时显示
              │
              └─ POST /worker/{job}/job/complete  (最终)
                    └─ orchestrator 记录状态、stop & remove_container

  ---
  五、和主进程的 AgenticLoop 差异

  虽然代码共享 AgenticLoop,但 worker 容器里的实例有几个关键差异:

  ┌────────────────┬─────────────────────────────────────────────────┬─────────────────────────────────────────────────┐
  │      差异      │                     主进程                      │                   Worker 容器                   │
  ├────────────────┼─────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
  │ LLM            │ 直接调 LlmProvider SDK                          │ ProxyLlmProvider HTTP                           │
  ├────────────────┼─────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
  │ 工具集         │ 全部(WASM/MCP/memory/builtin/routine/job/...) │ 只 8 个 builtin dev tools                       │
  ├────────────────┼─────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
  │ Reflection     │ 启用                                            │ 容器内不跑 reflection(reflection 跑在主进程)  │
  ├────────────────┼─────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
  │ Heartbeat      │ 启用                                            │ 不跑                                            │
  ├────────────────┼─────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
  │ Session 持久化 │ 写 DB                                           │ 容器进程不写 DB(事件通过 orchestrator 持久化) │
  ├────────────────┼─────────────────────────────────────────────────┼─────────────────────────────────────────────────┤
  │ Delegate       │ 主进程的 delegate                               │ ContainerDelegate(专用于事件回流)             │
  └────────────────┴─────────────────────────────────────────────────┴─────────────────────────────────────────────────┘

  所以: Worker 容器是一个 stateless executor——它有完整的 agent loop 状态机,但所有"持久性"和"权限相关"操作(LLM key、secrets、SSE、DB、reflections)都委托给 orchestrator。

  ---
  六、关键文件路径速查

  ┌──────────────────────┬────────────────────────────────────────────┬───────────────────┐
  │         主题         │                    文件                    │       行号        │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ WorkerRuntime 主入口 │ src/worker/container.rs                    │ 107-134           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ ContainerDelegate    │ src/worker/container.rs                    │ 91-104            │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ 工具 inline 执行     │ src/worker/container.rs                    │ 570-577           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ 凭证注入到子进程     │ src/worker/container.rs                    │ 565-568           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ ProxyLlmProvider     │ src/worker/proxy_llm.rs                    │ 44-66             │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ Worker HTTP 客户端   │ src/worker/api.rs                          │ 125-139           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ Orchestrator 路由    │ src/orchestrator/api.rs                    │ 121-142           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ LLM 代理端点         │ src/orchestrator/api.rs                    │ 201-262           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ 事件 → SSE           │ src/orchestrator/api.rs                    │ 317-463           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ Owner-scoped 解密    │ src/orchestrator/api.rs                    │ 498-579           │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ resolve_job_owner    │ src/orchestrator/api.rs                    │ 68-95             │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ 共享 AgenticLoop     │ crates/ironclaw_engine/src/agentic_loop.rs │ -                 │
  ├──────────────────────┼────────────────────────────────────────────┼───────────────────┤
  │ 容器工具注册         │ src/tools/registry.rs                      │ 527-529 + 582-606 │
  └──────────────────────┴────────────────────────────────────────────┴───────────────────┘

  ---
  一句话总结

  Worker 容器内跑的是完整 AgenticLoop(共享主进程的 loop 代码),但只有 8 个 builtin 工具、且 LLM 调用强制走 orchestrator 代理。它和主进程通过 HTTP+bearer token
  双向通信:容器拉任务/凭证、推送事件,orchestrator 持真实 LLM key / 解密 secrets / 广播 SSE / 持久化。容器是一个 stateless executor——所有权限敏感操作都委托给 orchestrator,停掉容器 = 失忆式安全(无
  key、无 secrets、无 LLM data 残留)。
● 让我把读到的代码细节整理一下,精确回答这个直接的问题。

  我读了 src/orchestrator/auth.rs、src/orchestrator/api.rs、src/orchestrator/job_manager.rs,还有调用点。结论很明确:

  直接答案

  不会。TokenStore 里两个字段的值都不会"传"到容器内部。

  TokenStore 只存在于 orchestrator 进程(也就是宿主进程、运行 IronClaw 主程序的那个进程)里。容器看到的只是:

  1. 一个 HTTP API 端点 http://orchestrator/worker/{job_id}/...
  2. 它必须自己拿着 token 来敲门
  3. 想拿凭据?去 GET /worker/{job_id}/credentials,orchestrator 才会现场去 secrets_store 解密,然后把 {env_var, value} 通过 HTTP 响应返回

  容器从 orchestrator 收到的 HTTP 响应体里,从来没有过 TokenStore 这个结构体本身,也没有过它的两个 HashMap 字段。

  关键证据

  TokenStore 只被这些地方用到(grep 整个 src/):
  - src/orchestrator/auth.rs —— 定义
  - src/orchestrator/api.rs —— orchestrator 的 HTTP handler 用
  - src/orchestrator/job_manager.rs —— 创建 job 时写入 grants
  - src/orchestrator/mod.rs —— 注入到 OrchestratorState

  全部都在 src/orchestrator/ 下,没有一处把 TokenStore 的字段值塞到 Docker env、args、bind mount、stdin 或网络数据包传给容器。

  唯一的"出口"是 HTTP 响应

  具体看 api.rs:498-560 的 get_credentials_handler:

  let grants = state.token_store.get_grants(job_id).await;  // 读 TokenStore

  // 拿到 grant 列表后,逐个去 secrets_store 解密
  for grant in &grants {
      let decrypted = secrets.get_decrypted(&job_user_id, &grant.secret_name).await?;
      credentials.push(CredentialResponse {
          env_var: grant.env_var.clone(),
          value: decrypted.expose_secret().to_string(),
      });
  }

  Json(serde_json::json!(credentials))  // 只返 {env_var, value} 给容器

  返回给容器的是 Vec<{env_var, value}>,不是 Vec<CredentialGrant>。secret_name(后端身份,比如 github_token)甚至都不会出现在响应里——容器拿到时只知道自己有个 GITHUB_TOKEN 环境变量,不知道它对应
  IronClaw secrets store 里的哪个条目。

  一个容易混淆的点

  前面回答里我提到 credential_grants_json 也存在 DB 里。那是个持久化的镜像,结构是 Vec<CredentialGrant> 的 JSON 序列化——也就是说 DB 里存的也只是 secret_name/env_var 的映射,不含明文 token,也不含明文
  secret value。

  DB 那份和 TokenStore 那份的区别:

  ┌──────────┬──────────────────────────────────┬───────────────────────────────────┐
  │          │   TokenStore.credential_grants   │   DB 的 credential_grants_json    │
  ├──────────┼──────────────────────────────────┼───────────────────────────────────┤
  │ 位置     │ orchestrator 内存                │ 持久化                            │
  ├──────────┼──────────────────────────────────┼───────────────────────────────────┤
  │ 生命周期 │ 进程退出即丢                     │ 跨进程保留                        │
  ├──────────┼──────────────────────────────────┼───────────────────────────────────┤
  │ 内容     │ Vec<CredentialGrant>             │ 同样 Vec<CredentialGrant> 的 JSON │
  ├──────────┼──────────────────────────────────┼───────────────────────────────────┤
  │ 含明文? │ 不含(只有 name → env_var 映射) │ 不含(同样的映射)                │
  └──────────┴──────────────────────────────────┴───────────────────────────────────┘

  容器在任何情况下都接触不到明文 secret value,除非自己主动拉 /credentials 并把响应内容塞进自己的环境。

  边界图

  ┌─────────────────────────────────────┐
  │ Orchestrator 进程                    │
  │                                     │
  │  TokenStore {                       │ ← 永远不出这个进程
  │    tokens: HashMap<job_id, token>   │
  │    credential_grants: HashMap<...>  │
  │  }                                  │
  │                                     │
  │  secrets_store (AES-256-GCM 解密)   │
  │         │                           │
  └─────────┼───────────────────────────┘
            │ HTTP: GET /worker/{job_id}/credentials
            │      Authorization: Bearer <token>
            ▼
     { "env_var": "...", "value": "..." }   ← 只有这个到容器
            │
  ┌─────────┼───────────────────────────┐
  │ 容器     ▼                            │
  │  自己把 value 塞进 os.environ        │
  └─────────────────────────────────────┘

  一个小但真实的副作用

  虽然 TokenStore 的字段值不进容器,但容器收到的明文 secret value 是从 TokenStore 的 grants 列表"派生"出来的——也就是说,如果 grants 列表被污染(比如错误的 secret_name),那解密得到的 value
  也会错(甚至解密失败返 403)。

  但这跟"字段值传到容器"是两回事。secret_name(grant 的字段之一)也不会出现在响应里——容器只看到自己得到的 env var 叫什么名字,不知道 orchestrator 内部是怎么映射的。

  ---
  简言之:TokenStore 是个 orchestrator 进程内的纯运行时状态机,对容器是"黑盒",容器只能通过带 token 的 HTTP 调用来"问询"它的派生结果,从来不会"接收"它的内容。

6. 提示队列

  用途:让用户/agent 在 sandbox 容器跑任务时,能往进行中的 sub-agent 追加后续指令。

  结构:Arc<Mutex<HashMap<job_id, VecDeque<PendingPrompt>>>>,按 job_id 隔离,FIFO 队列。
  
  写入侧(2 个入口):
  - Web UI:POST /api/jobs/{id}/prompt(仅 Claude Code / ACP 模式,worker 模式返 501)
  - 主 agent:job_prompt 工具调用

  读取侧(1 个入口):容器进程 HTTP poll GET /worker/{job_id}/prompt,带 Bearer token。

  容器侧的拉取时机:

  ┌────────────────────┬───────────────────────────────────────────────────────┐
  │        模式        │                         时机                          │
  ├────────────────────┼───────────────────────────────────────────────────────┤
  │ Worker 模式        │ 每次 LLM 调用前(before_llm_call 钩子里)             │
  ├────────────────────┼───────────────────────────────────────────────────────┤
  │ Claude Code bridge │ claude session 结束后,独立 polling loop + 2 秒 sleep │
  ├────────────────────┼───────────────────────────────────────────────────────┤
  │ ACP bridge         │ ACP session 结束后,tokio::select! 防 child 卡死      │
  └────────────────────┴───────────────────────────────────────────────────────┘

  三种模式都通过同一个 WorkerHttpClient::poll_prompt() 拉取。Worker 模式虽然代码层面在 poll,但 web UI 写不进去(501),所以端到端只有 Claude Code 和 ACP 模式可用。

  安全边界:/worker/{id}/... 路径 + Bearer token 鉴权;token 通过 IRONCLAW_WORKER_TOKEN 环境变量注入容器(不是 TokenStore 字段)。