跳到主要内容

数据截至 (上游 commit 53ea1e8ba6fd)

巧妙之处、边界与横向对比

本章讲什么: 前六章讲「它怎么运转」,这一章讲「你该带走什么」和「它会在哪崩」。最后给一张跨全书的总代码地图。


1. 巧妙之处(可借鉴的技术)

1.1 用「一文件一写者」把并发问题变成方向问题

妙在哪: 跨进程共享 SQLite 通常意味着锁、重试、SQLITE_BUSY。NanoClaw 直接把问题消掉了——不是解决写竞争,而是让写竞争在结构上不存在。每个文件恰好一个写者,读者用只读句柄。

于是「谁能改这个状态」这个问题永远有一个静态答案,代码里没有一处需要问「现在轮到我写了吗」。

src/session-manager.ts 文件头三条铁律 · src/mailbox/sqlite/session-db.ts:23-45 三个 open 函数

1.2 一个高频状态用文件 mtime,不用 DB

妙在哪: 心跳是最高频的信号(每个 SDK 事件一次)。写 DB 意味着高频跨挂载写;fs.utimesSync 一次系统调用,读侧 statSync 一次。选对存储介质本身就是优化。

container/agent-runner/src/heartbeat.ts:5 touchHeartbeat

1.3 一扇投递门,并且用 DB 当去重账本

妙在哪: 流式输出最容易出的两个 bug 是漏发和重发。NanoClaw 的做法是结构性地只留一扇门(声明了 emitsMidTurnText 的 provider,result 侧连内容都不发),然后用已经存在的 outbound.db 当去重依据——查 (turnStartSeq, segStartSeq] 这个 seq 窗口里有没有同目的地同内容的行。

没有引入进程内的「已发内容账本」,因为账本会和真实写入不同步。查真相源永远比维护副本可靠。

container/agent-runner/src/poll-loop.ts:944 wasWrittenInSeqWindow

1.4 批准满足 hold,永不推翻 deny

妙在哪: 大多数审批系统的实现是「批准 → 直接执行处理器」,时序漏洞就在这:批准和执行之间权限可能已经变了。

NanoClaw 的做法是批准后带着审批行重新进入同一个入口,结构性检查再跑一遍。审批行只是「人已经同意了」这一个事实的凭证,替代不了「你现在还有没有资格」。

加上审批行「解析时删除」这一条,重放天然只能执行一次。

src/guard/guard.ts:48-59 · src/delivery.ts:587 reenterGuardedDeliveryAction

1.5 让「不设防」在类型上不可省略

妙在哪: 安全检查最常见的失效方式不是「写错了」,是「忘了写」。registerDeliveryAction 的重载让你要么给 guard spec、要么给 unguarded(reason) 标记——省略是编译错误。

于是「决定不设防」这件事一定出现在 diff 里,而且 grep "unguarded(" 就是完整清单。

src/guard/types.ts:45 unguarded · src/delivery.ts:554-578

1.6 安全模块写清自己防不住什么

妙在哪: mount-security 的模块头有一整段 “SCOPE — read this before treating the list as protection”,明说黑名单只检查挂载、不递归,并且以一句「不要把这里的条目读成『这个文件是安全的』」收尾。

大部分安全文档只写防住了什么。写清边界的那份才真的防住了误用。

src/modules/mount-security/index.ts:46-53

1.7 拒绝 agent 时给出教学,而不是错误码

妙在哪: 频率超限的拒绝消息足足十几行,解释了为什么(烧配额、可能被封号)、正确做法是什么(写预检脚本)、怎么学(ncl tasks create --help)、以及需要显式确认的逃生舱。

对一个会读错误消息并自我修正的调用方,错误消息就是 API 文档

src/modules/scheduling/create.ts:11-23 RECURRENCE_LIMIT_WARNING

1.8 兼容性靠「行为忠实的兜底」而不是版本号

妙在哪: 引入渠道默认值声明时,没声明的旧适配器走一条兜底路径,注释保证它精确复现历史上由 supportsThreads 推导的路由行为。于是「只升级主干」这个动作对用户是零行为变化。

src/channels/channel-defaults.ts · src/channels/adapter.ts:276-283

1.9 纯函数化决策,IO 留在调用方

妙在哪: decideStuckAction 的输入全是普通值(now、心跳 mtime、容器启动时刻、容器状态、认领列表),文件读和 DB 读都在调用方。于是「什么时候该杀容器」这个最难测的逻辑变成一个可以穷举单测的纯函数。

src/host-sweep.ts:63

1.10 把「不可能的组合」变成不可表达

妙在哪: sessionMode: 'per-thread' 直接推导出 threads = 1,不提供独立的 threads 声明。于是「每线程会话 + 线程 id 被剥掉」这个自相矛盾的配置根本写不出来

比校验更好的是不可表达。

src/channels/adapter.ts:141-155


2. 边界与局限(诚实)

2.1 它刻意不做的事

不做理由(源码/README 依据)
多租户 / 团队版README 明说「为个人用户而建」;data/groups/ 都是本地目录
配置文件体系README:「定制 = 改代码」;container_configs 是 DB 表不是 YAML
监控面板 / 调试 UIREADME:描述问题给 Claude Code,它来处理
主干自带渠道适配器「Skills over features」——渠道住在 channels 分支
应用层的命令白名单安全边界是容器,不是权限检查

2.2 会在哪崩 / 会怎么疼

问题具体表现依据
容器日志丢失--rm 意味着容器退出后日志没了;主机侧只在 debug 级别记 stderr,并保留最后 10 行在非零退出时打出来src/container-runner.ts:708:182-190
单进程单线程投递轮询在一个 for 循环里逐个会话 await;会话数一多,1 秒的轮询周期实际会被拉长src/delivery.ts:137
轮询延迟是硬底主机 1 秒 + 容器 0.5~1 秒,端到端有秒级基线延迟ACTIVE_POLL_MS / POLL_INTERVAL_MS
容器冷启动每个新会话一次 docker run;还要跑 OneCLI ensureAgent + applyContainerConfigsrc/container-runner.ts:539
transcript 会撑爆冷恢复长期会话的 .jsonl 越来越大,SDK 每次 resume 全量重载;超过阈值第一轮就可能超过主机的 30 分钟天花板被杀providers/types.ts:51-64(所以才有 maybeRotateContinuation)
跨挂载页缓存污染Docker Desktop macOS 上会发生;只能靠容器退出 + 全新挂载恢复mailbox/sqlite/connection.ts:17-37
投递计数在内存主机重启后失败计数清零,已经失败 2 次的消息会重新获得 3 次机会src/delivery.ts:36-37
writeOutboundDirect 的 seq 可能不是偶数注释声称偶数,SQL 是 MAX(seq)+2;容器已写奇数行时结果仍为奇数,理论上有 UNIQUE 撞车后被 INSERT OR IGNORE 静默丢弃的窗口 (inferred)src/session-manager.ts:466
挂载黑名单不递归白名单里放了 ~,下面所有凭据都进容器src/modules/mount-security/index.ts:46-53
正则触发模式 fail-openengage_pattern 写错时 agent 会对所有消息响应src/router.ts:488-493
没有接线时用户消息被丢只记审计行 + 发审批卡;审批通过后由钩子重放src/router.ts:299-336
两侧 schema 手动同步主机 src/mailbox/sqlite/schema.ts 和容器 container/agent-runner/src/mailbox/sqlite/connection.ts 里各有一份表定义,靠人保持一致两处 CREATE TABLE

2.3 「小到能看懂」这个卖点的真实尺度

README 说「一个进程、几个源文件」。实际数字:

行数
主机源码(不含测试)~23,600
主机测试~20,900
容器 agent-runner(不含测试)~5,700

约 3 万行生产代码 + 2 万行测试。 这确实比它对标的 OpenClaw(README 称近 50 万行)小一个数量级,但「几个源文件」是修辞——src/ 下有 160+ 个非测试 .ts 文件。

不过一个关键事实是真的: 注释密度极高,很多文件的注释比代码长,关键设计决策都写在源码里。读这个项目的正确方式是读注释。


3. 横向对比

3.1 同 shelf 的兄弟项目

ai-agent-referencechat-agents 区里有 20 个项目。挑几个取舍轴不同的:

项目定位差异(基于各自定位摘要)与 NanoClaw 的关键分歧
openclawNanoClaw 的直接对标物,同样是多渠道自建个人助理NanoClaw 的 README 把它列为出发点:体量大得多、安全在应用层(允许列表、配对码)而非 OS 级隔离
langbot生产级 IM bot 平台,跨 Discord/Slack/Telegram/微信平台化 vs 个人化;NanoClaw 主干不发任何渠道
astrbot多平台聊天机器人 app,插件 + MCP插件生态 vs 「改代码」
cowagentIM 优先的超级助理,自带 agent 工具循环 + 记忆 + 技能自研 agent 循环 vs 直接用 Claude Agent SDK
eliza(子库文档尚未生成)TypeScript agent 框架,面向自治社交 agent框架 vs 成品应用

注意:上表基于各项目的定位摘要,不是对它们源码的判读。 要做真正的实现对比,请读对应子库文档。

3.2 取舍轴:它在哪一端

框架(给你搭) ←──────────────●──▶ 成品(装完就用)
NanoClaw

配置驱动 ←──●────────────────────▶ 改代码驱动
NanoClaw

应用层权限检查 ←────────────────●▶ OS 级隔离
NanoClaw

多租户/团队 ←●──────────────────▶ 单人自建
NanoClaw

自研 agent 循环 ←──────────────●─▶ 用官方 SDK
NanoClaw

3.3 「这个想法」在别处也能用

NanoClaw 的做法可以搬到哪
两个 SQLite 当跨沙箱 IPC任何「主进程 + 受限沙箱」的架构(代码执行服务、插件宿主)
一扇投递门 + DB 去重窗口任何要把 LLM 流式输出投递到外部系统的地方
批准重进 guard任何有「人工审批」环节的自动化系统
心跳文件 + 纯函数卡死判定任何要判「子进程活着但卡了」的场景
unguarded() 显式标记任何「忘了加检查」是主要失效模式的地方

4. 总代码地图(跨全书)

4.1 主机侧核心

主题文件路径符号名
进程入口与启动顺序src/index.tsmain / shutdown
入站路由src/router.tsrouteInbound / evaluateEngage / deliverToAgent
会话生命周期src/session-manager.tsresolveSession / writeSessionMessage / writeOutboundDirect
会话双库 SQLsrc/mailbox/sqlite/session-db.tsensureSchema / nextEvenSeq / syncProcessingAcks
容器 spawnsrc/container-runner.tswakeContainer / spawnContainer / buildMounts / hardeningArgs
容器运行时抽象(driver 缝)src/drivers/ · src/container-runner.tsSessionDriver / adoptRunningSessions(旧 container-runtime.tsreadonlyMountArgs / cleanupOrphans 已移除)
出站投递src/delivery.tsdeliverSessionMessages / drainSession / deliverMessage / registerDeliveryAction
巡检看门狗src/host-sweep.tssweep / decideStuckAction / resetStuckProcessingRows
特权动作闸门src/guard/guard.tsguard
guard 词汇src/guard/types.tsGuardDecision / unguarded
命令闸门src/command-gate.tsgateCommand
出网封锁src/egress-lockdown.tsensureEgressNetwork
启动熔断src/circuit-breaker.tsenforceStartupBackoff
配置常量src/config.tsMOUNT_ALLOWLIST_PATH / EGRESS_LOCKDOWN / CONTAINER_PIDS_LIMIT

4.2 主机侧模块

主题文件路径符号名
模块 barrelsrc/modules/index.ts顶层 import
生命周期注册表src/host-lifecycle.tsonHostStart / startHostModules
权限:访问层级src/modules/permissions/access.tscanAccessAgentGroup
审批原语src/modules/approvals/primitive.tsrequestApproval / pickApprover
跨 agent 路由src/modules/agent-to-agent/agent-route.tsrouteAgentMessage / forwardAttachedFiles
自我修改src/modules/self-mod/apply.tsapplyInstallPackages / applyAddMcpServer
挂载安全src/modules/mount-security/index.tsvalidateAdditionalMounts / DEFAULT_BLOCKED_PATTERNS
定时任务创建src/modules/scheduling/create.tsmakeTaskId / enforceRecurrenceLimit
重复任务src/modules/scheduling/recurrence.tshandleRecurrence / scriptBackoffMinutes
跨会话上下文src/modules/cross-session-context/fan.tsfanInboundMessage / fanOutboundMessage

4.3 渠道与 CLI

主题文件路径符号名
适配器接口src/channels/adapter.tsChannelAdapter / ChannelDefaults / InboundEvent
渠道注册表src/channels/channel-registry.tsregisterChannelAdapter / createChannelDeliveryAdapter
接线默认值src/channels/channel-defaults.tsresolveWiringDefaults / resolveThreadPolicy
Chat SDK 桥src/channels/chat-sdk-bridge.ts
Webhook 服务src/webhook-server.tsRawWebhookHandler
CLI 分发src/cli/dispatch.tsdispatch
CLI socket 服务src/cli/socket-server.tsstartCliServer
CLI 通用 CRUDsrc/cli/crud.ts

4.4 容器侧

主题文件路径符号名
容器入口container/agent-runner/src/index.tsmain
主轮询循环container/agent-runner/src/poll-loop.tsrunPollLoop / processQuery
投递门与拼接container/agent-runner/src/poll-loop.tsdeliverMidTurnBlocks / unresolvedTailStart / dispatchResultText
双库连接container/agent-runner/src/mailbox/sqlite/connection.tsopenInboundDb / getInboundDb
心跳 touchcontainer/agent-runner/src/heartbeat.tstouchHeartbeat
工具在飞状态container/agent-runner/src/db/container-state.tssetContainerToolInFlight
取消息container/agent-runner/src/db/messages-in.tsgetPendingMessages
写出站container/agent-runner/src/db/messages-out.tswriteMessageOut
prompt 格式化container/agent-runner/src/formatter.tsformatMessages / extractRouting
provider 契约container/agent-runner/src/providers/types.tsAgentProvider / ProviderEvent
Claude providercontainer/agent-runner/src/providers/claude.tsClaudeProvider / SDK_DISALLOWED_TOOLS
MCP 工具container/agent-runner/src/mcp-tools/core.tssendMessage / sendFile
阻塞式提问container/agent-runner/src/mcp-tools/interactive.tsaskUserQuestion
容器内 CLIcontainer/agent-runner/src/cli/ncl.tswriteRequest
预检脚本container/agent-runner/src/scheduling/task-script.tsrunScript

4.5 构建与安装

主题文件路径说明
容器镜像container/Dockerfile源码不打进镜像,/app/src 是运行时 RO 挂载
镜像构建脚本container/build.sh.envINSTALL_CJK_FONTS 传成 build-arg
一键安装nanoclaw.sh失败时自动交给 Claude Code 诊断并续跑
v1→v2 迁移migrate-v2.sh确定性部分脚本做,需要判断的部分 exec 进 Claude Code
技能指令语法scripts/skill-directives.tsnc: 栅栏的完整语法说明在文件头
技能执行引擎scripts/skill-apply.ts有日志、幂等

5. 一句话总结

NanoClaw 用一个不时髦的选择(跨挂载 SQLite 轮询)换来了一个很硬的性质:沙箱侧不需要任何主动的网络或 IPC 能力。围绕这个中心,它把「消息可靠投递」「特权动作审批」「凭据不落地」三件事都做成了结构性保证而不是运行时检查——而且把每一处的理由和边界都写在了源码注释里。

这个项目最该被读的部分,是它的注释。