NanoClaw 原始碼:Multi-Tenant Agent Hosting Platform 的設計邏輯
第一次看 NanoClaw 的原始碼,我有幾個地方不太懂。容器和 host 之間的 IPC 為什麼要用兩個 SQLite 檔案,而不是直接 pipe 或 socket?API key 為什麼不放 env var,要繞一個 HTTPS proxy?容器的 stdout 被完全忽略掉了:
// stdout is unused in v2 (all IO is via session DB)
container.stdout?.on('data', () => {});
這種設計,在你自己坐在終端機前操作的情境下,確實是 overkill。直接 pipe 更快,env var 更簡單,stdout 當然要看。但這些選擇有一個共同的前提:使用者就是操作者。你就是跑 agent 的那個人,出問題你馬上看到,agent crash 你馬上知道,API key 在你自己的機器上。
NanoClaw 的情境不是這樣的。
NanoClaw 跑在 server 上,24/7。它服務的是 Telegram、Discord、WhatsApp 上的「其他人」——使用者不是操作者,操作者通常也沒有在盯著 screen。更精確地說,NanoClaw 設計的對象是團隊:一個 operator 部署一個 server,服務整個團隊的成員,每個人有自己的 session,彼此完全隔離。Alice 的 agent crash 掉,Bob 的對話繼續跑,兩人的記憶不互相污染。這個差別讓威脅模型完全不同,也讓每一個「看起來 overkill」的設計選擇變得合理。
讀完精華版(2 分鐘),你會理解:
- 為什麼「API key 放 env var」在 agent 有 bash 能力的情境下是攻擊面
- Two-DB SQLite IPC 如何讓一個 session 的 crash 不影響其他人
- NanoClaw 的 Session 設計為什麼讓「容器 ephemeral,記憶持久」成為可能
- mention-sticky 如何用「session 存在」本身做訂閱狀態
- Self-mod + Approval flow 如何分離 agent 的能力和執行的權力
NanoClaw 是 multi-tenant agent hosting platform,不是 agent framework。底下跑的 agent 換成別的 LLM,設計邏輯完全不變。
精華版
NanoClaw 的三個設計目標:
| 目標 | 回應的威脅 | 對應設計 |
|---|---|---|
| Security | Agent 有 bash 能力,可以讀 env var、SSH key | OneCLI HTTPS Proxy 注入 key;mount 嵌套 RO/RW;self-mod 需人工 approve |
| Fault Containment | 一個 session 爆炸不能影響所有人 | Two-DB SQLite IPC;容器與 host 完全獨立 process |
| Resilience | Server 無人值守,crash 要自己恢復 | Continuation 在 init 立即持久化;circuit breaker;host sweep backoff |
各章節一句話:
- Two-DB IPC:NanoClaw 讓 host 只寫
inbound.db、容器只寫outbound.db,兩邊永遠不跨界,讓容器 crash 成為可恢復的讀取問題,而不是拖垮 host process 的並發衝突。 - OneCLI 憑證注入:NanoClaw 透過 HTTPS Proxy 在請求層注入 API key,讓容器的 process memory 裡從來沒有 raw key。即使 agent 被 prompt injection,
env | grep ANTHROPIC也找不到任何東西。 - Session 持久化與 Continuation:NanoClaw 在容器拿到
initevent 的瞬間就寫入 continuation ID,讓 mid-turn crash 後下次 wake 能從中斷點 resume,而不是從頭開始一個全新的 Claude session。 - 路由與 Mention-Sticky:NanoClaw 的 mention-sticky engage mode 用「session 存在」本身做訂閱狀態,第一次 @mention 創建 session,後續自動 engage,session expire 就自動取消訂閱——不需要獨立的訂閱表,沒有 zombie 訂閱。
- Self-Mod + Approval Flow:NanoClaw 讓 agent 可以透過 system message 請求安裝套件、新增 MCP server,但所有執行都在 host 端,需要人工 approve,讓能力和執行的權力徹底分離。
設計問題簡答:
Q:為什麼不用 stdin/stdout pipe 做 IPC? stdin/stdout pipe 把容器和 host 耦合在同一個執行週期——容器 crash,host 的 pipe 就斷了。SQLite Two-DB 讓容器死了 host 繼續跑,訊息留在 DB,等下次容器被喚醒時重試。
Q:API key 放 env var 哪裡不對?
Agent 有完整的 bash 能力。env | grep ANTHROPIC 一行就能拿到 key,再加 cat /root/.ssh/id_rsa。這不是假設 agent 會故意這樣做,而是一旦有 prompt injection 或 system prompt 漏洞,這個攻擊面就存在,而你看不到。
Q:Continuation 為什麼在 init 就寫,不等 result?
init 和 result 之間是 LLM 在處理請求的那段時間。如果容器在這段時間 crash 而 continuation 還沒持久化,下次 wake 就是全新的 Claude session,上下文全部不見。立即在 init 寫入讓 mid-turn crash 仍能 resume。
Q:Self-mod agent 和一般 agent 差在哪裡?
能力一樣(都有 bash),差別在於「修改自己環境」的操作無法在容器內直接完成。安裝套件需要 docker build,加 MCP server 需要改 DB——這些都在沙箱外,只有 host 能執行。Self-mod 讓 agent 只能提案,人工 approve 後才由 host 執行。
以下是完整版,按需取用。
Two-DB IPC:把容器 Crash 設計成可恢復事件
NanoClaw 的整個穩定性建立在一個原則上:每個 SQLite 檔案只有一個 writer。
Host Process (Node.js) Container (Bun)
───────────────────── ─────────────
inbound.db (WRITE) inbound.db (READ ONLY)
├── messages_in ├── poll pending rows
└── delivered └── mark processing
outbound.db (READ ONLY) outbound.db (WRITE)
└── messages_out → deliver ├── write AI responses
└── heartbeat
在解釋 IPC 機制之前,先說一個更根本的問題:為什麼不用 while loop?
大多數 agent 框架(包括 Hermes)的核心是一個 while loop——LLM 在跑,process 就活著。這個設計對單人使用沒問題,但在 multi-tenant 情境下是個定時炸彈:任何一個 session 遇到 unhandled exception,while loop 就炸,同一個 process 裡的所有 session 一起消失。Alice 的 agent 踩到一個 bug,Bob 的對話也沒了。
NanoClaw 的解法是讓每個 session 跑在自己的容器裡。容器死了,只有那個 session 受影響,host 和其他所有 session 繼續跑。而容器和 host 之間的通訊,就需要一個「容器死了也不會拖垮 host」的 IPC 機制——這就是 Two-DB 的由來。
為什麼不用 pipe 或 socket?
| 方式 | 問題 |
|---|---|
| stdin/stdout pipe | 容器和 host 耦合在同一個執行週期,容器 crash 直接影響 host |
| Unix socket | 需要連線管理、斷線重連邏輯 |
| HTTP/REST | 需要 server 在容器內,port 管理,認證 |
| SQLite Two-DB | Async、容器死了 host 不受影響、訊息留在 DB 等重試 |
Two-DB 的設計讓「容器 crash」從「系統失敗」變成「可調度事件」。Host 的 Sweep 每 60 秒掃一次,發現容器死了按 exponential backoff 重試:
const BACKOFF_BASE_MS = 5000;
// tries: 0→5s, 1→10s, 2→20s, 3→40s, 4→80s, 5→mark failed
但還有一個細節:Active Poll(1 秒)和 Sweep Poll(60 秒)可能同時處理同一個 session。NanoClaw 用 inflightDeliveries guard 解這個問題:
const inflightDeliveries = new Set<string>();
export async function deliverSessionMessages(session: Session): Promise<void> {
if (inflightDeliveries.has(session.id)) return; // skip,不 queue
inflightDeliveries.add(session.id);
try {
await drainSession(session);
} finally {
inflightDeliveries.delete(session.id);
}
}
選擇 skip-then-pickup 而不是 queue:下一個 tick(1 秒後)自然會追到剩下的 message。Queue 反而可能積壓,在無人值守的系統裡,這是比遺漏更難排查的問題。
Delivery 還有三次重試的上限,超過後 mark failed 不再重試——設計意圖是讓問題顯露出來,而不是默默重試到消失。在 24/7 server 上,「安靜失敗」比顯式報錯更難追。
API Key 永遠不進容器 Process Memory
Agent 有完整的 bash 能力。這在「你是唯一使用者」的情境下不是問題——你的機器,你的 key。但在 multi-tenant server 上,這個組合是一個攻擊面:
# 一旦 agent 被 prompt injection,這一行就夠了
env | grep ANTHROPIC → 拿到 key
cat /root/.ssh/id_rsa → 拿到 SSH key
這不是假設 agent 會「故意」這樣做,而是一旦有 prompt injection,或者 system prompt 有漏洞,這個路徑就存在,而 server 上的你看不到。
NanoClaw 的解法是讓 API key 永遠不進容器。取而代之的是 OneCLI HTTPS Proxy:
Container(agent)
│ ANTHROPIC_API_KEY 不存在於 env
│ HTTPS_PROXY = http://onecli-proxy:port
│
▼
Anthropic API request(沒有 Authorization header)
│
▼
OneCLI HTTPS Proxy
├── 看 proxy metadata → 找到 agent identifier
├── 從 Agent Vault 取出對應的 API key
├── 注入 Authorization header
└── 轉發到 api.anthropic.com
↳ 容器的 process memory 裡從來沒有 raw API key
代碼層面的設計意圖寫得非常直接:
// ❌ NanoClaw 不做這件事
args.push('-e', `ANTHROPIC_API_KEY=${apiKey}`);
// ✅ 實際做法
const onecliApplied = await onecli.applyContainerConfig(args, {
agent: agentIdentifier
});
if (!onecliApplied) {
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
}
沒有 OneCLI,直接拒絕 spawn。不是 fallback 到 env var,是硬停。
與此配套的是 Mount 架構。Docker mount 後者覆蓋前者的特性被刻意利用,做出精細的 RO/RW 嵌套:
/workspace/agent/ ← RW(group folder)
└── container.json ← RO nested(蓋在 RW 上面)
└── CLAUDE.md ← RO nested(每次 spawn 前 host 重新 compose)
/workspace/global/ ← RO(global memory)
/app/src/ ← RO(agent-runner source,所有 group 共享)
/home/node/.claude/ ← RW(Claude SDK state)
Agent 可以寫 CLAUDE.local.md(per-group memory),但 CLAUDE.md 由 host 管理——每次 spawn 前根據 container.json 的 skills 設定重新 compose。container.json 本身是 RO,agent 讀得到自己的設定,但寫入會直接失敗。
兩層設計加在一起的效果:即使 agent 被完全 compromise,它能觸及的東西也只有被明確允許的那些。
Session 是持久的,容器是 Ephemeral 的
多數 agent 框架裡,session 和 process 的生命週期是綁在一起的。Process 死了,session 就沒了。這在你自己操作時沒問題——重跑就好。在 24/7 server 上服務陌生人時,這個設計是無法接受的:使用者今天問了一個問題,明天繼續接著聊,中間 server 可能重開過,container 可能 crash 過,但使用者期望的是「記得我們的對話」。
NanoClaw 的設計是:容器是 ephemeral 的,session 狀態在 DB 裡。容器死了,下次訊息進來,host 用同樣的 session DB spawn 一個新容器,從中斷點繼續。
Continuation 是這個機制的關鍵。容器內的 agent runner 在收到 init event 的瞬間就持久化:
// container/agent-runner/src/poll-loop.ts
// init event 收到時立即持久化(不等 result)
if (event.type === 'init') {
queryContinuation = event.continuation;
setContinuation(providerName, continuation); // 寫進 session DB
}
init 和 result 之間是 LLM 在處理請求的時間。如果容器在這段時間 crash,沒有 continuation 就代表下次 wake 是全新的 Claude session,對話上下文完全不見。立即在 init 寫入讓 mid-turn crash 後仍能 resume。
Host 端的 Heartbeat 機制補充了這個設計。容器每次 SDK event 後 touch 一個檔案,host 讀 mtime:
function heartbeatMtimeMs(agentGroupId: string, sessionId: string): number {
const hbPath = heartbeatPath(agentGroupId, sessionId);
try {
return fs.statSync(hbPath).mtimeMs;
} catch {
return 0; // 檔案不存在 = 剛啟動,給 grace period
}
}
mtime = 0 不代表死掉,代表「剛出生還沒有 activity」——這個 edge case 讓 grace period 機制不會誤殺剛啟動的容器。Stuck 判斷有兩層邏輯:
層 1 - Absolute Ceiling(30 分鐘):
heartbeatAge > 30min → kill-ceiling
(Bash tool 可以宣告 timeout,ceiling 跟著延長)
層 2 - Claim Stuck(60 秒):
claimAge > 60s
AND heartbeatMtime <= claimedAt(認領後完全靜止)
→ kill-claim
(heartbeat 有更新代表在做事,算 active,不 kill)
「heartbeat 有更新代表在做事,不 kill」這個設計讓跑長任務的容器不會被誤判為 stuck。真正死掉的是「認領了訊息但完全靜止」的容器,不是「跑很久」的容器。
加上 Circuit Breaker 的啟動 backoff,這套設計讓 24/7 server 在沒有人值守的情況下,大多數問題都能自動恢復而不是讓人半夜收到 alert。
路由與 Session 隔離:多 Tenant 怎麼共存
一則訊息進來,NanoClaw 要決定三件事:要不要回應、誰來回應、這則訊息屬於哪個 session。這個決策鏈是 router.ts:
Channel Adapter → routeInbound(event)
├── messageInterceptor?(模組可攔截)
├── 讀 messaging group + wired agents
├── senderResolver() → userId
└── for each agent: evaluateEngage()
├── accessGate()
├── senderScopeGate()
└── engage → deliverToAgent(wake=true / wake=false)
三種 Engage Mode 各自解決不同的多 tenant 需求:
pattern 用正規表達式匹配訊息內容,讓多個 agent 連同一個 chat 時用不同 pattern 分工('^@Andy' / '^@Bob')。Bad regex fail open——設計意圖是讓 admin 看到 agent 意外回應後自己修正 pattern,而不是讓 pattern 錯誤導致 agent 完全靜音。
mention 透過平台層級的 @mention 觸發。Agent 的 NanoClaw 名字不重要,使用者透過平台的 username 觸發,這讓同一套 NanoClaw 可以在不同平台用不同 username 而不需要改任何設定。
mention-sticky 解決群組聊天的訂閱問題:第一次 @mention 後,後續訊息不需要再 @mention。
case 'mention-sticky': {
if (isMention) return true;
if (mg.is_group === 0) return false; // DM 不用 sticky
const existing = findSessionForAgent(agent.agent_group_id, mg.id, threadId);
return existing !== undefined; // Session 存在 = 已訂閱
}
這個設計的洞見是:session 存在本身就是訂閱狀態。Thread 第一次 @mention 創建 session,後續訊息因為 session 存在而繼續 engage。Session expire,findSessionForAgent 找不到,自動「取消訂閱」,不需要獨立的訂閱表,沒有 zombie 訂閱,訂閱的生命週期和 session 的生命週期完全一致。
Fan-out 讓同一則訊息可以路由到多個 agent,每個 agent 獨立評估 engage、有自己的 session 和容器。Fan-out 時的 Message ID 加了 namespace 避免 PRIMARY KEY 衝突:
function messageIdForAgent(baseId: string | undefined, agentGroupId: string): string {
return `${id}:${agentGroupId}`; // namespace by agent group
}
Security 設計也整合在這裡:access gate 拒絕的 sender,就算 ignored_message_policy='accumulate' 也不靜默儲存。把不信任 sender 的訊息存進容器環境,等於繞過了 gate 的安全意圖——NanoClaw 在路由層就把這個 edge case 堵住了。
能力和權力的分離:Self-Mod + Approval Flow
NanoClaw 的 Agent 有完整的 bash 能力和 MCP tool 調用能力,但有一件事它做不到:修改自己的容器環境。
原因是架構上的:容器的 container.json 被 nested mount 成 RO,agent 寫入會直接失敗。要安裝新套件需要 docker build,要加 MCP server 需要改 container_configs DB——這些都在容器沙箱外,只有 host 能做。
Self-mod 模組讓 agent 透過 system message「提案」,host 執行:
容器(agent 呼叫 MCP tool)
install_packages({ apt: ['python3-pandas'], npm: [] })
↓ system message: { action: 'install_packages', packages: [...] }
↓ outbound.db
Host(delivery poll 讀到)
handleInstallPackages()
↓ requestApproval({ title: 'Install packages: python3-pandas', options: ['Approve', 'Deny'] })
↓ approval card 發到 channel
Admin 在 Telegram/Discord 點 Approve
↓ buildAgentGroupImage(agentGroupId) ← docker build(最多 15 分鐘)
↓ updateContainerConfigScalars(image_tag)
↓ killContainer() ← 下次訊息進來用新 image spawn
add_mcp_server 不需要 rebuild image——Bun 直接執行 TS,改 config + kill 幾秒內完成。install_packages 需要最多 15 分鐘的 docker build,timeout 設定在 execSync 層級,不是靠 heartbeat 偵測。
值得注意的是模組本身的設計:Core(src/index.ts、src/router.ts、src/delivery.ts)不 import 任何模組,模組在自己被 import 時才注入 hook:
// Core 定義 hook point(router.ts)
let senderResolver: SenderResolverFn | null = null;
export function setSenderResolver(fn: SenderResolverFn): void { ... }
// 模組在 import 時自行注入(modules/permissions/index.ts)
setSenderResolver(resolveSender);
setAccessGate(checkAccess);
沒有模組,系統仍然完整運行——只是 allow-all 模式。這讓 NanoClaw 可以從最簡單的設定跑起來,按需要加功能,而不是一開始就要配置一堆東西才能跑。Self-mod、permissions、approvals 都是可選的,不安裝就是靜默 no-op。
這個設計的工程意圖是:Agent 有能力,但沒有執行的權力。「能力」是 bash 和工具調用,「權力」是修改自己所在的環境。兩者的分離讓 agent 在沙箱內有充分的自主性,但跨出沙箱的操作永遠需要人工在路徑上。
NanoClaw 最讓我印象深刻的不是哪個技術細節,而是它的問題定義。
大多數 agent 框架在問「agent 怎麼更強」,NanoClaw 在問「一個有危險能力的 agent,怎麼安全地活在不信任的多租戶環境裡」。這兩個問題的答案長得完全不一樣,也根本沒辦法互相比較好壞——它們解的不是同一個問題。
把 NanoClaw 裡面的 Claude 換成其他 LLM,Security + Fault Containment + Resilience 的架構設計邏輯完全不變。這就是「platform」和「framework」的差別。
更進一步:容器裡跑什麼 agent,NanoClaw 本身不 care。Provider 抽象已經存在——
resolveProviderName() → 'claude' | 'opencode' | 'ollama'
opencode 就是 coding agent,已經是內建 provider。理論上你可以把 Hermes-agent、coding agent、task-agent 任何東西塞進 NanoClaw 的容器,只要實作對應的 Provider contribution。NanoClaw 負責 multi-tenant 的 isolation、credential 管理、session 持久化;裡面跑什麼,是你的事。
這也意味著 NanoClaw 的設計有一個明確的 trade-off:用複雜度和 overhead(container spawn、polling latency、file-based IPC)換 multi-tenant 的 robustness。如果你只是個人用,這些設計都是不必要的——env var 夠了,while loop 夠了,stdout 夠了。NanoClaw 的複雜度不是過度設計,是 multi-tenant 這個問題本身要求的。
還有一個更直接的說法:NanoClaw 不是為你設計的,是為你的團隊設計的。它假設的場景是你部署、別人用——而「別人用」這件事,讓整個系統的設計從根本上就不一樣了。