跳到主要内容

openwork-server:OpenCode 之上的鉴权代理与文件系统 API

30 秒导读: OpenCode 本身是一个跑在 localhost 上、没有鉴权的编码引擎——谁能连上端口谁就是管理员。openwork-server 把它包起来:在它前面加一层 HTTP 服务,负责认证(你是谁)、授权(你能做什么)、审批(危险写操作要不要放行)、路径边界(能碰哪些目录),再把 OpenCode 的会话数据整理成一份稳定的只读模型吐给前端。一句话:它把「开发者的引擎」变成「可托管、可协作、可审计的控制面」。

本章聚焦这一层的服务端实现,主读 apps/server/src/server.tsapps/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代理面核心:把请求重写并转发给 OpenCodeserver.ts:884
TokenServicetoken→作用域 的解析与持久化tokens.ts:84
ApprovalService写操作的「允许/拒绝」闸门approvals.ts:16
ReloadEventStore内存里的 reload 事件环形缓冲events.ts:4
会话读模型把 OpenCode 原始数据整形成稳定结构session-read-model.ts

一条真实主线(高层,不进代码)

一个协作者的桌面 UI 想「把当前 prompt 发给引擎」:

  1. UI 请求 POST /w/ws_abc/opencode/session/<id>/command,带 collaborator token。
  2. 主处理器识别出这是代理面路径,调 requireClient 认证 → 得到一个 Actor{scope:"collaborator"}
  3. assertOpencodeProxyAllowed 检查作用域够不够(collaborator 可写、可回应审批)。
  4. resolveWorkspacews_abc 解析成一个真实的、且在授权根目录内的工作区。
  5. proxyOpencodeRequest 把路径从 /opencode/... 改写成 OpenCode 的 /session/.../command,注入工作区目录头,转发。
  6. 因为这是 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)在转发前会剥掉客户端的鉴权头(authorizationx-openwork-host-tokenx-openwork-client-idhostorigin),再按需换上工作区自己的 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能干什么
viewer1只读:GET/HEAD;不能改配置、不能回应审批
collaborator2日常读写:发 prompt、写文件、改会话
owner3管理:等价于桌面主机,能签发/吊销 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:191routes/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

3.3 工作区挂载与解析:把请求钉到一个真实、授权的目录

要解决的小问题: 每个请求都得知道「在哪个工作区、哪个磁盘目录里干活」,而且不能让人用 ../ 逃出授权范围。

从 URL 到工作区 ID

挂载解析函数很朴素——parseWorkspaceMount(server.ts:597)把 /w/ws_abc/foo 拆成 {workspaceId:"ws_abc", restPath:"/foo"}。工作区 ID 本身是路径的哈希(workspaces.ts:10 workspaceIdForPath,ws_ + sha256 前 12 位),保证同一目录稳定映射到同一 ID。

解析 + 授权 + 自愈:resolveWorkspace

真正的守门在 resolveWorkspace(server.ts:2504)。它做三件事:

resolveWorkspace(config, id)

├─ 1. 在 config.workspaces 里按 id 查(支持 rem_ 前缀别名)
│ 查不到 → 404 workspace_not_found

├─ 2. isAuthorizedRoot(resolve(path), authorizedRoots) server.ts:2538
│ 不在任一授权根目录下 → 403 workspace_unauthorized

└─ 3. (非只读时) ensureWorkspaceFiles + repairCommands
首次触碰的工作区自动补齐骨架文件,并触发一次引擎重载

授权检查 isAuthorizedRoot 用的是前缀 + 路径分隔符判断,而不是裸字符串 startsWith,避免 /home/user-evil 混进 /home/user:

// server.ts:2538 isAuthorizedRoot —— 必须相等或以 root+sep 开头
if (resolvedWorkspace === resolvedRoot) return true;
if (resolvedWorkspace.startsWith(resolvedRoot + sep)) return true;

更细的边界:授权目录(authorized-folders)

authorizedRoots 管的是「哪些工作区合法」;而授权目录管的是「OpenCode 在这个工作区里,除了工作区自身还被允许读写哪些外部目录」。它落在 OpenCode 配置的 permission.external_directory 里,openwork-server 提供一对 GET/PUT 路由把它抽象成一个干净的「文件夹白名单」列表(server.ts:1562server.ts:1572)。

内部用两个纯函数在「用户看到的文件夹」和「OpenCode 的 glob key」之间来回翻译:一个文件夹 /a/b 对应 key /a/b/*(authorizedFolderToExternalDirectoryKey,server.ts:1205);读回来时 externalDirectoryKeyToAuthorizedFolder(server.ts:1196)反向解析,并把无法识别为文件夹白名单的条目(hiddenEntries)原样保留,免得改一次白名单就把用户手写的高级规则冲掉。

3.4 能力协商:一份「这台服务器开了什么」的清单

要解决的小问题: 同一份客户端代码要面对配置各异的服务器(有的开了沙箱、有的是只读、有的关了收件箱)。与其让客户端猜,不如让服务器自报家门

GET /capabilities(routes/core.ts:262)返回 buildCapabilities(server.ts:1033)的结果,是一份结构化的功能矩阵。它的值几乎全部来自环境变量 + 只读标志:

// server.ts:1034 —— 写能力直接由 readOnly 反推
const writeEnabled = !config.readOnly;
// ...
skills: { read: true, write: writeEnabled, source: "openwork" },
sandbox: { enabled: sandboxEnabled, backend: sandboxBackend },
proxy: { opencode: opencodeConfigured },

沙箱、收件箱、发件箱、浏览器供给方等都由一组 resolveXxx() 函数从 OPENWORK_* 环境变量解析,并带默认值(例:resolveSandboxBackenddocker/container,否则 none,server.ts:1081;resolveBrowserProvidersandbox-headless/host-interactive/client-interactive 映射成放置位置与模式,server.ts:1131)。opencodeConfigured 则看是否有任一工作区配了 baseUrl——也就是「代理面到底通不通」。

3.5 会话/审批读模型与事件流:把易变的引擎数据变成稳定契约

这一节把三件相关的事放一起讲:读模型(整形引擎数据)、审批(拦截写操作)、事件流(通知客户端刷新)。它们共同构成「客户端看到的世界」。

读模型:用 zod 把 OpenCode 的原始形状锁住

前端不该直接吃 OpenCode 的原始 JSON——那个形状会随引擎版本漂移。会话路由(routes/sessions.ts)一律先经 session-read-model.ts 里的 buildSessionList / buildSession / buildSessionMessages / buildSessionSnapshot 整形。这些函数用 zod schema(session-read-model.ts:14 起)校验并 passthrough 引擎数据,产出一份稳定字段集。快照接口甚至并发拉取会话、消息、待办、状态四路再合并:

// routes/sessions.ts:139 —— 一次快照 = 四路并发聚合
const [session, messages, todos, statuses] = await Promise.all([
opencode.session.get(...), opencode.session.messages(...),
opencode.session.todo(...), opencode.session.status(),
]);
return buildSessionSnapshot({ session, messages, todos, statuses });

上游 400/404 还会被 remapSessionReadError(routes/sessions.ts:63)翻译成语义化的 invalid_query / session_not_found,而不是把裸的 opencode_request_failed 抛给前端。

审批:每个写操作先过闸门

写操作在真正落盘前调 requireApproval(server.ts:2985),它把一个 ApprovalRequest(动作、摘要、涉及路径、actor)交给 ApprovalService(approvals.ts:16)。行为取决于模式:

approval.mode == "auto" → 立即 { allowed:true } approvals.ts:31
approval.mode == "manual" → 挂起一个 Promise,等主机回应
超时(timeoutMs)→ 自动拒绝 approvals.ts:42

谁来回应? 桌面主机。GET /approvalsPOST /approvals/:id 都是 host 鉴权(routes/operations.ts:58:62),也就是只有 owner/host token 能列出待批项并回「allow/deny」。这就是「AI 要写东西,弹窗到你桌面上等你点」的服务端实现。被拒绝时抛 403 write_denied

事件流:轮询式的「该刷新了」

openwork-server 不推 WebSocket,而是维护内存事件缓冲让客户端轮询。两套并存:

  • ReloadEventStore(events.ts:4):配置/插件/技能/MCP 变更事件。带去抖——同一 (workspace, reason) 750ms 内只记一条(recordDebounced,events.ts:32),避免文件保存抖动刷屏。客户端 GET /workspace/:id/events?since=<cursor> 拉增量(routes/operations.ts:31)。
  • FileSessionStore(file-sessions.ts:33):文件读写的会话与逐条 write/delete/rename/mkdir 事件,也是 ?since= 游标增量拉取。

这些事件从哪来?一部分是路由里写操作后主动 emitReloadEvent;另一部分来自 reload-watcher.tsstartReloadWatchers(reload-watcher.ts:24)——它在磁盘上监视工作区配置目录,文件一变就往同一个 ReloadEventStore 记一条,于是「有人直接在磁盘上改了 opencode.json」也能让 UI 感知。


4. 深入实现:路由表是怎么装起来的

控制面所有路由都进一张扁平的 Route[]。注册机制小到可以一眼看完(routes/registry.ts):addRoute:param 形式的路径编译成正则、抽出参数名,连同 methodauthhandler 压进数组;matchRoute 线性遍历找第一个 method + 正则都命中的:

// routes/registry.ts:49 pathToRegex —— :id 变成一个捕获组
const pattern = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
keys.push(key);
return "([^/]+)";
});
return new RegExp(`^${pattern}$`);

createRoutes(server.ts:1297)就是把注册工作分模块委派出去,每个模块拿到一包共享的辅助函数(jsonResponseresolveWorkspacerequireClientScopeensureWritable…)后往同一个 routes 数组里塞:

注册器负责的路由域文件
registerCoreRoutes健康检查、/status/capabilities/whoami、token、env、voiceroutes/core.ts:58
registerWorkspaceRoutes工作区增删改、激活、显示名routes/workspaces.ts
registerSessionRoutes会话读、会话分组routes/sessions.ts:46
registerOperationRoutesreload 事件、引擎重载、审批routes/operations.ts:20
registerFileRoutes文件会话、批量读写、收件箱/发件箱、artifactsroutes/files.ts
(inline in createRoutes)配置读写、授权目录、cloud/claude 插件、审计、runtime-configserver.ts:1355+

一个观察:注册器之间零耦合,全靠传进去的函数包通信。这让 3000 多行的 server.ts 得以把「策略」(鉴权、审批、解析工作区)集中定义,把「端点」按域拆散到 routes/

引擎重载值得单独看。POST /workspace/:id/engine/reload 最终调 reloadOpencodeEngine(server.ts:2784):它 POST 引擎的 /instance/dispose 让它丢弃内存态、从磁盘配置重建,随后再把 runtime-DB 里的 MCP 通过引擎的动态接口重新推一遍(syncRuntimeMcpToOpencodeEngine,server.ts:2828)——因为 dispose 只会从磁盘配置重建,非主工作区的 runtime MCP 只能靠这次动态推送才能到达引擎。这层「配置改完 → 重载引擎 → 补推 MCP」的编排,正是控制面凌驾于引擎之上的体现。


5. 巧妙之处(可借鉴的技术)

  • 凭证隔离,双向都不漏。 代理转发前删掉客户端 openwork token,换上工作区自己的引擎 auth(server.ts:900-914);token 只存哈希,审计里也是哈希(tokens.ts:117server.ts:1002)。上游引擎永远看不到 openwork 的凭证,反之亦然。

  • 响应消毒防浏览器踩坑。 sanitizeProxyResponse(server.ts:949)删 content-encoding 等传输头,一句话堵住 ERR_CONTENT_DECODING_FAILED 这个跨运行时的经典代理 bug。

  • 长任务 fire-and-forget + 事件流兜底。 命令类请求不阻塞等待,立即 accepted,进展从 OpenCode 事件流回流(server.ts:923)。把「同步 RPC」拆成「提交 + 订阅」。

  • 作用域用 rank 数字比较,而非枚举散落。 scopeRank(server.ts:2554)让「≥ collaborator」这类判断变成一次整数比较,新增一档也不必改各处判断。

  • 授权目录保留未知条目。 读写文件夹白名单时把无法识别的 external_directory 条目当 hiddenEntries 原样带回(server.ts:1213server.ts:1260),UI 的简化视图不会误删用户的高级规则。

  • 配置 file→DB 迁移读时自愈。 readOpenworkConfigForWorkspace(server.ts:2703)以 DB 为准,首次读到旧的 .opencode/openwork.json 就顺手把内容播种进 DB,之后不再写文件——迁移无感、幂等。

  • 事件去抖。 recordDebounced(events.ts:32)让频繁的磁盘保存不会把事件流刷爆。


6. 边界与局限(诚实)

  • 单活跃工作区的心智模型。 无挂载的 /opencode/*/status 都落到 config.workspaces[0];用挂载 base URL 时还强制「嵌套的 /workspace/:id 必须等于挂载 id」(server.ts:746-754)。多工作区能力有,但对外表现刻意收敛成「一次一个」。

  • 事件是轮询而非推送。 没有 WebSocket/SSE;客户端靠 ?since= 游标轮询 ReloadEventStore / FileSessionStore。事件缓冲是内存里的环形队列(默认 200 / 500 条,events.ts:10file-sessions.ts:43),进程重启即丢、落后太多的游标会漏事件。

  • 审批是内存态、易失。 ApprovalService 的待批项只在内存(approvals.ts:18);进程重启,挂起的 Promise 全没,超时即拒。

  • 鉴权粒度到作用域为止。 三档 scope 是全局的,没有「按路径/按资源」的细粒度 ACL;授权边界靠 authorizedRoots + 授权目录在文件系统层兜底,而非每端点的策略。

  • 代理信任下游。 openwork-server 假定它前面的 OpenCode 引擎是可信的本地进程;它做的是「谁能调引擎」的门禁,不是「引擎内部行为」的沙箱(那是 sandbox/浏览器供给方等能力层的事,见 buildCapabilities)。


7. 横向对比(同库兄弟章)

openwork-server 在整个 OpenWork 里扮演「控制面 / API 网关」的角色,和其它层的分工:

  • 被谁拉起、被谁监管——02-orchestrator.md:主机运行时把 openwork-server 当作三个 sidecar 之一来监管。
  • 代理的引擎能扩展出什么——04-extensibility.md:技能 / MCP / 插件的具体机制(本章只讲这些资源在 API 上的读写与重载编排,不讲它们内部如何工作)。
  • 服务的前端长什么样——05-frontend.md:单一 React UI 如何消费这里的读模型与事件流。
  • 如何延伸到云端——06-remote-cloud.md:消息连接器与托管 worker 如何作为远程客户端接入本章的 API。

一句话取舍:openwork-server 选择了**「薄网关 + 强边界」**——自己不复刻引擎能力,只在引擎前加认证、授权、审批、路径边界与稳定读模型,让一个开发者向工具安全地多人化、可托管化。


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

主题文件关键符号
服务启动与请求主干apps/server/src/server.tsstartServer
控制面路由注册apps/server/src/server.tscreateRoutes
路由表原语apps/server/src/routes/registry.tsaddRoute / matchRoute / pathToRegex
OpenCode 代理核心apps/server/src/server.tsproxyOpencodeRequest / buildOpencodeProxyUrl
代理响应消毒apps/server/src/server.tssanitizeProxyResponse
目录头/目录 fetchapps/server/src/server.tsbuildOpencodeDirectoryHeader / createOpencodeDirectoryFetch
代理作用域闸门apps/server/src/server.tsassertOpencodeProxyAllowed / isSessionCommandProxyRequest
认证闸门apps/server/src/server.tsrequireClient / requireHost / requireHostToken
作用域分级apps/server/src/server.tsscopeRank / requireClientScope
Token 存储与解析apps/server/src/tokens.tsTokenService (scopeForToken / create)
工作区解析与授权apps/server/src/server.tsresolveWorkspace / isAuthorizedRoot
工作区挂载解析apps/server/src/server.tsparseWorkspaceMount / parseWorkspaceOpencodeMount
工作区 ID / 连接apps/server/src/workspaces.ts · opencode-connection.tsworkspaceIdForPath / resolveWorkspaceOpencodeConnection
授权目录翻译apps/server/src/server.tsexternalDirectoryKeyToAuthorizedFolder / authorizedFolderToExternalDirectoryKey
能力协商apps/server/src/server.tsbuildCapabilities / resolveSandboxBackend / resolveBrowserProvider
审批闸门apps/server/src/server.ts · approvals.tsrequireApproval / ApprovalService
会话读模型apps/server/src/session-read-model.tsbuildSessionList / buildSessionSnapshot
会话路由apps/server/src/routes/sessions.tsregisterSessionRoutes / remapSessionReadError
reload 事件流apps/server/src/events.ts · routes/operations.tsReloadEventStore (recordDebounced)
文件会话与事件apps/server/src/file-sessions.ts · routes/files.tsFileSessionStore / registerFileRoutes
磁盘变更监视apps/server/src/reload-watcher.tsstartReloadWatchers
引擎重载编排apps/server/src/server.tsreloadOpencodeEngine / syncRuntimeMcpToOpencodeEngine
核心/env/token 路由apps/server/src/routes/core.tsregisterCoreRoutes
审批/事件/重载路由apps/server/src/routes/operations.tsregisterOperationRoutes