M06 · SOURCE-GROUNDED TUTORIAL
MonkeyCode从源码学会它怎么工作 它是把多种 CLI 放入远程 VM 的控制平面;Agent 智能来自所选 CLI,平台负责隔离、凭据与协作。 我们不把 README 当结论,而是沿主循环、工具、上下文、权限、扩展、协作和状态一路读到实现。
Go/TS · Remote Agent Control Plane AGPL ddc3794fc31e 21 个结论 · 61 处引用
这门课怎么读
先建立直觉,再沿一条任务链钻进代码 参考教程的做法不是把 API 名称罗列出来,而是从一个小白能理解的问题开始,先解释“为什么需要这个机制”,再用概念对比、执行链路和固定提交的源码回答“它究竟怎么做”。本页把 MonkeyCode 的 21 个源码结论重新编排成十节课;每个结论都保留证据等级、文件路径、行号和可点击源码。
你会得到 一张可复述的架构地图 一次完整任务的链路追踪 能迁移到自研 Harness 的设计判断
你不会得到 把 README 功能当成已验证事实 把 prompt 约束说成 OS 沙箱 把一次双模型调用夸成多 Agent 平台
M00 · MAP
先看全景:这个 Agent 的控制面在哪里 下面的图不是产品宣传图,而是把固定提交里最关键的入口、循环、模型、工具、安全、状态和协作节点放在一张地图上。
核心机制 DB 预登记 → VM → Redis 交接 → 选定 CLI
上下文 只知道窗口上限;压缩和 memory 交给内层 Agent
适用建设 多人远程 Coding Agent SaaS、隔离租户、统一审计
M00.5 · TRACE
跟踪一个任务:从输入到交付 把下面九步当成你读源码时的“地图坐标”。每到一个节点,都能回到后面的章节查具体实现。
01 创建任务并预登记 DB 任务进入
↓
02 创建 VM 与临时密钥 把结果交给下一层
↓
03 Redis 交接运行态 把结果交给下一层
↓
04 启动选定 CLI 把结果交给下一层
↓
05 代理并锁定模型协议 把结果交给下一层
↓
06 转发 auto-approve / MCP 把结果交给下一层
↓
07 旁路解析流与用量 把结果交给下一层
↓
08 持久化任务状态 把结果交给下一层
↓
09 实时回放给协作者 交付/续跑
读图提醒 箭头只表示控制面之间的关系,不代表每个实现都同步、串行或拥有 OS 隔离;真正的边界要以对应章节的源码摘录和 caveat 为准。
M01 · ORIENTATION
先把 Agent 看成一台会交付的机器 如果只看 README,你知道它能做什么;钻进源码后,我们要知道它为什么能做、什么时候会停、失败后谁负责收拾。
先用一个生活比喻 把 Agent 想成一间带传送带的工作室:入口收任务,主循环决定下一步,模型负责提出动作,工具负责动手,状态账本负责让下一班人接着干。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
读源码时先问 本课的判断方式 这一层有没有独立证据? 没有就不把其他层的能力冒充成默认行为;回到固定提交继续追调用链。 谁拥有最终控制权? 区分模型输出、框架规则、用户审批和 OS 隔离四种不同力量。
这一章先建立通用概念 固定提交账本没有把该维度单独拆出,但它会在其他章节的源码路径中体现。先沿执行链路阅读,再回到报告页核对证据。
小练习 1 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M02 · LOOP
主循环:模型为什么会继续动 一次模型调用为什么会变成十几步?循环靠什么继续,靠什么停止?
先用一个生活比喻 像一个会看回执的快递员:模型先写行动单,工具返回回执,主循环把回执放回桌面,再让模型决定下一张行动单。
这套实现先回答了什么? MonkeyCode 更像机场塔台:它决定哪架飞机、在哪个跑道、带什么配置起飞,但不会替 Codex 或 Claude 亲自驾驶。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
源码问题 固定提交给出的线索 白话结论 它是多 CLI 的任务控制平面,不是第四套 Agent loop backend/pkg/taskflow/types.go:554MonkeyCode 更像机场塔台:它决定哪架飞机、在哪个跑道、带什么配置起飞,但不会替 Codex 或 Claude 亲自驾驶。 任务创建拆成数据库预登记、VM 创建、Redis 交接、运行态启动 backend/biz/task/usecase/task.go:556先把工单和工作间登记好,再等工作间真的上线,最后才把任务交给里面的 Agent。 Taskflow Create 失败被记录但吞掉,任务可能滞留 processing backend/pkg/lifecycle/taskhook.go:104工单已经盖了“处理中”,但真正开工失败后只记了一条日志,状态机可能还以为工作在继续。
01 L1 · fact · monkey-architecture-001
它是多 CLI 的任务控制平面,不是第四套 Agent loop
先看源码事实 Taskflow 协议把 CodingAgent 枚举为 Codex、Claude、MCAIReview、OpenCode;任务请求携带 system prompt、模型、配置文件、MCP 与资源,真正的推理—工具循环交给所选 CLI。
翻译成白话 MonkeyCode 更像机场塔台:它决定哪架飞机、在哪个跑道、带什么配置起飞,但不会替 Codex 或 Claude 亲自驾驶。
为什么这对自研重要 比较 Harness 时,应把平台编排能力与各 CLI 内核能力拆开计分。
固定提交源码摘录 复制
554 // ==================== CreateTask 类型 ====================
555
556 // CodingAgent 编码代理类型
557 type CodingAgent int
558
559 const (
560 CodingAgentCodex CodingAgent = iota + 1
561 CodingAgentClaude
562 CodingAgentMCAIReview
563 CodingAgentOpenCode
564 )
565
566 // LLM 模型配置
567 type LLM struct {
568 ApiKey string `json:"api_key"`
569 BaseURL string `json:"base_url"`
570 Model string `json:"model"`
571 ApiType string `json:"api_type,omitempty"` // 接口类型 anthropic | openai
572 Temperature *float32 `json:"temperature,omitempty"`
573 }
574
575 // ConfigFile 配置文件
576 type ConfigFile struct {
577 Path string `json:"path"`
578 Content string `json:"content"`
579 Mode *uint32 `json:"mode,omitempty"`
580 }
581
582 // TaskExecutionConfig 任务运行配置
583 type TaskExecutionConfig struct {
584 Envs map[string]string `json:"envs,omitempty"`
585 ConfigFiles []ConfigFile `json:"config_files,omitempty"`
586 McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
587 AgentResources *AgentResources `json:"agent_resources,omitempty"`
为什么相信这条结论?查看 3 处证据
02 L1 · fact · monkey-lifecycle-001
任务创建拆成数据库预登记、VM 创建、Redis 交接、运行态启动
先看源码事实 Create 先校验 host/model/concurrency 并预创建任务记录,再请求 2 核 8GiB VM,将完整 CreateTaskReq 以 TTL 写入 Redis,最后切到 pending;VM ready hook 取出请求并调用 TaskManager.Create。
翻译成白话 先把工单和工作间登记好,再等工作间真的上线,最后才把任务交给里面的 Agent。
为什么这对自研重要 分阶段便于恢复和审计,但 Redis 交接键成为启动链路的关键依赖。
固定提交源码摘录 复制
556 limit, err := a.resolveTaskConcurrencyLimit(ctx, user.ID)
557 if err != nil {
558 return nil, err
559 }
560 ctx = entx.WithTaskConcurrencyLimit(ctx, limit)
561
562 vmID := fmt.Sprintf("agent_%s", uuid.NewString())
563 prepared, err := a.repo.PrepareCreate(ctx, user, req, token, vmID)
564 if err != nil {
565 a.logger.With("error", err, "req", req).ErrorContext(ctx, "failed to create task")
566 return nil, err
567 }
568 if prepared == nil || prepared.ProjectTask == nil || prepared.Model == nil || prepared.Image == nil {
569 return nil, fmt.Errorf("failed to prepare task")
570 }
571 pt := prepared.ProjectTask
572 m := prepared.Model
573 i := prepared.Image
574 t := pt.Edges.Task
575 if t == nil {
576 return nil, fmt.Errorf("task edge is nil")
577 }
578 if git.URL == "" {
579 git.URL = pt.RepoURL
580 }
… 26 lines omitted; exact range 556–617 …
607 LLM: taskflow.LLMProviderReq{
608 Provider: taskflow.LlmProviderOpenAI,
609 ApiKey: m.APIKey,
610 BaseURL: m.BaseURL,
611 Model: m.Model,
612 },
613 Cores: "2",
614 Memory: 8 << 30,
615 Envs: env,
616 LogStore: normalizeTaskLogStore(t.LogStore),
617 })
为什么相信这条结论?查看 3 处证据
03 L1 · risk · monkey-lifecycle-002
Taskflow Create 失败被记录但吞掉,任务可能滞留 processing
先看源码事实 handleProcessing 在先写入 processing 后调用 TaskManager.Create;若调用失败只写 error log,不 return err,外层 withError 无法把任务转入 error。
翻译成白话 工单已经盖了“处理中”,但真正开工失败后只记了一条日志,状态机可能还以为工作在继续。
为什么这对自研重要 应把 Create 错误返回给生命周期管理器,并为 processing-without-session 增加 watchdog。
固定提交源码摘录 复制
104 reqKey := fmt.Sprintf("task:create_req:%s", id.String())
105 val, err := h.redis.Get(ctx, reqKey).Result()
106 if err != nil {
107 h.logger.With("task_id", id, "error", err).ErrorContext(ctx, "failed to get CreateTaskReq from redis")
108 return fmt.Errorf("failed to get CreateTaskReq from Redis: %w", err)
109 }
110
111 defer h.redis.Del(ctx, reqKey)
112
113 if err := h.repo.Update(ctx, &domain.User{}, id, func(up *db.TaskUpdateOne) error {
114 up.SetStatus(consts.TaskStatusProcessing)
115 return nil
116 }); err != nil {
117 return fmt.Errorf("failed to update task status: %w", err)
118 }
119
120 var createReq taskflow.CreateTaskReq
121 if err := json.Unmarshal([]byte(val), &createReq); err != nil {
122 h.logger.With("task_id", id, "error", err).ErrorContext(ctx, "failed to unmarshal CreateTaskReq")
123 return fmt.Errorf("failed to unmarshal CreateTaskReq: %w", err)
为什么相信这条结论?查看 2 处证据
小练习 2 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M03 · MODEL
模型调用:流式输出如何变成可执行步骤 模型输出的文字、思考、工具调用和错误,经过哪些转换才进入 Agent 状态?
先用一个生活比喻 模型像电话另一端的同事:你听到的不是一整段录音,而是一串实时片段;Harness 要边听边拼装,还要能在电话断线时留下可恢复的记录。
这套实现先回答了什么? 工作 VM 拿的是代金券,不是模型厂商的保险柜钥匙;平台看到券后再替它换成真正凭据。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
源码问题 固定提交给出的线索 白话结论 LLM proxy 用 VM 绑定临时密钥隐藏真实上游凭据 backend/biz/task/usecase/task.go:585工作 VM 拿的是代金券,不是模型厂商的保险柜钥匙;平台看到券后再替它换成真正凭据。 代理只放行三种 LLM 协议,并阻止任务偷换模型 backend/biz/llmproxy/proxy.go:28这不是任意 HTTP 隧道;票上写的是哪个模型,就只能点那个模型。 运行中切模仅支持 OpenCode,并通过 restart 恢复 session backend/biz/task/usecase/task.go:165换发动机不是所有车都支持:目前只有 OpenCode 能中途换模型,且本质是重启运行时再接回会话。
04 L1 · fact · monkey-provider-001
LLM proxy 用 VM 绑定临时密钥隐藏真实上游凭据
先看源码事实 任务拿到 ModelApiKey/runtime token 和平台 proxy URL;proxy 再从数据库解析 user、VM、provider、真实 BaseURL/API key,重写 Authorization/X-Api-Key 后访问上游。
翻译成白话 工作 VM 拿的是代金券,不是模型厂商的保险柜钥匙;平台看到券后再替它换成真正凭据。
为什么这对自研重要 可撤销密钥、统一用量和上游切换都集中在控制面。
固定提交源码摘录 复制
585 var runtimeToken string
586 if keys := m.Edges.Apikeys; len(keys) > 0 {
587 m.APIKey = keys[0].APIKey
588 m.BaseURL = a.cfg.LLMProxy.BaseURL + "/v1"
589 runtimeToken = keys[0].APIKey
590 }
为什么相信这条结论?查看 3 处证据
05 L1 · fact · monkey-provider-002
代理只放行三种 LLM 协议,并阻止任务偷换模型
先看源码事实 入口只接受 chat/completions、responses、messages;请求必须有 bearer 或 X-api-key,body 中 model 若与 token 绑定模型不一致直接 403。
翻译成白话 这不是任意 HTTP 隧道;票上写的是哪个模型,就只能点那个模型。
为什么这对自研重要 减小了凭据代理被滥用成通用外网代理或越权消费的空间。
固定提交源码摘录 复制
28 const upstreamFailureMessage = "连接上游模型失败,请检查模型配置,或重试"
29
30 var allowPaths = map[string]string{
31 "/v1/chat/completions": "/chat/completions",
32 "/v1/responses": "/responses",
33 "/v1/messages": "/messages",
34 }
为什么相信这条结论?查看 2 处证据
06 L1 · limitation · monkey-provider-003
运行中切模仅支持 OpenCode,并通过 restart 恢复 session
先看源码事实 SwitchModel 要求 processing、owner/privileged、模型授权;重建配置后若 runtime 不是 OpenCode 就报错,随后调用 TaskManager.Restart 并可选择 LoadSession。
翻译成白话 换发动机不是所有车都支持:目前只有 OpenCode 能中途换模型,且本质是重启运行时再接回会话。
为什么这对自研重要 Codex/Claude 的长任务无法享受平台级模型升降档。
固定提交源码摘录 复制
165 // SwitchModel 切换运行中任务使用的模型
166 func (a *TaskUsecase) SwitchModel(ctx context.Context, user *domain.User, taskID uuid.UUID, req domain.SwitchTaskModelReq) (*domain.SwitchTaskModelResp, error) {
167 t, owner, err := a.Info(ctx, user, taskID)
168 if err != nil {
169 return nil, err
170 }
171 if !owner && !a.isPrivileged(ctx, user.ID) {
172 return nil, errcode.ErrForbidden
173 }
174 if t.Status != consts.TaskStatusProcessing {
175 return nil, fmt.Errorf("task is not processing")
176 }
177 if t.VirtualMachine == nil {
178 return nil, fmt.Errorf("task virtual machine is nil")
179 }
180
181 taskOwnerID := t.UserID
182 if a.modelHook != nil {
183 if err := a.modelHook.ValidateAccess(ctx, taskOwnerID, req.ModelID.String()); err != nil {
184 return nil, err
185 }
186 }
187 model, err := a.modelRepo.Get(ctx, taskOwnerID, req.ModelID)
188 if err != nil {
189 return nil, err
… 18 lines omitted; exact range 165–218 …
208 var skillIDs, pluginIDs []string
209 if t.Extra != nil {
210 skillIDs = t.Extra.SkillIDs
211 pluginIDs = t.Extra.PluginIDs
212 }
213 coding, configs, agentRes, err := a.getCodingConfigs(ctx, t.CliName, model, skillIDs, pluginIDs, a.userScope(ctx, user), false)
214 if err != nil {
215 return nil, err
216 }
217 if coding != taskflow.CodingAgentOpenCode {
218 return nil, fmt.Errorf("switch model only supports opencode runtime")
为什么相信这条结论?查看 2 处证据
小练习 3 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M05 · CONTEXT
上下文:有限窗口怎样装下长任务 当对话、工具输出、计划和记忆越来越多,系统怎样决定留下什么、折叠什么、放到哪里?
先用一个生活比喻 上下文不是聊天记录,而是一张会整理的工作台:常用零件放桌面,旧材料装进档案盒,必要时只留下索引卡。
这套实现先回答了什么? 平台知道油箱标称多大,却不看里面还剩多少油;何时压缩历史由 OpenCode/Codex/Claude 自己决定。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
源码问题 固定提交给出的线索 白话结论 平台知道模型窗口上限,但不管理内层 compaction backend/biz/task/usecase/task.go:788平台知道油箱标称多大,却不看里面还剩多少油;何时压缩历史由 OpenCode/Codex/Claude 自己决定。 任务摘要是异步 UI 元数据,不是 Agent 记忆回写 backend/biz/task/service/tasksummary.go:32它会给长对话写一段“给人看的剧情简介”,但这段简介不会自动塞回 Agent 的脑子里。
11 L1 · limitation · monkey-context-001
平台知道模型窗口上限,但不管理内层 compaction
先看源码事实 OpenCode 配置缺省 context=200000、output=32000;Taskflow 请求只下发 prompt/config/session,MonkeyCode 仓库没有在推理循环中计算 live context 或触发 compaction 的代码。
翻译成白话 平台知道油箱标称多大,却不看里面还剩多少油;何时压缩历史由 OpenCode/Codex/Claude 自己决定。
为什么这对自研重要 跨 runtime 的长任务可靠性会随各 CLI 内核不同而波动。
固定提交源码摘录 复制
788 func modelRuntimeDefaults(m *db.Model) (thinking bool, contextLimit int, outputLimit int) {
789 thinking = m.ThinkingEnabled
790 contextLimit = cmp.Or(m.ContextLimit, 200000)
791 outputLimit = cmp.Or(m.OutputLimit, 32000)
792 return thinking, contextLimit, outputLimit
为什么相信这条结论?查看 3 处证据
12 L1 · fact · monkey-context-002
任务摘要是异步 UI 元数据,不是 Agent 记忆回写
先看源码事实 TaskSummaryService 从持久化 task log 取 conversation,用独立 LLM 延迟生成 summary 并写入 Task.summary;任务 Create/Continue 协议未读取该字段回灌内层会话。
翻译成白话 它会给长对话写一段“给人看的剧情简介”,但这段简介不会自动塞回 Agent 的脑子里。
为什么这对自研重要 摘要改善列表浏览和通知,不应被误计为上下文压缩或长期记忆。
固定提交源码摘录 复制
32 // TaskSummaryService 任务摘要生成服务
33 type TaskSummaryService struct {
34 cfg *config.Config
35 db *db.Client
36 llm *llm.Client
37 summaryQueue *delayqueue.TaskSummaryQueue
38 logger *slog.Logger
39 conversationReader ConversationReader
40
41 // 生命周期管理
42 cancel context.CancelFunc
43 wg sync.WaitGroup
44 }
45
46 type tasklogGateway interface {
47 QueryTurns(ctx context.Context, taskID uuid.UUID, taskCreatedAt time.Time, opts tasklog.QueryTurnsOpts, store consts.LogStore) (*tasklog.QueryTurnsResp, error)
48 }
49
50 type ConversationReader interface {
51 Fetch(ctx context.Context, taskID uuid.UUID, createdAt time.Time, store consts.LogStore, initialContent string, maxRounds int) ([]llm.Message, error)
52 }
53
54 type tasklogConversationReader struct {
55 gateway tasklogGateway
56 logger *slog.Logger
… 25 lines omitted; exact range 32–92 …
82 db: d,
83 llm: llmClient,
84 summaryQueue: sq,
85 logger: logger,
86 conversationReader: newTasklogConversationReader(tlg, logger),
87 }
88
89 // 启动消费者
90 s.Start(context.Background())
91
92 return s, nil
为什么相信这条结论?查看 3 处证据
小练习 5 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M06 · SECURITY
权限与沙箱:能做什么,在哪里做 审批按钮、规则引擎、容器和操作系统沙箱分别解决什么问题?为什么“问过用户”不等于“隔离了风险”?
先用一个生活比喻 审批像门卫问你有没有预约,沙箱像把访客关在指定房间;前者决定是否放行,后者限制放行后能摸到什么。
这套实现先回答了什么? Codex 在房间里面拿的是万能钥匙;安全取决于这个“房间”到底是不是一间真正隔离的 VM,而造房间的代码不在本仓库。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
源码问题 固定提交给出的线索 白话结论 隔离边界主要依赖仓库外 VM,Codex 内层 sandbox 明确关闭 backend/pkg/taskflow/types.go:72Codex 在房间里面拿的是万能钥匙;安全取决于这个“房间”到底是不是一间真正隔离的 VM,而造房间的代码不在本仓库。 平台只转发 auto-approve,具体权限语义继承所选 CLI backend/biz/task/usecase/task.go:157平台提供“自动点同意”的总开关,但什么动作需要同意、允许后能做多大,仍由里面那套 CLI 决定。 zip 资源有文件数、单文件、总量和 zip-slip 双重校验 backend/biz/agentresource/unpack.go:12插件压缩包不能假装很小再突然炸开,也不能用 ../../ 偷写 VM 里的其他路径。 SSRF guard 能防 DNS rebinding,但私有化示例默认关闭 backend/pkg/netguard/guard.go:53防护能力本身很认真,但自建用户照示例部署时默认不会打开这扇防火门。
13 L1 · limitation · monkey-sandbox-001
隔离边界主要依赖仓库外 VM,Codex 内层 sandbox 明确关闭
先看源码事实 平台协议申请带 host、image、CPU、memory、git 和 TTL 的 VirtualMachine;但 Codex 模板设置 sandbox_mode=danger-full-access,并将 /workspace 标为 trusted。仓库内只有 VM API 合同,没有底层 namespace/hypervisor enforcement。
翻译成白话 Codex 在房间里面拿的是万能钥匙;安全取决于这个“房间”到底是不是一间真正隔离的 VM,而造房间的代码不在本仓库。
为什么这对自研重要 部署评审必须联审 codingmatrix/Taskflow 执行器,不能因类型名叫 VirtualMachine 就自动认定强隔离。
固定提交源码摘录 复制
72 // VirtualMachine 虚拟机信息
73 type VirtualMachine struct {
74 ID string `json:"id"`
75 AccessToken string `json:"access_token,omitempty"`
76 EnvironmentID string `json:"environment_id"`
77 HostID string `json:"host_id"`
78 Hostname string `json:"hostname"`
79 Arch string `json:"arch"`
80 OS string `json:"os"`
81 Name string `json:"name"`
82 Repository string `json:"repository"`
83 Status VirtualMachineStatus `json:"status"`
84 StatusMessage string `json:"status_message"`
85 Cores int32 `json:"cores"`
86 Memory uint64 `json:"memory"`
87 Disk uint64 `json:"disk"`
88 TTL TTL `json:"ttl"`
89 ExternalIP string `json:"external_ip"`
90 CreatedAt int64 `json:"created_at"`
91 Version string `json:"version"`
92 }
为什么相信这条结论?查看 3 处证据
14 L1 · fact · monkey-permission-001
平台只转发 auto-approve,具体权限语义继承所选 CLI
先看源码事实 AutoApprove API 只把 task ID 和布尔值传给 Taskflow;Codex 模板使用 approval_policy=untrusted,OpenCode 模板却允许 doom_loop、外部目录和 .env 读取。
翻译成白话 平台提供“自动点同意”的总开关,但什么动作需要同意、允许后能做多大,仍由里面那套 CLI 决定。
为什么这对自研重要 统一 UI 不等于统一安全语义,需要为每个 runtime 建立可比较的 capability policy。
固定提交源码摘录 复制
157 // AutoApprove implements domain.TaskUsecase.
158 func (a *TaskUsecase) AutoApprove(ctx context.Context, _ *domain.User, id uuid.UUID, approve bool) error {
159 return a.taskflow.TaskManager().AutoApprove(ctx, taskflow.TaskApproveReq{
160 ID: id,
161 AutoApprove: &approve,
162 })
163 }
为什么相信这条结论?查看 3 处证据
15 L1 · fact · monkey-resource-003
zip 资源有文件数、单文件、总量和 zip-slip 双重校验
先看源码事实 默认限制 1000 文件、单文件 32MiB、总解压 256MiB;先查声明尺寸,再用 LimitReader 验证实际尺寸,并拒绝绝对路径、反斜线和 .. 逃逸。
翻译成白话 插件压缩包不能假装很小再突然炸开,也不能用 ../../ 偷写 VM 里的其他路径。
为什么这对自研重要 插件供应链仍需签名/哈希治理,但基础解包攻击面处理得较完整。
固定提交源码摘录 复制
12 // UnzipLimits guards the in-memory unzipper against zip bombs and overly
13 // large archives. All limits are inclusive of the value (size == limit is OK).
14 type UnzipLimits struct {
15 MaxFileSize int64 // per-entry uncompressed size
16 MaxTotalSize int64 // sum of all entries' uncompressed sizes
17 MaxFiles int // max number of file entries
18 }
19
20 // DefaultUnzipLimits is the policy used by the Resolver.
21 var DefaultUnzipLimits = UnzipLimits{
22 MaxFileSize: 32 << 20, // 32 MiB
23 MaxTotalSize: 256 << 20, // 256 MiB
24 MaxFiles: 1000,
25 }
26
27 // unzipToMemory reads a zip archive entirely from memory and returns its
28 // regular file entries. Directories are skipped. Paths are validated to
29 // reject zip-slip ("../..") and absolute paths. Entries that exceed the
30 // limits cause the entire archive to be rejected so callers can fall back
31 // to skipping the whole asset (matching the Resolver's per-skill policy).
为什么相信这条结论?查看 3 处证据
16 L1 · risk · monkey-security-001
SSRF guard 能防 DNS rebinding,但私有化示例默认关闭
先看源码事实 netguard 校验 http/https、特殊 IPv4 写法、localhost/private/metadata IP,并在 dial 时使用已验证 IP;测试覆盖十进制、八进制、十六进制、IPv6 和私有代理。然而示例配置明确为 SaaS 开启、私有化默认 false。
翻译成白话 防护能力本身很认真,但自建用户照示例部署时默认不会打开这扇防火门。
为什么这对自研重要 需要按部署信任边界决定默认值,并在关闭时明确告警。
固定提交源码摘录 复制
53 func (g *Guard) ValidateURL(ctx context.Context, rawURL string) error {
54 u, err := url.Parse(strings.TrimSpace(rawURL))
55 if err != nil {
56 return fmt.Errorf("parse url: %w", err)
57 }
58 if u.Scheme != "http" && u.Scheme != "https" {
59 return fmt.Errorf("unsupported scheme %q", u.Scheme)
60 }
61 if u.Hostname() == "" {
62 return errors.New("empty host")
63 }
64 if !g.Enabled() {
65 return nil
66 }
67 _, err = g.resolveHost(ctx, u.Hostname())
68 return err
69 }
为什么相信这条结论?查看 4 处证据
小练习 6 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M07 · ECOSYSTEM
指令、MCP、Skills 与插件:能力如何接进来 一条系统指令、一个 Skill、一个 MCP server 和一个插件,分别在什么时候进入上下文和执行路径?
先用一个生活比喻 这像给工作室接设备:说明书不是设备,设备也不等于电源;成熟 Harness 会分别治理发现、信任、加载、调用和卸载。
这套实现先回答了什么? 规则是小纸条直接塞进去,技能是压缩包让 VM 自己下载,插件还要告诉 OpenCode 从哪个入口文件启动。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
源码问题 固定提交给出的线索 白话结论 rules、skills、plugins 是三条不同投放链 backend/biz/task/usecase/task.go:795规则是小纸条直接塞进去,技能是压缩包让 VM 自己下载,插件还要告诉 OpenCode 从哪个入口文件启动。
17 L1 · fact · monkey-resource-001
rules、skills、plugins 是三条不同投放链
先看源码事实 rules 以内联 ConfigFile 写入 .ai-ready/rules;skills 对三种 CLI 都以 24h presigned zip URL 下发;plugins 只给 OpenCode,并将 entry 变成 file:// URL 注入 opencode.json。
翻译成白话 规则是小纸条直接塞进去,技能是压缩包让 VM 自己下载,插件还要告诉 OpenCode 从哪个入口文件启动。
为什么这对自研重要 资源协议兼顾小文本与大包,但各 runtime 的插件能力并不对齐。
固定提交源码摘录 复制
795 // agentRuleBaseDir / agentSkillBaseDir / agentPluginBaseDir 是 codingmatrix 在
796 // VM 内部约定的 .ai-ready/ 投放路径。rule 走 .md 平铺;skill / plugin 解 zip
797 // 后按目录结构展开;plugin 的 entry 字段再以 file:// 注入到 opencode.json 的
798 // `plugin` 数组里。Claude / Codex 不消费 plugin(spec §6.3)。
799 const (
800 agentRuleBaseDir = "${HOME}/.codingmatrix/project-tpl/.ai-ready/rules/"
801 agentSkillBaseDir = "${HOME}/.codingmatrix/project-tpl/.ai-ready/skills/"
802 agentPluginBaseDir = "${HOME}/.codingmatrix/project-tpl/.ai-ready/plugins/"
803 )
为什么相信这条结论?查看 4 处证据
小练习 7 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M08 · COLLABORATION
子 Agent:把一个大任务拆成可治理的协作 什么时候是普通工具调用,什么时候才算子 Agent?子 Agent 的上下文、预算、取消和结果怎样回到父 Agent?
先用一个生活比喻 不是把同事叫来聊天就叫协作;真正的协作要有工单、权限、截止时间、交付物和回收机制。
这套实现先回答了什么? 旁观者能看直播,任务主人才能按按钮和继续对话;掉线重连后还能从历史接上。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
源码问题 固定提交给出的线索 白话结论 远程协作以 owner write gate、历史回放和实时流为核心 backend/biz/task/handler/v1/task.go:323旁观者能看直播,任务主人才能按按钮和继续对话;掉线重连后还能从历史接上。 平台层没有可见的子 Agent 调度与独立治理实体 backend/pkg/taskflow/types.go:626如果 Codex/OpenCode 内部自己再派子 Agent,MonkeyCode 目前只会把它们看成同一间 VM 里的同一项任务,无法逐个授权、暂停和计费。
18 L1 · fact · monkey-remote-001
远程协作以 owner write gate、历史回放和实时流为核心
先看源码事实 WebSocket stream 根据任务 owner 决定 writable;非 owner 的上行消息被丢弃。attach 模式先订阅并回放历史,再消费实时事件;用户可继续输入、停止、取消当前操作、切 auto-approve 和回答 Agent 问题。
翻译成白话 旁观者能看直播,任务主人才能按按钮和继续对话;掉线重连后还能从历史接上。
为什么这对自研重要 它实现了人—Agent 远程接管,而不是多 Agent 共享计划或黑板。
固定提交源码摘录 复制
323 // Stream 任务数据流 WebSocket
324 //
325 // @Summary 任务数据流 WebSocket
326 // @Description 功能定位:该接口通过 WebSocket 转发任务运行数据。任务对话继续输入使用 `type=user-input`。
327 // @Description 数据格式约定:当前仅支持文本帧透传。服务端将 Agent 的原始文本数据包装为如下结构返回给前端(对应 domain.TaskStream):
328 // @Description ```json
329 // @Description { "type": "string", "data": "string", "kind": "string", "timestamp": 0 }
330 // @Description ```
331 // @Description user-input 上行新格式:
332 // @Description ```json
333 // @Description { "type": "user-input", "data": "{\"content\":\"57un57ut5aSE55CG6L+Z5Liq6Zeu6aKY\",\"attachments\":[{\"url\":\"https://example-bucket.oss-cn-hangzhou.aliyuncs.com/temp/a.txt\",\"filename\":\"a.txt\"}]}" }
334 // @Description ```
335 // @Description user-input 上行旧格式仍兼容:
336 // @Description ```json
337 // @Description { "type": "user-input", "data": "继续处理这个问题" }
338 // @Description ```
339 // @Description user-input 下行和历史返回统一使用新 JSON payload 字符串:
340 // @Description ```json
341 // @Description { "type": "user-input", "data": "{\"content\":\"57un57ut5aSE55CG6L+Z5Liq6Zeu6aKY\",\"attachments\":[]}", "timestamp": 0 }
342 // @Description ```
343 // @Description `attachments` 为可选附件列表,最多 10 个;每项包含 `url` 和 `filename`,URL 需要匹配后端配置的附件白名单前缀。
344 // @Description type 字段说明:
345 // @Description - task-started: 本轮任务启动
346 // @Description - task-ended: 本轮任务结束
347 // @Description - task-error: 本轮任务发生错误
… 26 lines omitted; exact range 323–384 …
374 func (h *TaskHandler) Stream(c *web.Context, req domain.TaskStreamReq) error {
375 user := middleware.GetUser(c)
376 task, owner, err := h.usecase.Info(c.Request().Context(), user, req.ID)
377 if err != nil {
378 return err
379 }
380
381 if req.Mode == "" {
382 req.Mode = "new"
383 }
384 return h.stream(c, user, task, owner, req.Mode)
为什么相信这条结论?查看 3 处证据
19 L2 · limitation · monkey-subagent-001
平台层没有可见的子 Agent 调度与独立治理实体
先看源码事实 Taskflow CreateTaskReq 只有单一 CodingAgent、单 VM、单 task/session;WebSocket 和 MCP 审计也都按同一 task/VM 归因。仓库未定义 child task、parent task、handoff 或 child capability 合同。
翻译成白话 如果 Codex/OpenCode 内部自己再派子 Agent,MonkeyCode 目前只会把它们看成同一间 VM 里的同一项任务,无法逐个授权、暂停和计费。
为什么这对自研重要 多 Agent 能力最多继承 runtime,本平台尚未形成可观测、可治理的协作控制面。
固定提交源码摘录 复制
626 // CreateTaskReq 创建任务请求
627 type CreateTaskReq struct {
628 ID uuid.UUID `json:"id"`
629 VMID string `json:"vm_id"`
630 SystemPrompt string `json:"system_prompt,omitempty"`
631 Text string `json:"text,omitempty"`
632 Attachments []Attachment `json:"attachments,omitempty"`
633 LLM LLM `json:"llm,omitzero"`
634 CodingAgent CodingAgent `json:"coding_agent,omitempty"`
635 Configs []ConfigFile `json:"configs,omitzero"`
636 McpConfigs []McpServerConfig `json:"mcp_configs,omitzero"`
637 Env map[string]string `json:"env,omitempty"`
638 LogStore string `json:"log_store,omitempty"`
639 AgentResources *AgentResources `json:"agent_resources,omitempty"` // skill/plugin presigned URLs + rule content forwarded to codingmatrix agent
640 }
为什么相信这条结论?查看 3 处证据
小练习 8 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M09 · STATE
会话、持久化与观测:让一次运行变成可追溯事实 如果进程崩了、用户刷新了、任务跑了一夜,系统凭什么恢复并解释“刚才究竟发生了什么”?
先用一个生活比喻 内存像白板,数据库像目录,append-only journal 像监控录像;可靠 Harness 不只保存最后答案,还保存每次转弯。
这套实现先回答了什么? 回答照常流给 Agent,同时平台在旁边读水表,不必让每个 CLI 各写一套计费代码。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
源码问题 固定提交给出的线索 白话结论 模型流被旁路解析,用量归因到 task/user/VM backend/biz/llmproxy/proxy.go:246回答照常流给 Agent,同时平台在旁边读水表,不必让每个 CLI 各写一套计费代码。 遥测采用 OTLP,但输出前做严格 allowlist 消毒 backend/pkg/telemetry/telemetry.go:29它不是把请求体、URL 和异常详情整包发给观测平台,而是先过一遍“只准这些字段出门”的白名单。
20 L1 · fact · monkey-observe-001
模型流被旁路解析,用量归因到 task/user/VM
先看源码事实 成功响应 body 被 UsageCapture 包装,兼容流式与非流式;token 结果被组装为含 task、user、provider、model、input/output/cache/total、request ID 的 usage event。
翻译成白话 回答照常流给 Agent,同时平台在旁边读水表,不必让每个 CLI 各写一套计费代码。
为什么这对自研重要 统一代理是跨 Harness 成本观测的高价值控制点。
固定提交源码摘录 复制
246 func (p *Proxy) modifyResponse(resp *http.Response) error {
247 if resp == nil || resp.Body == nil {
248 return nil
249 }
250 if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
251 return nil
252 }
253 ctx, ok := resp.Request.Context().Value(contextKey{}).(*proxyContext)
254 if !ok || ctx == nil || ctx.model == nil {
255 return nil
256 }
257 resp.Body = NewUsageCapture(p.logger, resp.Body, &UsageCaptureContext{
258 ctx: resp.Request.Context(),
259 path: normalizeUsageCapturePath(resp.Request.URL.Path),
260 stream: ctx.stream,
261 proxyCtx: ctx,
262 proxy: p,
263 })
264 return nil
为什么相信这条结论?查看 2 处证据
21 L1 · fact · monkey-observe-002
遥测采用 OTLP,但输出前做严格 allowlist 消毒
先看源码事实 OTLP exporter 用 batch processor;导出前过滤 span attributes、events、links 和 status,仅保留 task/session/request/VM、HTTP/RPC/DB 等允许字段,exception 只留类型并清空 status 描述。
翻译成白话 它不是把请求体、URL 和异常详情整包发给观测平台,而是先过一遍“只准这些字段出门”的白名单。
为什么这对自研重要 降低 prompt、token、密钥进入第三方 trace backend 的风险,但也牺牲部分排障细节。
固定提交源码摘录 复制
29 func Setup(ctx context.Context, cfg Config) (Shutdown, error) {
30 otel.SetTextMapPropagator(propagation.TraceContext{})
31 if !enabled(cfg) {
32 return func(context.Context) error { return nil }, nil
33 }
34
35 opts := []otlptracegrpc.Option{}
36 if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
37 if strings.Contains(endpoint, "://") {
38 opts = append(opts, otlptracegrpc.WithEndpointURL(endpoint))
39 } else {
40 opts = append(opts, otlptracegrpc.WithEndpoint(endpoint))
41 }
42 }
43 if cfg.Insecure {
44 opts = append(opts, otlptracegrpc.WithInsecure())
45 }
46
47 exporter, err := otlptracegrpc.New(ctx, opts...)
48 if err != nil {
49 return nil, err
50 }
51 tp, ok := otel.GetTracerProvider().(*sdktrace.TracerProvider)
52 if !ok {
53 _ = exporter.Shutdown(ctx)
… 1 lines omitted; exact range 29–65 …
55 }
56
57 processor := sdktrace.NewBatchSpanProcessor(
58 &resourceExporter{SpanExporter: exporter, resource: buildResource(cfg)},
59 sdktrace.WithMaxQueueSize(2048),
60 sdktrace.WithMaxExportBatchSize(512),
61 sdktrace.WithBatchTimeout(5*time.Second),
62 sdktrace.WithExportTimeout(5*time.Second),
63 )
64 tp.RegisterSpanProcessor(processor)
65 return processor.Shutdown, nil
为什么相信这条结论?查看 4 处证据
小练习 9 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M10 · ENGINEERING
测试、恢复与工程取舍:把漂亮机制变成可靠产品 哪些行为有测试证明?哪些只是配置或 prompt 约定?当安全、性能、可恢复性冲突时,源码选择了什么?
先用一个生活比喻 这像验收一座桥:图纸说明结构,测试证明承重,故障演练证明断电后还能不能让人安全回来。
本节阅读法 先问问题 → 读事实 → 看代码 → 做迁移判断
读源码时先问 本课的判断方式 这一层有没有独立证据? 没有就不把其他层的能力冒充成默认行为;回到固定提交继续追调用链。 谁拥有最终控制权? 区分模型输出、框架规则、用户审批和 OS 隔离四种不同力量。
这一章先建立通用概念 固定提交账本没有把该维度单独拆出,但它会在其他章节的源码路径中体现。先沿执行链路阅读,再回到报告页核对证据。
小练习 10 打开本节任意一个源码摘录,先遮住白话解释,只根据函数名、状态字段和调用顺序猜它解决什么问题;再展开证据列表,检查你的猜测有没有越过源码边界。
M11 · PRACTICE
把读懂变成会判断 下面的练习不要求你先写一个完整 Agent,而是训练你检查设计边界:事实是什么、推断是什么、如果换成自研产品要补哪一层。
Q1 它是多 CLI 的任务控制平面,不是第四套 Agent loop 问题: 如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?
参考答案 比较 Harness 时,应把平台编排能力与各 CLI 内核能力拆开计分。
证据:backend/pkg/taskflow/types.go:554 Q2 任务创建拆成数据库预登记、VM 创建、Redis 交接、运行态启动 问题: 如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?
参考答案 分阶段便于恢复和审计,但 Redis 交接键成为启动链路的关键依赖。
证据:backend/biz/task/usecase/task.go:556 Q3 Taskflow Create 失败被记录但吞掉,任务可能滞留 processing 问题: 如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?
参考答案 应把 Create 错误返回给生命周期管理器,并为 processing-without-session 增加 watchdog。
证据:backend/pkg/lifecycle/taskhook.go:104 Q4 隔离边界主要依赖仓库外 VM,Codex 内层 sandbox 明确关闭 问题: 如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?
参考答案 部署评审必须联审 codingmatrix/Taskflow 执行器,不能因类型名叫 VirtualMachine 就自动认定强隔离。
证据:backend/pkg/taskflow/types.go:72 Q5 远程协作以 owner write gate、历史回放和实时流为核心 问题: 如果把这段机制移植到你的 Agent,最先要确认哪个输入、状态或安全边界?
参考答案 它实现了人—Agent 远程接管,而不是多 Agent 共享计划或黑板。
证据:backend/biz/task/handler/v1/task.go:323
APPENDIX · SOURCE INDEX
本课读过的实现文件 文件索引帮助你在课程外继续追踪调用链;每一条路径来自固定提交的证据账本。
01 backend/pkg/taskflow/types.goL554–587, L626–640, L72–92, L122–139, L626–640, L626–640 02 backend/biz/task/usecase/task.goL875–928, L556–617, L622–687, L157–163, L585–590, L165–218, L253–299, L788–792, L795–803, L955–1002, L1004–1041, L982–1010, L741–767 03 backend/pkg/lifecycle/taskhook.goL92–130, L104–123, L129–136 04 backend/templates/codex.tmplL1–13, L1–6 05 backend/biz/task/handler/v1/task.goL323–384, L387–423, L640–721, L585–610 06 backend/templates/opencode.tmplL40–50, L27–40 07 backend/biz/llmproxy/proxy.goL160–185, L203–236, L28–34, L116–150, L246–264, L280–317 08 backend/biz/task/service/tasksummary.goL32–92, L121–145, L148–186 09 backend/biz/agentresource/resolver.goL13–29, L49–54 10 backend/biz/agentresource/types.goL99–126 11 backend/biz/agentresource/unpack.goL12–31, L38–98, L101–126 12 backend/biz/mcphub/auth/service.goL45–80, L22–28 13 backend/biz/mcphub/runtime/registry/service.goL56–80 14 backend/biz/mcphub/runtime/gateway/handler.goL144–185, L187–260, L263–267, L228–251 15 backend/biz/mcphub/billing/noop.goL9–20 16 backend/pkg/netguard/guard.goL53–69, L143–179 17 backend/pkg/netguard/guard_test.goL22–59 18 backend/config/server/config.yaml.exampleL7–10 19 backend/pkg/telemetry/telemetry.goL29–65, L95–107 20 backend/pkg/telemetry/sanitize.goL10–78, L102–127