一. 相关结构 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
▎ 这个数据结构。一、先把三层的安全模型摆清楚
┌─────────────────────────────────────────────────────────────┐
│ 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 字段)。