Skip to content

Releases: westanke/dsh-remote-bridge

2.8.0

Choose a tag to compare

@github-actions github-actions released this 29 Oct 01:40

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 定稿):

  1. POST /chat/schedules 的新建入口:内核 schedule Remote 只发布
    catalog|changed|delete|history|list|update,没有 create。实现改走宿主 cordis 服务
    ctx.schedule.create(sessionId, request);取不到该服务时回 503 UPSTREAM_UNAVAILABLE
    • details.reason(gateway/invocation-unavailable / schedule/create-unavailable)。
  2. Schedule.createdAt:内核 ScheduleRecord 没有创建时间字段,视图恒为 null(字段保留,
    客户端不得据此排序)。
  3. PATCH {enabled:false}:内核没有「暂停」操作,静默成功会让 UI 显示已暂停而提醒继续触发,
    因此回 400 BODY_INVALID + details.reason:'ENABLED_UNSUPPORTED';要停请 DELETE。
    另:列表只投影 cron 形态,at/every 提醒不在 REST 列表中出现。

门禁

  • npm run typecheck 0 错;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

Choose a tag to compare

@github-actions github-actions released this 27 Oct 09:12

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

Choose a tag to compare

@github-actions github-actions released this 25 Oct 04:58

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

Choose a tag to compare

@westanke westanke released this 23 Oct 15:16

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

Choose a tag to compare

@github-actions github-actions released this 22 Oct 17:22

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-im 5.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

Choose a tag to compare

@github-actions github-actions released this 22 Oct 12:08

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-256 18d3690462b4fff72299cb1a3c54623b929a3933597c47dc4486e9f0b9fc8095(带 v 与不带 v 两个文件名内容相同,见 Release 附件 SHA256SUMS.txt)。
  • 完整开发记录见仓库 docs/project/CHANGELOG-DEV.md 的 2.5.2 节。

2.5.1

Choose a tag to compare

@github-actions github-actions released this 21 Oct 21:51

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/Nginx proxy_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 出现任何转发头按代理拒 → 2 Host 必须回环、缺失也拒 →
    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:新增 tunnelActive getter(与 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

Choose a tag to compare

@github-actions github-actions released this 21 Oct 12:06

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

Choose a tag to compare

@westanke westanke released this 20 Oct 08:46

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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 09:06

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.ts 6 例、permission-presets.spec.ts 6 例,dsh-0.2-bridge.spec.ts 补 2 例)
  • docs:check 10 文档 + 版本一致性通过
  • 真机复验:投影带上 permissions.currentValue,App 权限 Pill 与菜单权限项正常显示;/permission workspace-write 返回 success,档位随之切换

配套

需要客户端 dsh-companion ≥ 2.2.45 才能吃到投影与权限档位。