Repository navigation
Releases: westanke/dsh-remote-bridge
Release list
2.8.0
v2.8.0 · REST BFF:分块上传 / 定时任务 / 官方语音(2026-10-28)
状态:代码与文档已齐,版本号已升到 2.8.0、未打 tag、未推送。 依据
BFF-REST-CONTRACT.md 新增九条只增不改的 REST 端点,让 App 在没有
/ws/mobile 或内核未注入对应服务时仍有同语义退路。
新增
- 分块上传(
chat.write/chat.read):…/attachments/uploads的
POST/GET/GET {uploadId}/PUT chunks/{i}/POST complete/DELETE {uploadId}。
1 MiB 服务端切片、1 小时票据、alreadyReceived断点续传、重复 PUT 幂等、缺块
409+details.missing、超限413、票据未知/过期/跨会话404。 - 定时任务(
chat.write/chat.read):GET/POST /chat/schedules、
PATCH/DELETE /chat/schedules/{id}。五段 Vixie cron + IANA 时区自校验(与内核同判据,
不 import 内核包);PATCH只改传入字段。 - 官方语音(
chat.write/chat.read):GET /chat/speech/catalog、
POST /chat/speech/transcriptions、PUT /chat/speech/config。复用ws-speech.ts的
4 MiB 上限、180 s 超时与目录归一;内核无服务时 200{available:false,reason},
不是 404。
能力位
新增三个位(App 侧已按这三个字符串分支,不得改名或加别名),各自都能在
docs/api/openapi.yaml 里指到依据路径,tests/capabilities.spec.ts 逐条核对:
attachment-chunked-upload-v1→POST /chat/sessions/{sessionId}/attachments/uploads,不降级。chat-schedules-v1→GET/POST /chat/schedules;宿主ctx.schedule缺席时不发位
(此时列表与新建都只会 503,发位等于挂一个必然失败的入口)。speech-transcription-v1→POST /chat/speech/transcriptions;只在 typert Gateway
整体缺席时剔除,内核没装语音包时仍发位 —— 因为目录端点回的是200 {available:false,reason},
客户端要据此区分「桥支持、本机没配」与「桥不支持」,两种状态的 UI 不同。
与契约的三处显式偏差
契约 docs/project/BFF-REST-CONTRACT.md 有三条要求在现行内核上无法照字面实现,
本轮按「尽力实现 + 明确退化」处理,契约文件本身未改(由 Lead 定稿):
POST /chat/schedules的新建入口:内核scheduleRemote 只发布
catalog|changed|delete|history|list|update,没有 create。实现改走宿主 cordis 服务
ctx.schedule.create(sessionId, request);取不到该服务时回503 UPSTREAM_UNAVAILABLEdetails.reason(gateway/invocation-unavailable/schedule/create-unavailable)。
Schedule.createdAt:内核ScheduleRecord没有创建时间字段,视图恒为null(字段保留,
客户端不得据此排序)。PATCH {enabled:false}:内核没有「暂停」操作,静默成功会让 UI 显示已暂停而提醒继续触发,
因此回400 BODY_INVALID+details.reason:'ENABLED_UNSUPPORTED';要停请DELETE。
另:列表只投影 cron 形态,at/every提醒不在 REST 列表中出现。
门禁
npm run typecheck0 错;npm run test全绿(含新增tests/bff-rest.spec.ts);
npm run docs:check(12 docs / STRUCTURE 72 锚点 / INTERFACES 87 锚点);
npm run build通过。- 文档同步:
docs/api/openapi.yaml(+9 路径 / +13 schema)、docs/project/API.md
(新增「REST 对等面」节 + 对等矩阵三项状态)、INTERFACES.md、STRUCTURE.md、
SECURITY.md。
2.7.2 · 版本升级与测试同步(2026-10-28)
基于:v2.7.1。主要完成版本号从 2.7.1 升级至 2.7.2,同步测试预期值、文档版本坏ges与产物校验值。
2.7.1
2.7.1 · 修「发文件永远失败」:解包内核的 {agent} / {error} 包装对象(2026-10-27)
基于:v2.7.0。一次纯缺陷修复,只有一处实现改动 + 三条回归用例,其余代码零改动。
现象与根因
真机上的表现是「发文件永远 400/500,报错信息完全指不到原因」,附件上传与 agent preset
选择两条路径都挂,而日志里只有一句:
DSH_TYPEERROR: Cannot read properties of undefined (reading 'header')
追到 agentFor():它拿 host.sessionController.resolveAgent(sessionId) 的返回值直接当
Agent 用。但内核返回的是包装对象,不是 Agent 本体 ——
dsh-api-session-controller/lib/index.js:365-368 的 liveAgent() 与 :221-246 的
resolve() 都是 { agent }(成功)/ { error }(失败)这个形状。
于是 agent.session.header.origin 变成 undefined.header.origin —— 桥把 {agent: {...}}
当成 Agent 传给 fileUploads.upload(),内核按 Agent 契约去读 session.header,
读到的是 undefined。报错点在消费侧,所以栈里看不到桥的位置,这也是它「指不到原因」
的真正理由(PITFALLS.md §论证要先追到消费侧 的又一例)。
修法
- 识别包装形状(对象且含
agent或error键):成功解出.agent;失败原样抛.error,
保留内核给的稳定 code。这一点是刻意的 —— 一律压成session/agent-unavailable会让
「会话不存在」看起来像「会话没在跑」,排查方向直接跑偏。 - 保留裸对象兼容:某个内核版本或测试替身直接返回 Agent 本体时照旧可用,不制造新分支。
- 三条回归用例钉住三种返回形状(包装成功 / 包装失败 / 裸对象),失败路径断言的是
「内核的 code 原样透出」而不是「抛了错」。
门验
tsc 0 错误、vitest 76 文件 / 701 用例全绿、docs:check 通过、build 通过。
2.7.0 · 对齐 DSH 0.2.1-rc.3 的四项能力提升(2026-10-25)
基于:v2.6.1。rc.3 与桥的兼容性做了全量核对(305 个包逐个 sha256 比对:285 个代码零改动、
20 个有实质改动,10 个硬依赖服务提供方全部落在零改动名单,4 个 slot 全保留、删除 0),
结论是桥不改一行也能在 rc.3 上跑(已实测启动 + 探活 + 客户端模块图 + 桥 bundle 全通)。
这一版做的是把 rc.3 送上门的新能力真正接进来,不是修兼容。
一、模型目录补上 contextWindow / maxTokens
ModelProviderGroupView.models 早就声明了这两个字段,但全仓无赋值 —— 永远是 undefined。
根因在契约层:内核 ModelCatalogModel 只有 id / name / description / reasoning,
容量只在 llm.resolveModelInfo(provider, model) 的 context / defaultMaxTokens 里。
做法:新增 modelCapacityOf,按 provider + model 缓存 10 分钟(与内核
DEFAULT_MODELS_MAX_AGE_MS 对齐),失败不缓存(一次 RPC 抖动不该让某模型十分钟没容量),
Promise.all 并发查(几十个模型串行会把选择器拖成几秒空白),且内核自己给了就不覆盖。
接入 sessions.models 与 llm.models 两处门面(两者同源)。
wire schema 无需改 —— docs/api/openapi.yaml 的 Model 与 ProviderModel 早已含这两个字段。
实测注意:DSH Web 的模型选择器只渲染 name,不读 contextWindow,所以 Web 界面上看不到
容量数字。该字段的实际受益方是读 REST 的客户端(安卓端已接,见 dsh-companion v2.2.55)。
二、插件清单多了右侧栏形态
2.7.0
2.7.0 · 对齐 DSH 0.2.1-rc.3 的四项能力提升(2026-10-25)
基于:v2.6.1。rc.3 与桥的兼容性做了全量核对(305 个包逐个 sha256 比对:285 个代码零改动、
20 个有实质改动,10 个硬依赖服务提供方全部落在零改动名单,4 个 slot 全保留、删除 0),
结论是桥不改一行也能在 rc.3 上跑(已实测启动 + 探活 + 客户端模块图 + 桥 bundle 全通)。
这一版做的是把 rc.3 送上门的新能力真正接进来,不是修兼容。
一、模型目录补上 contextWindow / maxTokens
ModelProviderGroupView.models 早就声明了这两个字段,但全仓无赋值 —— 永远是 undefined。
根因在契约层:内核 ModelCatalogModel 只有 id / name / description / reasoning,
容量只在 llm.resolveModelInfo(provider, model) 的 context / defaultMaxTokens 里。
做法:新增 modelCapacityOf,按 provider + model 缓存 10 分钟(与内核
DEFAULT_MODELS_MAX_AGE_MS 对齐),失败不缓存(一次 RPC 抖动不该让某模型十分钟没容量),
Promise.all 并发查(几十个模型串行会把选择器拖成几秒空白),且内核自己给了就不覆盖。
接入 sessions.models 与 llm.models 两处门面(两者同源)。
wire schema 无需改 —— docs/api/openapi.yaml 的 Model 与 ProviderModel 早已含这两个字段。
实测注意:DSH Web 的模型选择器只渲染 name,不读 contextWindow,所以 Web 界面上看不到
容量数字。该字段的实际受益方是读 REST 的客户端(安卓端已接,见 dsh-companion v2.2.55)。
二、插件清单多了右侧栏形态
成为 SidebarRightTabDefinition 提供方:注册 dsh-remote-bridge-plugins 这个 tab 类型,
带一个 guide 条目(order: 40,排在官方 files/terminal/browser 之后),body 复用
/settings/plugins。rc.3 起该卡片可被 pin、出现在 guide 的 More 菜单里;rc.2 的 GuideBody
没有 entryPins / entryActions / setPinned,卡片正常显示但没有 pin 与 More 菜单 —— 属预期降级。
用 ctx.inject(['sidebarRightTabs'], ...) 动态注入而不是写进静态 inject:sidebarRightTabs
来自可选安装的 @deepseek-ai/dsh-client-ui-sidebar-right,写死会让那种宿主上整个桥不启动。
三、POST /chat/sessions 新增可选 reuseBlank
DSH 0.2.1 桌面端点「新会话」会回到那个工作区已有的空白会话,但内核 RPC 不做这层挑选
(sessionController.create 只在调用方显式传了 sessionId 时才复用)。桥补上这层:
reuseBlank省略 =true,对齐桌面语义;显式false强制新建。- 判据与官方客户端
createInWorkspace逐字同义:blank && !archived && cwd 匹配 && 属于该工作区,
外加「声明了别的 preset 就跳过」(没声明不等于声明了别的,否则老会话全挑不中)。 - 老的
rootId + path入口没有工作区摘要可用,判据退化为「同 cwd 且空白」。
为什么默认 true 是安全的:老客户端从不带这个字段,带上的行为才是它期待的那个;
安卓端一直不传 sessionId,所以这一版让它每点一次「新会话」回到同一空白会话 —— 那正是
DSH 0.2.1 桌面端的行为。
四、设置页新增「插件」卡片
GET/PATCH /settings/plugins 两个端点 v2.5.0 就有了,但桥客户端一直没有调用它们的方法。
这一版补齐:WorkspaceApi.pluginInventory(checkUpdates) / togglePlugin(name, loaded),
设置页第七张卡(清单 / 启停 / 检查更新),以及「打开插件列表」按钮。
按钮调 pluginNavigation.openList() —— rc.2 没有这个方法(只有 openBundle),
所以做 typeof nav.openList === 'function' 特性检测,不支持时提示而不是假装成功。
版本判断在这里是错的:能力位才是契约,版本号只是间接证据。
插件清单的四个类型(PluginEntry / PluginInventory / PluginState / PluginToggleResult)
从 src/host/plugin-inventory.ts 迁到 src/shared/contracts.ts:浏览器半边必须能 import 它们,
而 client/ → host/ 是 STRUCTURE.md 明令禁止的依赖方向(会把 Node 内置模块拖进浏览器包)。
2.6.1
2.6.1 · SEC-H11 SSRF 与凭据外泄修复 + 设备身份自动识别(2026-10-23)
基于:v2.6.0(含 settings-namespaces-v1 能力位补漏)。这是一次安全修复,
起因是用户问「看桥还有其他的漏洞吗」——于是把审计做成实测而非推理:直接 curl 打
运行中的桥,用真实凭据验证攻击链是否成立。
发现过程
问题不在命名空间写策略,而在旁边那条专用路由。SECURITY.md 明写 llm-*
(供应商、模型、Base URL)只读,理由是「手机能改 Base URL = 把模型请求引到攻击者的
端点」;但 isSettingsNamespaceWriteAllowed 只被 mutateNamespace 调用,
createProvider/updateProvider/discover 三个写入口一次都不检查。同一个目标,
一条路堵死,另一条路敞着。
比「能改配置」更糟的是 discover:它把 baseURL 交给内核发 GET {baseURL}/models,
内核带着该供应商已配置的真实 key。实测起一个 python http.server,对官方
deepseek(DEEPSEEK_API_KEY 已配置)发起 discover,服务端日志里直接收到:
GET /v1/models
authorization: Bearer sk-…(真实生产 key)
user-agent: deepseek-harness/0.2.1-rc.2
也就是说这不是 SSRF 而已,是凭据外泄:攻击者只要让受害者点一下「发现模型」,
或者自己持 settings.write 直接调,就能拿到明文 key。
三个入口都没拦:createProvider 只判 input.baseURL.trim() === '',
updateProvider 只判 value === null || value === '',discover 原样透传。
持久化变体更彻底:PATCH /settings/providers/{id} 改 baseURL 会真写进配置,
此后每次聊天都把 key 打到新地址。而 router.ts 的审计只记 providerId 不记地址
(discover 的 baseURL 更是临时覆盖、不落配置),所以事后无法从审计追溯凭据被
打到哪个地址 —— 取证盲区。
零测试覆盖:grep baseURL tests/*.spec.ts 找不到任何 ssrf/private/169.254 断言,
tests/settings-adapter.spec.ts:79-86 只验了 https://api.example/v1 这条正常路径。
顺带发现配对默认权限自相矛盾:WebUI「生成配对码」默认勾满七档 scope(含
settings.write),而「生成配置文本」那条路径刻意只给五档(注释
No scopes on purpose)。CHANGELOG-DEV.md:2913-2915 又明写 settings.write
「是用户显式要求才给的东西」——三处声明与默认值互相打架。
修了什么
① 地址策略(新文件 src/host/provider-url-policy.ts,186 行)
三道闸门,三个写入口全部生效:
| # | 判据 | 拒绝码 | 挡的是什么 |
|---|---|---|---|
| 1 | 绝对 URL + 协议限 http/https |
400 PROVIDER_BASE_URL_INVALID |
file:/gopher:/data: 这类由 URL 解析器自己发起的协议 |
| 2 | 私网/回环/链路本地/CGNAT 默认拒绝 | 403 PROVIDER_BASE_URL_FORBIDDEN |
SSRF:内网探测、云元数据 169.254.169.254 |
| 3 | 已存凭据的供应商换 host 必须同时给新 key | 403 PROVIDER_BASE_URL_REPOINT_FORBIDDEN |
换成攻击者的公网域名 —— 闸门 2 对此无能为力 |
闸门 3 是实施中补的、审计报告里没有的:只拦私网不够。攻击者不需要内网,
他只需要一个公网域名 —— 内核照样会把 key 发过去。真正的不变量是「不能让内核把
已有凭据发到新目的地」。所以只在 baseURL 的 host 真的变了、且该供应商
credentials.describe 报 configured: true 时才查,纯改 models/显示名/同 host 改
path 都是零额外开销的日常操作。
allowPrivateProviderHosts 放行自建 LLM(ollama 默认 127.0.0.1:11434)。两种粒度:
带端口条目按 host:port 精确比对(推荐),裸 host 条目放行该主机全部端口。不支持
CIDR —— 只接受精确 host。理由:放行 192.168.1.5 等于放行它上面所有服务,而 ollama
只在 11434。
刻意不做 DNS 解析:校验时解析一次、内核发请求时再解析一次,中间可变(TOCTOU),
而且会给出「已经校验过」的错觉。攻击面因此被收窄到「攻击者必须控制一个公网域名」,
而不是「攻击者必须控制 DNS」。
2.6.0
2.6.0 · 通用设置命名空间面 + IM 远程开隧道(2026-10-22)
基于:v2.5.2。目标是不再为每个插件写专用设置接口 —— 手机端直接读内核按插件
划分的设置命名空间、按 schema 自行生成表单,新装插件无需改 App。
改动:
- 新增
GET /api/v1/settings/namespaces(settings.read)与
PATCH /api/v1/settings/namespaces/{ns}(settings.write),形状与错误码见
docs/project/API.md同名小节与openapi.yaml的SettingsNamespace*。 - 写策略默认只放行
ui-*与以locale结尾的命名空间;llm-*/permission/
agent-default-model/subagent-*只读,唯一放宽入口是插件配置
settingsWriteNamespaces(精确 ns 或前缀*,默认空)。理由见SECURITY.md。 - 密钥两道脱敏(内核
redactSecrets+ 桥按secrets[].path/键名兜底),审计
settings.namespace.mutate只记字段路径,不记值。 - 纯逻辑拆到新文件
src/host/settings-namespace.ts(写策略/脱敏/ops 校验/投影),
settings-adapter.ts只保留 RPC 调用与错误映射,避免越过 600 行单文件预算。 - 新增
tests/settings-namespaces.spec.ts:鉴权、策略、脱敏、revision 冲突、
ops 校验、审计,以及「非允许 ns 不触达内核 mutate」。
未做:未升 package.json 版本号、未发版;真实内核的 settings.mutate 已由桥代理,
真机验收待发版后由用户执行。
IM 远程开隧道:宿主命令 /tunnel 与工具 open_tunnel_config
动机:真实场景的缺口 —— 人已出门,才发现电脑上的隧道没开、配置文本也没发给自己。
此时没有任何入站通路,WebUI 与 App 都够不着;但 IM 机器人是本机主动连出去的长连接,
不需要入站端口,正好是这条「反向开机」通道。
改动:
- 新增宿主命令
/tunnel(全局层ctx.commands.register)与工具open_tunnel_config
(全局层ctx.tools.register,tools服务缺失时静默跳过),实现在新文件
src/host/im-command.ts。两者共用ApiRouter.openTunnelForCommand(),回的都是
「隧道地址 + 设备名 + 一行DSH1:配置文本」。 - 为什么两个入口都要:
@xmanrui/dsh-im5.4.0 的斜杠命令表封闭(内置 31 条 +
/compact//clear//permission三条固定委派),没有「未知命令转交宿主命令表」的通用派发、
也从不查commands.list—— IM 里发/tunnel只会变成一句普通消息。所以命令给能发命令的
客户端,工具给只会说话的 IM 窗口(用户说「帮我开隧道」→ 模型调工具)。 src/host/router.ts把POST /manage/cloudflare/create的实体抽成私有
createTunnel(createBody, requestId, actorLabel),新增公有openTunnelForCommand();
HTTP 入口与命令入口共用同一条实现,避免「一键出令牌」在两条路径上漂移。- 能力边界:只开不停、不接受参数、不读文件。隧道已在运行时复用当前地址出配置,
不重复startQuick(createTunnel里第二次 spawn 会撞TUNNEL_ALREADY_RUNNING)。 - 审计:
action=cloudflare.create,来源标签im-command(本机管理面是loopback-admin),
复用场景额外记details.reused=true。 - 不新增任何 REST 端点、不给管理面判据开任何口子:命令在进程内直接调用路由方法,
所以不需要(也不应该)为它豁免TUNNEL_MODE_ADMIN_DISABLED。 - 测试:
tests/im-tunnel-command.spec.ts(8 例:成功文案/参数拒绝/失败降级/并发保护/注册注销)
与tests/tunnel-create-config.spec.ts新增 4 例(起隧道+等地址、复用不 spawn、等不到地址抛错、
审计来源标签)。 - 文档:README 新增「在聊天窗口里远程开隧道」一节(含 allowlist 告警框)、
SECURITY.md 新增「IM 命令/tunnel的信任边界」、INTERFACES.md 注明宿主命令不在 REST 表内、
STRUCTURE.md 补文件行。
未做:未升版本号、未发版;口令/白名单门禁不在本仓——依赖 @xmanrui/dsh-im 的
访问策略(allowlist + canExecuteCommands),部署侧必须保持白名单模式。
一键清理未使用设备 + 审计页下线
2.5.2
dsh-remote-bridge v2.5.2
五维(安全 / 功能 / 性能 / 界面 / 按钮反馈)全量加固版,并修掉真机三轮隧道管理面回归。测试 67 个文件 / 557 个用例 + 8 个 Playwright 浏览器层用例全绿。
🔒 安全
- 修复高危免鉴权信息泄露:
GET /api/v1/debug/host-services此前在鉴权之前返回,任何人(含隧道/局域网)无凭据即可读到内核服务注入面与内部错误文本;现移到鉴权之后并要求settings.read。OpenAPI 的security豁免集与代码「鉴权前返回端点」建立自动双向 diff 门禁,防止再次漂移。 - 限流从「所有来源共用回环桶」改为本机 / 隧道双桶,消除隧道下扫描者连本机面板一起 429 的自伤。
- cloudflared 下载增加 SHA-256 完整性校验旁车(
CLOUDFLARED_SHA256),并统一两处不一致的钉版本。 - 非法 URI 编码(
%ZZ)统一返回 400 而非 500;CORS 只对回环回显、不再回显 admin 请求头;错误响应脱敏(不回显绝对路径与完整镜像 URL)。 - 四条敏感管理操作的审计补齐
requestId且与业务同事务(审计失败则业务回滚);交接图 stat 前置限流 + 文件名哈希自证;列表游标严校;/manage前缀改精确匹配。
🚀 功能与性能
- 能力位按 jobs 服务可用性运行时过滤(缺 jobs 控制器时不再虚发三个标志位);OpenAPI 补齐 4 个已实现端点;审批 / 提问 10 分钟无响应自动结算(审批按 delegate,绝不默认批准)。
- WebSocket 广播改 per-socket 队列:连接间并行、单连接内保序,慢连接不再连坐所有客户端;30s 心跳 + 连续两次无 pong 踢连接 + 4MiB 背压 close(1013) + 32 连接 / 1 MiB 消息上限;溢出优先丢弃增量帧并下发新事件
connection.resyncRequired。 - 历史裁剪从「每条最多序列化三次」改为单遍 + 长度缓存;文件 ETag 从全量 SHA-256 改为 O(1) 弱 ETag 并真正返回 304;审计 / 幂等两表加保留策略(90 天 / 50 万行、幂等 24 小时);新增只读指标采集(RPC 耗时 P50/P95)。
🎛 界面与按钮反馈
- 修复窄屏两个硬伤:CSS 里
@mediaa拼写错误导致整条 720px 媒体规则失效(375px 下表单三列、保存按钮被挤成 37px);box-sizing 漏盖内联设置容器导致横向溢出。新增 375px 视口的 Playwright 契约测试锁死。 - 普通按钮 / 输入框跟随宿主深浅色令牌(此前深色主题下白底黑字);禁用按钮统一半透明 +
not-allowed;危险操作(删根、吊销设备、关远程、停隧道)统一二次确认。 - 隧道进度文案改 i18n(此前服务端硬编码中文,英文界面也显示中文);新增成功反馈条;配对码加倒计时与复制、隧道地址一键复制;审计空态与分页;设备显示最后使用时间、uuid 缩写;图片灯箱点图片不再误关闭、加载失败有占位、多图可前后切换;面板顶栏挂上语言开关;清理死样式。
🚇 隧道「一键出令牌」(真机回归修复)
- 「开启隧道并生成配置」此前要先开隧道再点一次生成,且隧道一开管理面即安全锁定,第二步必然 403 —— 现改为在同一次开启请求内等地址并搭车生成
DSH1:令牌,一次点击直接拿到可复制配置。 - 隧道运行中管理面安全锁定保持不变,但**「停止云隧道」始终可用**(stop 是唯一豁免的写操作,唯一效果是把管理面重新锁回,不构成提权;真实隧道来源的
cf-*转发请求仍一律 403)。 - 修复重进设置页时药丸误显「隧道已停止」、所有按钮灰掉的问题(刷新顺序导致豁免的隧道状态查询没执行):锁定期间除两个隧道出口外全部禁用并显示常驻说明,停止隧道后自动恢复。
校验
- npm 包:
dsh-remote-bridge-2.5.2.tgz,635,325 字节,SHA-25618d3690462b4fff72299cb1a3c54623b929a3933597c47dc4486e9f0b9fc8095(带 v 与不带 v 两个文件名内容相同,见 Release 附件SHA256SUMS.txt)。 - 完整开发记录见仓库
docs/project/CHANGELOG-DEV.md的 2.5.2 节。
2.5.1
2.5.1 · 堵住隧道/反代下的管理面提权(安全修复,2026-10-21)
基于什么
2026-10-21 的安全审计报告 §3.1 与 §3.6 定案的两条真 bug(报告:
研究类/安全与界面优化-20261021/安全审计-dsh-remote-bridge.md):
- 高危|远程提权:
/manage/**与回环 admin 头(X-Dsh-Workspace-Admin: 1)的唯一判据
是「TCP 来源是否回环 + Origin 是否回环」。而插件自带的 cloudflared Quick Tunnel 用
--url http://127.0.0.1:PORT转发,公网请求进到 bridge 时remoteAddress恒为 127.0.0.1,
回环校验对隧道流量完全失效。实测无凭据POST /manage/pairings→ 201 → 兑换出完整设备令牌
(scopes 含files.write);GET /manage/roots直接吐全部授权根的绝对路径。
同类拓扑:ngrok、frp、Caddy/Nginxproxy_pass 127.0.0.1、SSH 端口转发、Tailscale Serve。
放大器另有两条:isLoopbackOrigin(undefined) === true(curl 天然不带 Origin 即为「同源」),
以及 Host 头完全没人校验(DNS 重绑定不设防)。 - 中危|
.heif交接图 100% 打不开:写盘表把image/heif映射成扩展名heif,
读取表把heif声明成image/heif,而嗅探器对 ISO-BMFF 的hei*/hev*/mif*一律返回
image/heic;一致性校验要求完全相等,于是恒 415。
干了什么
src/host/auth.ts:新增统一判据adminRefusal/isLocalAdminRequest,
四层纵深(0 隧道运行中整体拒 → 1 出现任何转发头按代理拒 → 2Host必须回环、缺失也拒 →
3 socket 与 Origin 回环)。requireAdmin与principal({allowAdmin})共用同一份,
并各自给出分明的错误码:TUNNEL_MODE_ADMIN_DISABLED/ADMIN_PROXY_FORWARDED/
ADMIN_HOST_NOT_LOOPBACK/ADMIN_LOOPBACK_REQUIRED(后者是 2.5.0 就有的码,
安卓端只消费它的文案,保留作兜底)。
Origin缺失仍然放行,这是刻意的:它不再是信任来源,真正确立信任的是判据 0/1/2;
翻严只会打死curl与本机脚本调试,挡不住隧道(隧道流量先撞判据 0)。理由写进了源码注释。src/host/cloudflared.ts:新增tunnelActivegetter(与publicState.running
同一个表达式),作为判据 0 的真相来源。src/index.ts:把CloudflaredTunnelManager的创建提前到AuthService之前,
并以闭包() => tunnels.tunnelActive注入 —— 请求时刻现读,隧道后来启停也能跟上。src/host/router.ts:解掉「隧道一开,面板连隧道状态都读不到」的自相矛盾。
TUNNEL_MODE_EXEMPT_PATHS让GET /manage/cloudflare/status|activity豁免判据 0
(供本机面板读「隧道开着」;判据 1/2/3 照旧生效,所以隧道来源连这两个端点也 403、
读不到隧道地址 —— 「远程读得到 running 但读不到 url」被明确否决,那要在判据 1 上再开口子)。- 判据 1 增加了 Cloudflare 边缘头(
Cf-Connecting-IP/Cf-Ray/Cf-IpCountry/Cf-Visitor):
因为 cloudflared 的httpHostHeader默认为空、会把Host改写成回环地址,真实隧道下
判据 2 挡不住它。实测确认(只改 Host 能拿到 200,加边缘头才被挡)。 src/host/image-handoff.ts:一致性校验改为按图片家族比较(sameImageFamily),
image/heic与image/heif同族放行。没有让嗅探器伪造image/heif——
HEIC/HEIF 在字节层无法区分,让嗅探器返回它无法自证的值等于让内容自证这道闸门说谎。
跨家族错配(png 字节配.jpg名、heic 字节配.avif名)仍然全部 415。- 版本:
package.json与src/shared/version.ts同步 2.5.0 → 2.5.1(安全修复,patch 版);
OpenAPI / AsyncAPI 的info.version同步。
测了什么
tests/admin-loopback-escalation.spec.ts:原有 5 个it.fails全部改回it并真实通过;
文件从 6 例扩到 16 例,新增:判据 0 的整体拒绝、桌面端(回环 Origin + 回环 Host +
admin 头)仍然放行、隧道状态可读但url被隐去、转发头被拒、
requireAdmin与principal({allowAdmin})对同一请求结论一致、判据矩阵(纯函数层,
含四种码与「允许缺 Origin、绝不允许缺 Host」)。tests/handoff-extension-consistency.spec.ts:.heif由「断言 415」改为「断言 200」,
并保留「嗅探器永远不会返回image/heif」这条根因断言(现在它是性质而非 bug)。tests/security.spec.ts/tests/http-api.spec.ts:按新契约更新(转发头场景由 401 改 403 并断言码)。- 全量:47 个测试文件 / 396 个用例 / 0 失败 / 0 expected fail(修复前 386 例含 5 个 expected fail)。
typecheck与docs:check均通过。
2.5.0
dsh-remote-bridge v2.5.0
目录元数据透传(新)
设置「模型」页要对齐官方 Web 的「添加模型提供商 → 第三方模型提供商」,前提是客户端能看到目录里还没配置的协议商及其默认地址、环境变量、认证方式。这些信息内核早就在 llm/listConfigurableProviders 里返回了,只是没往前送。
现在 GET /api/v1/settings/providers 的每一行都会带上(全部可选,缺省不出现):
settingsNs/settingsPath— 这条 profile 落在哪个命名空间(目录条目多半是llm-pi-ai)defaultBaseURL— 适配器给的默认地址baseURLEnv— 可覆盖默认地址的环境变量,按优先级排序nativeAuth—{ env?: string[], oauth?: boolean },适配器自读的凭据来源
所以「休眠」的目录条目(configured: false)也能直接作为添加卡的候选,settingsNs == 'llm-pi-ai' 的那批还能免填地址直接问模型目录。
ProviderPatch.models 支持 null(新语义)
- 数组 = 整体替换模型目录(空数组 = 自定义空目录)
null= unset,恢复适配器默认目录 —— 这是客户端「恢复默认模型」按钮的依赖语义
expectedRevision 不匹配时仍返回 409 SETTINGS_CONFLICT。
兼容性
新字段全部可选,旧客户端不受影响;旧客户端继续按数组写 models 也不会触发 unset。
五处同步
docs/project/API.md / docs/api/openapi.yaml(新增 NativeAuthHint,models 可 null)/ docs/api/asyncapi.yaml / kotlin-sdk(NativeAuthHint、ProviderView 三字段、resetProviderModels、deleteProvider)/ tests/settings-adapter.spec.ts(目录元数据投影 + models: null 两用例)。
pnpm check 全绿:42 文件 349 测试 + docs:check + build。
v2.4.3
dsh-remote-bridge v2.4.3
后台任务(新功能)
远程场景的真焦虑:在手机上让 agent 跑个长任务,然后切走或放下手机 —— 此时没有任何地方能看到「它还在跑吗」,更没有地方能「让它停下」。
三个端点,全部挂在会话维度下(内核的 list / kill 都要 sessionId,owner fence 是唯一的访问规则):
GET /chat/sessions/{sessionId}/jobs— 清单(首帧即全量,不做分页)GET /chat/sessions/{sessionId}/jobs/{jobId}/output— 已保留输出(?from=增量续读)POST /chat/sessions/{sessionId}/jobs/{jobId}/kill— 终止
能力位 job-list-v1 / job-output-v1 / job-kill-v1;Kotlin SDK 同步三个方法。
【重要】patch 的 inject 覆盖插件 inject
cordis.patch.yml 里插件行的 inject 覆盖插件自己 export const inject,不是取并集。于是「插件声明了、patch 漏了」的服务读起来报 cannot get property "x" without inject —— 与「内核压根没有这个服务」的报错一模一样,肉眼分不出来。
这条长期掩盖了 4 个服务缺失:permissionPresets 曾被连续多份文档断言为「web profile 注不进、只能走内置兜底目录」,实际是 patch 没声明它;fileUploads / attachments / jobController 同理。
修法:inject 从 10 项补到 14 项(与 src/index.ts 对齐),并新增 docs:check 门禁双向比对两者(少了报「缺少」、多了报「多出」,多了更糟 —— 声明不存在的依赖让整个 entry 永久 INACTIVE)。
真机验证:探针 14/14 全绿;权限档位 BFF 返回内核真实三档(read-only / workspace-write / danger-full-access),不再是内置兜底的两档。
宿主服务注入面探针
GET /debug/host-services —— 回答一个此前只能靠猜的问题:这个内核上插件 inject 声明的服务到底有几个真的注进来了。启动时也会把摘要打进 DSH 日志。
history 首屏少一次 RPC
0.2.1 起 SessionFollowFrame 的 snapshot 分支自带 projections,@patch history-projections-passthrough 的 until 条件已达成。改为优先用自带的,取不到才回落(保留回落:最低支持版本仍是 0.2.0)。
兼容性
engines.dsh:>=0.2.0-rc.2 <0.3.0(^0.2.0会漏掉0.2.0-rc.2,那是上一轮适配所依据的内核)- 已在 DSH
0.2.1-rc.2上真机验证;0.2.0-rc.2仍是已验证基线 - 无需改码即可运行:插件刻意不 import 任何
@deepseek-ai/*,全部宿主服务经结构类型 + Host 注入消费
文档门禁新增
docs:check 从两段扩到三段:新增「根 README 双站 + CHANGELOG 口径」与「patch inject 双向对齐」。README 双站漂移、patch inject 漏声明这两类问题从此机器可抓。
产物
dsh-remote-bridge-2.4.3.tgz — 572,953 字节,SHA-256 8cc567ac6bc8d6722846f18917ea739a98d5fe0cfce802291d8669e8effedcb3
升级
dsh plugin --profile web add dsh-remote-bridge(App 侧 dsh-companion 的后台任务面板依赖本版能力位;插件未升级前手机端不会显示该入口。)
v2.4.2
v2.4.2
三个问题:history 不下发投影、权限档位没有出口、所有斜杠命令失效。
1. history 透传 projections
GET /chat/sessions/{id}/messages 顶层此前只有 events / hasMore / historyFormatVersion,完全没有 projections —— 客户端连「当前权限档位」都读不到。这是 dsh-companion F1「权限档位 Pill 从不渲染」的上游根因。
现在首屏 follow snapshot 与 beforeSeq 分页两条分支都把 sessionController.projections() 并进响应。取不到不抛错:历史是主功能、投影是附加信息,缺席时客户端走降级路径。
(副产物:投影里还有 tokenUsage、contextPressure、contextBreakdown、sessionStats、turnOutline、plan、subagentCatalog 等 20 个键,后续功能可直接用。)
2. 新增 GET /api/v1/chat/permission-presets
权限档位的候选项只在内核独立远程服务 permissionPresets.catalog() 上,桥没有任何入口能透出去。新端点(scope 同 agent-presets,chat.read)出网前经过归一:丢无 value 的行、缺 name 退回 value、空串 description 不透传。
该端点容错读取:permissionPresets 在本 profile 不可注入(cannot get property … without inject)。按本仓 attachments/fileUploads 的既有结论,这个异常的含义是「压根没有这个可注入服务」,不是时机问题 —— 旁证是内核该能力本身在工作(/permission 可列档位、投影里有 currentValue)。故用 optionalService 读取,缺失时返回内核默认两档,而不是让端点 500。
不放 read-only:它是合法 sandbox 模式,但内核没为它配 preset,而 /permission <preset> 切的是 preset 名 —— 给出一个切不过去的选项比不显示更糟。
3. 修所有斜杠命令失效
内核 commands.execute 是四参 execute(agent, line, submittedAttachments, signal),桥只传了三个,于是 signal 实际收到 undefined,内核随后读 signal.aborted 就抛错。
症状很有迷惑性:所有斜杠命令全废,报出来的却是 Cannot read properties of undefined (reading 'aborted'),与权限、与会话冷热都无关。真机上表现为「会话中改不了权限」。桩测试此前写成 (_agent, line),正好放过了这个错位;现在断言第三、四参。
安装
# npm 包名(会从 Gitee Release 拉本 tgz)
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.4.2/dsh-remote-bridge-v2.4.2.tgz- 体积 558,704 字节
- SHA-256
0260fe57e8187d4a9421ffce939cae08d997bcbcdc282093fd900cc131e3067f
门禁
- 40 个测试文件 / 305 用例 / 0 失败(新增
history-projections.spec.ts6 例、permission-presets.spec.ts6 例,dsh-0.2-bridge.spec.ts补 2 例) docs:check10 文档 + 版本一致性通过- 真机复验:投影带上
permissions.currentValue,App 权限 Pill 与菜单权限项正常显示;/permission workspace-write返回 success,档位随之切换
配套
需要客户端 dsh-companion ≥ 2.2.45 才能吃到投影与权限档位。