把本地与远程 Harness 收进同一窗口的标签栏
30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/4 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add OahzOb/dsh-tabs该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
是什么
dsh 原生插件 · browser
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 30 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 16 处,展示前 16 条)
- 读取本地文件src/main.js:942
return app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')) - 读取本地文件src/devices.js:60
await access(path) - 读取本地文件src/devices.js:180
const stored = await readFile(STORE_PATH, 'utf8').catch(() => undefined) - 读取本地文件src/devices.js:191
const seeded = JSON.parse(await readFile(SEED_PATH, 'utf8')) - 写入本地文件src/devices.js:232
await mkdir(HOME_DIR, { recursive: true }) - 写入本地文件src/devices.js:233
await writeFile(STORE_PATH, `${JSON.stringify({ devices }, null, 2)}\n`, 'utf8') - 读取本地文件src/connect.js:86
if (existsSync(candidate)) return candidate - 发起网络请求src/connect.js:181
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(5000) }) - 执行外部命令来自配置/变量src/connect.js:462
const probe = spawn(ssh, [...remote.sshOptions(device), target, 'uname -s'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) - 执行外部命令来自配置/变量src/connect.js:579
const server = spawn(ssh, [...remote.sshOptions(device), target, remote.remoteCommand(program, platform)], { - 执行外部命令来自配置/变量src/connect.js:603
const tunnel = spawn(ssh, [...remote.sshOptions(device), '-N', '-L', `${String(localPort)}:127.0.0.1:${String(remotePort)}`, target], { - 执行外部命令固定命令src/connect.js:748
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }) - 执行外部命令来自配置/变量src/connect.js:810
const child = spawn(argv[0], argv.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) - 读取本地文件src/localdsh.js:74
return platform === 'win32' && !existsSync(candidate) ? 'cmd.exe' : candidate - 执行外部命令固定命令src/localdsh.js:226
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }) - 执行外部命令来自配置/变量src/localdsh.js:247
const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
扫描于 2026/11/4(0 天前) · 范围 入口闭包 + 全包扫描 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 本地与远程收进标签栏
- 标签页即窗口边框
README
由 DeepSeek 最新模型翻译生成dsh-tabs
DeepSeek Harness (DSH) Web 界面的标签页宿主。一个窗口,一条始终可见的标签栏,每个 Harness 一个标签页:本地的那个,加上每台通过 SSH 隧道连上的远程机器各一个。
本地标签页与远程标签页是平级的,不是它们的主机。 正因如此,这东西是一个应用而不是插件——插件拿不到的四样东西都在为什么这不是插件里。
快速开始
1. PATH 上有一个较新的 dsh,用 Node 装的。打包好的 Desktop 应用不算:它把 Harness 塞在 app.asar 里,也不会装 dsh 命令。
2. 先安装,再下载 Electron。 Electron 44 没有 postinstall 步骤,所以那 ~150 MB 的二进制文件得单独跑一条命令:
npm install
npm run install-electron
3. 启动。
npm start
把检出目录放在任何地方,就是别放 $DSH_HOME。 在 Windows 上,Electron 根本没法从这个目录里启动——它会静默退出,没有报错也没有输出——实测数据在别放进 $DSH_HOME。
展开完整文档(README 剩余 1 节)
dsh-tabs
DeepSeek Harness (DSH) Web 界面的标签页宿主。一个窗口,一条始终可见的标签栏,每个 Harness 一个标签页:本地的那个,加上每台通过 SSH 隧道连上的远程机器各一个。
本地标签页与远程标签页是平级的,不是它们的主机。 正因如此,这东西是一个应用而不是插件——插件拿不到的四样东西都在为什么这不是插件里。
快速开始
1. PATH 上有一个较新的 dsh,用 Node 装的。打包好的 Desktop 应用不算:它把 Harness 塞在 app.asar 里,也不会装 dsh 命令。
2. 先安装,再下载 Electron。 Electron 44 没有 postinstall 步骤,所以那 ~150 MB 的二进制文件得单独跑一条命令:
npm install
npm run install-electron
3. 启动。
npm start
把检出目录放在任何地方,就是别放 $DSH_HOME。 在 Windows 上,Electron 根本没法从这个目录里启动——它会静默退出,没有报错也没有输出——实测数据在别放进 $DSH_HOME。
它做什么
- 本地标签页跑本机自己的 dsh web --no-open --port 0,从它打印的就绪行里读出端口和会话 token,然后让一个 <webview> 指向它。
- 远程标签页通过 SSH 在对面跑同一个程序,再开第二条裸的 ssh -N -L 隧道,连到第一条连接公布的端口。那台机器上什么都不用装。
- 收尾时关掉 stdin,远程程序正是阻塞在这上面,于是对面的服务器会被回收,而不是继续占着端口。
特性
| 标签栏一直在 | 标签栏就是窗口边框,所以它永远不会跟着它所切换的界面一起滚走。 |
| 在任何地方按 Alt+1…9 | 在主进程里处理,所以焦点深埋在远程界面里时按键照样能到——页面里的监听器根本看不到它。 |
| 本地 Harness 也是平级的一员 | 本地 Harness 和每个远程 Harness 用同一套标签页语义,在同一个窗口里。 |
| 对面什么都不用装 | 一台 SSH 服务器加一个用 Node 装的 dsh 就是全部要求;端口由远程自己的系统挑,所以连接不会和谁手动起的服务器撞上。 |
| 设备簿用插件的 schema | $DSH_HOME/dsh-tabs.json——自己的文件,首次运行时从 Desktop 插件的 remote-devices.json 播种,永远不会被两个进程同时写入。 |
| 失败信息看得懂 | 连接失败时会引用对面的原话,包括 Windows 主机上 PowerShell 产生的 CLIXML 错误流。 |
| 版本下限会自己开口 | src/localdsh.js 在本地标签页启动前检查 dsh --version,所以旧版命令行会自己报上名来,而不是 45 秒后变成一个凭据错误。 |
要求
你连上的机器: 一台 SSH 服务器,PATH 里有 dsh,用 Node 装的。
Windows 主机还要额外启用 sshd,Windows 默认不开—— 见远程端需要什么。
这台机器: 用 Node 装的 dsh,版本 0.2.0-rc.2 或更新,因为本地标签页跑的就是它。低于这个下限,命令行读不了凭据存储,它打印的报错说的是 API key,而不是安装太旧。
平台: 这里写的一切都在 Windows 上构建和测量。POSIX 和 macOS 分支在源码里,离线测试套件也覆盖了;但这份 checkout 没有拿它们跑过真实主机。
安装
从源码装,因为还没有打包好的构建。 没有安装器,没有便携压缩包,所以也没有校验和可验:快速开始就是安装的全部。这台机器需要的两个环境兜底写在运行它里 —— 一个 ELECTRON_MIRROR,给 GitHub release CDN 连不上的网络用;一个可写的 TEMP,给拒绝在自己工作区之外调 mkdtemp 的环境用。
使用它
- 标签页是本地 Harness 加通讯录里的每台设备。标签页是配置好的东西,不是临时视图:选中一个空闲的会启动它,× 是断开它而不是删掉它。删设备是 + 弹出层的活。
- Alt+1…9 选中标签页,在任何地方都行,包括你正在远程界面里打字的时候。
- + 列出设备、添加设备、删除设备。
文档
本文件剩下的部分是工程记录:架构为什么是这样,测了什么、怎么测的,以及塑造了它的那些失败。它故意写得很长 —— 每一节都是一个花了代价才学到的决定。
| 为什么这不是插件 | 插件不可能有的四样东西,以及天花板在哪 |
| 运行它 | 安装、Electron 版本锁定,以及环境毒不了的那个版本检查 |
| 设备 | 通讯录、它唯一的写入路径,以及 shell 能看见的那个字段 |
| 远程端需要什么 | PATH 里的 dsh,以及在 Windows 主机上启用 sshd |
| 一次连接怎么走 | 两条 ssh 连接,以及回收对端的拆除契约 |
| Windows 远程端 | PowerShell 程序,以及为什么本地标签页不能和它做成对称 |
| 目录结构 | 每个文件,以及谁拥有它 |
| 测试 | 四个离线套件、一个在线套件,以及它们读的变量 |
| Android:兄弟客户端 | 第二个客户端、两边都实现的协议,以及它移植过来的产物 |
| The studies | Why there is no embedded Node on Android — and what a WebView does with the tunnel |
| contract.json | The protocol stated in one place a non-JavaScript client can read |
dsh-tabs 桌面端(中文)
把 DeepSeek Harness (DSH) 的网页界面收进一个窗口:本地一个,其它机器上的若干个走 SSH 隧道,一台机器一个标签页,标签栏常驻。
本地标签页和远程标签页是对等的,它不是远程标签页的宿主 —— 这正是它必须做成应用、而不是插件的原因,四条理由见 Why this is not a plugin。
快速开始
1. PATH 上一个足够新的 dsh,用 Node 安装。打包的桌面应用不算:它的 Harness 装在 app.asar 里,不提供 dsh 命令。
2. 装依赖,再单独下载 Electron。 Electron 44 没有 postinstall,那个约 150 MB 的二进制要自己跑一条命令:
npm install
npm run install-electron
3. 启动。
npm start
把这个 checkout 放在 $DSH_HOME 以外的任何地方。 在 Windows 上 Electron 从那个目录里根本起不来 —— 静默退出,没有任何报错和输出;测量过程见 Keep it out of $DSH_HOME。
它能做什么
- 本地标签页:在本机跑 dsh web --no-open --port 0,从它打印的就绪行里读回端口和会话 token,让一个 <webview> 打开它。
- 远程标签页:把同一个程序通过 SSH 在对端跑起来,再开一条裸的 ssh -N -L 隧道,转发到第一条连接报出的端口。对端不装任何东西。
- 关闭靠关 stdin:对端程序正阻塞在 stdin 上,所以远端服务会被回收,而不是留着占住端口。
功能
| 标签栏常驻 | 标签栏就是窗口边框本身,不会跟着它切换的界面一起滚走。 |
| 任何位置都能 Alt+1…9 | 快捷键在主进程处理,所以焦点在远程界面深处时按键照样到达 —— 页面里的监听器永远看不到那些键。 |
| 本地 Harness 是对等的 | 本地和每一台远端在同一个窗口里共用同一套标签语义。 |
| 对端不装任何东西 | 只需要一个 SSH 服务和对端 PATH 上用 Node 安装的 dsh;端口由远端系统自己挑,绝不会和谁手动起的服务撞端口。 |
| 设备簿沿用插件 schema | $DSH_HOME/dsh-tabs.json,独立一个文件,首次运行时从桌面插件的 remote-devices.json 播种,绝不会有两个进程同时写它。 |
| 失败能读懂 | 连接失败会引用对端自己的原话,包括 Windows 主机上 PowerShell 产生的 CLIXML 错误流。 |
| 版本下限会自己说话 | 本地标签页启动前,src/localdsh.js 先查 dsh --version;CLI 太旧会直接报名,而不是 45 秒后变成一个凭据错误。 |
环境要求
被连的机器:一个 SSH 服务,以及 PATH 上用 Node 安装的 dsh。Windows 主机还要额外启用 sshd,Windows 默认不开 —— 见 What the remote needs。
本机:PATH 上 0.2.0-rc.2 或更新的 dsh,因为本地标签页跑的就是它。低于这个下限, CLI 读不了凭据库,它报出来的是"API key 缺失",而不是"CLI 太旧"。
平台:这里记录的一切都是在 Windows 上构建和实测的。POSIX 与 macOS 分支在源码里、也被离线套件覆盖,但都没有从这个 checkout 对真实主机跑过。
安装
只能从源码跑,因为目前还没有打包产物。 没有安装包、没有便携包,因此也没有校验和可对:快速开始 就是全部安装步骤。本机踩到的两个环境坑(GitHub release CDN 不可达时设 ELECTRON_MIRROR、环境拒绝在 workspace 外 mkdtemp 时改 TEMP)见 Running it。
用法
- 标签页 是本地 Harness 和设备簿里的每一台机器。标签页是配置好的东西,不是临时视图:选中一个空闲的就会启动它,而 × 是断开、不是删除。删设备在 + 面板里。
- Alt+1…9 切标签,在任何位置都有效,包括你正在远程界面里打字的时候。
- + 列出设备、添加设备、删除设备。
文档
本文件其余部分是工程记录(英文):架构为什么是这样、实测了什么、怎么测的,以及塑造了它的那些失败。它很长是有意的 —— 每一节都是一个花了代价才换来的决定。
技术细节一律以英文原文为准,中文部分不重复翻译,避免两份说明逐渐说不到一起去。
The engineering record
The sections below are in English only. They are the record of what was measured and
why each decision is what it is: the failures that shaped this application, the numbers
behind its timeouts, and the two protocol artefacts a second client ports from.
译成两份描述,迟早会各说各话,而代码和测试只会引用其中一份。
为什么这不是插件
同样的功能有一个 Desktop 插件版本(plugins/dsh-remote-devices),能用。但它也撞上了一道天花板,再多的插件代码也顶不开,原因在于嵌入式界面被允许待在哪里:
| 想要的 | 插件 | 原因 |
| 一个始终可见的标签栏 | 不行 | 它必须待在自己切换的那个界面里,于是跟着界面一起消失 |
| 标签放进窗口边框 | 不行 | 那一行是 Electron 的原生菜单;窗口边框只有一个位置,而且仅限 macOS |
| 焦点在远程界面里时按 Alt+1…9 | 不行 | 按键事件不会进入 <webview> 访客,而 shell 的快捷键注册表会替换整份定义列表 |
| 一个与远程标签平级的本地标签 | 不行 | 插件是本地 Harness 的访客,不是它的宿主 |
自己掌管窗口,这四个问题全部消失。尤其是 before-input-event 会在主进程里为每个 web contents 触发——访客也算——而且发生在按键送达任何地方之前,所以同一个处理函数既能覆盖焦点在本应用边框里的情况,也能覆盖焦点深在远程界面里的情况。
运行
npm install # installs the electron wrapper
npm run install-electron # downloads the ~150 MB binary (Electron 44 has no postinstall)
npm start
Electron 版本被保守地钉住了
依赖是精确版本——"electron": "44.0.0"——这正是这台机器上 Harness 桌面应用所运行的版本(Electron/44.0.0、Chrome/152.0.7977.54,从它自己的二进制里读出来的),因此已知在这里可用。
当初钉版本的证据比看上去要弱,后来已经重新认真测过。 之后的补丁版本 44.5.1 曾被观察到产生真正的 APPCRASH(electron.exe 内部 0xC0000005),而 44.0.0 产生的是静默的 STATUS_BREAKPOINT——但那次对比是在 $DSH_HOME 里做的,在那里任何 Electron 都会失败,所以可能只是拿同一个原因的两个症状在比。
2026-10-04 在 Harness home 之外的一个干净目录里重跑,并清掉了 harness 自己的 ELECTRON_RUN_AS_NODE:
| 二进制 | 文件里写的 | --version 回答的 | 窗口 | 12 秒后 | 关闭 |
| 44.0.0(钉住的) | 44.0.0 | v44.0.0 | dsh-tabs 起来了 | 有响应 | WM_CLOSE,退出码 0 |
| 44.5.1 | 44.5.1 | v44.5.1 | dsh-tabs 起来了 | 有响应 | WM_CLOSE,退出码 0 |
两次运行都没有写入任何提到 electron.exe 的 Application 日志条目,也都没有泄漏 dsh web。所以 44.5.1 并没有坏,钉住版本也没有挡住什么修复。 有两个理由让它留在原地:44.0.0 是这台机器上桌面应用所运行的版本,因此这一对是已知能配合工作的;而升级换不来任何已经测出来的好处。该怀疑的说法从来都不是「44.5.1 有问题」——而是「早先的对比显示了这一点」,可它并没有。
无论装的是哪个版本,窗口不出现时,第一件要做的事就是单独运行那个二进制文件——它绕过 npm,并打印退出码:
.\node_modules\electron\dist\electron.exe --version ; $LASTEXITCODE
打印出版本号,说明二进制文件没问题,问题出在应用里。没有任何输出,加上 -2147483645,说明它根本没启动——先检查上面那个目录,再用 Get-WinEvent -LogName Application 查 APPCRASH,看它是否点名 electron.exe。
在相信任何一种结果之前,先清掉 ELECTRON_RUN_AS_NODE。 设置了这个变量后,electron.exe 就是一个 Node 解释器:--version 打印的是 Electron 内置的 Node 版本,所以健康的二进制文件会回答 v24.18.1,而本文档承诺的是 v44.0.0;坏掉的则什么都不回答。这个字符串不是 Electron 的版本,而一个看起来不对的版本号,远比一次静默退出更容易让你去追查一个坏掉的下载。
这个变量由启动 shell 的那个进程按进程设置,不在 User 或 Machine 作用域,所以除了下面这一种检查,其他任何检查都看不到它:
$env:ELECTRON_RUN_AS_NODE = $null # clear it for the child
(Get-Item .\node_modules\electron\dist\electron.exe).VersionInfo.ProductVersion
第二行直接从文件本身读出 44.0.0,完全不受环境影响,所以它是更好的首选检查。
同一个变量还会悄悄毁掉 npm start,而那种失败比版本号错误更糟:Electron 把 src/main.js 当普通 Node 脚本运行,require('electron') 返回的是二进制文件的路径而不是 API,运行会在第一个 app.on(...) 处死于 TypeError: Cannot read properties of undefined (reading 'on')——这条消息点名的是本应用的一行代码,而不是那个变量。main.js 现在会在文件开头检测这种状态,带着诊断结果和修复方法退出。
如果所在网络访问不了 GitHub release CDN,就把下载指向镜像:
$env:ELECTRON_MIRROR = "https://registry.npmmirror.com/-/binary/electron/"
npm run install-electron
如果安装失败,在 %LOCALAPPDATA%\Temp 下报 EPERM ... mkdtemp,那就给安装器一个可写的临时目录——@electron/get 通过 fs.mkdtemp 创建暂存目录,有些环境不允许它在工作区之外创建:
$env:TEMP = "$PWD\.tmp"; $env:TMP = $env:TEMP
npm run install-electron
设备
设备簿位于 $DSH_HOME/dsh-tabs.json,用的 schema 和插件的一样。它故意做成单独的文件:两个进程写同一个 JSON 文件会互相覆盖,而这个应用绝不能破坏 Desktop 插件还在使用的设备簿。首次运行时,插件的 remote-devices.json 会被复制结束,所以在那里配置的设备会在这里等待。
每台设备是 {label, host, user, sshPort, directory?}。Windows 主机不需要额外配置——shell 家族会自动检测。
所有写入都走同一道门
src/devices.js 暴露 mutate(change),所有编辑这本名册的代码都用它:添加、删除,以及检测到的 shell 家族。再也没有谁先调 load() 再调 save() 了。
这不是为了整洁。只要中间可能有别的写入者,load() 之后再 save() 就是一次丢失更新——第二个写入者的副本会留下来,第一个写入者的改动就没了。这本名册实际有两个写入者会重叠:每次标签页激活都会持久化检测到的平台,而 + 弹出框编辑同一个文件。在这个项目上实测,这种组合可能让刚被删除的设备复活,或者悄悄丢掉刚保存的编辑——而且发生得足够频繁,驱动这两条路径的测试套件大约每三次运行就失败一次。
与插件共享 schema,才让这件事从理论风险变成真实风险,而修复也有一条对应的限制,值得直说:mutate 只在本进程内串行化写入者。把插件进程挡在外面靠的是文件分离,这也正是文件要分离的全部理由。
有一个字段会被放进 shell,所以有一个字段受限
directory 是名册里唯一一个不只是传给程序、而是作为文本写进 shell 程序的值——出现在四个不同分支里,而它们对如何加引号并不一致:
| 分支 | 嵌入方式 | 安全? |
| POSIX 远程 / 本地 | shellSingleQuote——单引号,内嵌引号翻倍 | 安全,由构造保证 |
| Windows 远程 | windowsLiteral,放在以 -EncodedCommand 发送的脚本里 | 安全,由构造保证 |
| Windows 本地 | cd /d "<directory>" && dsh web … 交给 cmd /c | 不安全——而且只有它不安全 |
cmd /c 接收的是单个命令字符串,而 cd /d 需要路径带双引号,所以目录里只要含 ",引号就被闭合,后面的一切都变成新命令:
directory ~/work" & calc.exe & "
cmd /c cd /d "~/work" & calc.exe & "" && dsh web --no-open --port 0
修复方式是拒绝这个值,而不是转义它。 %VAR% 和延迟展开的 !VAR! 即使在双引号内部也会展开,所以看起来已经安全加引号的路径,仍可能在 cd 看到它之前被改写,而且 cmd 在命令行和批处理文件里的规则还不一样。为一个通常只存 C:\work 这类值的字段,把这些规则全都正确复现一遍,不值得。所以 remote.directoryProblem() 拒绝 " ' % ! & | < > ^,其余值正常加引号。POSIX 刻意不受限:单引号的 POSIX 字符串里根本没有元字符,所以 ~/work; rm -rf / 在那里只是个奇怪的目录名。
关于它,有两点值得了解:
- 这个值会被检查两次 —— 一次是在弹出层保存它的时候,这样操作者还盯着输入框时就能看到拼写错误;另一次是在 connectLocal 里,因为这本「账本」是 JSON 文件,也可能被人手动编辑。
- 这个注入是潜在的,不是活跃的。 activate() 从未把目录传给本地标签页,所以那段未转义的字符串拼接无法通过 UI 触达。不过还是修掉了:这个参数存在的目的正是如此,而下一次用到它的改动就会让它变成活跃的。
远程端需要什么
需要通过 Node 安装的 dsh,而不是打包好的 Desktop 应用。Desktop 把它的 Harness 放在 app.asar 里,不安装 dsh 命令,所以只装了 Desktop 的机器不能当远程端。本地标签页出于同样的原因有同样的要求,它用的是这台机器 PATH 上的 dsh。
如果本地标签页看起来比你熟悉的 Desktop 应用旧,原因就在这里:这台机器上的全局 CLI 和 Desktop 应用里打包的 Harness 是两套不同的安装。用 npm install -g @deepseek-ai/dsh@latest 更新它。
Windows 远程端还需要 SSH 服务器,而 Windows 默认不启用它。在管理员 PowerShell 里执行:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server' -Enabled True
-Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
授权密钥。非管理员账户读取 %USERPROFILE%\.ssh\authorized_keys;管理员账户则读取 %ProgramData%\ssh\administrators_authorized_keys,而且这个文件的 ACL 必须限制为 SYSTEM 和 Administrators,否则 sshd 会忽略它。
连接是怎么建立的
每台设备刻意用两条 ssh 连接:
1. 服务器。 dsh web --no-open --port 0 在远端运行,让远程端的操作系统挑一个空闲端口,这样连接永远不会和某人手动启动的服务器撞端口。真实端口从就绪行里读回来。
2. 隧道。 一条裸的 ssh -N -L 转发,指向第一条连接公布的端口。
拆解才是关键部分。通过 ssh 启动的远程命令,在连接断开时并不会可靠地退出 —— 在这个项目上实测,一条普通的 exec dsh web,甚至放在 ssh -tt 下的那种,都能在客户端被直接杀掉后继续存活,留下一个孤儿服务器占着远程端口。所以服务器被放到后台,远程 shell 改为阻塞在 stdin 上:sshd 会给它一个管道,连接一结束就到达 EOF,无论怎么结束,服务器总能被回收。这就是为什么 ssh 客户端总是拿到一个从不写入、也不提前关闭的 stdin 管道,也是为什么 stopTab 会在杀掉任何东西之前先关掉那个管道。
Windows 远程端
Windows 主机上没有 bash -lic 可用,所以换成一个 PowerShell 程序。shell 家族靠检测得出,不靠配置:uname -s 作为单个 argv 元素发送,不带任何元字符,因此 POSIX 主机会在 stdout 上回答 Linux 或 Darwin,而 cmd.exe 和 PowerShell 都会执行失败、什么都不打印。只有 ssh 自己的退出码 255 才表示连接失败,这个码直接透传上报——主机不可达时只失败一次,带上 ssh 的错误,而不是在平台猜测下失败两次。
这条路径是可行的,而且值得把话说准,因为同样的构造在本地标签页上会失败。 Windows 程序用 Start-Process -NoNewWindow 启动服务器,而这恰恰就是 Windows 本地标签页坏掉的原因——在那里,PowerShell 由本应用带着管道启动,服务器拿不到可用的 stdout。走 ssh 时,同一个脚本改由 sshd 启动,在真实 Windows 主机上实测可行:对端被归类为 windows,就绪行到达,隧道建立,界面加载。
差别在于父进程交给 PowerShell 的句柄设置——一个控制台,还是一组管道——这一点还没有进一步查清。记录下来的意义在于:这两种情况不能再被「改成对称」了:本地标签页曾经被改成与这个程序一致,结果完全启动不了。
Windows 程序保留了 POSIX 约定的两半:服务器的 stdout 被继承,所以就绪行顺着通道流下来,不需要临时文件;[Console]::In.ReadToEnd() 会一直阻塞,直到 stdin 到达 EOF。脚本用 -EncodedCommand(base64 UTF-16LE)传入,这是唯一能扛过 ssh 拼接 argv、远端的 cmd.exe,以及一个满是 cmd 本来会吞掉的字符的脚本的引号策略。
目录结构
src/main.js 窗口、标签页、隧道、快捷键 src/preload.js 渲染进程对主进程的全部视图 src/remote.js 远端要运行什么——一份副本,见下文 src/devices.js 设备簿 src/renderer/ 标签栏与访客宿主 assets/icon.svg 应用图标,32 像素及以上 assets/icon-small.svg 同一图标,用于 16、20 和 24,此时上面的细节已经失效 tools/smoke.mjs 离线检查 tools/render-icon.cjs 图标 SVG 转 PNG,经由 Chromium,按 shell 要求的尺寸 tools/make-ico.cjs 把这些 PNG 打包成一个 .ico
图标由一张图生成,每个尺寸各生成一次,小尺寸档位来自第二张图。 assets/icon.svg 说明这个应用是什么——一个带标签栏的窗口,一条虚线连向另外两台机器,这正是整个项目想说的话:一个窗口,每台机器一个会话,不在这里的机器通过一条链路访问。tools/render-icon.cjs 经由 Chromium 把它栅格化——Chromium 正是这个应用本身所用的引擎——tools/make-ico.cjs 把结果打包成 .ico。
关于它,有三件事是渲染整套尺寸并观察之后定下来的:
- 16 就是按 16 渲染的,不是从 256 缩下来的。 缩小得到的图和直接小尺寸渲染是两幅不同的画,而在 16 像素上,唯一的问题就是形状还立不立得住。
- 32 以下改画法,而不是缩小。 assets/icon-small.svg 舍弃第二个标签页,留出更宽的边距,把连线画成实线:虚线节奏在 64 像素能表达「跨越一段距离」,到 16 像素就什么也说不出来,而实线仍然说明两个形状是连在一起的。小尺寸这一档保住含义,丢掉细节。
- 配色是量出来的,不是挑出来的。 #fbfbfb 和 #31343c 分别是 DeepSeek Harness 桌面应用自身图标里的底板色和标记色,直接从它的像素里读出来。
Android 客户端的启动图标是同一幅画拆成自适应图标的两个图层,几何定义放在那边的 design/ic_launcher.svg。两者靠手工保持同步,所以改动标记就要改两个仓库——另一种做法是让一个仓库去读另一个仓库的资源,那会破坏「每份检出各自独立」这条规则。
src/remote.js 是 dsh-remote-devices 插件里那些纯构建函数的副本。独立应用不能从一个运维人员可能删掉的插件目录里导入,所以逻辑是复制而不是共享——tools/smoke.mjs 会断言每个复制过来的函数仍与原版逐字节一致。一旦漂移,测试套件直接失败,而不是日后冒出一个莫名其妙的连接失败。
别把它放进 $DSH_HOME
这是最耗时间的一个故障,而根源在目录,不在代码。 在构建所用的这台机器上,Electron 无法从 Harness 主目录下的任何位置启动。用同一份 73 个文件的发行版复制到五个位置实测——字节完全相同,文件数已核对:
| 位置 | electron.exe --version |
| <home>\.dsh\apps\dsh-tabs\node_modules\electron\dist | 无输出,退出码 -2147483645 |
| <home>\.dsh\apps\dsh-tabs\inside-dsh-dist | 无输出,退出码 -2147483645 |
| <home>\.dsh\electron-probe3\dist | 无输出,退出码 -2147483645 |
| <home>\dsh-probe3\dist | v44.0.0,退出码 0 |
| <root>\dsh-electron-test | v44.0.0,退出码 0 |
问题出在确切的目录名,而不是路径的形状:同一父目录下的 .dshX、.dsh2 和 .x 都能用,C:\Users\…\.dsh 不行,而 .dsh 是同一卷上一个普通 NTFS 目录,没有重解析点。node.exe 在 .dsh 里跑得好好的——这是 Electron 特有的问题。
-2147483645 是 0x80000003,即 STATUS_BREAKPOINT,Chromium 的 BreakDebugger() 在没有调试器挂载时就是以这个码终止:一个致命检查,发生在任何日志能写出来之前。所以症状是静默空转——npm start 打印完横幅就回到提示符,一点错误都没有。
机制尚不清楚。已经排除的项,每一项都靠实测:它不是代码签名(换成已签名的 Electron 也会复现同样的故障),不是 Smart App Control(关掉它再重启没有任何变化,机器的代码完整性日志里也没有任何一条记录提到 Electron),不是启动它的进程(用 WMI 创建的进程,放在启动器的 job object 之外,行为完全一样),不是文件名(改名没有任何变化),不是 PATH(只保留系统级 PATH 也没有变化),不是下载损坏(zip 的 SHA-256 与 Electron 自己的 checksums.json 一致),也不是解压不完整(73 个文件,全都在)。
所以:把这个应用放到 $DSH_HOME 之外的任何地方。 它住在承载它的工作区容器里,紧挨着它的 Android 兄弟,正是出于这个原因——上面的测量是在浅一层的目录里做的,而移动之后没有任何变化。它的一切都不依赖所在位置。
本地标签页需要较新的 dsh,而不是它自己的凭据
这一节替换了一个错误诊断,所以值得当作一类 bug 的警示来读,而不是当作单个实例。
症状是本地标签页第一轮就失败:
llm-deepseek: no API route for provider route "deepseek-official";
store DEEPSEEK_API_KEY through the credentials service (the web Models page
writes it), or export DEEPSEEK_API_KEY in the launching environment
MISSING_CREDENTIAL
这条消息说,从 shell 启动的 Harness 需要在环境里有 DEEPSEEK_API_KEY,而当时就照字面接受了这个说法:这里记下的结论是,只有桌面 shell 才能访问 $DSH_HOME/.credentials.yaml 里的加密存储并把密钥交出去,所以第二个本地 Harness 永远拿不到它。
这是错的,一个时间戳就能推翻它。 凭据存储已经两天没被写入,环境里也仍然没有 DEEPSEEK_API_KEY——可本地标签页却开始正常工作了。这中间唯一变化的东西是全局 CLI:它原来是 0.1.7-rc.2,升级到了 0.2.0-rc.2,也就是桌面应用内置的同一个版本。dsh 自己会读凭据存储;PATH 上的版本只要足够新,就能读。 旧版本会退回到上面那条消息,它让你导出变量,因为那是它唯一能走的路。
所以没有什么需要配置——而确实有一个依赖是这个应用没有检查的,现在它检查了:
本地标签页运行 PATH 上的任何 dsh,而它的版本是关键。
落后两个版本就足以弄坏它,报出的错误指向凭据,而不是指向版本。
src/localdsh.js 会在本地标签页启动任何东西之前运行 dsh --version,如果版本不对就拒绝,并给出它找到的版本、它需要的版本,以及修复用的命令。关于它,有两点值得知道:
- 下限是这个应用实测过的版本,0.2.0-rc.2,而不是可能能用的最老版本。0.1.7-rc.2 已知会坏;落后一个版本尚未以任何方式测量,而乐观设定的下限只会什么都报告不出来,操作者照样收到凭据错误。
- 它不会让坏掉的安装变得可用——它会让它说出来。 旧版 dsh 会一直挂起,直到 45 秒就绪超时,然后才报告凭据错误,这是这个应用能产生的最没用的组合。检查大约在
tenth of a second and names the thing to change.
The POSIX probe asks for an interactive login shell (bash -lic), the same one the
Harness itself is started with, because an nvm install puts its bin directory on PATH
from ~/.bashrc — a probe without -i would report "not found" for a machine where the launch works.
The three remedies the message suggests (store it from the Models page, export it, or
run no second local Harness) remain valid, but none of them was the actual fix, and none
of them is needed on a current install.
Known limitation: a force-killed application leaks its Harness
Closing the window is clean — verified: the application quits, the tree kill in connect.js runs, and the local Harness disappears with it. That is the path an operator actually takes.
Terminating the process outright still leaks it. Nothing inside the child can
prevent that: the application is the one that knows the process tree, and a
terminated application runs no code.
How the Windows local tab is started, and why it is not symmetric
The local tab used to be wrapped in the same PowerShell program as a Windows
remote, so that it would inherit the same stdin-EOF teardown. That was wrong,
and it broke the local tab entirely — the symptom was the Harness never announced a URL within 45s. Measured against the same dsh:
| launch | result |
| cmd /c "dsh web --no-open --port 0" | readiness line in 1.5 s (re-measured 2026-10: 1.4 / 1.4 / 1.5 / 1.7 s over four runs) |
| powershell -Command "dsh web …" | exit 1 |
| the Start-Process -NoNewWindow wrapper | no output at all for 30 s |
The repeat was not idle: a first attempt at re-measuring this read 23.7 s, which
against a 45 s budget would have made the timeout a live risk. It was a measurement
artefact — Start-Process -RedirectStandardOutput plus a polling loop — and the same four runs under tools/readiness.cjs, which spawns what the application actually spawns, came back at the original figure. The number above is the one to trust because the probe and the application share a launch path; a hand-rolled cmd /c in a terminal shares neither the stdio shape nor the process tree.
Start-Process -NoNewWindow does not give the server a usable stdout when
PowerShell was itself spawned with pipes, which is how this application spawns it.
So Windows uses cmd /c, which means the Windows local tab has no stdin
contract — POSIX still does — and what reaps it is the tree kill instead:
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
Two details there are load-bearing and were each paid for by a leak:
- /T — an npm global install puts a .cmd shim between the shell and the Harness, so the tree is cmd.exe → dsh.cmd → node and killing one leaves two.
- spawnSync, not spawn — an asynchronous taskkill is a child of the application, and this runs during before-quit: the application exits and takes it along before it has done anything. Blocking the quit for the tens of milliseconds it takes is the entire point.
A leaked Harness is a stray process holding a loopback port, not a corrupted
state: the next launch starts a fresh one on a fresh port.
A third detail, found by accident: the kill has a window
stopLocal closes stdin before it runs the tree kill, and that ordering opens a
window in which the tree walk finds nothing. taskkill /T walks children of the
pid it is given; if the shell has already exited — which is exactly what closing stdin is asking it to do — there is no child list left to walk, and the Harness underneath it survives holding its port.
This was found from the outside: a probing run of tools/readiness.cjs left a
Harness listening on 127.0.0.1:55747 minutes after the run that created it had
reported success. The other runs in that batch were reaped normally, so the window
is narrow rather than wide, and the probe has since been reordered to kill the
tree before closing stdin — after which five consecutive runs reaped cleanly, with the probe checking for new listeners after every one.
No run in those five was reaped by EOF: every one reported taskkill /T /F, which
is independent confirmation that the Windows local tab has no stdin contract to
fall back on.
That reordering is not applied to stopLocal here. Doing it gives up the only
measurement of whether the command's own stdin contract did anything, and on
POSIX — where the contract is the one that works — the ordering is deliberate. readiness.cjs reaping in the safe order and reporting which case it saw (stopped by exit on EOF versus taskkill /T /F) is the evidence that would justify changing the application.
Android: a sibling client, not a port of this one
The Android client exists and is in use. It lives in a sibling repository,
../dsh-tabs-android, and it was built from the two studies below after they decided
what it should be. The studies are kept here because their conclusion is not history:
it is a decision about what may be shared between the two clients, and that decision
constrains what either repository is allowed to grow into.
The studies are a pair, and they were researched separately:
| Study | The question it answers |
| android-ssh-feasibility.md | Embed Node, port to Kotlin, or vendor an ssh binary — and which JVM SSH library |
| android-webview-proxy-feasibility.md | What the WebView does with the resulting http://127.0.0.1:<port> URL |
The decision worth recording: the Node code is not shared
The obvious plan — embed a Node runtime in the APK and reuse src/remote.js — cannot work, and it fails totally rather than partially: nodejs-mobile, the only real candidate, does not implement child_process at all, and src/connect.js is defined by it — spawn ssh, parse the readiness line, spawn a second ssh -N -L for the tunnel. The same runtime's last release was 2024-10-07 on Node 18, which reached end-of-life on 2025-04-30.
So the Android client is a native Kotlin client with an in-process SSH
session — com.github.mwiede:jsch plus BouncyCastle — and it shares no source with this application. What it shares is the protocol: the readiness line, the remote program, the platform split, and the teardown-by-EOF contract.
That protocol has two artefacts, and they play different roles:
| Artefact | Role |
| contract.json | The protocol, stated in one place a non-JavaScript client can read — the readiness pattern, the budgets, the ssh options, and the exact bytes of every remote program. Generated by node tools/contract.cjs from src/remote.js, so it cannot drift from the code. |
| src/remote.js | This repository's copy of the builders. |
src/remote.js is the copy, not the original. The original is the
dsh-remote-devices Desktop plugin, and test:smoke asserts the thirteen shared
builders here are byte-identical to it. A port that copies src/remote.js
therefore copies a copy — and when the plugin moves, the copy follows it and the suite re-pins both, which is the only thing keeping them honest. The contract is the stable artefact to port from; neither JavaScript file is.
The three rules a port gets wrong by default
The contract carries them, and they are worth reading before writing Kotlin rather
than after, because each is a case where the obvious implementation is the
insecure one:
| Rule | Where it lives | The trap |
| A changed host key is refused, not accepted | hostKeys | JSch's StrictHostKeyChecking=no also accepts a changed key — the i == CHANGED branch only throws for ask/yes. Copying the switch name from sshOptions downgrades every connection after the first. |
| A launch directory can contain shell metacharacters and must be quoted or refused | remoteProgram.directoryRule, directorySafety | Only the Windows local branch is unsafe, and it is unsafe because cmd /c takes one command string. POSIX is fine unquoted-looking; a port that "simplifies" the quoting breaks ~/... paths. |
| Teardown is closing stdin, not killing the process | teardown | Killing the client leaves the far side's server running and holding its port — measured, and the reason the remote program blocks on cat > /dev/null. |
The contract is also the reason these can be checked at all: a Kotlin client can
read docs/contract.json in a test and compare its own builders against the
reference bytes, which is the same property test:smoke enforces between this
repository's copy and the plugin.
Both artefacts answer one question — what must a second client produce? — and tools/readiness.cjs is how the part of that question living outside this repository stays honest:
node tools/readiness.cjs # start a real local dsh web, time it, stop it
Run five times on this machine it produced 1.5 / 1.6 / 1.6 / 1.6 / 1.7 s against
a 45 s budget, every run with the readiness line in the shape READY_LINE parses
and no run leaking a process. That is npm test's one deliberate blind spot: the
suites are offline, and a dsh that changed its output format would break the
local tab, every remote tab, the Desktop plugin and the Android client at once
without any offline check noticing.
The library caveat that will bite first. JSch's
StrictHostKeyChecking=no accepts a changed host key silently, so it is
weaker than the accept-new this application passes to ssh
(src/remote.js:sshOptions, and sshOptions in the contract). Reproducing this
client's actual behaviour needs a custom HostKeyRepository — roughly 40 lines — and no default JSch configuration provides it.
What the studies do not claim, and what the client has since settled
Neither study was tested on a device or an emulator: every library claim in them comes
from published source, documentation, or artifact inspection, and both carry an
explicit list of what could not be verified. The items that decided the port were on
that list — the BouncyCastle provider collision on Android, the minSdk floor for Ed25519, and whether a specialUse foreground service survives Play review — and the first was expected to be settled by a spike before any application code.
It was, and worse than the study feared. JSch ships its Ed25519 implementation
under META-INF/versions/15/ in a multi-release JAR, and Android's runtime does not
implement those: it links the base class, whose constructor throws
UnsupportedOperationException("SignatureEd25519 requires Java15+"). BouncyCastle is
therefore the only route JSch has, and it has to be registered at start-up or the
handshake fails with the library sitting unused on the classpath. That, and the rest
of what the client measured on real hardware, is in ../dsh-tabs-android's README — which is the record now, not these studies.
The studies also rule out more than they choose: vendoring an ssh binary is legal
but needs a Node fork anyway, Termux is excluded by requiring the user to install
Termux, and no pure-JS SSH client avoids the Node built-ins that a WebView-resident
runtime cannot provide.
The Windows remote: what the watchdog does not cover
The Windows branch reports a child that dies before announcing a URL — but only after the child has been launched. A failure before that line, such as Set-Location refusing a directory that does not exist, exits the program with the watchdog never reached. Measured against box-b: the far side printed
Set-Location : Cannot find path 'C:\definitely-not-a-directory-dsh-tabs' because it does not exist.
and exited in 2.9 s, while the client waited out its full 45-second readiness
timeout.
What that cost has since been paid off, without touching the watchdog. The remote
had already said what was wrong; the client was throwing it away. awaitReady now
keeps the stderr it was only transcribing and quotes it in the failure, so the same
connect reports within 2.9 seconds:
the remote command exited with code 1 before announcing a URL — it said: Set-Location : Cannot find path 'C:\…' because it does not exist.
Getting that sentence out is its own small problem, because PowerShell serialises its
error stream as CLIXML when stderr is redirected: the far side's 1131 bytes were
one <Objs …> document in which the useful line sat inside an record with its newlines escaped. connect.js decodes it and keeps the first error record — the sentence that names the fault; the records after it are the source excerpt and CategoryInfo, which are for whoever edits the script rather than whoever is trying to connect.
What remains, and why it is not fixed: the watchdog would have to run concurrently
with blocking on stdin rather than before it, because those are the two things the
program does and blocking is its whole teardown contract. That is a change in shape to
a program now verified end to end against a real Windows host, and the operator no
longer has to guess — they get the reason either way. The watchdog's own message also prints an empty exit code, because Start-Process -PassThru leaves $proc.ExitCode unset without -Wait, and WaitForExit() and Refresh() do not fill it in either (PowerShell 5.1).
Test
npm test # every offline suite
npm run test:live # against a real device, no window
npm run test:live -- <device-id> # pick one from the book
npm run contract -- --check # fail if docs/contract.json is stale
npm run readiness # start a real local dsh web, time it, stop it
npm test runs four suites, each aimed at a layer that can be exercised without a
window:
| Suite | What it drives | Why it exists |
| test:smoke | the source, the builders' output, and this document | drift, platform rules, byte-parity with the plugin, the protocol contract, and the runbook's own claims |
| test:connect | src/connect.js | port allocation, the readiness reader, tunnel probing |
| test:main | src/main.js against a stubbed Electron | the tab lifecycle and the Alt+digit routing |
| test:renderer | src/renderer/app.js in jsdom | the tab bar, guest reuse, panels, and the IPC calls it makes |
test:smoke also reads this document. Four of its checks exist because each
names a defect that no amount of source inspection would have caught: the
electron.exe --version recipe in Running it has to stay paired with the
ELECTRON_RUN_AS_NODE warning beside it, every DSH_ variable named here has to
be one a source file actually reads — the table at the end of this section once named a variable by a spelling no file contained — no heading may be left standing over nothing at all, and every local link has to resolve, which is the only thing watching the paths to the studies above: no source file names them, so a moved or renamed one is a dead end nothing else would notice.
Four more read every file in the repository, this document included, and they are
about what it is allowed to carry once it is somebody else's checkout: no
drive-absolute path that only exists on the machine this was built on, no user
profile, no private or carrier-grade address, and .shots/ has to stay ignored — the screenshots are evidence and they show a real device book. The fourth exists because the first two fail silently when their patterns stop matching, which is exactly what a clean repository looks like: it assembles a path belonging to nobody and asserts that the guards would fire on it.
They are written as patterns rather than as a list of names to avoid, because a
check that spells out what it forbids publishes it*. Every machine-local path that
had reached this document is now written as a place rather than a path:
<root>\dsh-electron-test, or an elided C:\….
Two tools are deliberately outside npm test, because the suites are offline and
these are not:
| Tool | What it does |
| tools/contract.cjs | Regenerates docs/contract.json from src/remote.js. test:smoke fails if the file is stale, so the regeneration is a deliberate act with a reviewable diff. |
| tools/readiness.cjs | Starts a real local dsh web, checks the readiness line still arrives in the shape READY_LINE parses, times it against the 45 s budget, and reaps it. This is the blind spot the offline suites cannot cover. |
Both are CommonJS (.cjs) rather than ESM (.mjs) like the suites: they require
src/remote.js, which is CommonJS, and .mjs cannot require.
jsdom is not Chromium, so test:renderer does not prove the window looks right.
What it proves is the reconciliation logic — one guest per (tab, url), correct hiding, correct panels, correct calls — which is where the bugs were: it caught a popover marker that was true for every device, and test:connect caught a readiness line that resolved with a truncated URL when the pipe split it.
test:live drives src/connect.js against a real device end to end: platform
detection, the readiness line, the tunnel binding, the token → cookie exchange,
the Harness document, the unauthenticated fence, the unary RPC carrier, and the
teardown leaving no orphan on the far side. It reads the device book and never
writes to it.
test:main is why the shortcut lives in the main process rather than the page:
key events do not cross into a <webview> guest, so a renderer-side listener
would only ever see focus in the app's own chrome. The suite presses keys through
before-input-event and checks which tab wins.
Two environment variables make the suites work from a directory that is not
inside $DSH_HOME, which is where this application has to live:
| Variable | Why |
| DSH_REMOTE_DEVICES_PLUGIN | The path to the plugin's index.js, for the parity check. Without it a few plausible locations are tried, and if none matches the check reports SKIP rather than passing quietly — losing it means losing the only thing that notices the copy drifting. |
| DSH_TABS_TEST_HOME | Where test:main puts its scratch device book. The suite creates a directory, and some environments refuse that outside their own workspace. Unset, it tries <repo>\.tmp-main-test and then the system temp directory, and prints which one it used. |
Never run these files directly against a real $DSH_HOME. test:main
overwrites the device book it is pointed at — that is how it arranges each test's world — so node tools/main.mjs from a shell that already exports DSH_HOME to the real home puts test fixtures in place of the operator's devices. That happened while this suite was being written, and it emptied a book holding two real devices; they survived only because the plugin's remote-devices.json still had them and this application seeds from it.
Both suites now defend themselves. test:main ignores a DSH_HOME that is
$DSH_HOME or the home directory itself and falls back to
DSH_TABS_TEST_HOME, saying so; it redirects rather than refusing, because
refusing would break npm test in exactly those shells. test:smoke moves
itself to a scratch directory for the same reason, since it only needs a book to
write and throw away.预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
OahzOb/dsh-tabs
把本地与远程 Harness 收进同一窗口的标签栏
相关插件 · 浏览器
智能体插件框架deepseek-ai/deepseek-harness
DeepSeek Harnessdsh是由 DeepSeek AI 开发的开源 agent harness智能体框架。
★ 270,422已汉化DSH
71.9
多设备 Agent 看板YaoApp/yao
✨ 所有 Agent 和工作区集中管理,覆盖你的每一台设备。任务看板追踪进度,桌面端、移动端、浏览器、API…
★ 8,147已汉化DSH
71.2
可扩展侧边栏工作台omdsh-dev/DSH-better-sidebar
DSH 的双工作台:原生右侧栏 + 自绘底部面板,并把 ctx.betterSidebar 服务开放给所有插件
★ 4,761已汉化DSH
71.1
浏览器自动化桥Tencent/BrowserSkill
你的 Agent。你的浏览器。连接,就能协作。
★ 9,967已汉化DSH
71
AI 小说创作工作台EthanYoQ/AI-Novel-Writer
AI 小说作家 / AI Novel Writer
★ 1,591已汉化DSH
70.7
智能体浏览器引擎platonai/Browser4
DeepSeek Harness 集成
★ 1,175已汉化DSH
70.4
这是你的仓库?认领条目