跳到主要内容

远程与云:消息连接器与托管 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 章。前几章讲的是"单机怎么转": 桌面外壳主机运行时 orchestratoropenwork-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.tstelegram.ts
startBridge总装配:建适配器、连 OpenCode、装事件流与健康服务器apps/opencode-router/src/bridge.ts:248
handleInbound一条入站消息的主流程:配对→命令→目录→session→promptbridge.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/tokenapps/app/src/react-app/domains/workspace/remote-workspace-*
EE den-*企业版:把 worker 做成托管云(鉴权/计费/provisioning)ee/apps/den-apiden-webinference

2.3 主线走一遍(高层)

  1. 用户在 Telegram 私聊里发 "帮我看看 README"。
  2. Telegram 适配器(telegram.ts:247bot.on("message"))收到,忽略机器人自己发的、抽出文本、下载附件,归一成 InboundMessage,回调 handleInbound
  3. handleInbound 先问:这个 bot 是私有的吗?是的话没配对就拦下(见 §3.3)。
  4. 再问:这是斜杠命令吗?(/dir/opus…)是的话走 handleCommand(见 §3.4)。
  5. 否则:解析出该聊天绑定的目录、做沙箱校验、取或建一个 OpenCode session。
  6. 用该聊天当前的模型,对那个 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:36isTelegramPeerId 要求纯数字),对 Slack 是 频道:线程时间戳 编码(slack.ts:49formatSlackPeerId)。

目录从哪来(优先级从高到低,见 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:3normalizeScopedDirectoryPath)。

自动绑定 是个便利:公有渠道下,一个从没绑过目录的聊天,第一次说话就会被 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):

命令切到
/opusanthropic/claude-opus-4-5-20251101
/codexopenai/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:89extractMediaCandidates),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.tstest*):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.mdDEN_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 workerConnect remote 连上。至此,你在桌面看到的 agent 已经跑在云上、而不是你的笔记本里。


5. 企业/云 EE:把 worker 做成托管服务

⚠ 组件性质: ee/ 目录下的一切(den-apiden-webinferenceden-worker-runtimeden-worker-proxylanding)都是 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.tslimits.ts
den-worker-runtimeworker 运行时根:装 openwork-orchestrator、内置 opencode、用 openwork 启动ee/apps/den-worker-runtime/README.md
den-worker-proxyworker 前置代理:签名预览 URL、限流、按 host token 转发ee/apps/den-worker-proxy/src/app.ts
den-controller已废弃,合并进 den-apiee/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-apisrc/workers/(不在路由处理器里,src/routes/workers/README.md 末尾特别注明);runtime endpoints 用存着的 host token + 实例 URL 代理到 worker 运行时。底层沙箱用 Daytona(den-worker-proxy/src/app.ts import 了 @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 Authrouter → 它连的 OpenCode 服务器opencode.ts:11-14(username/password)
worker Access token桌面/客户端 → 远程 worker 的 openwork-serverConnect remote 表单 + 第 3 章 bearer 鉴权
渠道 bot tokenrouter → 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:9createClient)。本地时 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.tsfindActiveInferenceKey / 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:36isTelegramPeerId);且对方没先跟 bot 说过话时,Telegram 会返回 chat not found,router 只能提示用户让对方先 /start
  • 沙箱只挡目录穿越,不是全权限模型。 path-scope 保证聊天不能跳出工作区根,但工作区内的权限由 OpenCode 的 permission 规则决定(opencode.ts:25buildPermissionRules,router 只在 allow/deny 两档间选)。
  • WhatsApp 已被移除。 代码里还能看到清理旧 pairing_requests 表的迁移(db.ts:129),说明历史上有过 WhatsApp,现只剩 Telegram + Slack 两个渠道(config.ts:13ChannelName)。
  • EE 是闭源商业件。 本章对 den-* 只做高层描述;其鉴权/计费/provisioning 的实现细节不在开源可读范围,den-controller 甚至只是个指向 den-api 的废弃桩(den-controller/README.md)。

8. 代码地图(导航索引)

按符号名可 grep,比行号抗漂移。

主题文件路径符号名
桥总装配 / 主入口apps/opencode-router/src/bridge.tsstartBridge
入站消息主流程apps/opencode-router/src/bridge.tshandleInbound
斜杠命令分发apps/opencode-router/src/bridge.tshandleCommand
适配器统一接口apps/opencode-router/src/bridge.tsAdapter(type)
限时启动apps/opencode-router/src/bridge.tsstartAdapterBounded
per-user 模型读写apps/opencode-router/src/bridge.tsgetUserModel / setUserModel / getUserModelKey
模型预设apps/opencode-router/src/bridge.tsMODEL_PRESETS
配对码哈希/抽取apps/opencode-router/src/bridge.tshashPairingCode / extractPairingCodeFromCommand
Telegram 私有准入门apps/opencode-router/src/bridge.tshandleTelegramPairingGate
目录沙箱解析apps/opencode-router/src/bridge.tsresolveScopedDirectory
目录穿越校验apps/opencode-router/src/path-scope.tsisWithinWorkspaceRootPath / normalizeScopedDirectoryPath
本地状态存储apps/opencode-router/src/db.tsBridgeStore(upsertBinding / upsertSession)
Telegram 适配器apps/opencode-router/src/telegram.tscreateTelegramAdapter / isTelegramPeerId
Slack 适配器apps/opencode-router/src/slack.tscreateSlackAdapter / formatSlackPeerId
OpenCode 客户端(Basic Auth)apps/opencode-router/src/opencode.tscreateClient / buildPermissionRules
媒体落盘/解析apps/opencode-router/src/media-store.tsMediaStore
配置与身份加载apps/opencode-router/src/config.tsloadConfig / TelegramIdentity / SlackIdentity
健康服务器 / 主动推送apps/opencode-router/src/health.tsstartHealthServer(POST /send)
桌面选远端工作区apps/desktop/electron/remote-workspace.mjsselectOpenworkWorkspaceForConnection
远程连接目标/诊断apps/app/src/react-app/domains/workspace/remote-workspace-diagnostics.tsRemoteWorkspaceConnectionTarget
远程连接表单apps/app/src/react-app/domains/workspace/remote-workspace-fields.tsxRemoteWorkspaceFields
EE 控制面(worker/计费)ee/apps/den-api/src/routes/workers/billing.ts / core.ts / runtime.ts
EE 托管推理网关ee/apps/inference/src/proxy.tsregisterProxyRoutes / findActiveInferenceKey
EE worker 运行时ee/apps/den-worker-runtime/(装 openwork-orchestrator + vendored opencode)