openwork-server:OpenCode 之上的鉴权代理与文件系统 API
30 秒导读: OpenCode 本身是一个跑在
localhost上、没有鉴权的编码引擎——谁能连上端口谁就是管理员。openwork-server 把它包起来:在它前面加一层 HTTP 服务,负责认证(你是谁)、授权(你能做什么)、审批(危险写操作要不要放行)、路径边界(能碰哪些目录),再把 OpenCode 的会话数据整理成一份稳定的只读模型吐给前端。一句话:它把「开发者的引擎」变成「可托管、可协作、可审计的控制面」。
本章聚焦这一层的服务端实现,主读 apps/server/src/server.ts 与 apps/server/src/routes/。技能 / MCP / 插件这些可扩展性机制的细节属于第 4 章;openwork-server 在主机运行时里如何被 orchestrator 当作 sidecar 监管、如何被拉起属于第 2 章。本章只讲这个进程自己:请求进来后发生了什么。
1. 这是什么(零基础也能懂)
一句话定义
openwork-server 是一个 filesystem-backed API——它自己 package.json 的描述就写着 "Filesystem-backed API for OpenWork remote clients"(apps/server/package.json:4)。它坐在 OpenCode 引擎前面,对外暴露一套带 token、带审批、带路径授权的 REST 接口,让远程客户端(桌面 UI、消息连接器、云 worker)能安全地驱动引擎、读写工作区文件。
它解决什么问题 / 给谁用
先理解被它包住的东西:OpenCode 是一个本地编码 agent 引擎,默认监听 http://127.0.0.1:4096,任何能访问该端口的进程都是完全的管理员,没有 token、没有作用域、没有审批。这在「一个开发者、一台自己的机器」下没问题,但一旦你想:
- 让手机 / 另一台电脑上的 UI 远程连过来,
- 让协作者只读你的会话、但不能改配置,
- 让 AI 的每一次危险写操作(改配置、装插件、写文件)先弹一个「允许 / 拒绝」,
- 把它限制在几个授权目录里、别乱碰整块磁盘,
裸 OpenCode 就全都做不到了。openwork-server 就是补上这一整层「控制面」的中间件。
它能做什么(功能清单)
| 能力 | 白话 |
|---|---|
| 反向代理 OpenCode | 把 /opencode/* 请求透传给真正的引擎,顺带做鉴权与响应清洗 |
| Token 作用域鉴权 | owner / collaborator / viewer 三级,viewer 只读 |
| 审批闸门 | 每个写操作先经 manual 审批(桌面主机回「允许/拒绝」)或 auto 直接放行 |
| 工作区挂载路由 | 用 /w/<id>/… 或 /workspace/<id>/… 把请求绑定到某个工作区 |
| 授权目录 | 只允许在白名单根目录下的工作区里操作(authorizedRoots) |
| 会话读模型 | 把 OpenCode 原始会话/消息/待办用 zod 校验并整形成稳定结构 |
| 事件流 | 轮询式 reload / 文件事件流,让 UI 知道「配置变了、该重载了」 |
| 能力协商 | /capabilities 告诉客户端「这台服务器开了哪些功能」 |
| 文件系统 API | 批量读写工作区文件、收件箱/发件箱、文件会话 |
用起来什么样
它就是一个装好即用的二进制:
# 全局装,不需要 Bun 运行时(README 里的 Quick start)
npm install -g openwork-server
openwork-server --workspace /path/to/workspace --approval auto
启动时若 token 是自动生成的,会打印在日志里。之后客户端带上 Authorization: Bearer <token> 就能调 API。裸调一个健康检查:
curl http://127.0.0.1:8787/health
# => {"ok":true,"version":"0.17.4","opencodeVersion":"...","uptimeMs":123}
一句话直觉
把 OpenCode 想成一台没锁门的发动机房,openwork-server 是门口的前台 + 门禁 + 登记簿。 发动机(OpenCode)只管干活;要不要放你进、你能进哪几间、你动了什么要不要签字,全归前台管。发动机自己一行都不用改。
本节不涉及底层代码;记住这个「前台/门禁」的心智模型即可。
2. 顶层全景(它大概怎么转)
一次请求的生命周期
服务的唯一入口是 startServer(apps/server/src/server.ts:658),它构造一个巨大的 fetch(request) 处理器挂到 Node HTTP 服务上。每个请求都走同一条主干,而主干在最前面就分出两条完全不同的「面」:
HTTP 请求进来
│
┌────────▼─────────┐
│ fetch() 主处理器 │ server.ts:681
│ (先建 finalize │
│ 收尾:CORS+日志) │
└────────┬─────────┘
│
路径是 /opencode 或 /w/<id>/opencode
或 /workspace/<id>/opencode 吗?
┌────────┴─────────┐
是 │ │ 否
┌───────────▼──────┐ ┌───────▼────────────┐
│ 代理面(Proxy) │ │ 控制面(Control) │
│ 透传给 OpenCode │ │ 匹配 REST 路由 │
└───────────┬──────┘ └───────┬────────────┘
│ │
requireClient 鉴权 按 route.auth 选鉴权
assertOpencodeProxy… (none/client/host/host-token)
proxyOpencodeRequest route.handler(ctx)
│ │
└────────┬─────────┘
▼
finalize(response)
加 CORS、记一行日志
怎么读这张图: 从上往下是请求流向;最关键的岔路是「代理面 vs 控制面」——同一个端口,但两套逻辑。代理面几乎是透明转发,控制面才是 openwork-server 自己的业务(配置、插件、会话、文件、token)。
主要部件与职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
startServer | 组装依赖、建 fetch 处理器、启动 HTTP 服务 | server.ts:658 |
createRoutes | 注册所有控制面 REST 路由 | server.ts:1297 |
路由表 Route[] + matchRoute | 极简的「正则路径 + 鉴权模式 + handler」表 | routes/registry.ts |
proxyOpencodeRequest | 代理面核心:把请求重写并转发给 OpenCode | server.ts:884 |
TokenService | token→作用域 的解析与持久化 | tokens.ts:84 |
ApprovalService | 写操作的「允许/拒绝」闸门 | approvals.ts:16 |
ReloadEventStore | 内存里的 reload 事件环形缓冲 | events.ts:4 |
| 会话读模型 | 把 OpenCode 原始数据整形成稳定结构 | session-read-model.ts |
一条真实主线(高层,不进代码)
一个协作者的桌面 UI 想「把当前 prompt 发给引擎」:
- UI 请求
POST /w/ws_abc/opencode/session/<id>/command,带 collaborator token。 - 主处理器识别出这是代理面路径,调
requireClient认证 → 得到一个Actor{scope:"collaborator"}。 assertOpencodeProxyAllowed检查作用域够不够(collaborator 可写、可回应审批)。resolveWorkspace把ws_abc解析成一个真实的、且在授权根目录内的工作区。proxyOpencodeRequest把路径从/opencode/...改写成 OpenCode 的/session/.../command,注入工作区目录头,转发。- 因为这是
session/command,采用发射后不管(fire-and-forget)语义,立刻回{ok:true, accepted:true},真正的结果稍后从 OpenCode 事件流里出来。
下面逐个拆开这些机制。
3. 核心原理(逐个机制,由浅入深)
3.1 OpenCode 反向代理:透明转发,但要「消毒」
要解决的小问题: 前端不想认识两个不同的服务(openwork-server + OpenCode),它只想对一个 base URL 说话。所以 openwork-server 必须把打到自己身上的 /opencode/* 请求,透明地转给真正的引擎——但转发不是「原样复制」那么简单,有三个坑要填。
坑一:路径改写与目录头
代理要把 /opencode/session/xxx 变成引擎认识的 /session/xxx,并告诉引擎「在哪个目录里干活」。路径改写在 buildOpencodeProxyUrl(server.ts:829):它剥掉 /opencode 前缀、拼上工作区的 baseUrl。
目录信息则通过一个自定义头 x-opencode-directory 传递。有意思的是非 ASCII 目录名要 URL 编码,否则 HTTP 头塞不下:
// server.ts:837 buildOpencodeDirectoryHeader —— 含非 ASCII 才编码
function buildOpencodeDirectoryHeader(directory: string) {
return /[^\x00-\x7F]/.test(directory) ? encodeURIComponent(directory) : directory;
}
proxyOpencodeRequest(server.ts:884)在转发前会剥掉客户端的鉴权头(authorization、x-openwork-host-token、x-openwork-client-id、host、origin),再按需换上工作区自己的 OpenCode 认证头(Basic auth,来自 resolveWorkspaceOpencodeConnection,见 opencode-connection.ts:12)。也就是说:客户端的 openwork token 绝不会漏给下游引擎,两套凭证是隔离的。
坑二:响应「消毒」——防 gzip 解码崩溃
这是一个真实 踩过的坑。OpenCode(Bun 的原生 fetch)返回的响应里,即便 body 已经被解码成明文,响应头里可能还留着 content-encoding: gzip。浏览器一看这个头就去解压一段其实没压过的明文,直接 ERR_CONTENT_DECODING_FAILED,整条 /opencode/* 就废了。修复就是在回给客户端前把这几个传输层头删掉:
// server.ts:949 sanitizeProxyResponse —— 删掉会误导浏览器的传输头
function sanitizeProxyResponse(response: Response): Response {
const headers = new Headers(response.headers);
headers.delete("content-encoding");
headers.delete("transfer-encoding");
headers.delete("content-length");
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
坑三:命令是「发射后不管」
普通代理请求会 await fetch(...) 拿到响应再回。但 POST /session/:id/command(发一条 prompt 去跑)是长任务——同步等它跑完既慢又容易超时。所以这一类请求特殊处理:发出去就不等了,立刻回 {ok:true, accepted:true},真正的进展由 OpenCode 的事件流推送:
// server.ts:923 —— 命令类请求 fire-and-forget
if (isSessionCommandProxyRequest(method, proxyPath)) {
void fetch(targetUrl, { method, headers, body }).catch(() => {
// Command failures are surfaced through the OpenCode event stream.
});
return jsonResponse({ ok: true, accepted: true });
}
判定函数 isSessionCommandProxyRequest(server.ts:654)用正则匹配 POST /session/<id>/command。
代理面的三个入口
代理面能从三种路径进来,主处理器按顺序尝试(server.ts:729-772):
| 路径形态 | 含义 | 解析函数 |
|---|---|---|
/workspace/<id>/opencode/... | 规范挂载 | parseWorkspaceOpencodeMount (server.ts:611) |
/w/<id>/opencode/... | 短挂载别名 | parseWorkspaceMount (server.ts:597) |
/opencode/... | 无挂载,落到 workspaces[0] | 直接前缀判断 |
3.2 Actor 鉴权与作用域分级:你是谁 + 你能做什么
要解决的小问题: 裸 OpenCode 无鉴权。openwork-server 要区分三类身份,并按「读 < 写 < 管理」分级。
三种作用域
TokenScope 只有三档(types.ts:9),排名由 scopeRank 给出:
// server.ts:2554 scopeRank —— 数字越大权限越高
function scopeRank(scope: TokenScope): number {
if (scope === "viewer") return 1;
if (scope === "collaborator") return 2;
return 3; // owner
}
| 作用域 | rank | 能干什么 |
|---|---|---|
viewer | 1 | 只读:GET/HEAD;不能改配置、不能回应审批 |
collaborator | 2 | 日常读写:发 prompt、写文件、改会话 |
owner | 3 | 管理:等价于桌面主机,能签发/吊销 token |
三种鉴权闸门
路由在注册时声明自己需要哪种 AuthMode(registry.ts:6),主处理器据此选闸门(server.ts:782-789):
route.auth 闸门函数 接受的凭证
────────── ────────────── ─────────────────────────
"none" → (无) 任何人
"client" → requireClient 任意有效 Bearer token(任意作用域)
"host" → requireHost host token 或 owner 级 Bearer
"host-token" → requireHostToken 只认桌面 host token(如 /env 秘密)
requireClient(server.ts:990)从 Authorization: Bearer 取 token,交给 TokenService.scopeForToken 换作用域,组装成一个 Actor:
// server.ts:1002 —— Actor 带上 tokenHash(不存明文)与 scope
return { type: "remote", clientId, tokenHash: hashToken(token), scope };
注意 token 从不明文留存——审计和文件会话里记的都是 hashToken(token) 的哈希。
双层授权:先认证,handler 内再查作用域
requireClient 只保证「token 有效」,不保证「作用域够」。写操作的路由 handler 里会再调一次 requireClientScope:
// server.ts:2560 requireClientScope —— rank 不够就 403
function requireClientScope(ctx: RequestContext, required: TokenScope): void {
const scope = ctx.actor?.scope;
if (!scope) throw new ApiError(401, "unauthorized", "Missing token scope");
if (scopeRank(scope) < scopeRank(required)) {
throw new ApiError(403, "forbidden", "Insufficient token scope", { required, scope });
}
}
于是「一个 collaborator 能发 prompt 但 viewer 不能」这种规则,在几乎每个写路由开头都是一行 requireClientScope(ctx, "collaborator")(例见 routes/sessions.ts:191、routes/files.ts:802)。
代理面还有一条专门的闸门 assertOpencodeProxyAllowed(server.ts:631):viewer 只能 GET/HEAD,且不许通过代理去 POST /permission/:id/reply——否则一个只读者就能自己批准 OpenCode 的权限弹窗了。这里有段注释记录了 #1918:早期把它做成「仅 owner 能回应」,结果把 SPA 唯一持有的 collaborator token 也挡在外面,让每个交互式权限对话框都无法回答、工具调用永远卡在 running。
token 从哪来:TokenService
TokenService(tokens.ts:84)把 token 存成一个哈希列表文件(tokens.json)。签发时生成 owt_<random> 明文只回一次,存的是哈希:
// tokens.ts:113 —— 明文 token 只在创建时返回一次
const token = `owt_${shortId().replace(/-/g, "")}`;
// ... record.hash = hashToken(token)
解析时有个隐藏约定:配置里的静态 config.token 被直接当成 collaborator(tokens.ts:144),这就是桌面 SPA 引导期用的那个 OPENWORK_TOKEN。