﻿---
title: "把本地 Agent 变成平台上的 Bot：架构、现状与安全风险"
description: "阿里云万有无界、扣子、Multica 都支持把电脑上的 Claude Code、Codex 这类编程 agent 接进平台，让同事在群里 @ 它干活。顺着这个功能往下追：为什么要把 agent 留在本地，平台怎么找到一台没有公网 IP 的电脑，怎么把交互式 CLI 变成可调用的 worker，几个任务和你自己怎么共用同一个现场，agent 又怎么反过来以平台身份干活；最后看看现在都有谁在做，以及这件事和“给电脑装了个木马”到底差在哪里。"
pubDate: 2026-10-14
tags: ["AI", "Agent", "架构", "安全"]
clc: "TP18"
hero: "./hero.svg"
refs:
  - id: "1"
    title: "创建 Agent · 本地接入"
    url: "https://docs.wanyo.cn/agent/create"
    collect: "wanyo-agent-create"
    site: "万有无界文档"
  - id: "1b"
    title: "万有无界 - 企业多 Agent 协作平台"
    url: "https://www.qianwenai.com/agents/wanyou"
    collect: "qianwenai-agents-wanyou"
    site: "万有无界"
    note: "产品页"
  - id: "2"
    title: "接入本地 Agent"
    url: "https://docs.coze.cn/cozespace_local_agent"
    collect: "coze-cozespace-local-agent"
    site: "扣子文档"
  - id: "3"
    title: "How to run Claude Code as a Linear agent with Cyrus and Hookdeck CLI"
    url: "https://hookdeck.com/webhooks/platforms/how-to-run-claude-code-as-a-linear-agent-with-cyrus-and-hookdeck-cli.md"
    collect: "hookdeck-webhooks-platforms-how-to-run-claude-co"
    site: "Hookdeck"
  - id: "4"
    title: "App server"
    url: "https://learn.chatgpt.com/docs/app-server"
    collect: "chatgpt-app-server"
    site: "OpenAI Codex 文档"
  - id: "5"
    title: "Remote Control"
    url: "https://code.claude.com/docs/en/remote-control"
    collect: "claude-remote-control"
    site: "Claude Code 文档"
  - id: "6"
    title: "CLI_AND_DAEMON.md"
    url: "https://github.com/multica-ai/multica/blob/main/CLI_AND_DAEMON.md"
    collect: "github-multica-ai-multica-cli-and-daemon-md"
    site: "Multica"
  - id: "6b"
    title: "守护进程与运行时"
    url: "https://multica.ai/docs/daemon-runtimes"
    collect: "multica-daemon-runtimes"
    site: "Multica"
  - id: "7"
    title: "server/pkg/agent/agent.go"
    url: "https://github.com/multica-ai/multica/blob/main/server/pkg/agent/agent.go"
    collect: "github-multica-ai-multica-server-pkg-agent-agent"
    site: "Multica 源码"
  - id: "8"
    title: "项目资源"
    url: "https://multica.ai/docs/project-resources"
    collect: "multica-project-resources"
    site: "Multica 文档"
  - id: "9"
    title: "server/internal/daemon/execenv/runtime_config_sections.go"
    url: "https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/runtime_config_sections.go"
    collect: "github-multica-ai-multica-server-internal-daemon"
    site: "Multica 源码"
  - id: "9b"
    title: "AI 编程工具对照"
    url: "https://multica.ai/docs/providers"
    collect: "multica-providers"
    site: "Multica 文档"
  - id: "10"
    title: "认证令牌"
    url: "https://multica.ai/docs/auth-tokens"
    collect: "multica-auth-tokens"
    site: "Multica 文档"
  - id: "11"
    title: "Push events into a running session with channels"
    url: "https://code.claude.com/docs/en/channels"
    collect: "claude-channels"
    site: "Claude Code 文档"
  - id: "12"
    title: "本地纳管 Agent 使用说明"
    url: "https://help.aliyun.com/zh/agentcore/guidelines-for-the-use-of-local-nanoagent"
    collect: "aliyun-zh-agentcore-guidelines-for-the-use-of-lo"
    site: "阿里云 AgentCore 文档"
  - id: "13"
    title: "阿里云万有无界公测报道"
    url: "https://www.pingwest.com/w/317000"
    collect: "pingwest-w-317000"
    site: "品玩"
  - id: "13b"
    title: "agentclientprotocol.com"
    url: "https://agentclientprotocol.com/"
    collect: "agentclientprotocol"
    site: "ACP"
  - id: "13c"
    title: "Core Concepts and Components in A2A"
    url: "https://a2a-protocol.org/latest/topics/key-concepts/"
    collect: "a2a-protocol-latest-topics-key-concepts"
    site: "A2A Protocol"
    note: "A2A Server（Remote Agent）是一个暴露 HTTP 端点、实现 A2A 协议的 agent"
  - id: "14"
    title: "server/pkg/agent/claude.go"
    url: "https://github.com/multica-ai/multica/blob/main/server/pkg/agent/claude.go"
    collect: "github-multica-ai-multica-server-pkg-agent-claud"
    site: "Multica 源码"
  - id: "15"
    title: "安全模型"
    url: "https://multica.ai/docs/security-model"
    collect: "multica-security-model"
    site: "Multica 文档"
  - id: "16"
    title: "远程控制"
    url: "https://www.codebuddy.ai/docs/zh/cli/remote-control"
    site: "CodeBuddy 文档"
  - id: "17"
    title: "一条命令，把本地 AI 变成钉钉群内的机器人"
    url: "https://www.dingtalk-global.com/zh/news/activity/make-ai-help-team-work-dingtalk-dws-team-bot-260706"
    collect: "dingtalk-global-zh-activity-make-ai-help-team-wo"
    site: "钉钉"
  - id: "18"
    title: "server/internal/daemon/execenv/codex_memory.go"
    url: "https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/codex_memory.go"
    collect: "github-multica-ai-multica-server-internal-daemon-2"
    site: "Multica 源码"
  - id: "18b"
    title: "[Bug]: Reused Codex CODEX_HOME leaks stale memories into unrelated issue tasks"
    url: "https://github.com/multica-ai/multica/issues/3130"
    site: "GitHub · multica-ai/multica"
  - id: "19"
    title: "server/internal/daemon/local_directory.go"
    url: "https://github.com/multica-ai/multica/blob/main/server/internal/daemon/local_directory.go"
    collect: "github-multica-ai-multica-server-internal-daemon-3"
    site: "Multica 源码"
  - id: "20"
    title: "server/internal/daemon/execenv/local_worktree.go"
    url: "https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/local_worktree.go"
    collect: "github-multica-ai-multica-server-internal-daemon-4"
    site: "Multica 源码"
  - id: "21"
    title: "Worktrees"
    url: "https://learn.chatgpt.com/docs/environments/git-worktrees"
    collect: "chatgpt-environments-git-worktrees"
    site: "OpenAI Codex 文档"
  - id: "22"
    title: "Run parallel sessions with worktrees"
    url: "https://code.claude.com/docs/en/worktrees"
    collect: "claude-worktrees"
    site: "Claude Code 文档"
---

最近在阿里云的「万有无界」里看到一个功能，叫「本地接入」[^1][^1b]。平台给你一段 `curl` 命令，命令里带着一个 60 秒内有效的 token；在自己电脑的终端里执行它，保持终端别关，平台就能识别到你本机的 agent。接入之后，这个 agent 出现在平台的 Agent 列表里，「和云端 Agent 一样可对话、可拉入频道」，同事在频道里 @ 它，它就在你的电脑上开工。

<!-- TODO: 补一张万有无界「本地接入」界面或频道里 @ 本地 agent 的截图 -->

再往外看，这并不是一家的想法。字节的扣子 3.0 有 coze-bridge，Multica 有 `multica daemon`，钉钉有 `dws dev connect`，Anthropic 和 OpenAI 也各自给 Claude Code、Codex 做了远程能力。大家都在做同一件事：**把一个跑在个人电脑上的编程 agent，变成平台可以调用的 bot。**

这个方案最厉害的地方在于，本地的 agent 带着完整的上下文现场：你的代码、没提交的改动、它自己的会话历史、你登录好的各种工具。平台侧则可以直接把平台上的活派给它。但仔细想想，又有几个问题绕不过去：

1. 云端 agent 已经很多了，为什么还要把 agent 留在本地？
2. 一台躲在家用路由器后面、没有公网 IP 的电脑，平台是怎么找到它、给它派活的？
3. 这些 agent CLI 是给人在终端里用的交互程序，怎么把它们变成能被程序调用的 worker？
4. 好几个任务同时派下来，你自己也还在改代码，agent 是直接在你的目录里干，还是在一份副本里干？
5. 派活只是一半。agent 怎么反过来，以平台成员的身份在平台上干活？
6. 现在都有谁在做，做到了什么程度？
7. 最后也是最要紧的：这样一来，平台是不是可以像控制木马一样控制我的电脑？

本文就顺着这几个问题往下讲。架构部分主要以开源的 Multica 为例，因为它是少数把整条链路的源码和设计文档都公开了的实现。

---

## 一、本地上下文现场的价值

### 现象

先看云端 agent 是怎么干活的。把一个 issue 交给 GitHub Copilot 的云端 agent、Codex cloud 或者 Devin，它们的第一步几乎都一样：在厂商的云上开一个干净的沙箱，把仓库 clone 下来，装依赖，然后开始读代码。

这个流程对一个开源仓库的小 bug 很好用。可一旦换成我们日常真实的工作，问题就来了：

- 我本地有一半没提交的改动，想让 agent 接着往下改，云端看不到；
- 仓库要连公司内网的数据库和服务才能跑起来，云端沙箱连不进去；
- 我在本机登录好了 `gh`、`aws`、`kubectl`，配好了几个 MCP server，云端要重新配一遍凭据；
- 前天我和 Codex 在终端里聊了一下午，它已经摸清了这个模块的来龙去脉，云端的新会话什么都不知道。

### 分析：现场由什么组成

这些问题背后是同一件事：**agent 干活需要的不只是代码，而是一整个“现场”。** 把它拆开看：

| 现场要素 | 本地 agent | 云端沙箱 |
|---|---|---|
| 代码 | 眼前的 checkout，包括未提交改动、未跟踪文件，甚至几十 GB 的大仓库 | 每次重新 clone，只有已经推送的内容 |
| 会话 | CLI 自己的会话历史可以续上 | 大多每次全新开始 |
| 凭据与工具 | 本机已登录的 CLI、SSH key、MCP server、skills | 要单独注入 secret |
| 网络 | 能访问内网、VPN 后面的服务 | 通常只有公网 |
| 额度 | 用你自己的 ChatGPT / Claude 订阅 | 按 token 或 credits 另外计费 |

### 第一次尝试：把现场搬上云

最直觉的思路是把现场搬过去：给云端沙箱配 devcontainer、注入 secret、开 VPN 隧道。这能解决一部分问题，但总有搬不动的东西。未提交的改动要先推到某个分支，会话历史在本机的 `~/.codex` 里，内网要单独打通，每多一样东西，就多一份配置和一份泄露的风险。

### 解决：反过来，让 agent 留在现场

既然现场搬不动，那就反过来：**agent 留在现场，只把“派活”这件事搬上云。** 平台负责“谁、什么时候、干什么”，本地负责“在现场干”。

这正是万有无界、扣子和 Multica 的共同思路。扣子的文档说得最直白：「扣子只提供对话入口，Agent 本身仍运行在你自己电脑上，不会迁移到云端。」[^2]

把两条路放在一起看，差别就很清楚了。往云上搬，现场里的每样东西都要单独接一根管子；留在现场，跨过网络的只有派活这一件事。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 454" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="lab-scene-title lab-scene-desc">
<title id="lab-scene-title">把现场搬上云，还是让 agent 留在现场</title>
<desc id="lab-scene-desc">左边是你电脑上的现场：没提交的 calc.go、~/.codex 里的会话、~/.aws/credentials 和内网数据库。右上是把现场搬进云端沙箱的做法：代码要先推分支，凭据要注入 secret，内网要开 VPN，会话历史搬不过去。右下是平台只负责派活的做法：一条 MUL-42 的派活消息直接交给留在现场的 codex。</desc>
<defs>
<marker id="lab-scene-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="lab-scene-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="454" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="422" rx="8"/>
<text class="t-zone" x="36" y="44">你的电脑 · 现场</text>
<rect class="dd-zone" x="408" y="16" width="376" height="232" rx="8"/>
<text class="t-zone" x="428" y="44">云端沙箱 · 把现场搬过去</text>
<rect class="dd-zone" x="408" y="264" width="376" height="174" rx="8"/>
<text class="t-zone" x="428" y="292">平台 · 只负责派活</text>
<path class="dd-edge" d="M376 109 H418" marker-end="url(#lab-scene-arrow)"/>
<path class="dd-edge" d="M376 161 H418" marker-end="url(#lab-scene-arrow)"/>
<path class="dd-edge" d="M376 187 H418" marker-end="url(#lab-scene-arrow)"/>
<path class="dd-edge" d="M204 312 V206" marker-end="url(#lab-scene-arrow)"/>
<text class="t-note" x="216" y="262">直接读写</text>
<path class="dd-edge-acc" d="M424 330 H382" marker-end="url(#lab-scene-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="344" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H370 Q375.4 64.6 375.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="376" y2="96"/>
<text class="t-name" x="48" y="85">现场</text>
<text class="t-mono" x="360" y="85" text-anchor="end">~/work/shop-api</text>
<text class="t-mono" x="48" y="113">代码</text>
<text class="t-val" x="96" y="113">M internal/refund/calc.go</text>
<text class="t-note" x="360" y="113" text-anchor="end">未提交</text>
<line class="dd-hair" x1="32" y1="122" x2="376" y2="122"/>
<text class="t-mono" x="48" y="139">会话</text>
<text class="t-val" x="96" y="139">~/.codex/sessions/</text>
<text class="t-note" x="360" y="139" text-anchor="end">聊了一下午</text>
<line class="dd-hair" x1="32" y1="148" x2="376" y2="148"/>
<text class="t-mono" x="48" y="165">凭据</text>
<text class="t-val" x="96" y="165">~/.aws/credentials</text>
<text class="t-note" x="360" y="165" text-anchor="end">gh、kubectl 已登录</text>
<line class="dd-hair" x1="32" y1="174" x2="376" y2="174"/>
<text class="t-mono" x="48" y="191">网络</text>
<text class="t-val" x="96" y="191">pg.corp.internal:5432</text>
<text class="t-note" x="360" y="191" text-anchor="end">VPN 后面</text>
<rect class="dd-card" x="424" y="64" width="344" height="136" rx="6"/>
<path class="dd-card-head" d="M424.6 96 V70 Q424.6 64.6 430 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="424" y1="96" x2="768" y2="96"/>
<text class="t-name" x="440" y="85">沙箱</text>
<text class="t-mono" x="752" y="85" text-anchor="end">git clone acme/shop-api</text>
<text class="t-mono" x="440" y="113">代码</text>
<text class="t-val" x="488" y="113">git push origin wip</text>
<text class="t-note" x="752" y="113" text-anchor="end">先推到分支</text>
<line class="dd-hair" x1="424" y1="122" x2="768" y2="122"/>
<text class="t-mono" x="440" y="139">会话</text>
<line class="dd-hair" x1="424" y1="148" x2="768" y2="148"/>
<text class="t-mono" x="440" y="165">凭据</text>
<text class="t-val" x="488" y="165">AWS_SECRET_ACCESS_KEY</text>
<text class="t-note" x="752" y="165" text-anchor="end">注入 secret</text>
<line class="dd-hair" x1="424" y1="174" x2="768" y2="174"/>
<text class="t-mono" x="440" y="191">网络</text>
<text class="t-val" x="488" y="191">wg-quick up corp</text>
<text class="t-note" x="752" y="191" text-anchor="end">打通内网</text>
<rect class="dd-derived" x="484" y="126" width="268" height="18" rx="6"/>
<text class="t-note" x="496" y="139">新会话，之前聊过的都不知道</text>
<text class="t-say" x="428" y="228">虚线：会话历史留在本机，沙箱里只能从头开始。</text>
<rect class="dd-card" x="424" y="312" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 344 V318 Q424.6 312.6 430 312.6 H762 Q767.4 312.6 767.4 318 V344 Z"/>
<line class="dd-edge" x1="424" y1="344" x2="768" y2="344"/>
<text class="t-name" x="440" y="333">派活</text>
<text class="t-mono t-acc" x="752" y="333" text-anchor="end">MUL-42</text>
<text class="t-mono" x="440" y="361">频道</text>
<text class="t-val" x="488" y="361">#shop-backend</text>
<line class="dd-hair" x1="424" y1="370" x2="768" y2="370"/>
<text class="t-mono" x="440" y="387">内容</text>
<text class="t-val" x="488" y="387">退款金额少算了运费</text>
<line class="dd-hair" x1="424" y1="396" x2="768" y2="396"/>
<text class="t-mono" x="440" y="413">指派</text>
<text class="t-val" x="488" y="413">本地 agent · codex</text>
<rect class="dd-card" x="32" y="312" width="344" height="36" rx="6"/>
<text class="t-name" x="48" y="335">codex</text>
<text class="t-mono" x="104" y="335">在 ~/work/shop-api 里处理 <tspan class="t-acc">MUL-42</tspan></text>
<text class="t-say" x="36" y="384">跨过网络的只有这一条派活消息。</text>
</svg>
</div>
<figcaption>图 1 · 把现场搬上云，每样东西都要单独接一根管子，会话历史还搬不过去；让 agent 留在现场，跨过网络的只剩一条派活消息</figcaption>
</figure>

**可是平台在云上，电脑在家里的路由器后面，平台要怎么找到它？**

---

## 二、平台与本地的连接

### 现象

一台普通的开发机，通常没有公网 IP。它在家用路由器或公司防火墙的 NAT 后面，外面的服务器根本没法主动连进来。

### 第一次尝试：开端口或打隧道

最直接的办法是让本地 agent 当服务端：开一个端口，或者用 Cloudflare Tunnel、ngrok 之类的工具打一条隧道，让平台通过 webhook 推任务进来。

社区里确实有人这么做。比如自托管 Cyrus（把 Linear 的 issue 派给本地 Claude Code 的开源项目）时，就需要一条隧道来接收 Linear 的 webhook[^3]。Codex 的 `app-server` 也能监听 WebSocket 端口。

但这条路的问题很明显：你等于在自己电脑上开了一个对外的入口，鉴权全靠自己做好。Codex 的文档就专门提醒过，app-server 的 WebSocket 传输还是“experimental and unsupported”，而且「Non-loopback WebSocket listeners currently allow unauthenticated connections by default during rollout」[^4]，也就是说，非回环地址上的监听在推出阶段默认不做鉴权。

### 解决：由本地主动连出去

几乎所有成熟的方案都选了反方向：**不让平台连进来，而是让本地进程主动连出去，然后一直保持这条连接。** 出站连接能穿过 NAT 和绝大多数防火墙，本机也不用开任何入站端口。

Anthropic 对 Claude Code Remote Control 的描述就是这个模型：「Your local Claude Code session makes outbound HTTPS requests only and never opens inbound ports on your machine. When you start Remote Control, it registers with the Anthropic API and polls for work.」[^5]

剩下的问题是：一条出站连接，平台怎么知道它是“你的”？答案是**配对**。平台先生成一个短期有效的一次性 token，你在本机执行带着这个 token 的命令，本地进程用它向平台证明身份，换来一个长期凭据。万有无界的 token 有效 60 秒，扣子是 60 分钟[^2]，Multica 用 `multica setup` 完成同样的事。

Multica 的守护进程（daemon）把这套机制做得比较完整[^6][^6b]：

- daemon 启动后和服务端保持一条 **WebSocket** 连接。有新任务时，服务端只通过它发一个“唤醒”信号，daemon 再主动来领取任务。
- 万一 WebSocket 断了，daemon 还会每 **30 秒**轮询一次作为兜底，保证任务不会因为一次断线永远卡在队列里。
- daemon 每 **15 秒**发一次心跳，服务端据此判断这台电脑是否在线。

为什么出站就行、入站就不行？关键在路由器的 NAT 表。家里所有设备共用一个公网地址，路由器只有在里面的设备先发起连接时，才会记下一条“公网端口对应哪台内网机器的哪个端口”的映射。从外面主动推进来的请求查不到映射，路由器不知道该转给谁，只能丢掉（除非你在路由器上手动配了端口转发，那就是上面“开端口”的那条路）。而 daemon 连出去的那条 WebSocket 一直不断，映射也就一直在，平台的唤醒信号顺着它就能回到你的电脑上。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 456" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="lab-nat-title lab-nat-desc">
<title id="lab-nat-title">NAT 只放行从里面发起的连接</title>
<desc id="lab-nat-desc">平台直接推到路由器公网地址 8080 端口的 webhook 没有映射，被 NAT 丢弃。daemon 带着 mul_ 令牌主动连出去，GET /api/daemon/ws 建立 WebSocket，路由器记下 :51234 到 :40112 的映射；平台的 daemon:task_available 唤醒信号顺着这条映射回到 daemon，daemon 再主动 POST 领取任务，并每 15 秒发一次心跳。</desc>
<defs>
<marker id="lab-nat-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="lab-nat-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="456" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="424" rx="8"/>
<text class="t-zone" x="36" y="44">平台 · api.multica.ai</text>
<rect class="dd-zone" x="264" y="16" width="272" height="424" rx="8"/>
<text class="t-zone" x="284" y="44">路由器 · NAT</text>
<rect class="dd-zone" x="552" y="16" width="232" height="424" rx="8"/>
<text class="t-zone" x="572" y="44">你的电脑 · 192.168.1.23</text>
<path class="dd-edge dd-dash" d="M232 124 H372"/>
<line class="dd-edge" x1="378" y1="118" x2="390" y2="130"/>
<line class="dd-edge" x1="378" y1="130" x2="390" y2="118"/>
<text class="t-note" x="400" y="148" text-anchor="middle">没有映射，丢弃</text>
<path class="dd-edge-acc" d="M568 180 H238" marker-end="url(#lab-nat-arrow-acc)"/>
<text class="t-note" x="400" y="204" text-anchor="middle">出站，放行，记下 <tspan class="t-mono t-acc">:51234 ⇄ :40112</tspan></text>
<path class="dd-edge-acc" d="M232 236 H562" marker-end="url(#lab-nat-arrow-acc)"/>
<text class="t-note" x="400" y="260" text-anchor="middle">命中映射，转给 <tspan class="t-mono t-acc">192.168.1.23:51234</tspan></text>
<path class="dd-edge" d="M568 292 H238" marker-end="url(#lab-nat-arrow)"/>
<text class="t-note" x="400" y="316" text-anchor="middle">出站，放行</text>
<path class="dd-edge" d="M568 348 H238" marker-end="url(#lab-nat-arrow)"/>
<text class="t-note" x="400" y="372" text-anchor="middle">出站，放行</text>
<rect class="dd-card" x="32" y="64" width="200" height="312" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H226 Q231.4 64.6 231.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="232" y2="96"/>
<text class="t-name" x="48" y="85">Multica 服务端</text>
<line class="dd-hair" x1="32" y1="152" x2="232" y2="152"/>
<line class="dd-hair" x1="32" y1="208" x2="232" y2="208"/>
<line class="dd-hair" x1="32" y1="264" x2="232" y2="264"/>
<line class="dd-hair" x1="32" y1="320" x2="232" y2="320"/>
<text class="t-mono" x="48" y="120">POST 203.0.113.7:8080</text>
<text class="t-note" x="48" y="140">想直接把任务推进来</text>
<text class="t-val" x="48" y="176">凭 mul_… 认出是你</text>
<text class="t-note" x="48" y="196">连接一直保持</text>
<text class="t-mono" x="48" y="232">daemon:task_available</text>
<text class="t-note" x="48" y="252">只是一个唤醒信号</text>
<text class="t-val" x="48" y="288">返回任务内容</text>
<text class="t-val" x="48" y="344">据此判断在不在线</text>
<rect class="dd-card" x="568" y="64" width="200" height="312" rx="6"/>
<path class="dd-card-head" d="M568.6 96 V70 Q568.6 64.6 574 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="568" y1="96" x2="768" y2="96"/>
<text class="t-name" x="584" y="85">multica daemon</text>
<line class="dd-hair" x1="568" y1="152" x2="768" y2="152"/>
<line class="dd-hair" x1="568" y1="208" x2="768" y2="208"/>
<line class="dd-hair" x1="568" y1="264" x2="768" y2="264"/>
<line class="dd-hair" x1="568" y1="320" x2="768" y2="320"/>
<rect class="dd-derived" x="580" y="104" width="176" height="40" rx="6"/>
<text class="t-note" x="592" y="128">监听 :8080（要自己开）</text>
<text class="t-mono" x="584" y="176">GET /api/daemon/ws</text>
<text class="t-val" x="584" y="196">Bearer mul_…</text>
<text class="t-val" x="584" y="232">收到唤醒</text>
<text class="t-note" x="584" y="252">顺着自己连出去的那条线</text>
<text class="t-mono" x="584" y="288">POST …/tasks/claim</text>
<text class="t-note" x="584" y="308">主动来领任务</text>
<text class="t-mono" x="584" y="344">POST …/heartbeat</text>
<text class="t-note" x="584" y="364">每 15s；WS 断了每 30s 轮询</text>
<text class="t-say" x="284" y="408">NAT 只放行从里面发起的连接，</text>
<text class="t-say" x="284" y="428">唤醒信号顺着这条连接回来。</text>
<text class="t-say" x="572" y="408">虚线：要让平台连进来，</text>
<text class="t-say" x="572" y="428">就得在本机开端口或打隧道。</text>
</svg>
</div>
<figcaption>图 2 · 平台推进来的请求在 NAT 上找不到映射，只能被丢掉；daemon 主动连出去的 WebSocket 在路由器上留下一条映射，唤醒信号就顺着它回来</figcaption>
</figure>

这里有个容易忽略的细节：**电脑离线了怎么办？** 万有无界和扣子的处理很简单：关掉终端或电脑休眠，agent 就掉线，要重新走一遍接入流程[^1][^1b][^2]。Multica 则让任务在队列里等：只有当运行时的心跳缺席超过一个宽限期，**并且**这条任务自己也已经排队这么久，它才会失败[^6][^6b]。这样你合上笔记本去开会，回来打开电脑，积压的活会自动接着干。

**连上了，平台说“去干活”。可是 agent CLI 是给人用的交互式终端程序，daemon 要怎么驱动它？**

---

## 三、交互式 CLI 的程序化驱动

### 现象

Codex 和 Claude Code 生来是给人用的：你在终端里打字，它回答、提议改代码、问你要不要执行某条命令。daemon 没有手，也没法盯着屏幕。

### 分析：每个 CLI 都留了一个“无头”入口

好在这些 CLI 都提供了给程序调用的接口，只是各家不一样：

- Claude Code 可以用 `-p` 以非交互模式运行，并以 `stream-json` 的格式把每一步输出成结构化事件；
- Codex 提供 `app-server`，一套 JSON-RPC 2.0 协议：`thread/start` 新建一个会话线程，`turn/start` 发起一轮任务，`turn/steer` 在一轮任务进行中插话[^4]；
- 还有不少工具实现了 ACP（Agent Client Protocol，Zed 牵头的编辑器与 agent 之间的协议）。

如果平台想同时支持很多家 CLI，就需要一层适配。Multica 支持 26 种 agent CLI，它的做法是把差异收敛到一个只有一个方法的 Go 接口里：

```go
type Backend interface {
    Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
}
```

源码注释写明，这个设计照搬了开源项目 happy-cli 的 AgentBackend 模式[^7]。真正的复杂度都藏在 `ExecOptions` 里：工作目录、模型、思考强度、要续上的会话 ID、MCP 配置，以及一大堆超时（握手超时、首轮无进展超时、空闲超时、中断确认超时）。每一个超时背后，大概都有一次某家 CLI 卡死的经历。

**能驱动 CLI 了，下一个问题是：它该在哪个目录里干活？**

---

## 四、现场的并发与隔离

### 现象

第一节的结论是 agent 要留在现场。最直接的理解，就是让 daemon 把 CLI 的工作目录设成你的仓库，比如 `~/work/shop-api`。

可一旦它成了平台上的 bot，情况就和你自己在终端里用它不一样了。设想这样一个下午：你正改着 `internal/refund/calc.go`，还没提交；同事在频道里 @ 它修 MUL-42（退款金额算错了）；几分钟后，另一个同事又 @ 它查 MUL-43。三方同时在写同一份工作副本：

- 两个任务的改动交错落在同一棵目录树上，`git status` 里分不清哪一行是谁改的；
- 一个任务为了跑测试执行了 `git stash` 或者切了分支，另一个任务脚下的文件就变了；
- 你自己的半成品也混在里面，agent 可能顺手把它“修”掉，也可能把它当成需求的一部分。

这里其实有两个问题：**任务和任务之间要隔离，任务和你之间也要隔离。**

### 第一次尝试：一个目录同一时间只给一个任务

最省事的办法是排队。Multica 的 `in_place`（原地）模式就是这么做的：daemon 给每个目录挂一把锁，同一时间只让一个任务进去，后到的任务进入 `waiting_local_directory` 状态等待[^8]。锁按解析完符号链接之后的真实路径来加，两条不同的路径指向同一个目录，也会排进同一个队列。daemon 还拒绝把 `/`、`/Users`、`/home` 或者你的 home 目录本身当作工作目录，因为那等于把整个账号都交给了 agent[^19]。

排队解决了任务之间打架，却带来两个新问题。

第一是慢。前一个任务要跑 20 分钟的构建，后面的人就得等 20 分钟。Multica 在 issue #7344 里就踩过这个坑：连一轮只读的聊天都排在了构建后面，后来只好把聊天从锁里豁免出去[^19]。

第二个更根本：**这把锁锁不住你。** 源码注释说得很直白，这把锁只防“两个长时间运行的编码任务把改动交错写进同一棵树”，它从来不是通用的写屏障，也成不了，因为你的编辑器、终端和脚本一直在不加锁地写同一个目录[^19]。文档也提醒，原地模式下智能体会直接看到并修改当前分支和未提交的文件，Multica 不会替你切分支、stash、commit 或者开 PR[^8]。任务结束后，它的改动和你的半成品混在同一个工作副本里，中间没有一条清楚的边界。

### 第二次尝试：每个任务一份干净的副本

要隔离，最顺手的工具是 git worktree。同一个仓库可以同时检出多棵工作树，每棵在自己的目录、自己的分支上，共用同一个对象库。给每个任务 `git worktree add` 一棵，任务之间互不干扰，也碰不到你的目录。

Multica 关联 GitHub 仓库时，默认就是这么做的：在本机 clone 一份缓存，每次运行开一棵 worktree，同一个仓库上的运行可以随意并发[^8]。agent 仍然跑在你的电脑上，凭据、内网、CLI 的登录状态这些**机器层面**的现场都还在。

但 `git worktree add` 检出的是 `HEAD`。你还没提交的那半个 `calc.go`、刚写好还没 `git add` 的 `scripts/backfill.sql`，在新的工作树里统统不存在。agent 会去 review 一份你已经不用的代码，第一节说的**目录层面**的现场又丢了。

这也是为什么 Multica 在文档里把“直接关联本地目录”称为「逃生舱，不是更方便的默认项」，它举的典型用户是一个 checkout 就几十 GB、没法每次重新 clone 的游戏项目[^8]。目录层面的现场最有价值，也最难安全地用起来。

### 解决：副本从你眼前的状态开始

Multica 的 `worktree`（并行）模式把前两次尝试拼在了一起：**用 worktree 拿到隔离，再把你的目录原样回放进去，拿回现场。** 它分三步走[^20]：

1. **给你的目录拍一张快照。** daemon 把 `GIT_INDEX_FILE` 指向自己工作区里的一个临时 index，在上面执行 `git add -A` 和 `git write-tree`，再用 `git commit-tree -p HEAD` 生成一个提交。这个提交的树就是你此刻的目录：已跟踪文件的修改、未跟踪的新文件、删除和权限变化都在里面。整个过程只往对象库里写数据，不碰你的 index、文件和 stash，也不用去抢 `.git/index.lock`。
2. **在 worktree 里回放快照。** daemon 在自己的目录（默认是 `~/multica_workspaces/`）下开一棵 worktree，检出分支 `agent/<agent>/<issue>`，再用 `git cherry-pick --no-commit` 把快照放进去。文档的原话是：「智能体从你眼前的状态开始，而不是从 `HEAD` 开始。」[^8]
3. **把结果交付成一个分支。** agent 跑完后，它留下的未提交改动会被自动提交到这个分支上，然后 worktree 被删掉。你用 `git log`、`git diff` 审阅，自己决定 merge 还是 cherry-pick，Multica 不替你合并。如果连这次提交都做不了，比如仓库开了 `commit.gpgSign` 而 daemon 拿不到签名密钥，worktree 会被刻意保留下来，不会连同改动一起删掉。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 464" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="lab-wt-title lab-wt-desc">
<title id="lab-wt-title">worktree 模式：从快照开始，交付成分支</title>
<desc id="lab-wt-desc">daemon 用私有 index 对你的目录执行 git add -A 和 commit-tree，生成快照提交 a71c0de，它的树就是你眼前的目录，包括没提交的 calc.go 和新文件 backfill.sql，但不包括被忽略的 .env。快照被 cherry-pick 进 daemon 工作区里的 worktree，agent 从这里开始改；跑完后改动自动提交到分支 agent/codex/mul-42。同一时间，另一个任务在 agent/codex/mul-43 的 worktree 里并行。你的工作副本只被读过。</desc>
<defs>
<marker id="lab-wt-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="lab-wt-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="464" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="416" height="432" rx="8"/>
<text class="t-zone" x="36" y="44">你的仓库 · ~/work/shop-api</text>
<rect class="dd-zone" x="448" y="16" width="336" height="432" rx="8"/>
<text class="t-zone" x="468" y="44">daemon 的工作区 · ~/multica_workspaces</text>
<path class="dd-edge-acc" d="M224 200 V258" marker-end="url(#lab-wt-arrow-acc)"/>
<text class="t-mono" x="236" y="234">git add -A → commit-tree</text>
<path class="dd-edge-acc" d="M416 309 H432 Q440 309 440 301 V117 Q440 109 448 109 H458" marker-end="url(#lab-wt-arrow-acc)"/>
<text class="t-mono" x="456" y="200">cherry-pick --no-commit</text>
<path class="dd-edge" d="M700 174 V327 Q700 335 692 335 H422" marker-end="url(#lab-wt-arrow)"/>
<text class="t-note" x="688" y="262" text-anchor="end">交付成分支</text>
<rect class="dd-card" x="32" y="64" width="384" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H410 Q415.4 64.6 415.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="416" y2="96"/>
<text class="t-name" x="48" y="85">工作副本</text>
<text class="t-note" x="400" y="85" text-anchor="end">你正改到一半</text>
<text class="t-mono" x="48" y="113">HEAD</text>
<text class="t-val" x="96" y="113">main @ 3f9e1c2</text>
<line class="dd-hair" x1="32" y1="122" x2="416" y2="122"/>
<text class="t-mono" x="48" y="139">M</text>
<text class="t-val" x="96" y="139">internal/refund/calc.go</text>
<text class="t-note" x="400" y="139" text-anchor="end">没提交</text>
<line class="dd-hair" x1="32" y1="148" x2="416" y2="148"/>
<text class="t-mono" x="48" y="165">??</text>
<text class="t-val" x="96" y="165">scripts/backfill.sql</text>
<text class="t-note" x="400" y="165" text-anchor="end">新文件</text>
<line class="dd-hair" x1="32" y1="174" x2="416" y2="174"/>
<text class="t-mono" x="48" y="191">!!</text>
<text class="t-val" x="96" y="191">.env</text>
<text class="t-note" x="400" y="191" text-anchor="end">被 .gitignore 忽略</text>
<rect class="dd-card" x="32" y="264" width="384" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 296 V270 Q32.6 264.6 38 264.6 H410 Q415.4 264.6 415.4 270 V296 Z"/>
<line class="dd-edge" x1="32" y1="296" x2="416" y2="296"/>
<text class="t-name" x="48" y="285">.git</text>
<text class="t-note" x="400" y="285" text-anchor="end">两边共用同一个对象库</text>
<text class="t-mono" x="48" y="313">快照</text>
<text class="t-mono t-acc" x="96" y="313">a71c0de</text>
<text class="t-note" x="400" y="313" text-anchor="end">tree = 你眼前的目录</text>
<line class="dd-hair" x1="32" y1="322" x2="416" y2="322"/>
<text class="t-mono" x="48" y="339">分支</text>
<text class="t-val" x="96" y="339">agent/codex/mul-42</text>
<text class="t-note" x="400" y="339" text-anchor="end">交付在这里</text>
<line class="dd-hair" x1="32" y1="348" x2="416" y2="348"/>
<text class="t-mono" x="48" y="365">记录</text>
<text class="t-val" x="96" y="365">refs/multica/local-state/…</text>
<text class="t-note" x="400" y="365" text-anchor="end">增量基准</text>
<rect class="dd-card" x="464" y="64" width="304" height="110" rx="6"/>
<path class="dd-card-head" d="M464.6 96 V70 Q464.6 64.6 470 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="464" y1="96" x2="768" y2="96"/>
<text class="t-name" x="480" y="85">worktree</text>
<text class="t-mono" x="752" y="85" text-anchor="end">agent/codex/mul-42</text>
<text class="t-mono" x="480" y="113">起点</text>
<text class="t-mono t-acc" x="528" y="113">a71c0de</text>
<text class="t-note" x="752" y="113" text-anchor="end">而不是 HEAD</text>
<line class="dd-hair" x1="464" y1="122" x2="768" y2="122"/>
<text class="t-mono" x="480" y="139">agent</text>
<text class="t-val" x="528" y="139">M internal/refund/calc.go</text>
<line class="dd-hair" x1="464" y1="148" x2="768" y2="148"/>
<text class="t-mono" x="480" y="165">收尾</text>
<text class="t-val" x="528" y="165">没提交的改动自动 commit</text>
<rect class="dd-card" x="464" y="360" width="304" height="36" rx="6"/>
<text class="t-name" x="480" y="383">worktree</text>
<text class="t-mono" x="552" y="383">agent/codex/mul-43</text>
<text class="t-note" x="752" y="383" text-anchor="end">同时在跑</text>
<text class="t-say" x="36" y="404">你的工作副本只被读过：文件、index、stash 都没动。</text>
<text class="t-say" x="468" y="428">被忽略的 .env 不进快照，副本里没有它。</text>
</svg>
</div>
<figcaption>图 3 · 快照把你眼前的目录拍成一个提交，回放进独立的 worktree；agent 从这里开始干，结果交付成一个分支，你的工作副本从头到尾只被读过</figcaption>
</figure>

同一个 issue 的下一轮评论来了，又会怎样？daemon 会重新检出同一个分支，让 agent 站在上一轮的成果上接着干。这时不能把你的整个目录再回放一遍，否则上一轮已经带进去的改动会被重复提出，和 agent 改过的同一行撞车。所以 Multica 在你的仓库里给每个分支留了一个隐藏 ref `refs/multica/local-state/<branch>`，记下上一轮看到的目录状态，下一轮只回放“上一轮到现在”你新改的部分[^20]。

如果你新改的行恰好 agent 也改过，git 判断不了谁对，这一轮就带着冲突启动，由 agent 先把合并做完。冲突没解决，这一轮就什么都不交付，下一轮再把同样的改动送上来[^8]。宁可失败，也不悄悄丢掉你的修改。

副本也有它的代价。快照用的是 `git add -A`，被 `.gitignore` 忽略的文件不会被带过去：`.env` 不在，`node_modules` 也不在，agent 要么重新装依赖，要么就缺一份配置。未跟踪的文件超过 2000 个或 200 MiB，任务会直接失败，而不是从一棵你认不出来的树开始[^8]。

### 别家怎么选

这个两难不是 Multica 一家的。从 IM 和平台接进来的方案，大多默认原地干活。Claude Code 的 Remote Control 以服务模式运行时，默认 `--spawn same-dir`，文档直说「all sessions share the current working directory, so they can conflict if editing the same files」，需要时可以切成每个会话一棵 worktree[^5]。阿里云 AgentCore 的本地纳管默认在执行连接命令的那个目录里干活，不同会话默认可以同时跑 2 个，文档没有提到隔离[^12]。

桌面上的编排工具则普遍默认一个任务一棵 worktree，分歧在于带不带你没提交的改动。Codex app 会带过去：「If you chose a branch with local changes, Codex applies the uncommitted changes to the worktree as well.」[^21] Claude Code 的 `--worktree` 默认从仓库的默认分支切出，只检出已跟踪的文件[^22]。至于 `.env` 这类被忽略的文件，两家都用一个 `.worktreeinclude` 文件列出需要额外复制过去的内容[^21][^22]。

把三种做法放在一起对比：

| | 原地（`in_place`） | 干净的副本 | 回放现场的 worktree |
|---|---|---|---|
| 看得到没提交的改动 | 能 | 不能 | 能，被忽略的文件除外 |
| 多个任务同时跑 | 排队 | 可以 | 可以 |
| 会不会写你的工作副本 | 会 | 不会 | 不会 |
| 结果在哪 | 混在你的工作副本里 | 一个分支 | 一个分支 |

### 现场的最后一块：会话

会话也是现场的一部分。Multica 会保存 CLI 返回的会话 ID（Codex 的 thread、Claude Code 的 session），同一个 issue 的下一次运行会被路由到能访问这段会话的那台电脑上去续接[^9][^9b]。在 worktree 模式下，它和分支一起构成了一条连续的工作线：分支记着代码改到了哪，会话记着 agent 想到了哪。

**到这里，平台已经能把活派给本地 agent，agent 也能在现场把活干完。可是干完之后呢？**

---

## 五、agent 反向操作平台

### 现象

“派活”只是平台到 agent 的一个方向。一个真正的“同事”还要能反过来在平台上干活：中途汇报进展，把任务状态改成“待审核”，拆出子任务，@ 另一个 agent 帮忙。否则它只是一个被远程打开的终端，而不是平台上的成员。

### 第一次尝试：把最终输出贴回去

最简单的办法是等 CLI 跑完，把它的最终输出当成一条回复贴回平台。很多 IM 桥接项目就是这么做的。

问题是这只能“一问一答”：agent 没法在干到一半时汇报，没法修改平台上的对象，更没法把活转交给别的 agent。而且平台也分不清这条回复是谁、凭什么权限写的。

### 解决：同一个 CLI，加一张一次性的工牌

Multica 的做法分三步，都可以在源码里找到[^9][^9b][^10]：

1. **告诉 agent 它在哪。** 每次运行前，daemon 在工作目录里写一份说明文件。按 CLI 的不同，它叫 `CLAUDE.md`、`AGENTS.md` 或其他名字，开头第一句是：「You are a coding agent in the Multica platform. Use the `multica` CLI to interact with the platform.」
2. **给它和人一样的工具。** agent 用的是人也在用的那个 `multica` CLI：`multica issue comment add` 发评论，`multica issue status` 改状态，`multica issue wakeup create` 设置一个稍后再来干活的提醒。
3. **给它一张只对这一次运行有效的工牌。** daemon 领取任务时，服务端签发一个以 `mat_` 开头的临时令牌 `MULTICA_TOKEN`，它绑定当前的用户、工作区、智能体和这次运行，最长有效 24 小时，运行结束就清理掉[^10]。agent 用它写回的每一条评论，都记在这个智能体的名下。

下面这张图把整个往返串起来了。顺着强调色的那条线，可以看到这张工牌是怎么从平台签发、经由你的电脑、最后又回到平台上留下痕迹的。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="lab-loop-title lab-loop-desc">
<title id="lab-loop-title">一次派活的完整往返</title>
<desc id="lab-loop-desc">Issue MUL-42 指派给智能体后在平台上产生一条 Run，并签发运行级令牌 mat_9c…；你电脑上的守护进程通过出站连接领取这条 Run，把说明文件和令牌写进工作目录，启动 Codex；Codex 调用 multica CLI，用同一个令牌把评论和状态写回 MUL-42。</desc>
<defs>
<marker id="lab-loop-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="lab-loop-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="440" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="272" height="408" rx="8"/>
<text class="t-zone" x="36" y="44">平台（云端）</text>
<rect class="dd-zone" x="304" y="16" width="480" height="408" rx="8"/>
<text class="t-zone" x="324" y="44">你的电脑 · 用户 dev</text>
<path class="dd-edge" d="M152 184 V226" marker-end="url(#lab-loop-arrow)"/>
<text class="t-note" x="164" y="212">指派后入队</text>
<path class="dd-edge-acc" d="M272 288 H314" marker-end="url(#lab-loop-arrow-acc)"/>
<text class="t-note" x="296" y="278" text-anchor="middle">领取</text>
<path class="dd-edge-acc" d="M520 288 H562" marker-end="url(#lab-loop-arrow-acc)"/>
<text class="t-note" x="544" y="278" text-anchor="middle">写入</text>
<path class="dd-edge-acc" d="M668 232 V190" marker-end="url(#lab-loop-arrow-acc)"/>
<text class="t-note" x="680" y="216">启动</text>
<path class="dd-edge-acc" d="M568 128 H526" marker-end="url(#lab-loop-arrow-acc)"/>
<text class="t-note" x="544" y="118" text-anchor="middle">调用</text>
<path class="dd-edge-acc" d="M320 128 H278" marker-end="url(#lab-loop-arrow-acc)"/>
<text class="t-note" x="296" y="118" text-anchor="middle">写回</text>
<rect class="dd-card" x="32" y="72" width="240" height="112" rx="6"/>
<path class="dd-card-head" d="M32.6 104 V78 Q32.6 72.6 38 72.6 H266 Q271.4 72.6 271.4 78 V104 Z"/>
<line class="dd-edge" x1="32" y1="104" x2="272" y2="104"/>
<text class="t-name" x="48" y="93">Issue</text>
<text class="t-mono" x="256" y="93" text-anchor="end">MUL-42</text>
<text class="t-mono" x="48" y="121">assignee</text>
<text class="t-val" x="136" y="121">Codex 工程师</text>
<line class="dd-hair" x1="32" y1="130" x2="272" y2="130"/>
<text class="t-mono" x="48" y="147">status</text>
<text class="t-val" x="136" y="147">in_review</text>
<line class="dd-hair" x1="32" y1="156" x2="272" y2="156"/>
<text class="t-mono" x="48" y="173">comment</text>
<text class="t-val" x="136" y="173">by Codex 工程师</text>
<rect class="dd-card" x="32" y="232" width="240" height="112" rx="6"/>
<path class="dd-card-head" d="M32.6 264 V238 Q32.6 232.6 38 232.6 H266 Q271.4 232.6 271.4 238 V264 Z"/>
<line class="dd-edge" x1="32" y1="264" x2="272" y2="264"/>
<text class="t-name" x="48" y="253">Run</text>
<text class="t-mono" x="256" y="253" text-anchor="end">task 7f3a…</text>
<text class="t-mono" x="48" y="281">status</text>
<text class="t-val" x="136" y="281">running</text>
<line class="dd-hair" x1="32" y1="290" x2="272" y2="290"/>
<text class="t-mono" x="48" y="307">token</text>
<text class="t-acc t-mono" x="136" y="307">mat_9c…</text>
<line class="dd-hair" x1="32" y1="316" x2="272" y2="316"/>
<text class="t-mono" x="48" y="333">expires</text>
<text class="t-val" x="136" y="333">≤ 24h</text>
<rect class="dd-card" x="320" y="72" width="200" height="112" rx="6"/>
<path class="dd-card-head" d="M320.6 104 V78 Q320.6 72.6 326 72.6 H514 Q519.4 72.6 519.4 78 V104 Z"/>
<line class="dd-edge" x1="320" y1="104" x2="520" y2="104"/>
<text class="t-name" x="336" y="93">multica CLI</text>
<text class="t-note" x="504" y="93" text-anchor="end">以智能体身份</text>
<text class="t-mono" x="336" y="121">comment</text>
<text class="t-val" x="408" y="121">add MUL-42</text>
<line class="dd-hair" x1="320" y1="130" x2="520" y2="130"/>
<text class="t-mono" x="336" y="147">status</text>
<text class="t-val" x="408" y="147">in_review</text>
<line class="dd-hair" x1="320" y1="156" x2="520" y2="156"/>
<text class="t-mono" x="336" y="173">auth</text>
<text class="t-acc t-mono" x="408" y="173">mat_9c…</text>
<rect class="dd-card" x="568" y="72" width="200" height="112" rx="6"/>
<path class="dd-card-head" d="M568.6 104 V78 Q568.6 72.6 574 72.6 H762 Q767.4 72.6 767.4 78 V104 Z"/>
<line class="dd-edge" x1="568" y1="104" x2="768" y2="104"/>
<text class="t-name" x="584" y="93">Codex</text>
<text class="t-mono" x="752" y="93" text-anchor="end">app-server</text>
<text class="t-mono" x="584" y="121">thread</text>
<text class="t-val" x="664" y="121">resume</text>
<line class="dd-hair" x1="568" y1="130" x2="768" y2="130"/>
<text class="t-mono" x="584" y="147">turn</text>
<text class="t-val" x="664" y="147">brief + 任务</text>
<line class="dd-hair" x1="568" y1="156" x2="768" y2="156"/>
<text class="t-mono" x="584" y="173">shell</text>
<text class="t-val" x="664" y="173">git, gh, npm</text>
<rect class="dd-card" x="320" y="232" width="200" height="112" rx="6"/>
<path class="dd-card-head" d="M320.6 264 V238 Q320.6 232.6 326 232.6 H514 Q519.4 232.6 519.4 238 V264 Z"/>
<line class="dd-edge" x1="320" y1="264" x2="520" y2="264"/>
<text class="t-name" x="336" y="253">守护进程</text>
<text class="t-mono" x="504" y="253" text-anchor="end">multica daemon</text>
<text class="t-mono" x="336" y="281">连接</text>
<text class="t-val" x="408" y="281">出站 WebSocket</text>
<line class="dd-hair" x1="320" y1="290" x2="520" y2="290"/>
<text class="t-mono" x="336" y="307">心跳</text>
<text class="t-val" x="408" y="307">15s</text>
<line class="dd-hair" x1="320" y1="316" x2="520" y2="316"/>
<text class="t-mono" x="336" y="333">轮询</text>
<text class="t-val" x="408" y="333">30s 兜底</text>
<rect class="dd-card" x="568" y="232" width="200" height="112" rx="6"/>
<path class="dd-card-head" d="M568.6 264 V238 Q568.6 232.6 574 232.6 H762 Q767.4 232.6 767.4 238 V264 Z"/>
<line class="dd-edge" x1="568" y1="264" x2="768" y2="264"/>
<text class="t-name" x="584" y="253">工作目录</text>
<text class="t-note" x="752" y="253" text-anchor="end">每次运行一个</text>
<text class="t-mono" x="584" y="281">AGENTS.md</text>
<text class="t-val" x="688" y="281">brief</text>
<line class="dd-hair" x1="568" y1="290" x2="768" y2="290"/>
<text class="t-mono" x="584" y="307">MULTICA_TOKEN</text>
<text class="t-acc t-mono" x="688" y="307">mat_9c…</text>
<line class="dd-hair" x1="568" y1="316" x2="768" y2="316"/>
<text class="t-mono" x="584" y="333">cwd</text>
<text class="t-val" x="688" y="333">你的仓库</text>
<text class="t-say" x="36" y="384">平台从不连进你的电脑，</text>
<text class="t-say" x="36" y="404">连接由守护进程主动发起。</text>
<text class="t-say" x="324" y="394">令牌只对这一次运行有效，写回的评论记在智能体名下。</text>
</svg>
</div>
<figcaption>图 4 · 平台签发的一次性令牌顺着“领取 → 写入 → 启动 → 调用”走一圈，又回到平台上，agent 就以智能体身份完成了反向操作</figcaption>
</figure>

### 一个踩出来的规则：别在后台等

这套“反向操作”还有一个不太直观的约束，写在 Multica 给 agent 的说明文件里[^9][^9b]：**顶层的这一轮一结束，平台就认为任务结束了。** 所以 agent 不能把一个命令丢到后台、然后说“我等它跑完再汇报”，因为没有人会来叫醒它。

源码注释里记录了这条规则是怎么来的：最初只写了原则，agent 还是会执行 `gh pr checks --watch` 一直盯着 CI，后来只好在说明里点名禁止 `gh pr checks --watch`、`gh run watch` 和 `sleep` 轮询，并告诉它「本地测试通过；CI 运行中：<PR 链接>」就是一次完整的交付。真要等一个外部条件，就用 `multica issue wakeup create` 让平台记住，条件满足后再开一次新的运行。

这也说明了“反向操作”的本质：**平台是记忆，agent 是一次次短暂的执行。** 状态要写回平台，而不是留在 agent 的脑子里。

**机制讲完了。这件事现在都有谁在做？**

---

## 六、行业现状

把市面上的方案放在一起看，可以用两个问题把它们分开：**agent 的推理循环跑在哪里？平台能不能用程序给它派活？** 按这两个问题，大致能分出五种架构：

| 架构 | agent 循环在哪 | 怎么连接 | 代表 |
|---|---|---|---|
| A. 平台配对 + 本地连接器 | 本地 | 平台生成一次性 token 的命令，本地连接器出站连回平台 | 万有无界、扣子 3.0、阿里云 AgentCore |
| B. 本地 daemon 领任务 | 本地 | daemon 长连接或轮询领任务，再启动无头 CLI | Multica、Cyrus、Factory、Warp |
| C. 会话中继 | 本地 | 厂商中继镜像一个正在运行的会话，人从手机或网页操控 | Claude Code Remote Control、Codex Remote、Copilot CLI |
| D. IM 桥接 | 本地 | 本地进程直接连 IM 的长连接，群里 @ 就是派活 | 钉钉 `dws dev connect`、cc-connect、OpenClaw、Claude Code Channels |
| E. 云端循环 + 本地执行 | 云端 | 本地 worker 出站连接，只替云端执行文件编辑和命令 | Cursor My Machines、Devin Outposts |

这张表里有三点值得展开。

**第一，连接层已经收敛了。** 从阿里、字节、钉钉到 Anthropic、OpenAI，几乎所有人都用了“出站长连接 + 一次性配对 token”这套组合。万有无界的 curl 加 60 秒 token、扣子的 coze-bridge 加 60 分钟 token、钉钉的一条 `dws dev connect`，本质上是同一件事。2026 年，“能把本地 agent 接上平台”已经是标配，不再是什么新鲜能力。

**第二，“遥控器”和“同事”不是一回事。** C 类方案（Remote Control、Codex Remote）主要是让**人**从手机上操控自己电脑上的会话，平台本身并不主动派活。真正让平台用程序派活的，是 A、B、D 三类。Anthropic 的 Claude Code Channels 是官方给出的一个口子：一个 MCP server 可以把聊天消息、CI 结果、webhook 事件推进一个**正在运行的**本地会话，Claude 处理完再从同一个通道回复[^11]。至于 E 类，虽然也用了你的电脑，但推理和会话都在厂商云上，本地只是执行工具的“手”。它保留了文件、内网和凭据这部分现场，却没有保留本地 CLI 的会话和你的订阅额度。

**第三，反向操作几乎没人公开讲清楚。** 万有无界和扣子的公开文档都写了“平台 @ 本地 agent”这个方向，但本地 agent 用什么身份、通过什么接口反过来操作平台，没有公开。阿里云 AgentCore 的本地纳管 agent 则「只能作为 Team Worker 使用，不支持设置为 Leader」[^12]，也就是只能接活，不能派活。Multica 是少数把这一层公开出来的实现。

标准协议也还没跟上。ACP 默认走 stdio，是为“编辑器和本机 agent”设计的，对远程 agent 的完整支持还在推进中[^13b]；A2A 是服务器对服务器的协议，默认对方暴露一个可以直接访问的 HTTP 端点[^13c]，并不适合躲在 NAT 后面的个人电脑。万有无界宣传支持 A2A，但目前没有公开接口规范[^13]。现阶段各家用的基本都是自己的私有长连接协议。

**这么多平台都在你的电脑上开了一个口子。这个口子到底有多大？**

---

## 七、安全风险

### 现象：这和木马有什么区别

把前面几节串起来看，会得到一个让人不太舒服的结论：一个常驻后台的进程，主动向外连接一台服务器，等着远端发来指令，然后在你的电脑上执行任意命令。这个描述和远程控制木马的结构是一样的。

那它拿到的是“最高权限”吗？准确地说不是 root，而是**启动它的那个系统用户的全部权限**。Claude Code 甚至会在 root 下拒绝跳过权限检查，Multica 的源码里专门处理了这个报错，提示用户换一个非 root 用户来运行 daemon[^14]。可是在一台个人开发机上，“你这个用户的全部权限”几乎就是所有值钱的东西：代码、SSH 私钥、`~/.aws/credentials` 里的云凭据、浏览器的 cookie 文件、公司内网。

它和木马的区别不在能力，而在于**你是知情安装的、有身份认证、有审计记录**。

### 分析：CLI 自带的权限层去哪了

Claude Code 和 Codex 本身是有权限设计的：Claude Code 有权限模式和 allow/deny 规则，Codex 有只读、工作区可写、完全访问三档沙箱和审批策略。问题在于，桥接层会不会保留它们。

情况分成三种：

- **主动关掉的。** Multica 在无人值守时给 Codex 用 `sandbox_mode = "danger-full-access"`，给 Claude Code 用 `--permission-mode bypassPermissions`，审批请求自动应答。它的安全文档说得很坦白：「请把每一次运行都当作无沙箱来对待」[^15]。腾讯 CodeBuddy 的远程任务默认也是 `bypassPermissions`[^16]。
- **把审批转到手机或 IM 上的。** Claude Code Channels 可以把权限请求转发到聊天里让你批准；钉钉的 `dws dev connect` 在执行前会推一张审批卡片，「你点同意才真正执行」[^17]；扣子让你给每种 agent 单独选权限档，比如 Codex 可以选“请求批准”或“完全访问权限”[^2]。
- **决策在厂商云上、你的电脑只负责执行的**，也就是 E 类。它在结构上最接近远程控制：推理发生在别人的服务器上，你的电脑只负责照着执行。

为什么这么多平台选择把审批关掉？因为 bot 是无人值守的，每一步都等人点“同意”，就没法异步干活了。Multica 的理由更进一步：半吊子的文件系统沙箱会让 `gh`、`aws`、`kubectl` 处于“未登录”状态，以难以排查的方式破坏正常工作，却依然挡不住 agent 读取凭据再通过网络发出去。所以它「不假装自己是边界」[^15]。

### 真正的风险在三个入口

比“权限有多大”更值得关心的，是**谁能让它用上这些权限**。一条指令要走到你的私钥面前，有三条路：

1. **谁能给它发指令。** 工作区成员、群里能 @ 它的人。各家都做了账号绑定和白名单：Multica 的 IM 机器人对每条消息都重新校验发送者的成员身份，Claude Code Channels 只接收白名单里的发送者，还提醒你「Anyone who can reply through the channel can approve or deny tool use in your session」[^11]。
2. **平台本身。** 成千上万台开发机都主动连着同一个平台，等它发指令。平台一旦被攻破，它就是一个现成的僵尸网络控制端。中继不做端到端加密的方案（Claude Code Remote Control 的会话内容会经过 Anthropic 的服务器[^5]），厂商还能看到内容。
3. **提示词注入。** agent 会读 issue、网页、README、文档。任何能把文字塞进它上下文的人，都可能借它的手执行命令。这条路**完全不经过平台的身份校验**。

下面这张图画的就是第三条路。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="lab-risk-title lab-risk-desc">
<title id="lab-risk-title">一条指令和你的私钥之间隔着什么</title>
<desc id="lab-risk-desc">群聊 @ 和 Issue 评论要经过平台的成员身份校验才能派活给 Codex；README 里夹带的文字被 Codex 直接读到，不经过平台。Codex 的沙箱被设成 danger-full-access、审批自动通过，CLI 权限层形同虚设，指令一路走到用户 dev 能碰到的 ~/.aws/credentials。</desc>
<defs>
<marker id="lab-risk-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="lab-risk-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="224" height="388" rx="8"/>
<text class="t-zone" x="36" y="44">指令从哪来</text>
<rect class="dd-zone" x="256" y="16" width="176" height="388" rx="8"/>
<text class="t-zone" x="276" y="44">平台</text>
<rect class="dd-zone" x="448" y="16" width="336" height="388" rx="8"/>
<text class="t-zone" x="468" y="44">你的电脑 · 用户 dev</text>
<path class="dd-edge" d="M224 90 H266" marker-end="url(#lab-risk-arrow)"/>
<path class="dd-edge" d="M224 142 H244 Q248 142 248 138 V128 Q248 124 252 124 H266" marker-end="url(#lab-risk-arrow)"/>
<path class="dd-edge" d="M416 116 H474" marker-end="url(#lab-risk-arrow)"/>
<text class="t-note" x="444" y="106" text-anchor="middle">派活</text>
<path class="dd-edge-acc" d="M224 314 H452 Q460 314 460 306 V156 Q460 148 468 148 H474" marker-end="url(#lab-risk-arrow-acc)"/>
<path class="dd-edge-acc" d="M624 160 V258" marker-end="url(#lab-risk-arrow-acc)"/>
<rect class="dd-card" x="32" y="72" width="192" height="36" rx="6"/>
<text class="t-name" x="48" y="95">群聊</text>
<text class="t-mono" x="88" y="95">@Codex 修一下 CI</text>
<rect class="dd-card" x="32" y="124" width="192" height="36" rx="6"/>
<text class="t-name" x="48" y="147">Issue</text>
<text class="t-mono" x="96" y="147">MUL-42 评论</text>
<rect class="dd-card" x="32" y="296" width="192" height="36" rx="6"/>
<text class="t-name" x="48" y="319">README</text>
<text class="t-acc t-mono" x="112" y="319">夹带的指令</text>
<text class="t-say" x="36" y="364">这条路不经过平台的</text>
<text class="t-say" x="36" y="384">身份校验。</text>
<rect class="dd-card" x="272" y="72" width="144" height="88" rx="6"/>
<path class="dd-card-head" d="M272.6 104 V78 Q272.6 72.6 278 72.6 H410 Q415.4 72.6 415.4 78 V104 Z"/>
<line class="dd-edge" x1="272" y1="104" x2="416" y2="104"/>
<text class="t-name" x="288" y="93">平台</text>
<text class="t-mono" x="288" y="121">校验</text>
<text class="t-val" x="328" y="121">成员身份</text>
<line class="dd-hair" x1="272" y1="130" x2="416" y2="130"/>
<text class="t-mono" x="288" y="147">派给</text>
<text class="t-val" x="328" y="147">在线 daemon</text>
<rect class="dd-card" x="480" y="72" width="288" height="88" rx="6"/>
<path class="dd-card-head" d="M480.6 104 V78 Q480.6 72.6 486 72.6 H762 Q767.4 72.6 767.4 78 V104 Z"/>
<line class="dd-edge" x1="480" y1="104" x2="768" y2="104"/>
<text class="t-name" x="496" y="93">Codex</text>
<text class="t-note" x="752" y="93" text-anchor="end">daemon 拉起</text>
<text class="t-mono" x="496" y="121">sandbox_mode</text>
<text class="t-val" x="608" y="121">danger-full-access</text>
<line class="dd-hair" x1="480" y1="130" x2="768" y2="130"/>
<text class="t-mono" x="496" y="147">审批</text>
<text class="t-val" x="608" y="147">自动通过</text>
<rect class="dd-derived" x="480" y="184" width="288" height="52" rx="6"/>
<text class="t-name" x="496" y="206">CLI 权限层</text>
<text class="t-say" x="496" y="226">虚线：本来存在，无人值守时被关掉</text>
<rect class="dd-card" x="480" y="264" width="288" height="136" rx="6"/>
<path class="dd-card-head" d="M480.6 296 V270 Q480.6 264.6 486 264.6 H762 Q767.4 264.6 767.4 270 V296 Z"/>
<line class="dd-edge" x1="480" y1="296" x2="768" y2="296"/>
<text class="t-name" x="496" y="285">用户 dev 能碰到的</text>
<text class="t-mono" x="496" y="313">~/.ssh/id_ed25519</text>
<text class="t-note" x="752" y="313" text-anchor="end">私钥</text>
<line class="dd-hair" x1="480" y1="322" x2="768" y2="322"/>
<text class="t-acc t-mono" x="496" y="339">~/.aws/credentials</text>
<text class="t-note" x="752" y="339" text-anchor="end">云凭据</text>
<line class="dd-hair" x1="480" y1="348" x2="768" y2="348"/>
<text class="t-mono" x="496" y="365">git push origin main</text>
<text class="t-note" x="752" y="365" text-anchor="end">仓库写权限</text>
<line class="dd-hair" x1="480" y1="374" x2="768" y2="374"/>
<text class="t-mono" x="496" y="391">10.0.0.0/8</text>
<text class="t-note" x="752" y="391" text-anchor="end">内网</text>
</svg>
</div>
<figcaption>图 5 · 身份校验只守住了经过平台的那条路；CLI 权限层被关掉之后，一条夹带的指令和你的云凭据之间，只剩系统用户这一层</figcaption>
</figure>

### 被忽略的一点：现场会串味

还有一种风险不来自攻击者，而来自“现场”本身。Codex 自带一个自动记忆功能，会把每轮对话的摘要写进 `$CODEX_HOME/memories/`，下一轮再读回来，用户级的 `~/.codex/memories/` 也可能被读到。Multica 在源码里默认把它关掉了，注释写的理由是不让宿主机或之前任务的记忆越过工作区边界[^18]。起因是 GitHub issue #3130：Multica 复用了同一个 `CODEX_HOME`，一个毫不相干的新任务一启动，就被注入了另一个已经删掉的项目留下的记忆[^18b]。

这说明现场是一把双刃剑：它让 agent 更懂你，也可能把不该带进来的上下文带进来。Multica 的选择是只保留**可见、可编辑**的上下文通道，也就是 issue 的描述和评论、会话 ID、说明文件和 skills，把不透明的记忆关掉。

### 解决：把边界放在 agent 外面

既然 agent CLI 自己的权限层靠不住，合理的做法就是把边界放到它外面。Multica 的建议按从轻到重排列是：专用 Unix 用户、容器、虚拟机[^15]。结合前面三个入口，可以整理成一份清单：

- **换一个人来跑。** 用专用的系统账号、容器、虚拟机，或者一台专用的 Mac mini 来跑 daemon，不要用你自己的账号。
- **只给最小的凭据。** 用只对一个仓库有效的 deploy key，而不是个人 SSH 私钥；云凭据用权限收窄的临时 token；无关的生产凭据不要留在这个账号的 home 目录里。
- **限制出网。** 给这个账号或容器加出网白名单，读到了凭据也发不出去。
- **收紧“谁能派活”。** 只让需要的人能触发它，群聊里不要默认对所有人开放。
- **危险操作保留人工审批。** 推送到主分支、删除、部署这类操作，宁可慢一点，也要转到手机上让人点一下。
- **关掉不透明的记忆，保留审计日志。**

---

## 小结

回顾一下这条问题链：

1. **为什么要把 agent 留在本地？** 因为 agent 干活需要的是整个现场：未提交的改动、会话历史、本机凭据、内网和订阅额度。现场搬不上云，就让 agent 留在现场，只把派活搬上云。
2. **平台怎么找到一台没有公网 IP 的电脑？** 不让平台连进来，而是让本地进程主动连出去，用一次性 token 完成配对，再靠心跳和轮询兜底保持在线。
3. **怎么把交互式 CLI 变成 worker？** 用各家 CLI 的无头接口（`-p`、`app-server`、ACP），外加一层适配。
4. **agent 在哪个目录里干活？** 原地干看得到现场，却只能排队，也锁不住你自己的改动；干净的副本能并发，现场又丢了。回放现场的 worktree 两头兼顾：先给你的目录拍一张快照，在独立的工作树里回放，结果交付成一个分支，你的工作副本从头到尾只被读过。
5. **agent 怎么反过来在平台上干活？** 给它一份说明文件、一个和人一样的 CLI，以及一张只对这一次运行有效的令牌；状态写回平台，而不是留在 agent 的脑子里。
6. **现在谁在做？** 几乎所有人。连接层已经收敛，差距在于平台能不能用程序派活、agent 能不能反向操作平台；标准协议还没跟上。
7. **是不是像木马？** 结构上是，它拿到的是启动它的那个用户的全部权限，而且很多方案在无人值守时关掉了 CLI 的权限层。区别在于知情、认证和审计。真正的边界不在 agent CLI，而在你给它的那个账号、容器或虚拟机。

把本地 agent 变成平台上的 bot，本质上是在自己的电脑上安装一个远程执行代理。它能带来的效率是真实的，它打开的口子也是真实的。用之前，先想清楚这个代理以谁的身份在跑。

---

## 参考资料
