🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

远程 SSH 工作区 junjiangao/dsh-plugin-remote-ssh

DeepSeek 客户端
junjiangao11 天前有提交已提供中文文档扫描:含敏感能力兼容 / 相关生态

在侧栏管理 SSH 主机,把远端目录接入当前或独立窗口

29.6/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/27 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

相关生态插件(非 dsh 原生,按其对应运行时安装)
git clone https://github.com/junjiangao/dsh-plugin-remote-ssh.git

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:仅索引
是什么
生态应用
桌面端 / Web 外壳,不以 dsh plugin add 安装
装得上吗
本站尚未做安装检查
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 11 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件写入剪贴板(不计风险)
查看扫描证据(共 126 处,展示前 107 条)
  • 写入剪贴板
    client.js:962
    await navigator.clipboard.writeText(value);
  • 写入本地文件
    lib/controller.js:113
    await mkdir(this.paths.dataDir, { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/controller.js:114
    await mkdir(this.paths.instancesDir, { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/controller.js:115
    await mkdir(this.paths.tmpDir, { recursive: true, mode: 0o700 });
  • 删除本地文件
    lib/controller.js:208
    await rm(overlayPath(this.paths.instancesDir, safeFileKey(existing)), { force: true }).catch(() => undefined);
  • 写入本地文件
    lib/controller.js:231
    await mkdir(this.paths.tmpDir, { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/controller.js:232
    await writeFile(temporary, renderHostBlock({ ...host, remoteWorkspace: undefined }), {
  • 删除本地文件
    lib/controller.js:257
    if (temporary !== undefined) await rm(temporary, { force: true }).catch(() => undefined);
  • 写入本地文件
    lib/controller.js:419
    await mkdir(mountPath, { recursive: true, mode: 0o700 });
  • 读取本地文件
    lib/controller.js:643
    cwd: existsSync(workspace.path) ? workspace.path : undefined,
  • 读取本地文件
    lib/execution-router.js:421
    if (typeof head !== 'string' || !isAbsolute(head) || !existsSync(head)) return false;
  • 读取本地文件
    lib/execution-router.js:429
    if (typeof script !== 'string' || !isAbsolute(script) || !existsSync(script)) return false;
  • 读取本地文件
    lib/execution-router.js:435
    const cwd = typeof spec.cwd === 'string' && existsSync(spec.cwd) ? spec.cwd : this.localCwd;
  • 读取本地文件
    lib/paths.js:68
    if (name.includes('/') || name.includes('\\')) return existsSync(name) ? name : undefined;
  • 读取本地文件
    lib/paths.js:74
    if (existsSync(candidate)) return candidate;
  • 读取本地文件
    lib/paths.js:85
    return JSON.parse(readFileSync(file, 'utf8'));
  • 读取本地文件
    lib/paths.js:114
    if (argv[2] !== undefined && isAbsolute(argv[2]) && existsSync(join(argv[2], 'package.json'))) {
  • 读取本地文件
    lib/paths.js:140
    if (existsSync(wrapper)) result.cliPath = wrapper;
  • 读取本地文件
    lib/paths.js:143
    if (existsSync(desktopWrapper)) result.cliPath = desktopWrapper;
  • 写入本地文件
    lib/store.js:64
    await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
  • 读取本地文件
    lib/store.js:67
    raw = await readFile(this.file, 'utf8');
  • 写入本地文件
    lib/store.js:92
    await rename(this.file, backup);
  • 写入本地文件
    lib/store.js:204
    await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/store.js:207
    await writeFile(temporary, body, { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/store.js:208
    await chmod(temporary, 0o600).catch(() => undefined);
  • 写入本地文件
    lib/store.js:209
    await rename(temporary, this.file);
  • 写入本地文件
    lib/ssh-config.js:124
    await mkdir(dirname(target), { recursive: true, mode: 0o700 });
  • 读取本地文件
    lib/ssh-config.js:128
    content = await readFile(target, 'utf8');
  • 读取本地文件
    lib/ssh-config.js:147
    await lstat(candidate);
  • 写入本地文件
    lib/ssh-config.js:149
    await copyFile(target, candidate);
  • 写入本地文件
    lib/ssh-config.js:150
    await chmod(candidate, 0o600).catch(() => undefined);
  • 读取本地文件
    lib/ssh-config.js:154
    await atomicWrite(target, next, existed ? (await stat(target)).mode & 0o777 : 0o600);
  • 读取本地文件
    lib/ssh-config.js:163
    content = await readFile(target, 'utf8');
  • 读取本地文件
    lib/ssh-config.js:170
    await atomicWrite(target, content.slice(0, located.start) + content.slice(located.end), (await stat(target)).mode & 0o777);
  • 读取本地文件
    lib/ssh-config.js:177
    return await realpath(file);
  • 写入本地文件
    lib/ssh-config.js:186
    await mkdir(dirname(file), { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/ssh-config.js:188
    await writeFile(temporary, content, { encoding: 'utf8', mode });
  • 写入本地文件
    lib/ssh-config.js:189
    await chmod(temporary, mode).catch(() => undefined);
  • 写入本地文件
    lib/helper.js:271
    await mkdir(workspace, { recursive: true, mode: 0o700 });
  • 读取本地文件
    lib/runtime-profile.js:49
    return JSON.parse(await readFile(file, 'utf8'));
  • 读取本地文件
    lib/runtime-profile.js:125
    return existsSync(file) ? file : undefined;
  • 读取本地文件
    lib/runtime-profile.js:145
    exists: existsSync(this.dir),
  • 读取本地文件
    lib/runtime-profile.js:203
    const info = await stat(candidate);
  • 写入本地文件
    lib/runtime-profile.js:227
    await mkdir(this.dir, { recursive: true, mode: 0o700 });
  • 读取本地文件
    lib/runtime-profile.js:229
    const current = await readFile(packageFile, 'utf8').catch(() => undefined);
  • 写入本地文件
    lib/runtime-profile.js:231
    if (current !== next) await writeFile(packageFile, next, { encoding: 'utf8', mode: 0o600 });
  • 读取本地文件
    lib/runtime-profile.js:233
    if (!existsSync(workspaceFile)) await writeFile(workspaceFile, renderWorkspaceYaml(), { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/runtime-profile.js:233
    if (!existsSync(workspaceFile)) await writeFile(workspaceFile, renderWorkspaceYaml(), { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/runtime-profile.js:235
    if (!existsSync(patchFile)) await writeFile(patchFile, '[]\n', { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/runtime-profile.js:243
    await writeFile(markerFile, `${JSON.stringify(marker, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/runtime-profile.js:247
    await copyFile(REMOTE_REFERENCE_SOURCE, this.remoteReferencePath());
  • 写入本地文件
    lib/overlay.js:172
    await mkdir(instancesDir, { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/overlay.js:175
    await writeFile(temporary, content, { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/overlay.js:176
    await chmod(temporary, 0o600).catch(() => undefined);
  • 写入本地文件
    lib/overlay.js:177
    await rename(temporary, file);
  • 删除本地文件
    lib/instance.js:140
    await rm(this.#pidFile(hostId), { force: true }).catch(() => undefined);
  • 执行外部命令来自配置/变量
    lib/instance.js:186
    const child = spawn(command.file, args, {
  • 读取本地文件
    lib/instance.js:279
    files = readdirSync(this.instancesDir).filter((name) => name.endsWith('.pid'));
  • 读取本地文件
    lib/instance.js:287
    record = JSON.parse(await readFile(file, 'utf8'));
  • 删除本地文件
    lib/instance.js:289
    await rm(file, { force: true }).catch(() => undefined);
  • 删除本地文件
    lib/instance.js:293
    await rm(file, { force: true }).catch(() => undefined);
  • 删除本地文件
    lib/instance.js:308
    await rm(file, { force: true }).catch(() => undefined);
  • 读取本地文件
    lib/instance.js:314
    if (this.runtime.cliPath !== '' && existsSync(this.runtime.cliPath)) {
  • 读取本地文件
    lib/instance.js:326
    if (existsSync(cli)) return { file: this.runtime.nodePath, prefix: [cli] };
  • 写入本地文件
    lib/instance.js:357
    await writeFile(
  • 读取本地文件
    lib/instance.js:405
    return (await readFile(`/proc/${pid}/cmdline`, 'utf8')).replace(/ /g, ' ').trim();
  • 删除本地文件
    lib/instance.js:424
    await rm(join(instancesDir, `${hostId}.pid`), { force: true }).catch(() => undefined);
  • 读取本地文件
    lib/instance.js:430
    if (!existsSync(file)) return;
  • 写入本地文件
    lib/instance.js:431
    await rename(file, `${file}.${suffix}`).catch(() => undefined);
  • 读取本地文件
    lib/remote-home.js:77
    state = JSON.parse(await readFile(file, 'utf8'));
  • 写入本地文件
    lib/remote-home.js:124
    await mkdir(join(file, '..'), { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/remote-home.js:126
    await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/remote-home.js:127
    await rename(temporary, file);
  • 读取本地文件
    lib/remote-home.js:133
    if (!existsSync(source)) return;
  • 读取本地文件
    lib/remote-home.js:136
    existing = await lstat(destination);
  • 读取本地文件
    lib/remote-home.js:143
    if ((await readlink(destination)) === source) return;
  • 删除本地文件
    lib/remote-home.js:147
    await unlink(destination).catch(() => undefined);
  • 写入本地文件
    lib/remote-home.js:162
    await mkdir(join(home, 'profiles'), { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/remote-home.js:163
    await mkdir(join(home, 'storages'), { recursive: true, mode: 0o700 });
  • 读取本地文件
    lib/remote-home.js:168
    profileLinkInfo = await lstat(profileLink);
  • 读取本地文件
    lib/remote-home.js:173
    const target = await readlink(profileLink).catch(() => undefined);
  • 删除本地文件
    lib/remote-home.js:175
    await unlink(profileLink).catch(() => undefined);
  • 写入本地文件
    lib/pending-session.js:43
    await mkdir(dirname(file), { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/pending-session.js:50
    await writeFile(temporary, `${JSON.stringify(body, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
  • 写入本地文件
    lib/pending-session.js:51
    await rename(temporary, file);
  • 读取本地文件
    lib/pending-session.js:64
    raw = JSON.parse(await readFile(file, 'utf8'));
  • 删除本地文件
    lib/pending-session.js:68
    await rm(file, { force: true }).catch(() => undefined);
  • 删除本地文件
    lib/pending-session.js:80
    await rm(pendingSessionFile(dshHome), { force: true }).catch(() => undefined);
  • 读取本地文件
    lib/remote-workspaces.js:105
    parsed = JSON.parse(await readFile(file, 'utf8'));
  • 读取本地文件
    lib/remote-workspaces.js:181
    names = await readdir(dir);
  • 读取本地文件
    lib/remote-workspaces.js:193
    parsed = JSON.parse(await readFile(join(dir, name), 'utf8'));
  • 读取本地文件
    lib/mixed-registry.js:95
    const raw = await readFile(this.file, 'utf8');
  • 写入本地文件
    lib/mixed-registry.js:119
    await rename(this.file, backup).catch(() => undefined);
  • 写入本地文件
    lib/mixed-registry.js:209
    await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
  • 写入本地文件
    lib/mixed-registry.js:215
    await writeFile(temporary, `${JSON.stringify(document, undefined, 2)}\n`, { mode: 0o600 });
  • 写入本地文件
    lib/mixed-registry.js:216
    await rename(temporary, this.file);
  • 读取本地文件
    lib/mixed-runtime.js:74
    const manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
  • 读取本地文件
    lib/mixed-runtime.js:83
    const names = await readdir(source, { withFileTypes: true });
  • 写入本地文件
    lib/mixed-runtime.js:89
    await mkdir(scopedDestination, { recursive: true });
  • 读取本地文件
    lib/mixed-runtime.js:90
    const children = await readdir(scopedSource, { withFileTypes: true });
  • 读取本地文件
    lib/mixed-runtime.js:121
    const marker = JSON.parse(await readFile(join(treeDir, 'ready.json'), 'utf8'));
  • 删除本地文件递归
    lib/mixed-runtime.js:143
    await rm(root, { recursive: true, force: true });
  • 写入本地文件
    lib/mixed-runtime.js:144
    await mkdir(join(tree, 'node_modules', '@deepseek-ai'), { recursive: true });
  • 写入本地文件
    lib/mixed-runtime.js:152
    await writeFile(
  • 执行外部命令来自配置/变量
    lib/ssh.js:54
    child = spawn(file, args, {
  • 读取本地文件
    lib/ssh.js:235
    return readFileSync(file, 'utf8');
  • 写入本地文件
    lib/remote-directory-picker.js:172
    await mkdir(localPath, { recursive: true, mode: 0o700 });
扫描于 2026/11/2(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

依赖的 DSH / Cordis 模块
@deepseek-ai/schemastery
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

DeepSeek Harness 插件:管理 SSH 主机、打开官方 dsh-ssh 远程窗口,把远程工作区挂载到当前窗口

README

由 DeepSeek 最新模型翻译生成
@junjiangao/dsh-plugin-remote-ssh 英文版:README.en.md 在 DeepSeek Harness Web UI 左侧栏管理 SSH 主机,并把远端工作区以两种方式接入: - 独立远端窗口:每台主机可打开一个远端工作窗口,基于官方 dsh-ssh,文件、命令和终端都跑在远端; - 同一窗口混合模式:不新开窗口,直接把远端目录登记为当前桌面窗口的一个 Workspace,本地会话与远端会话同时存在(插件内置执行路由器按路径分派 fs/subprocess/sandbox)。 这个插件不再注册任何模型工具。它只负责「主机管理 + 远端窗口编排」,真正的 SSH 能力由官方 provider 家族提供: - @deepseek-ai/dsh-ssh:每台主机一个 helper 连接 - @deepseek-ai/dsh-fs-ssh:把远端文件系统接成 fs - @deepseek-ai/dsh-subprocess-ssh:把远端进程/终端接成 subprocess - @deepseek-ai/dsh-sandbox-ssh:远端沙箱 功能 - 侧栏入口:Web/桌面端左侧栏出现「远程 SSH」,主区展示主机列表。 - 主机来源:默认读取 ~/.ssh/config,导入的条目只读;插件自己托管的主机可添加、编辑、删除。
展开完整文档(README 剩余 1 节)
@junjiangao/dsh-plugin-remote-ssh

英文版:README.en.md

在 DeepSeek Harness Web UI 左侧栏管理 SSH 主机,并把远端工作区以两种方式接入:

- 独立远端窗口:每台主机可打开一个远端工作窗口,基于官方 dsh-ssh,文件、命令和终端都跑在远端;
- 同一窗口混合模式:不新开窗口,直接把远端目录登记为当前桌面窗口的一个 Workspace,本地会话与远端会话同时存在(插件内置执行路由器按路径分派 fs/subprocess/sandbox)。

这个插件不再注册任何模型工具。它只负责「主机管理 + 远端窗口编排」,真正的 SSH 能力由官方 provider 家族提供:

- @deepseek-ai/dsh-ssh:每台主机一个 helper 连接
- @deepseek-ai/dsh-fs-ssh:把远端文件系统接成 fs
- @deepseek-ai/dsh-subprocess-ssh:把远端进程/终端接成 subprocess
- @deepseek-ai/dsh-sandbox-ssh:远端沙箱

功能

- 侧栏入口:Web/桌面端左侧栏出现「远程 SSH」,主区展示主机列表。
- 主机来源:默认读取 ~/.ssh/config,导入的条目只读;插件自己托管的主机可添加、编辑、删除。
- 添加/编辑共用一个表单:别名、显示名、HostName、User、Port、IdentityFile、ProxyJump、ProxyCommand、IdentitiesOnly、ForwardAgent、KeepAlive、StrictHostKeyChecking、UserKnownHostsFile、ConnectionAttempts、ConnectTimeout、任意额外指令、远端工作目录、远端 helper 目录。
- 先检查再安装:打开主机前检查 SSH、远端平台、Node/npm、helper、工作目录和本地 runtime;helper 缺失或版本不匹配时先弹出确认对话框,确认后才在远端执行 npm 安装。
- 独立远端窗口:每次打开都用官方 SSH provider 启动一个独立的 DSH Web 实例(独立 profile、独立端口、隔离的 DSH home),在浏览器新标签页打开带鉴权的 URL。
- 远端工作区与 @ 文件:远端窗口不再显示本机项目列表,默认工作区直接指向远端路径;@ 文件候选由插件通过官方 fs-ssh 读取远端目录,而不是扫描本机。
- 远端目录选择器:远程窗口里点「添加工作区」浏览的是 SSH 主机上的目录,而不是本机文件夹;新建目录也在远端执行。
- 「远程工作区」侧栏:主界面侧栏新增一个只读聚合面板,按「远程机 + 工作区」列出各主机隔离 home 中的远端工作区与会话,可直接打开/停止窗口。
- 会话深链:在「远程工作区」面板点某个会话,会打开对应远端窗口并直接定位到该会话(基于隔离 home 中的短期标记,不依赖 URL 参数)。
- 同窗口混合模式:「远程工作区」面板的「在此窗口打开」把远端目录登记为普通 Workspace;新会话的 Bash、读写文件、列目录走 SSH 世界,本地 Workspace 不受影响。
- 不使用模型工具:不向 ctx.tools 注册 ssh_exec / ssh_upload 等工具,避免和官方 agent 能力重复。

系统要求

- DeepSeek Harness >= 0.2.1-rc.2 < 0.3.0,Node.js ^22.19.0 || >=24.0.0。
- 本机:Linux 或 macOS;OpenSSH 客户端(ssh),且 ~/.ssh/config 支持 Include(OpenSSH 7.3+)。
- 远端:Linux 或 macOS;/bin/sh;Node.js ^22.19.0 || >=24.0.0;npm;可写的远端工作目录。
- 远端 helper 安装需要访问 npm registry(或配置镜像);本机 runtime profile 安装需要访问 npm registry。

Windows 客户端暂不支持(官方 dsh-ssh provider 只面向 POSIX 客户端)。

安装

通过插件管理器(推荐)

插件清单声明了 dsh.bundle.patch。为了接管本地/远端执行,补丁先禁用基础 provider 行,再插入一个 Host 行:

- id: subprocess
disabled: true
- id: sandbox
disabled: true
- id: fs-sandbox
disabled: true

- insert:
- id: remote-ssh
name: '@junjiangao/dsh-plugin-remote-ssh'
config: {}

被禁用的三行由插件的执行路由器重新提供:本地世界仍挂载官方 dsh-fs-sandbox / dsh-subprocess-local / dsh-sandbox-local,因此本地行为不变;远端世界按需挂载 SSH provider。

该行以空配置激活,全部默认值见配置项。安装/启用后需要重启 Harness Host 或重新加载 profile 才能加载新的 Host 模块。

本地开发

在 profile 的 package.json 中把包指向本地目录,并加入 bundle 列表:

{
"dependencies": {
"@junjiangao/dsh-plugin-remote-ssh": "link:/absolute/path/to/dsh-plugin-remote-ssh"
},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "@junjiangao/dsh-plugin-remote-ssh"]
}
}
}
然后在该配置档中安装依赖并重启 Host。插件自身只有 @deepseek-ai/schemastery 一个 peer 依赖,其余 Node 内置模块和 Harness 提供的能力不需要额外安装。

快速使用

1. 重启后打开 Harness Web UI,点击左侧栏「远程 SSH」。
2. 列表分为两组:
- 托管主机:插件写入 ~/.dsh/remote-ssh/ssh_config 的主机,可编辑/删除。
- 来自 ~/.ssh/config:只读导入条目,可「复制为托管」后再改。
3. 点击「添加主机」,填写别名和主机名即可保存;~/.ssh/config 顶部会自动出现一个受管理的 Include 块。
4. 点击某台主机的「测试」验证 SSH 连通性;点击「打开」进入准备流程。
5. 首次打开时如果远端没有匹配版本的 helper,会先显示安装确认和将要执行的 npm 命令;确认后等待安装、runtime 准备和窗口启动。
6. 窗口启动后点击「打开窗口」在浏览器新标签页打开远程工作区;「停止」结束该主机的实例。
7. 侧栏「远程工作区」按「远程机 + 工作区」汇总已打开的远端工作区与会话;点击会话会自动打开对应窗口并定位到该会话。
8. 想在当前窗口同时使用本地和远端时,在「远程工作区」点「在此窗口打开」,详见混合模式。

同一台主机同时只保留一个远端窗口实例。重复点击「打开」会复用已运行的实例。在远程窗口内点「添加工作区」时,目录浏览器读取的是远端文件系统;首次选择新目录后,插件会按 mirrorWorkspace 在本机创建同路径空目录以通过工作区 realpath 校验。

存储与 ssh_config

| 路径 | 内容 |
| ~/.dsh/remote-ssh/hosts.json | 插件托管的 host 记录(0600) |
| ~/.dsh/remote-ssh/ssh_config | 由 host 记录生成的 OpenSSH 片段;不要手改 |
| ~/.dsh/remote-ssh/instances/<id>.yml | 每次打开时生成的该主机 Loader overlay |
| ~/.dsh/remote-ssh/instances/<id>.pid | 正在运行的远端窗口进程记录,用于崩溃恢复 |
| ~/.dsh/remote-ssh/homes/<host-id>/ | 每台主机的隔离 DSH home;只继承凭据/设置文件,工作区登记指向远端路径 |
| ~/.dsh/remote-ssh/homes/<host-id>/storages/pending-session.json | 主界面点会话时写入的待定位会话标记(0600,TTL 5 分钟,远端窗口读取后删除) |
| ~/.dsh/remote-ssh/mixed/workspaces.json | 混合模式登记表:主机、远端路径与本地镜像路径(0600) |
| ~/.dsh/remote-ssh/mixed/mounts/<host-id>/ | 混合模式的本地镜像目录,只用于 Workspace realpath,不复制远端文件 |
| ~/.dsh/remote-ssh/mixed/runtime/<version>/tree/ | 混合模式的私有模块树(官方本地 provider + SSH 包),保证模块身份一致 |
| ~/.ssh/config | 顶部插入受管理的 Include 块;首次修改前备份为 config.dsh-remote-ssh.bak |
| ~/.dsh/profiles/dsh-remote | 插件准备的本地远端运行时配置档 |

生成的 ~/.ssh/config 片段大致如下(Include 指向托管文件):

BEGIN dsh-plugin-remote-ssh (managed; do not edit)
Include /home/me/.dsh/remote-ssh/ssh_config
END dsh-plugin-remote-ssh

被托管的每个 Host 默认写入 StrictHostKeyChecking yes。首次连接由插件用 accept-new 完成引导并把主机密钥写入 known_hosts,之后官方 provider 使用严格校验。可以按主机把该值改为 accept-new 或 no。

删除全部托管主机后,Include 块会保留但指向空片段,这是有意为之,避免频繁改写用户配置。

配置项

在配置档的 cordis.patch.yml 中按行 id 覆盖。行覆盖会替换整个 config,因此只写需要的键也可以,其余键会取 Config 默认值;如果你依赖旧值,请显式写全。

- id: remote-ssh
config:
profileName: dsh-remote
inheritDesktopSettings: true
sshPackageVersion: 0.2.1-rc.2
remoteNode: /usr/bin/node

| 键 | 默认值 | 说明 |
| sshExecutable | ssh | 引导和诊断使用的 OpenSSH 客户端 |
| sshConfigPath | ~/.ssh/config | 读取主机并维护受管理 Include 的用户配置 |
| dataDir | ~/.dsh/remote-ssh | 插件数据目录 |
| profileName | dsh-remote | 准备并启动的本地 runtime 配置档名 |
| sshPackageVersion | 当前 DSH 版本 | 官方 SSH 包版本;为空时使用运行中的 Harness 版本 |
| helperPackage | @deepseek-ai/dsh-ssh | 远端安装的 helper npm 包 |
| helperRoot | ~/.dsh-remote-ssh/helper | 远端 helper 父目录,按版本分子目录 |
| npmRegistry | 未设置 | 远端 helper 安装使用的 npm registry |
| remoteNode | 自动发现 | 远端 Node 绝对路径;默认先查 SSH PATH,再探测 fnm/nvm/volta 等常见安装目录 |
| connectTimeoutMs | 15000 | 单次 SSH 连接超时 |
| commandTimeoutMs | 30000 | 单条远端诊断命令超时 |
| installTimeoutMs | 600000 | 远端 helper 或本地 runtime 安装超时 |
| openTimeoutMs | 90000 | 等待远端窗口打印 URL 的截止时间 |
| inheritDesktopSettings | true | 把桌面 profile 的用户补丁应用到远端窗口(沿用模型/UI 设置) |
| mirrorWorkspace | true | 在本机创建与远端同路径的空目录,绕过工作区 realpath 校验 |
| permissionMode | workspace-write | 远端窗口的沙箱模式:workspace-write / read-only / danger-full-access |

主机表单还支持以下按主机字段(保存到 hosts.json 并写入 ssh_config):

alias、displayName、description、hostname、user、port、identityFile、proxyJump、proxyCommand、identitiesOnly、forwardAgent、serverAliveInterval、serverAliveCountMax、strictHostKeyChecking、userKnownHostsFile、connectionAttempts、connectTimeout、extraOptions、remoteWorkspace、remoteHelperRoot。

extraOptions 不接受 Host、Match、Include,并且拒绝重复指令。

打开流程

openHost 在 Host 侧按以下阶段执行,并把阶段写回快照供面板轮询:

1. checking:doctor 检查 SSH、远端平台、Node/npm、helper 版本、工作目录、主机密钥和本地 profile。
2. installing-helper:如果 helper 缺失或版本不匹配,且调用方传了 allowInstall: true,在远端执行 npm 安装并计算入口文件 sha256;会优先使用 doctor 探测到的绝对 npm,并把 Node 目录加入安装命令的 PATH(兼容 fnm/nvm 不在非交互 SSH PATH 的情况)。
3. preparing-runtime:在本地准备 dsh-remote profile(首次安装 4 个官方 SSH 包)。
4. starting:准备隔离的 DSH home,并用该主机的 overlay 启动独立 Web 实例,等待它打印 dsh web: http://127.0.0.1:<port>/?<token>。overlay 同时挂载远端目录选择器与会话深链插件。
5. running:URL 可用;openUrl 把 URL 返回给浏览器,由面板 window.open 打开。
6. failed / stopping / idle:失败、停止和空闲状态。

启动前插件会准备独立的 DSH home:把 profiles/dsh-remote 链接进去,继承 .credentials.yaml、.env、settings.yaml 和 AGENTS.md,并写入一条指向远端 workspace 的工作区记录。这样远端窗口不会复用桌面的本机项目/会话列表,默认工作区就是远端目录。

overlay 会关闭本地的 subprocess、sandbox、fs-sandbox,插入官方 SSH 的四行,并把 sandbox-policy.workspaceRoot 改成远端工作目录;同时关闭本机的 file-reference-local,插入跟随 fs-ssh 的远端文件引用提供者。默认的 directory-picker(桌面端会弹本机原生文件夹对话框)也会被替换为插件的远端浏览后端;另挂一个小的会话深链插件,用来领取主界面投递的待定位会话。官方 provider 是单例:一个远端窗口只服务一台主机,因此每台主机使用独立实例和独立 overlay。

混合模式(同一窗口)

除了独立远端窗口,插件还支持把远端目录直接放进当前桌面窗口:

1. 打开侧栏「远程工作区」,在某台主机的远端工作区或主机标题旁点「在此窗口打开」。
2. 插件会检查该主机、展开 ~、确认远端目录可写、确认 helper 已安装并准备好 dsh-remote profile;首次会创建本地镜像目录 ~/.dsh/remote-ssh/mixed/mounts/<host-id>/<name>-<hash>。
3. 面板用普通 Workspace API 登记镜像目录并跳转到它;新建会话的 cwd 就是这个镜像路径。
4. 在该会话里执行 Bash、读写文件、列目录时,路由器按路径把调用分派到该主机的 SSH 世界;其他本地 Workspace 的会话仍走本机执行。

路由优先级:镜像挂载前缀 → 当前工具会话的远端环境(tools/execute 的 session cwd)→ cwd 所在镜像 → 本地。远端会话里的绝对路径(如 /etc/hostname)按远端解释,不会误读本机文件。

注意:混合模式只接管 fs、subprocess、sandbox 三个服务。内容搜索仍使用打包的本地 ripgrep(镜像目录没有远端文件),@ 文件候选、LSP、open-in-app、spill 等仍按本机实现;需要完整远端语义时请使用独立远端窗口。

登记信息存放在 ~/.dsh/remote-ssh/mixed/workspaces.json;RPC removeMixedWorkspace 可移除登记并断开该主机世界,但不会删除普通 Workspace 记录。

安全

- 不在 hosts.json、ssh_config、overlay 或日志中保存 SSH 密码、token;认证完全交给 OpenSSH(密钥、agent、ProxyCommand)。
- ssh/scp 不经过本地 shell 拼接;参数以 argv 传递。远端命令通过明确的 /bin/sh -c 执行。
- 浏览器和 Host 之间使用 Harness Connection 自带的鉴权 Fetch 通道,每个端点一条精确路由。
- 远端窗口 URL 中的 token 只在 openUrl 返回值中使用;实例日志和错误详情会脱敏后再返回浏览器。
- hosts.json、生成的 ssh_config、overlay 和 pid 记录都以 0600 写入;~/.ssh/config 首次修改前会备份。
- 官方服务商启动 helper 时使用 BatchMode=yes、StrictHostKeyChecking=yes、ForwardAgent=no、ClearAllForwardings=yes;插件不会绕过这些限制。

已知限制

- 一台主机一个远端窗口:官方 dsh-ssh 在单个 composition 中是单例,不能在同一窗口切换多台主机。
- 不是完整 VS Code Remote:@ 文件引用和目录选择器已经改为读取远端文件系统,但 workspace changes、open-in-app、spill、skill filesystem 等仍按本机工作区实现,可能不可用或行为不一致;目前通过同路径本地镜像缓解工作区 realpath 校验。
- PTC 不可用:node-script helper 没有可执行 bootstrap,overlay 会关闭 ptc-runtime 和 workflow-ptc。
- globs/grep:远端 glob/grep 可能依赖远端 ripgrep;没有安装时相关工具会失败或回退。
- 没有文件 watch:远端文件变化不会通过本机 fs watch 推送。
- 端口转发/agent 转发:官方服务商固定 ClearAllForwardings=yes、ForwardAgent=no;需要转发请在 ~/.ssh/config 的 ProxyCommand / ProxyJump 层解决。
- helper 安装依赖 npm 和网络:npm 12 会提示 install-scripts 被阻断,但 node-pty 自带 prebuilds,通常不影响 helper 运行。Node/npm 由 fnm、nvm、volta 等管理时,插件会探测其常见目录;也可用 remoteNode 指定绝对路径。
- 本地镜像副作用:mirrorWorkspace: true 会在本机创建与远端同路径的目录(只建目录,不建文件);如果远端路径在本机不可创建(如 /root),添加工作区会失败而不是登记一个不存在的路径。
- 会话深链是尽力而为:标记 5 分钟过期、读取即删;远端会话已删除或客户端过慢时只打开远端窗口,不会定位到会话;在面板点击会话时若 helper 尚未安装,会先显示失败与安装提示。
- 混合模式不覆盖所有子系统:只有 fs/subprocess/sandbox 被路由;搜索用本地 ripgrep、@ 候选按本地镜像扫描、LSP/open-in-app/spill 仍按本机实现。需要完整远端语义请用独立远端窗口。
- 混合模式的 cwd 是镜像路径:模型看到的工作目录是本地镜像路径,绝对远端路径在同一会话内仍可用;排查时注意区分两个命名空间。

故障排查

| 现象 | 排查 |
| 侧栏没有「远程 SSH」 | 确认 bundle 已加入配置档、Host 已重启;浏览器强刷一次客户端资源 |
| 导入列表为空 | 检查 ~/.ssh/config 的 Host 是否含通配符(通配符不会导入)、ssh -G <alias> 是否成功 |
| 保存主机失败 | 检查别名是否重复、端口/路径是否合法;服务端会回滚并保留原文件 |
| 测试连接失败 | 先用终端执行 ssh -v <alias>;注意 ProxyCommand 中的代理是否可用 |
| 提示 helper 未安装 | 确认远端 Node/npm 可用、npm registry 可达;fnm/nvm/volta 安装会自动探测,仍失败时设置 remoteNode 或按对话框命令手动执行 |
| 窗口启动超时 | 查看「详情」中的日志尾部;常见原因是远端 helper 崩溃、官方 SSH 行未激活或 Host 密钥校验失败 |
| 远端窗口能打开但文件/终端不可用 | 检查远端 workspace 是否可写;查看日志中 ssh、fs-ssh、subprocess-ssh 是否 pending/报错 |
| 主机密钥严格校验失败 | 先让插件用 accept-new 完成首次连接,或手动 ssh <alias> 写入 known_hosts |

开发与验证

npm test # 单元测试 + sshd 夹具集成测试
npm run verify:static # 静态结构/清单/语法检查
npm run verify:client # 客户端契约与 locale 一致性
npm run verify:docs # README/架构文档一致性
npm run verify:e2e # 使用测试夹具的端到端编排检查
node verification/live-e2e.mjs --alias <alias> --version 0.2.1-rc.2 # 可选:真实 SSH 主机的完整打开流程
node verification/live-remote-window.mjs --alias <alias> --version 0.2.1-rc.2 # 可选:真实远端窗口 UI(目录选择器 + 会话深链 + 复用窗口)

实现细节见 docs/architecture.md。测试夹具见 tests/rig/README.md。

许可

本项目使用 MIT 许可证,版权归 junjiangao 所有。
预览分享卡片长什么样 ↓junjiangao/dsh-plugin-remote-ssh
分享 · DeepSeek Harness Hub
junjiangao/dsh-plugin-remote-ssh

在侧栏管理 SSH 主机,把远端目录接入当前或独立窗口

这是你的仓库?认领条目

回到顶部