@tiphareth/dsh-hardssh 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -74
- package/lib/fs.js +33 -7
- package/lib/types/fs.d.ts +24 -0
- package/package.json +2 -2
- package/src/fs.ts +42 -8
package/README.md
CHANGED
|
@@ -1,74 +1,77 @@
|
|
|
1
|
-
# dsh-hardssh — SSH 远程工作区 + SSH 运维插件
|
|
2
|
-
|
|
3
|
-
已适配 DSH **0.1.5**(实测内核 `0.1.5-rc.1`;本包版本 `0.2.
|
|
4
|
-
|
|
5
|
-
1. **SSH 运维**:右侧栏「SSH」Tab(从右侧栏标签条的「+」或右侧栏引导页入口打开)→ Web 终端(xterm + WebSocket PTY)、文件上传下载、本地端口转发隧道、当前服务器的远端命令;`ssh_list` / `ssh_exec` / `ssh_upload` / `ssh_download` / `ssh_tunnel` / `ssh_cluster` 六个 Agent 工具;主机配置存 `~/.dsh/dsh-ssh.json`。
|
|
6
|
-
2. **SSH 工作区**:左侧侧栏的全局入口行 → 中央面板管理服务器与工作区(增删改查 / `~/.ssh/config` 导入);绑定后本地 harness 的 fs/subprocess 经接缝门面透明路由到远程主机执行(read/write/edit/bash 在绑定会话中即远程操作);`remote_*` 三个 Agent 工具(`remote_ls` / `remote_search` / `remote_status`)用于显式操作远端工作区。
|
|
7
|
-
|
|
8
|
-
## 核心优势
|
|
9
|
-
|
|
10
|
-
- **对插件零改动**:`cordis.patch.yml` 禁用部署自带的 `fs-sandbox` / `subprocess` 行,由本包提供路由门面。任何走标准 `ctx.fs` / `ctx.subprocess` 的插件与标准工具,在 SSH 工作区会话里自动运行在远端。
|
|
11
|
-
- **通用工作区底座**:`WorkspaceRecord` / `Provider` / `Connection` / 能力契约 + Registry / Ledger / Router,与 SSH 解耦。SSH 只是一个 provider(如 `ssh`、`local`),可继续接 docker / wsl / 云 devbox,上层插件与 UI 不改;单一运行时,全链路读同一个台账。
|
|
12
|
-
- **少数插件只需改几处接口**:给 agent 的执行手册见 [`SKILLS.md`](./SKILLS.md)——判定命令、接口对照表、可照抄代码与自检清单。
|
|
13
|
-
|
|
14
|
-
## 架构
|
|
15
|
-
|
|
16
|
-
- **单一共享实例**:`HostStore` + `SshEngine`(ssh2 连接池)在 `src/index.ts` 创建一次,SSH 运维与 SSH 工作区共用同一引擎 —— 配置变更(PATCH/DELETE)同时失效所有连接,无双池问题。
|
|
17
|
-
- **接缝切换**:`cordis.patch.yml` 禁用部署自带的 `fs-sandbox` / `subprocess` 行,由 `dsh-hardssh/fs`、`dsh-hardssh/subprocess` 提供 provider 路由门面(本地 = 沙箱化原实现;远端 = 该 workspace 连接上的 `workspace.fs` / `workspace.process` capability)。
|
|
18
|
-
- **REST**:`/api/dsh-ssh`(运维路由,loopback-only)+ `/api/dsh-hardssh`(工作区路由,loopback-only)。
|
|
19
|
-
- **工作区核心服务**:`ctx.workspaceCore`(通用 WorkspaceCore:台账 + provider 路由 + capability 连接)是**唯一**的工作区运行时;`ctx.hardsshCore` 只保留 `hosts` + `engine` 供 SSH 专用集成消费(如四列 IDE 形态的 `dsh-workbench-tiphareth`)。
|
|
20
|
-
- **公开入口**:`@tiphareth/dsh-hardssh/base`(通用底座实现)、`@tiphareth/dsh-hardssh/workspace`(平台无关类型面)。
|
|
21
|
-
|
|
22
|
-
## 界面入口(全部走标准插件扩展点)
|
|
23
|
-
|
|
24
|
-
插件不使用任何 DOM 注入;两个界面都是内核公开的槽位注册:
|
|
25
|
-
|
|
26
|
-
| 界面 | 入口 | 槽位 |
|
|
27
|
-
|---|---|---|
|
|
28
|
-
| **SSH 工作区管理**(服务器 + 工作区增删改查,服务器行带已连接/未连接徽章) | 左侧侧栏「新会话」与「工作区」之间的全局入口行 → 中央面板 | `sidebar.panellist`(行)+ `main`(面板,key 同为 `dsh-hardssh-workspaces`) |
|
|
29
|
-
| **SSH 运维**(终端 / 传输 / 隧道 / 当前服务器命令) | 右侧栏标签条的「+」或右侧栏引导页入口 | `ctx.sidebarRightTabs.register`(类型)+ `sidebar.right.pane.tab`(正文)+ `sidebar.right.pane.tab.title`(标签文字) |
|
|
30
|
-
|
|
31
|
-
右侧栏 Tab 是 **page 类型**(不声明 `patterns`),只按 kind 打开,由用户从右侧栏自己的入口打开;插件不会强制展开右侧栏。右侧栏 Tab 实例是**每会话独立**的(内核的会话作用域语义),因此切换会话后需要重新打开该 Tab。
|
|
32
|
-
|
|
33
|
-
## 会话绑定语义(操作台不选服务器)
|
|
34
|
-
|
|
35
|
-
- 操作台的 SSH 目标由**当前会话的 `cwd`** 决定:最长匹配的 SSH 工作区锚点胜出,取该工作区的 `alias` 与 `remoteRoot`。终端 / 传输 / 隧道 / 命令四个子页都强制使用它,**不提供服务器下拉框**。
|
|
36
|
-
- 会话在本地工作区(或未绑定任何 SSH 工作区)时,操作台不挂载任何操作组件,改为渲染**模糊蒙版**并提示「SSH 操作台仅适用于 SSH 工作区会话」。
|
|
37
|
-
- 切换会话时操作台随 `Session → alias` 自动切换,并重置子页状态。
|
|
38
|
-
- 数据源是公开的 `ctx.sessions.list`(`current` + `byId[id].cwd`)+ `WorkspaceManager` 快照,两者都可订阅,不存在第二份工作区句柄。
|
|
39
|
-
|
|
40
|
-
## 连接行为
|
|
41
|
-
|
|
42
|
-
- **启动/刷新只连接当前会话的服务器**:连接闸门在 `ctx.sessions.list` 的 `phase === 'ready'` 之前不建立基准,避免历史会话被误判为「新建会话」而逐台探测;本地会话不触发任何连接。
|
|
43
|
-
- **非交互失败可见**:探测失败(网络不可达、认证失败、主机密钥异常、重试耗尽)会弹出「无法连接服务器」对话框并显示具体原因;用户主动取消密码/指纹弹窗不算失败。
|
|
44
|
-
- **状态徽章只读**:左侧面板每 3 秒读取 `/api/dsh-ssh/connections`(连接池 live alias 列表)刷新「已连接 / 未连接」徽章,**不会**主动拨号。
|
|
45
|
-
|
|
46
|
-
## 安装
|
|
47
|
-
|
|
48
|
-
```sh
|
|
49
|
-
# profile
|
|
50
|
-
dsh plugin --profile <name> add
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
pnpm --filter dsh-hardssh
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
1
|
+
# dsh-hardssh — SSH 远程工作区 + SSH 运维插件
|
|
2
|
+
|
|
3
|
+
已适配 DSH **0.1.5**(实测内核 `0.1.5-rc.1`;本包版本 `0.2.3`)。在 DSH Web GUI 中提供两块能力(单包单引擎):
|
|
4
|
+
|
|
5
|
+
1. **SSH 运维**:右侧栏「SSH」Tab(从右侧栏标签条的「+」或右侧栏引导页入口打开)→ Web 终端(xterm + WebSocket PTY)、文件上传下载、本地端口转发隧道、当前服务器的远端命令;`ssh_list` / `ssh_exec` / `ssh_upload` / `ssh_download` / `ssh_tunnel` / `ssh_cluster` 六个 Agent 工具;主机配置存 `~/.dsh/dsh-ssh.json`。
|
|
6
|
+
2. **SSH 工作区**:左侧侧栏的全局入口行 → 中央面板管理服务器与工作区(增删改查 / `~/.ssh/config` 导入);绑定后本地 harness 的 fs/subprocess 经接缝门面透明路由到远程主机执行(read/write/edit/bash 在绑定会话中即远程操作);`remote_*` 三个 Agent 工具(`remote_ls` / `remote_search` / `remote_status`)用于显式操作远端工作区。
|
|
7
|
+
|
|
8
|
+
## 核心优势
|
|
9
|
+
|
|
10
|
+
- **对插件零改动**:`cordis.patch.yml` 禁用部署自带的 `fs-sandbox` / `subprocess` 行,由本包提供路由门面。任何走标准 `ctx.fs` / `ctx.subprocess` 的插件与标准工具,在 SSH 工作区会话里自动运行在远端。
|
|
11
|
+
- **通用工作区底座**:`WorkspaceRecord` / `Provider` / `Connection` / 能力契约 + Registry / Ledger / Router,与 SSH 解耦。SSH 只是一个 provider(如 `ssh`、`local`),可继续接 docker / wsl / 云 devbox,上层插件与 UI 不改;单一运行时,全链路读同一个台账。
|
|
12
|
+
- **少数插件只需改几处接口**:给 agent 的执行手册见 [`SKILLS.md`](./SKILLS.md)——判定命令、接口对照表、可照抄代码与自检清单。
|
|
13
|
+
|
|
14
|
+
## 架构
|
|
15
|
+
|
|
16
|
+
- **单一共享实例**:`HostStore` + `SshEngine`(ssh2 连接池)在 `src/index.ts` 创建一次,SSH 运维与 SSH 工作区共用同一引擎 —— 配置变更(PATCH/DELETE)同时失效所有连接,无双池问题。
|
|
17
|
+
- **接缝切换**:`cordis.patch.yml` 禁用部署自带的 `fs-sandbox` / `subprocess` 行,由 `dsh-hardssh/fs`、`dsh-hardssh/subprocess` 提供 provider 路由门面(本地 = 沙箱化原实现;远端 = 该 workspace 连接上的 `workspace.fs` / `workspace.process` capability)。
|
|
18
|
+
- **REST**:`/api/dsh-ssh`(运维路由,loopback-only)+ `/api/dsh-hardssh`(工作区路由,loopback-only)。
|
|
19
|
+
- **工作区核心服务**:`ctx.workspaceCore`(通用 WorkspaceCore:台账 + provider 路由 + capability 连接)是**唯一**的工作区运行时;`ctx.hardsshCore` 只保留 `hosts` + `engine` 供 SSH 专用集成消费(如四列 IDE 形态的 `dsh-workbench-tiphareth`)。
|
|
20
|
+
- **公开入口**:`@tiphareth/dsh-hardssh/base`(通用底座实现)、`@tiphareth/dsh-hardssh/workspace`(平台无关类型面)。
|
|
21
|
+
|
|
22
|
+
## 界面入口(全部走标准插件扩展点)
|
|
23
|
+
|
|
24
|
+
插件不使用任何 DOM 注入;两个界面都是内核公开的槽位注册:
|
|
25
|
+
|
|
26
|
+
| 界面 | 入口 | 槽位 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **SSH 工作区管理**(服务器 + 工作区增删改查,服务器行带已连接/未连接徽章) | 左侧侧栏「新会话」与「工作区」之间的全局入口行 → 中央面板 | `sidebar.panellist`(行)+ `main`(面板,key 同为 `dsh-hardssh-workspaces`) |
|
|
29
|
+
| **SSH 运维**(终端 / 传输 / 隧道 / 当前服务器命令) | 右侧栏标签条的「+」或右侧栏引导页入口 | `ctx.sidebarRightTabs.register`(类型)+ `sidebar.right.pane.tab`(正文)+ `sidebar.right.pane.tab.title`(标签文字) |
|
|
30
|
+
|
|
31
|
+
右侧栏 Tab 是 **page 类型**(不声明 `patterns`),只按 kind 打开,由用户从右侧栏自己的入口打开;插件不会强制展开右侧栏。右侧栏 Tab 实例是**每会话独立**的(内核的会话作用域语义),因此切换会话后需要重新打开该 Tab。
|
|
32
|
+
|
|
33
|
+
## 会话绑定语义(操作台不选服务器)
|
|
34
|
+
|
|
35
|
+
- 操作台的 SSH 目标由**当前会话的 `cwd`** 决定:最长匹配的 SSH 工作区锚点胜出,取该工作区的 `alias` 与 `remoteRoot`。终端 / 传输 / 隧道 / 命令四个子页都强制使用它,**不提供服务器下拉框**。
|
|
36
|
+
- 会话在本地工作区(或未绑定任何 SSH 工作区)时,操作台不挂载任何操作组件,改为渲染**模糊蒙版**并提示「SSH 操作台仅适用于 SSH 工作区会话」。
|
|
37
|
+
- 切换会话时操作台随 `Session → alias` 自动切换,并重置子页状态。
|
|
38
|
+
- 数据源是公开的 `ctx.sessions.list`(`current` + `byId[id].cwd`)+ `WorkspaceManager` 快照,两者都可订阅,不存在第二份工作区句柄。
|
|
39
|
+
|
|
40
|
+
## 连接行为
|
|
41
|
+
|
|
42
|
+
- **启动/刷新只连接当前会话的服务器**:连接闸门在 `ctx.sessions.list` 的 `phase === 'ready'` 之前不建立基准,避免历史会话被误判为「新建会话」而逐台探测;本地会话不触发任何连接。
|
|
43
|
+
- **非交互失败可见**:探测失败(网络不可达、认证失败、主机密钥异常、重试耗尽)会弹出「无法连接服务器」对话框并显示具体原因;用户主动取消密码/指纹弹窗不算失败。
|
|
44
|
+
- **状态徽章只读**:左侧面板每 3 秒读取 `/api/dsh-ssh/connections`(连接池 live alias 列表)刷新「已连接 / 未连接」徽章,**不会**主动拨号。
|
|
45
|
+
|
|
46
|
+
## 安装
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
# 已发布 npm(当前 0.2.3):profile 机制,热插拔,需重启 dsh
|
|
50
|
+
dsh plugin --profile <name> add @tiphareth/dsh-hardssh
|
|
51
|
+
|
|
52
|
+
# 开发/迭代:源码链接(改码重建 lib/ 后重启即生效)
|
|
53
|
+
dsh plugin --profile <name> add link:<repo>/packages/dsh-hardssh
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 开发
|
|
57
|
+
|
|
58
|
+
在仓库根目录执行:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
pnpm --filter dsh-hardssh typecheck # 类型检查
|
|
62
|
+
pnpm test # 默认测试套件(vault 加密用例已移出,约 12s)
|
|
63
|
+
pnpm test:vault # 只跑 vault 用例(约 21s,scrypt 派生故意慢)
|
|
64
|
+
pnpm --filter dsh-hardssh build # 产出 lib/(构建前先清空,避免陈旧产物)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> profile 通过 `link:` 指向本包源码目录,因此改码后只需重新 `build`(产出 `lib/`)并重启
|
|
68
|
+
> `dsh web` 即生效;不要把依赖改成 `file:` 安装副本,那会让 profile 加载一份快照而不是源码。
|
|
69
|
+
|
|
70
|
+
## 安全模型
|
|
71
|
+
|
|
72
|
+
- `/api/dsh-ssh/*` 与 `/api/dsh-hardssh/*` 仅限 loopback(含同源校验)。
|
|
73
|
+
- 认证材料沿用 `~/.dsh/dsh-ssh.json`(0600 / 0700),不新增存储。
|
|
74
|
+
- 路径 gate:远程操作 root 必须等于 resolved remoteRoot;相对路径禁止 `..`;`workspace.fs` / `workspace.process` capability 在解析后的 canonical 路径上再做一次 root 收敛(symlink 逃逸 fail closed)。
|
|
75
|
+
- 远程操作消耗真实远程资源:工具描述与宣告段明确「先确认再执行」;`remote_search` 有深度与条数上限;`glob` / `grep` 在 SSH 会话里会被**显式拒绝**(本机 ripgrep 读不到服务器内容)并指向 `remote_search`。
|
|
76
|
+
- SSH 模式下本机沙箱不对远程执行生效(远程进程无法被本地内核沙箱约束):门面的 `sandboxMode` 委托本地后端的真实模式(`write` / `edit` 的沙箱升级入口据此注册),而远端世界的升级策略在门面处**显式丢弃**。
|
|
77
|
+
- 凭据默认不落盘;`secretStorage: vault` 时以 AES-256-GCM + scrypt 加密存储于 `~/.dsh/ssh-secrets/dsh-ssh-vault.json`(被 fs seam 拒绝访问),且 `DSH_CREDENTIAL_PASSWORD` 自动解锁默认关闭(需 `vaultAutoUnlock: env`)。会话密码按**连接存活期**复用,连接池回收即失效。
|
package/lib/fs.js
CHANGED
|
@@ -3,7 +3,7 @@ import { t as anchorRoot } from "./ledger-B-LXlftp.js";
|
|
|
3
3
|
import { n as legacyVaultPath, r as vaultDirectory } from "./vault-3gpWct2Q.js";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import { FileSystem } from "@deepseek-ai/dsh-fs";
|
|
6
|
+
import { FileSystem, FsError } from "@deepseek-ai/dsh-fs";
|
|
7
7
|
import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
|
|
8
8
|
/** Strict WFS key prefix (includes the double slash + separator). */
|
|
9
9
|
const WFS_NAMESPACE_MARKER = `wfs://`;
|
|
@@ -320,6 +320,36 @@ function genericFsWorldForNamespace(core, namespace) {
|
|
|
320
320
|
remoteRoot: record.location.root
|
|
321
321
|
};
|
|
322
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Refuse a path that sits inside the managed anchor root but is owned by no
|
|
325
|
+
* registered workspace — as a NOT-FOUND answer, not as a fatal error.
|
|
326
|
+
*
|
|
327
|
+
* The path stays unreachable either way (it is never handed to the local
|
|
328
|
+
* backend, so a bound session cannot read or write client files through the
|
|
329
|
+
* anchor window). What changes is how callers SEE the refusal: `FS_NOT_FOUND`
|
|
330
|
+
* is the DSH contract for "this path does not exist", and the harness relies on
|
|
331
|
+
* it when walking UP from the session cwd looking for a project root —
|
|
332
|
+
* `dsh-agent-instructions` probes `<dir>/.git` per ancestor and treats ONLY
|
|
333
|
+
* `FS_NOT_FOUND` as "keep walking" (any other error aborts the whole run),
|
|
334
|
+
* `dsh-skill-filesystem` does the same. With a bare Error the very first step
|
|
335
|
+
* above the anchor (`<anchorRoot>/.git`) killed the run with
|
|
336
|
+
* "fs-ssh: … is inside the workspace anchor root but no registered workspace
|
|
337
|
+
* owns it (fail closed)". The message is kept verbatim so the refusal is still
|
|
338
|
+
* self-explanatory in logs.
|
|
339
|
+
*/
|
|
340
|
+
function refuseUnownedAnchorPath(path) {
|
|
341
|
+
throw new FsError(`fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`, "FS_NOT_FOUND");
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The shipped `worldForAnchorPath` deps hook: the workspace owning an absolute
|
|
345
|
+
* anchor path (this session's or a SIBLING's), else the anchor-window refusal.
|
|
346
|
+
* Exported so the seam can be exercised without re-implementing the policy.
|
|
347
|
+
*/
|
|
348
|
+
function anchorWorldFor(core, anchorRootDir, path) {
|
|
349
|
+
const world = genericFsWorldFor(core, path, [anchorRootDir]);
|
|
350
|
+
if (world !== void 0) return world;
|
|
351
|
+
if (isPathUnderAnchor(anchorRootDir, path)) refuseUnownedAnchorPath(path);
|
|
352
|
+
}
|
|
323
353
|
/** Mount the generic switching filesystem facade. */
|
|
324
354
|
function apply(ctx) {
|
|
325
355
|
const localCtx = ctx.isolate("fs");
|
|
@@ -335,11 +365,7 @@ function apply(ctx) {
|
|
|
335
365
|
localRoots: [join(homedir(), ".dsh"), join(homedir(), ".agents")],
|
|
336
366
|
localRootExclusions: [anchorRootDir],
|
|
337
367
|
deniedRoots: [vaultDirectory(), legacyVaultPath()],
|
|
338
|
-
worldForAnchorPath: (path) =>
|
|
339
|
-
const world = genericFsWorldFor(ws, path, [anchorRootDir]);
|
|
340
|
-
if (world !== void 0) return world;
|
|
341
|
-
if (isPathUnderAnchor(anchorRootDir, path)) throw new Error(`fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`);
|
|
342
|
-
},
|
|
368
|
+
worldForAnchorPath: (path) => anchorWorldFor(ws, anchorRootDir, path),
|
|
343
369
|
worldFor: (cwd) => {
|
|
344
370
|
if (!ws.isReady()) {
|
|
345
371
|
if (cwd !== void 0 && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
@@ -366,4 +392,4 @@ function apply(ctx) {
|
|
|
366
392
|
});
|
|
367
393
|
}
|
|
368
394
|
//#endregion
|
|
369
|
-
export { apply, genericFsWorldFor, genericFsWorldForNamespace, inject, name };
|
|
395
|
+
export { anchorWorldFor, apply, genericFsWorldFor, genericFsWorldForNamespace, inject, name, refuseUnownedAnchorPath };
|
package/lib/types/fs.d.ts
CHANGED
|
@@ -24,6 +24,30 @@ export declare const inject: string[];
|
|
|
24
24
|
export declare function genericFsWorldFor(core: WorkspaceCore, cwd: string | undefined, reservedAnchorRoots: readonly string[]): WorkspaceWorld | undefined;
|
|
25
25
|
/** Resolve one namespaced target key, failing closed for stale namespaces. */
|
|
26
26
|
export declare function genericFsWorldForNamespace(core: WorkspaceCore, namespace: string): WorkspaceWorld | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Refuse a path that sits inside the managed anchor root but is owned by no
|
|
29
|
+
* registered workspace — as a NOT-FOUND answer, not as a fatal error.
|
|
30
|
+
*
|
|
31
|
+
* The path stays unreachable either way (it is never handed to the local
|
|
32
|
+
* backend, so a bound session cannot read or write client files through the
|
|
33
|
+
* anchor window). What changes is how callers SEE the refusal: `FS_NOT_FOUND`
|
|
34
|
+
* is the DSH contract for "this path does not exist", and the harness relies on
|
|
35
|
+
* it when walking UP from the session cwd looking for a project root —
|
|
36
|
+
* `dsh-agent-instructions` probes `<dir>/.git` per ancestor and treats ONLY
|
|
37
|
+
* `FS_NOT_FOUND` as "keep walking" (any other error aborts the whole run),
|
|
38
|
+
* `dsh-skill-filesystem` does the same. With a bare Error the very first step
|
|
39
|
+
* above the anchor (`<anchorRoot>/.git`) killed the run with
|
|
40
|
+
* "fs-ssh: … is inside the workspace anchor root but no registered workspace
|
|
41
|
+
* owns it (fail closed)". The message is kept verbatim so the refusal is still
|
|
42
|
+
* self-explanatory in logs.
|
|
43
|
+
*/
|
|
44
|
+
export declare function refuseUnownedAnchorPath(path: string): never;
|
|
45
|
+
/**
|
|
46
|
+
* The shipped `worldForAnchorPath` deps hook: the workspace owning an absolute
|
|
47
|
+
* anchor path (this session's or a SIBLING's), else the anchor-window refusal.
|
|
48
|
+
* Exported so the seam can be exercised without re-implementing the policy.
|
|
49
|
+
*/
|
|
50
|
+
export declare function anchorWorldFor(core: WorkspaceCore, anchorRootDir: string, path: string): WorkspaceWorld | undefined;
|
|
27
51
|
/** Mount the generic switching filesystem facade. */
|
|
28
52
|
export declare function apply(ctx: Context): void;
|
|
29
53
|
//# sourceMappingURL=fs.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiphareth/dsh-hardssh",
|
|
3
3
|
"description": "Generic workspace base for the dsh harness (local + remote workspace providers with per-workspace routing), with SSH as one concrete provider: bind a server directory as an SSH workspace so the session's file I/O and commands run on that host, manage hosts and workspaces in the GUI, and operate remote servers through the ssh_* / remote_* agent tools",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -164,4 +164,4 @@
|
|
|
164
164
|
"SKILLS.md"
|
|
165
165
|
],
|
|
166
166
|
"license": "BSD-3-Clause"
|
|
167
|
-
}
|
|
167
|
+
}
|
package/src/fs.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
18
18
|
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
|
|
19
|
+
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
19
20
|
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
|
20
21
|
import { homedir } from 'node:os'
|
|
21
22
|
import { join } from 'node:path'
|
|
@@ -83,6 +84,42 @@ export function genericFsWorldForNamespace(core: WorkspaceCore, namespace: strin
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Refuse a path that sits inside the managed anchor root but is owned by no
|
|
89
|
+
* registered workspace — as a NOT-FOUND answer, not as a fatal error.
|
|
90
|
+
*
|
|
91
|
+
* The path stays unreachable either way (it is never handed to the local
|
|
92
|
+
* backend, so a bound session cannot read or write client files through the
|
|
93
|
+
* anchor window). What changes is how callers SEE the refusal: `FS_NOT_FOUND`
|
|
94
|
+
* is the DSH contract for "this path does not exist", and the harness relies on
|
|
95
|
+
* it when walking UP from the session cwd looking for a project root —
|
|
96
|
+
* `dsh-agent-instructions` probes `<dir>/.git` per ancestor and treats ONLY
|
|
97
|
+
* `FS_NOT_FOUND` as "keep walking" (any other error aborts the whole run),
|
|
98
|
+
* `dsh-skill-filesystem` does the same. With a bare Error the very first step
|
|
99
|
+
* above the anchor (`<anchorRoot>/.git`) killed the run with
|
|
100
|
+
* "fs-ssh: … is inside the workspace anchor root but no registered workspace
|
|
101
|
+
* owns it (fail closed)". The message is kept verbatim so the refusal is still
|
|
102
|
+
* self-explanatory in logs.
|
|
103
|
+
*/
|
|
104
|
+
export function refuseUnownedAnchorPath(path: string): never {
|
|
105
|
+
throw new FsError(
|
|
106
|
+
`fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`,
|
|
107
|
+
'FS_NOT_FOUND',
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The shipped `worldForAnchorPath` deps hook: the workspace owning an absolute
|
|
113
|
+
* anchor path (this session's or a SIBLING's), else the anchor-window refusal.
|
|
114
|
+
* Exported so the seam can be exercised without re-implementing the policy.
|
|
115
|
+
*/
|
|
116
|
+
export function anchorWorldFor(core: WorkspaceCore, anchorRootDir: string, path: string): WorkspaceWorld | undefined {
|
|
117
|
+
const world = genericFsWorldFor(core, path, [anchorRootDir])
|
|
118
|
+
if (world !== undefined) return world
|
|
119
|
+
if (isPathUnderAnchor(anchorRootDir, path)) refuseUnownedAnchorPath(path)
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
86
123
|
/** Mount the generic switching filesystem facade. */
|
|
87
124
|
export function apply(ctx: Context): void {
|
|
88
125
|
const localCtx = ctx.isolate('fs')
|
|
@@ -106,14 +143,7 @@ export function apply(ctx: Context): void {
|
|
|
106
143
|
// pre-relocation path is denied too, because a failed move deliberately
|
|
107
144
|
// leaves the original file in place.
|
|
108
145
|
deniedRoots: [vaultDirectory(), legacyVaultPath()],
|
|
109
|
-
worldForAnchorPath: (path) =>
|
|
110
|
-
const world = genericFsWorldFor(ws, path, [anchorRootDir])
|
|
111
|
-
if (world !== undefined) return world
|
|
112
|
-
if (isPathUnderAnchor(anchorRootDir, path)) {
|
|
113
|
-
throw new Error(`fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`)
|
|
114
|
-
}
|
|
115
|
-
return undefined
|
|
116
|
-
},
|
|
146
|
+
worldForAnchorPath: (path) => anchorWorldFor(ws, anchorRootDir, path),
|
|
117
147
|
worldFor: (cwd) => {
|
|
118
148
|
if (!ws.isReady()) {
|
|
119
149
|
if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
@@ -128,6 +158,10 @@ export function apply(ctx: Context): void {
|
|
|
128
158
|
const world = genericFsWorldFor(ws, cwd, [anchorRootDir])
|
|
129
159
|
if (world !== undefined) return world
|
|
130
160
|
if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
161
|
+
// Deliberately NOT the FS_NOT_FOUND refusal above: `cwd` is the session's
|
|
162
|
+
// own identity, not a path being probed. An unowned cwd means the
|
|
163
|
+
// workspace was deleted out from under a live session, which must stay
|
|
164
|
+
// loud instead of degrading into "not found" on every relative path.
|
|
131
165
|
throw new Error(`fs-ssh: '${cwd}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`)
|
|
132
166
|
}
|
|
133
167
|
return { backend: localFs, namespace: '' }
|