远程与云:消息连接器与托管 worker
30 秒导读: OpenWork 的口号是 "local-first, cloud-ready"——先在你自己电脑上一键跑起来,需要时再显式把它推向远程和团队。本章讲清这条路上的三块拼图:
opencode-router(把一台跑着 OpenCode 的机器接到 Slack / Telegram,并按目录把不同聊天路由到不同工作区)、桌面端的 "Add a worker → Connect remote"(用一个 URL + token 连上一台远程 worker),以及企业版ee/(把 worker 本身做成托管云服 务:鉴权、计费、provisioning)。
本章是 OpenWork 系列的第 6 章。前几章讲的是"单机怎么转": 桌面外壳、 主机运行时 orchestrator、 openwork-server 鉴权代理、 可扩展性、 前端。 这一章讲的是"怎么从单机走出去"。凡是涉及 managed credentials(托管凭据)和 actor scopes(参与者作用域)的地方,会回指第 2、3 章,不重复它们的内容。
1. 这是什么(零基础也能懂)
一句话定义: 本章的主角 opencode-router 是一根消息桥——它盯着一台已经在跑的 opencode 服务器,同时连着 Slack 和 Telegram;有人在聊天里 @ 它,它就把这句话喂给 OpenCode 跑一轮,再把结果发回聊天窗口。
解决什么问题 / 给谁用:
想象你已经用 OpenWork 桌面版在自己电脑上跑起了一个 AI agent,能改代码、能读文件。现在的痛点是:
- 你出门了,只有手机,想让它继续干活;
- 你想让同事也能用你这套配置好的 agent,但不想教他们装桌面软件;
- 你想让 agent 待在一个"永远在线"的地方,而不是你合上笔记本它就没了。
opencode-router 解决前两个:它让任何有 Slack / Telegram 的人都能对着你 那台机器上的 agent 说话。企业版的托管 worker 解决第三个:把那台"永远在线的机器"直接租给你。
它能做什么(功能):
- 把 Slack(Socket Mode)和 Telegram 的消息,翻译成对 OpenCode 的一次
session.prompt; - 按
(渠道, 身份, 聊天对象) → 目录的绑定,把不同聊天路由到不同的工作区目录; - 每个聊天对象可以有自己的当前模型(
/opus、/codex切换); - 收发图片 / 音频 / 文件(媒体);
- 私有 Telegram bot 用配对码(
/pair <code>)做首次准入; - 桌面端可以把这一切指向一台远程 worker,而不是本地。
用起来什么样: 最小的一条真实交互长这样(来自 apps/opencode-router/README.md):
# 先在某处跑起一个 opencode 服务器
opencode serve --port 4096 --hostname 127.0.0.1
# 再跑 router(它读 .env / 配置文件里的 bot token)
opencode-router start
# 之后在 Telegram 里直接跟 bot 说话,或用 HTTP 主动推一条消息:
curl -sS "http://127.0.0.1:3005/send" \
-H 'Content-Type: application/json' \
-d '{"channel":"telegram","directory":"/path/to/workdir","text":"hello"}'
一句话直觉/类比: 把 opencode-router 当成 agent 的电话总机:OpenCode 是坐在办公室里干活的人,router 是前台——它接进来的每通电话(Slack 线程、Telegram 聊天),查一下"这通电话该转到哪个房间(目录)、用哪位分机(模型)",再把话转进去、把答复转出来。
本节不涉及底层代码。记住一件事就够:router 自己不做 AI,它只做"把聊天精确接到某个 OpenCode 工作区"这件路由的事。
2. 顶层全景(它大概怎么转)
这节讲清楚"一条消息从聊天窗口进来,到 OpenCode 出结果,中间经过哪些部件"。
2.1 一张图:一条消息的旅程
先说怎么读这张图:从上到下是一条入站消息的处理顺序,命中每一层都可能被拦下(准入、配对、目录解析)。
Slack / Telegram 用户
│ @mention 或私聊
▼
┌─────────────────────────┐
│ 适配器 Adapter │ slack.ts / telegram.ts
│ · 收原始事件 │ 按 identity(哪个 bot/app)
│ · 抽文本 + 下载媒体 │ 归一成 InboundMessage
└───────────┬─────────────┘
│ onMessage()
▼
┌─────────────────────────┐
│ handleInbound (bridge) │ bridge.ts
│ ① 私有 bot? → 配对门 │ handleTelegramPairingGate
│ ② 命令? (/dir /opus…) │ handleCommand
│ ③ 解析目录 + 沙箱校验 │ resolveScopedDirectory
│ ④ 取/建 session │ BridgeStore (SQLite)
│ ⑤ 选模型 (per-user) │ getUserModel
└───────────┬─────────────┘
│ session.prompt({ directory, model })
▼
┌─────────────────────────┐
│ OpenCode 服务器 │ 本地 127.0.0.1:4096
│ (真正跑 agent 的地方) │ 或 远程 worker URL
└───────────┬─────────────┘
│ 回复文本 / 工具事件
▼
适配器把结果发回原聊天线程
关键要看懂:router 是无状态到 OpenCode 的一层薄壳。所有"这个人是谁、该去哪个目录、用什么模型"的 状态,要么在一个本地 SQLite(BridgeStore),要么在一张进程内的 Map(模型覆盖)。真正重的活全在下游 OpenCode。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 适配器 Adapter | 连一个具体渠道(一个 bot / 一个 slack app),收发消息、下载媒体 | apps/opencode-router/src/slack.ts、telegram.ts |
startBridge | 总装配:建适配器、连 OpenCode、装事件流与健康服务器 | apps/opencode-router/src/bridge.ts:248 |
handleInbound | 一条入站消息的主流程:配对→命令→目录→session→prompt | bridge.ts:1741 |
BridgeStore | 本地 SQLite,存 session / 目录绑定 / 准入名单 / 设置 | apps/opencode-router/src/db.ts:33 |
path-scope | 目录沙箱:把每个聊天关到工作区根目录以内 | apps/opencode-router/src/path-scope.ts |
createClient | 造一个连 OpenCode 的 SDK 客户端(带 Basic Auth) | apps/opencode-router/src/opencode.ts:9 |
MediaStore | 落盘入站媒体、解析出站文件路径 | apps/opencode-router/src/media-store.ts:46 |
| 健康服务器 | 本地 HTTP:健康检查、改配置、POST /send 主动推消息 | apps/opencode-router/src/health.ts |
| 桌面 Connect remote | 桌面端把工作区 指向一台远程 worker 的 URL/token | apps/app/src/react-app/domains/workspace/remote-workspace-* |
EE den-* | 企业版:把 worker 做成托管云(鉴权/计费/provisioning) | ee/apps/den-api、den-web、inference … |
2.3 主线走一遍(高层)
- 用户在 Telegram 私聊里发 "帮我看看 README"。
- Telegram 适配器(
telegram.ts:247的bot.on("message"))收到,忽略机器人自己发的、抽出文本、下载附件,归一成InboundMessage,回调handleInbound。 handleInbound先问:这个 bot 是私有的吗?是的话没配对就拦下(见 §3.3)。- 再问:这是斜杠命令吗?(
/dir、/opus…)是的话走handleCommand(见 §3.4)。 - 否则:解析出该聊天绑定的目录、做沙箱校验、取或建一个 OpenCode session。
- 用该聊天当前的模型,对那个 session 发
session.prompt,把可见文本回发。
下面逐个拆核心机制。
3. 核心原理(逐个机制,由浅入深)
3.1 适配器生命周期:创建、限 时启动、热重载
它要解决的小问题: 一个 router 进程要同时连多个 bot(多个 Telegram、多个 Slack app),而且要能在不重启进程的情况下加一个新 bot、停一个旧 bot。任何一个渠道启动卡住(Slack 要连 WebSocket),都不能拖垮其它渠道。
思路/直觉: 把每个渠道抽象成一个统一的 Adapter 接口(start / stop / sendText / sendMessage …),startBridge 只管拿着一堆 Adapter 转;启动时给每个 Adapter 一个"限时"窗口——超时就当它"正在起"、继续下一个,而不是死等。
统一接口 定义在 bridge.ts:23:
type Adapter = {
key: string;
name: ChannelName; // "telegram" | "slack"
identityId: string; // 哪个 bot/app
start(): Promise<void>;
stop(): Promise<void>;
sendText(peerId, text): Promise<void>;
// …sendMessage / sendFile / sendTyping 可选
};
装配 在 startBridge 里,按配置里 enabled !== false 的身份逐个造适配器(bridge.ts:411-435):Telegram 用 createTelegramAdapter,Slack 用 createSlackAdapter,都以 adapterKey(channel, identityId)(即 "telegram:default",见 bridge.ts:200)为键存进一张 Map。
限时启动 是这里最巧的一处。startAdapterBounded(bridge.ts:41)把 adapter.start() 和一个定时器赛跑:
// 示意,非源码:start 与超时赛跑,谁先到听谁的
const winner = await Promise.race([
adapterStart.then(() => ({ kind: "outcome" })), // 起好了
delay(timeoutMs).then(() => ({ kind: "timeout" })), // 8 秒还没起
]);
if (winner.kind === "timeout") return { status: "timeout" }; // 不阻塞,继续
真源码里,启动循环给每个适配器 8 秒窗口(bridge.ts:2191-2212);超时就只是打一条 "adapter starting..." 状态,继续起下一个;真正报错(onError)才把它 从 Map 里删掉(bridge.ts:2196)。重点看:一个卡住的 Slack 连接不会挡住 Telegram。
热重载 让 router 能在运行中增删身份。健康服务器暴露 POST /identities/telegram(health.ts:302)之类的接口;处理逻辑(bridge.ts:880-960 一带)会:更新内存里的身份列表 → 若该身份已有适配器就先 existing.stop()(bridge.ts:942)→ 用新配置 createTelegramAdapter 重建并重新 start。这就是桌面/orchestrator 能"边跑边加一个 bot"的底层机制。
停机 时 startBridge 返回的 stop()(bridge.ts:2218)会依次:关健康服务器、abort 所有事件订阅、清打字定时器、await adapter.stop() 逐个停适配器、关 SQLite。
3.2 目录路由:把每个聊天关进一个工作区
它要解决的小问题: 一个 router 后面可能挂着好几个项目目录。"张三在 Telegram 里的私聊"该落到 /work/projectA,"某个 Slack 频道的线程"该落到 /work/projectB。而且绝不能让谁通过 /dir ../../etc 逃出工作区根目录。
路由键 是三元组 (channel, identityId, peerId)。peerId 对 Telegram 是数字 chat_id(telegram.ts:36 的 isTelegramPeerId 要求纯数字),对 Slack 是 频道:线程时间戳 编码(slack.ts:49 的 formatSlackPeerId)。
目录从哪来(优先级从高到低,见 bridge.ts:1826-1831):
| 来源 | 含义 |
|---|---|
binding.directory | 这个聊天之前用 /dir 显式绑过的目录 |
session.directory | 当前活跃 session 记着的目录 |
identityDirectory | 这个 bot/app 在配置里的默认目录 |
defaultDirectory | 全局兜底 OPENCODE_DIRECTORY |
解析出候选后,先做一道危险根目录检查:如果不是显式绑定、而候选却是 /(或 Windows 盘符根),直接拒绝(bridge.ts:1832),让用户 /dir <path> 明确指定。
沙箱校验是精华。 resolveScopedDirectory(bridge.ts:459)把用户给的路径 resolve 成绝对路径,再用 isWithinWorkspaceRootPath 判断它是否落在工作区根以内:
// path-scope.ts:17 isWithinWorkspaceRootPath —— 判断 candidate 是否在 root 之内
const relativePath = relative(rootForComparison, resolvedForComparison);
if (!relativePath || relativePath === ".") return true; // 就是根
if (relativePath.startsWith("..") || isAbsolute(relativePath)) // 逃出去了
return false;
这段是防目录穿越的关键:任何 resolve 后落到根之外的路径,relative() 会以 .. 开头,直接判 false。Windows 上还额外做了 verbatim 前缀(\\?\)剥离和大小写归一(path-scope.ts:3 的 normalizeScopedDirectoryPath)。
自动绑定 是个便利:公有渠道下,一个从没绑过目录的聊天,第一次说话就会被 upsertBinding 到解析出的目录(bridge.ts:1853-1855),下次直接命中。私有 Telegram bot 例外——它必须先配对(见下一节)。
换目录会重置 session。 /dir 命令(bridge.ts:2100)绑定新目录后立刻 deleteSession(bridge.ts:2116),因为 OpenCode 的 session 是绑在目录上的,换目录就得开新 session。
3.3 配对码:私有 Telegram bot 的首次准入
它要解决的小问题: Telegram bot 的用户名是公开的,任何人都能找到你的 bot 发消息。如果这个 bot 直接连着你的私人工作区,那就等于把 agent 敞开给全世界。私有模式要求:陌生人第一次必须出示一个配对码才放行。
思路/直觉: router 不存明文配对码,只存它的 SHA-256 哈希。用户发 /pair ABC123,router 把 ABC123 归一(去空白、转大写、去非字母数字)后哈希,和存着的哈希比。对上了才把这个聊天绑到工作区。这样即使配置文件泄露,也拿不到原始码。
归一 + 哈希 在 bridge.ts:225-231:
function normalizePairingCodeValue(value) {
return value.trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); // 抹平大小写/空格/连字符
}
function hashPairingCode(value) {
return createHash("sha256").update(normalizePairingCodeValue(value)).digest("hex");
}
从命令里抠出码 用 extractPairingCodeFromCommand(bridge.ts:233),它认 /pair <code>、也认群里的 /pair@botname <code> 形式:
const match = trimmed.match(/^\/pair(?:@[A-Za-z0-9_]+)?\s+(.+)$/i);
准入门 handleTelegramPairingGate(bridge.ts:1655)是这套逻辑的收口,处理顺序值得一看(每一步不过就回一条引导语):
是私有 bot 吗? ── 否 ──▶ 放行(continue)
│是
已有绑定/session? ── 是 ──▶ 放行(已配过)
│否
消息里有 /pair <code> 吗? ── 无 ──▶ 回"这是私有 bot,发 /pair <code>"
│有
配置里有 pairingCodeHash 吗? ── 无 ──▶ 回"缺配对码,联系 host 重连"
│有
hash(输入) === 存的 hash? ── 否 ──▶ 回"配对码错误,重试"
│是
解析身份目录 + 沙箱校验 ──▶ upsertBinding + deleteSession ──▶ 回"配对成功"
配对成功后(bridge.ts:1724),这个聊天被绑到该身份的目录,之后就走正常流程。注意: 配对是 per-peer 的一次性动作,记在 SQLite 的 bindings 表里,重启不丢。
3.4 per-user 模型选择:每个聊天自己的当前模型
它要解决的小问题: 同一个 bot,张三想用 Claude Opus 深度改代码,李四想用便宜快的模型闲聊。得让每个聊天对象各自记住"我现在用哪个模型",互不干扰。
思路/直觉: 用一张进程内的 Map,键是 channel:identityId:peerId,值是 {providerID, modelID}。用户发 /opus 就往里写,发 /reset 就删掉回到默认。这是内存态、不落盘——重启 router 就 回默认,设计上把它当"临时偏好"而非持久配置。
键与读写 在 bridge.ts:182-198:
function getUserModelKey(channel, identityId, peerId) {
return `${channel}:${identityId}:${peerId}`; // 每个聊天一个键
}
function getUserModel(channel, identityId, peerId, defaultModel) {
return userModelOverrides.get(key) ?? defaultModel; // 没覆盖就用默认
}
function setUserModel(channel, identityId, peerId, model) {
if (model) userModelOverrides.set(key, model);
else userModelOverrides.delete(key); // 传 undefined = 清除
}
预设 是两个快捷键(bridge.ts:174):
| 命令 | 切到 |
|---|---|
/opus | anthropic/claude-opus-4-5-20251101 |
/codex | openai/gpt-5.2-codex |
/model | 只查看当前模型,不改 |
/reset | 清模型覆盖 + 删 session,彻底重开 |
命令分发 在 handleCommand(bridge.ts:2037):命中 MODEL_PRESETS[command] 就 setUserModel 并回一条确认(bridge.ts:2049-2057);/reset 则同时 setUserModel(…, undefined) 和 deleteSession(bridge.ts:2069-2070)。
落到 prompt 的一步在主流程 bridge.ts:1897:跑 prompt 前先 getUserModel(...) 取有效模型,只有存在时才把 model 塞进 session.prompt 参数(bridge.ts:1950-1956)。工作区里还能放一个 .opencode/agents/opencode-router.md,首行 @agent <name> 指定 OpenCode agent、其余作为消息指令,和模型一起注入(bridge.ts:263-282)。
3.5 媒体与投递:图片/音频/文件双向流动
它要解决的小问题: 聊天不只有文字。用户发来一张截图要 agent 看,agent 生成一个 PDF 要发回去。router 得把渠道各自的文件协议,归一成 OpenCode 能吃的本地文件。
入站 时适配器把渠道的文件下载到本地。Telegram 从 photo/document/audio/voice 里挑候选(telegram.ts:89 的 extractMediaCandidates),Slack 从事件的 files 里挑(带 Authorization: Bearer <botToken> 才能下私有文件,slack.ts:189)。落盘走 MediaStore.saveInboundBuffer,路径按 inbound/日期/渠道/身份/peer/ 分层、文件名做安全化(media-store.ts:53-94)。
出站 时按 part 类型分 发:Telegram 用 sendPhoto/sendAudio/sendDocument(telegram.ts:363-390),Slack 用 files.uploadV2(slack.ts:389)。每次投递都包在 withDeliveryRetry 里做重试,失败用 classifyDeliveryError 归类(可重试 / 不可重试)。
主动推送 不经聊天也行:健康服务器的 POST /send(health.ts:594)让本地进程(比如桌面 UI、orchestrator)直接把文本+媒体推给某个目录下所有绑定的 peer,或指定 peerId。这是桌面端"把 agent 的产物发到我 Telegram"的通道。
4. 桌面远程工作区:Add a worker → Connect remote
这节讲桌面端怎么把"本地工作区"换成"远程 worker"——这是 local→remote 的用户可见入口。
本地 vs 远程的对称性是关键设计。 OpenWork 桌面 UI 面对的永远是一个 openwork-server(见第 3 章)的 HTTP 端点。本地模式下这个端点是 orchestrator 在 localhost 拉起的;远程模式下,它只是换成了一台远程机器的 URL。UI 逻辑几乎不变,变的只是 baseUrl 和一个 token。 这正是 "cloud-ready" 能低成本实现的原因。
连接的三要素 由 RemoteWorkspaceFields 表单收集(apps/app/src/react-app/domains/workspace/remote-workspace-fields.tsx):
| 字段 | 说明 |
|---|---|
| Worker URL | 远程 worker 的地址,如 https://worker.example.com |
| Access token | 可选;worker 要求时才填(呼应第 3 章的 bearer 鉴权) |
| Remote directory | 可选;把这个聊天/工作区定位到远程机上的某个子目录 |
连接目标 归一成 RemoteWorkspaceConnectionTarget(remote-workspace-diagnostics.ts:11):{ kind:"openwork", baseUrl, token, workspaceId }。连接前会跑一串诊断(remote-workspace-diagnostics.ts 的 test*):URL 是否合法、能否 health / capabilities / status、目标是不是一台真正的 OpenWork worker(非 OpenWork 的远端会被明确拒绝,并提示 "only remote workers can be tested",remote-workspace-diagnostics.ts:157)。
选中远端上的哪个工作区 由 selectOpenworkWorkspaceForConnection(apps/desktop/electron/remote-workspace.mjs:21)决定:给了目录就按目录匹配远端返回的工作区列表,没给就用远端的 activeId 或第一个。
从云 web 一键跳桌面。 企业版 den-web 会生成一个深链接:.../connect-remote?...,自动把 worker URL/token 作为参数注入(ee/apps/den-web/README.md 的 DEN_WEB_OPENWORK_APP_CONNECT_URL 说明)。桌面用 openwork-links.ts 解析这个链接(注意 bypassAddWorkerModal 参数,apps/app/src/app/lib/openwork-links.ts:57),直接进连接流程,免去手动复制粘贴。
README 里的官方路径(README.md:36):托管 worker 从 web 应用结账后启动,再从桌面 Add a worker → Connect remote 连上。至此,你在桌面看到的 agent 已经跑在云上、而不是你的笔记本里。
5. 企业/云 EE:把 worker 做成托管服务
⚠ 组件性质:
ee/目录下的一切(den-api、den-web、inference、den-worker-runtime、den-worker-proxy、landing)都是 EE 商业组件,不属于开源核心(ee/LICENSE单独授权)。本节只做高层概览,帮你理解"托管 worker 云"这一层大概怎么分工,不深入实现。
它补的是什么。 §4 里桌面连的那台"远程 worker",总得有人去开机、发 token、按用量收费。开源核心不管这些;ee/ 就是把这套"云控制面"做出来。
部件分工(高层) 汇总如下:
| EE 部件 | 角色(高层) | 依据 |
|---|---|---|
den-api | 控制面(Hono):鉴权 / 组织 / 管理 / worker 生命周期与计费 | ee/apps/den-api/README.md,路由在 src/routes/workers/ |
den-web | 云前端 app.openworklabs.com:登录、列/连 worker、跳桌面 | ee/apps/den-web/README.md |
inference | 托管推理网关:凭 API key 代理到底层模型 provider,带用量限额 | ee/apps/inference/src/proxy.ts、limits.ts |
den-worker-runtime | worker 运行时根:装 openwork-orchestrator、内置 opencode、用 openwork 启动 | ee/apps/den-worker-runtime/README.md |
den-worker-proxy | worker 前置代理:签名预览 URL、限流、按 host token 转发 | ee/apps/den-worker-proxy/src/app.ts |
den-controller | 已废弃,合并进 den-api | ee/apps/den-controller/README.md |
鉴权 / 计费 / provisioning 各自落在哪:
- 鉴权:
den-api挂 Better Auth(/api/auth/*),另有 desktop handoff 路由(/v1/auth/*)把网页登录态交回桌面(den-api/README.md:32-33)。worker 心跳则用独立的 worker token、不走用户鉴权(src/routes/workers/README.md:activity 心跳是唯一例外)。 - 计费:worker 路由组下的
billing.ts管面向用户的云 worker 计费(src/routes/workers/billing.ts);web 端把用户导到组织计费页(den-web/README.md:19)。 - provisioning:真正开 worker 的逻辑在
den-api的src/workers/(不在路由处理器里,src/routes/workers/README.md末尾特别注明);runtime endpoints 用存着的 host token + 实例 URL 代理到 worker 运行时。底层沙箱用 Daytona(den-worker-proxy/src/app.tsimport 了@daytonaio/sdk)。
关键连接:runtime 复用开源 orchestrator。 托管 worker 并不是另写一套——它在 Render 上把 openwork-orchestrator 装进去、vendored 一份匹配版本的 opencode,再用 openwork 命令启动(den-worker-runtime/README.md)。也就是说,云上那台 worker 跑的,和你本地第 2 章 orchestrator 拉起的是同一套东西。 这就是 "同一个 OpenCode,从 localhost 平滑到云" 在部署层面的具体体现。
6. local ↔ remote 之间,凭据怎么衔接
这节把散在各处的凭据线索串起来,回答一个核心问题:从单机走到远程,token 和身份是怎么一路接上的?
分清两类凭据。 一定要把它们分开看,否则容易混:
| 凭据 | 保护谁 → 谁 | 在哪 |
|---|---|---|
| OpenCode Basic Auth | router → 它连的 OpenCode 服务器 | opencode.ts:11-14(username/password) |
| worker Access token | 桌面/客户端 → 远程 worker 的 openwork-server | Connect remote 表单 + 第 3 章 bearer 鉴权 |
| 渠道 bot token | router → Slack/Telegram 平台 | 配置文件 / env(config.ts:15 起) |
| 云用户会话 / worker token | 用户/worker → den-api 控制面 | EE Better Auth / worker activity token |
第一跳:router → OpenCode。 router 用 OPENCODE_SERVER_USERNAME/PASSWORD 组成 Basic Auth 头连 OpenCode(opencode.ts:9 的 createClient)。本地时 OpenCode 就在 127.0.0.1;这套凭据换成远程 URL 也一样成立——因为它连的始终是"一个 openwork-server 端点",本地远程同构。
第二跳:桌面 → 远程 worker。 桌面把 §4 里填的 token 作为 bearer 交给远程的 openwork-server。这正是第 3 章讲的鉴权代理和 actor scopes:openwork-server 是 OpenCode 前面的守门人,它校验这个 token、把请求限定在允许的工作区和文件系统 API 内。桌面这端几乎无感——它只是多带了一个 header。
第三跳:managed credentials 的托管。 单机时,provider key(给模型用的 API key)是你自己填的、放本地。走到云 worker,EE 的 inference 网关接管这件事:客户端拿一个托管的 inference key,由网关在后端解析到真正的 provider key(inference/src/keys.ts 的 findActiveInferenceKey / getOpenRouterProviderKey),并施加用量限额。这样团队成员不用各自持有 provider key——这就是第 2 章提到的 managed credentials 在云侧的落点。
一句话把三跳连起来: 聊天 bot token 让 router 接得到消息,Basic Auth 让 router 连得上 OpenCode,worker access token + openwork-server 的 actor scopes 让远程访问被约束,托管 inference key 让模型调用不必人手发 provider 密钥——四层凭据各管一段,拼成从 localhost 到团队云的完整链路。
7. 边界与局限(诚实)
- router 的 per-user 模型是内存态,不落盘。
userModelOverrides是进程内 Map(bridge.ts:180),重启 router 后所有/opus、/codex覆盖丢失,回到默认。session 和目录绑定则在 SQLite 里持久(db.ts)。 - 群聊默认关闭。 Telegram / Slack 的群消息只有在
GROUPS_ENABLED=true且 bot 被 @mention 时才响应(telegram.ts:267-294);默认只认私聊 / DM。 - Telegram 目标必须是数字 chat_id。
@username不能作为peerId直接发送(telegram.ts:36的isTelegramPeerId);且对方没先跟 bot 说过话时,Telegram 会返回chat not found,router 只能提示用户让对方先/start。 - 沙箱只挡目录穿越,不是全权限模型。
path-scope保证聊天不能跳出工作区根,但工作区内的权限由 OpenCode 的 permission 规则决定(opencode.ts:25的buildPermissionRules,router 只在allow/deny两档间选)。 - WhatsApp 已被移除。 代码里还能看到清理旧
pairing_requests表的迁移(db.ts:129),说明历史上有过 WhatsApp,现只剩 Telegram + Slack 两个渠道(config.ts:13的ChannelName)。 - EE 是闭源商业件。 本章对
den-*只做高层描述;其鉴权/计费/provisioning 的实现细节不在开源可读范围,den-controller甚至只是个指向den-api的废弃桩(den-controller/README.md)。
8. 代码地图(导航索引)
按符号名可 grep,比行号抗漂移。
| 主题 | 文件路径 | 符号名 |
|---|---|---|