@zhangfengshun/dsh-remote-ssh 2.4.8 → 2.4.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.4.10] — 非 loopback 访问不再全线 403:信任判定改用宿主 webRuntime.trustedHosts(issue #12)
6
+ ### 修复
7
+ - **信任判定与 `/api` 网关、better-sidebar 同源**(感谢 @Linhaojing 的验证矩阵与自我更正后的方案):此前 `isTrusted()` 硬编码 loopback 白名单(`localhost` / `127.0.0.1` / `::1` / `0.0.0.0`),从局域网(例如经配对设备或反向代理访问)打开 GUI 时,本插件的 **5 类路由全部 403** —— `/remote-ssh/api/*` 全部 RPC、6 个被拦截的 `fs.*`、`git.*`、文件上传、`/sidebar/file`,也就是文件树 / 读写 / 搜索 / 重命名 / 删除 / git / 上传全线失效,插件几乎等于没装。而同一 Host 下 DSH 官方路由正常,说明是本插件单方面比宿主更严。
8
+ - 现在改为读宿主官方运行时能力 **`ctx.webRuntime.trustedHosts`**(启动时采样的局域网 IP 字面量 + `--trusted-host` 指定的 authority)——与 `/api` 网关的 fence 及 better-sidebar 的 `src/trust-fence.ts` 完全一致(后者是 `@deepseek-ai/dsh-client-connection` 的 `api-request-trust.ts` 的拷贝,上游未导出这些 helper,故按同样方式复刻并在注释中标注来源);
9
+ - **按请求实时读取** `trustedHosts`:用户改动信任列表无需重启插件;
10
+ - **软注入 `webRuntime`**(`ctx.inject(["webRuntime"])` 而非硬 `inject`):万一宿主将来改名/移除该服务,插件仍能挂载,只是远程访问退回 loopback-only,行为与 2.4.10 之前一致——**零回归**;
11
+ - 安全性不降低:`sec-fetch-site: cross-site` 与新增的 **Origin 围栏**(带 Origin 时必须与本请求 hostname 一致;`Origin: null` 拒绝)都在,Host 白名单依旧存在,只是信任源从硬编码换成宿主权威列表;用户未配置信任列表时 `trustedHosts` 只含 loopback,行为与之前完全相同。
12
+ - **403 文案不再误导**:此前无论何种拒绝原因都回 `missing x-requested-with header`,把排查方向带偏(提问者一开始就在查客户端是否漏带头)。现在按原因区分:缺头 → `missing x-requested-with header`(错误码仍为 `csrf`,保持兼容)、Host 不可信 → 明确提示 `untrusted host: … start DSH with --trusted-host <host[:port]> …`、跨站 → `cross-site request refused`、Origin 不符 → `origin does not match the request host`;`/sidebar/file` 的纯文本 403 也带准确原因。
13
+
14
+ ### 测试
15
+ - 新增 `tests/trust-fence.test.mjs`(**61 条断言**):loopback 判定(`localhost` / `[::1]` / `127.x.x.x` 整段 / 越界段拒绝 / `0.0.0.0` 历史放行)、authority 端口语义(条目带端口精确匹配、不带端口按主机名、无效条目跳过)、**逐条复现 issue #12 的验证矩阵**(`127.0.0.1:3080` ✅ / `localhost:3080` ✅ / 局域网 IP 无信任列表 ❌ / 局域网 IP + `trustedHosts` ✅)、cross-site 与 Origin 围栏(含 Edge 151 的「Origin 缺端口」特例与 `Origin: null` 拒绝)、文案区分(Host 被拒不再报缺头、并给出 `--trusted-host` 指引)、以及**组合行为**(提取真实 `requestTrust` + 桩 `trustedHosts`:可信局域网 + 缺头 → 报 `header`;信任列表被替换后立即生效)与接线断言(5 处调用点、软注入、实时读取、错误码兼容)。
16
+
17
+ ## [2.4.9] — 「添加工作区」目录选择器支持新建目录(issue #11)
18
+ ### 新增
19
+ - **「添加工作区」弹窗的目录选择器新增「新建目录」**(感谢 @Linhaojing 的定位与接线建议):此前本地 / 远程两个 tab 都只有「打开 / 上级 / 选择此目录」,要把**尚不存在**的目录加为工作区(例如远端起新项目 `~/lhj/new-project`)必须先跳出 DSH 用别的终端 `mkdir`。现在底部操作区多一个「📁 新建目录」按钮:点开输入行 → 填名字(回车即提交)→ 在当前 `path` 下创建 → **自动进入新目录**,紧接着点「选择此目录」即可成为工作区,全程不离开 DSH(对齐 VSCode「新建文件夹」的交互预期)。
20
+ - **两个 tab 都支持**:远程走既有 `api("mkdir")`(`mkdir -p` + 缓存失效,与远程资源管理器的「新建目录」同一条已验证链路);本地新增宿主 API `mkdirLocal`(`fs.mkdir(recursive)`),补齐此前本地侧完全没有入口的缺口;
21
+ - **名字校验**:只接受单个路径段——空名、`.`、`..`、含 `/` 或 `\` 一律拦截并给出中英文提示(避免「在 path 下新建」变成任意位置写入);
22
+ - **路径拼接**:`joinChild()` 沿用基路径的分隔符(POSIX `/`、Windows `\`,含 `C:\` 根与 `~` 前缀),不改变远端路径语义;
23
+ - **失败与异常都不影响弹窗**:后端报错(如远端只读)展示原文且不跳转;Promise 异常被捕获;提交中按钮禁用并显示「处理中…」。
24
+
25
+ ### 测试
26
+ - 新增 `tests/dir-picker-newdir.test.mjs`(**48 条断言**):名字校验(空/空白/`.`/`..`/含分隔符/中文/含点号)、路径拼接(POSIX、尾部多分隔符、空基路径、根路径、`~`、Windows 反斜杠、盘符根、非 ASCII)、**组件行为**(提取 DirPicker 里真实的 `submitNewDir` 逻辑注入桩状态:非法名不调用 createFn、成功进入新目录、后端未回 path 时回退 joinChild、失败不跳转、异常被捕获、未进入新建状态时 no-op)、以及接线断言(两个 tab 都传 createFn、远程/本地分流、宿主 `mkdirLocal` 用 recursive mkdir、5 个 i18n 键中英齐全)。
27
+
5
28
  ## [2.4.8] — 修复大仓里 `@` 补全不可用:索引排除必须发生在截断之前(issue #10 实测反馈)
6
29
  ### 修复
7
30
  - **根因**:2.4.7 的索引命令在 git 分支里是 `git ls-files … | head -n 20001 | sed …`——**先截断、后排除**(排除原本只在客户端 `refParseIndexOutput` 里做)。而 `git ls-files --cached --others` 的输出**不是全局字典序**:未跟踪文件按 readdir 顺序先输出,`node_modules/` 这类目录可能占满前两万行。实测报告者的仓库 node_modules 有 **18,880 条(84.5%)**,把 `head` 配额吃掉 94%,`AGENTS.md` 落在第 **20417** 行被整段切掉,`src/**`(1,742 个文件)同样全部缺失——症状是 `@AGENTS` 搜不到、返回一堆 pip 内部目录(命中 300 分子序列分支)。
package/README.md CHANGED
@@ -46,7 +46,7 @@
46
46
  **一条命令安装**(无需 token、API Key 或额外配置):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.8
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.10
50
50
  ```
51
51
 
52
52
  安装后**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
@@ -64,7 +64,7 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
64
64
  **三步上手**
65
65
 
66
66
  1. **设置 → 远程连接** → 添加连接(主机 / 端口 / 用户 / 密钥)→ 点「测试连接」验证;已有 `~/.ssh/config` 可直接一键导入
67
- 2. **添加工作区** → 选「选择远程目录…」→ 选连接 → 浏览并选择远程目录(该目录会成为原生 DSH 工作区)
67
+ 2. **添加工作区** → 选「选择远程目录…」→ 选连接 → 浏览并选择远程目录(该目录会成为原生 DSH 工作区);目录还不存在时点「📁 新建目录」就地创建(本地 / 远程 tab 均支持),创建后自动进入新目录
68
68
  3. 进入该工作区会话:内置「文件」页签直接显示远程文件(编辑保存直写远程),「终端」页签自动 SSH 到该工作区的**远程目录**(仅密钥认证)
69
69
 
70
70
  **会话内直接对模型说**(远程工作区会话中免填连接参数):
@@ -201,6 +201,8 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
201
201
  | 「文件」页签树根显示镜像目录 ID(如 `wmu3sxe24jpvg`) | 2.4.6 起已修复:树根改为显示**远程目录名**(如 `IB_Robot`),悬停可见完整远程路径;该标签不经过 `fs.*` 路由,由客户端渲染层替换 |
202
202
  | `@` 补全只搜到镜像里那几个文件 | 2.4.7 起已修复:远程工作区会话的 `@` 补全改列远端文件(索引缓存 60s + 900ms 查询预算);若仍只有镜像文件,确认 2.4.7 已装入并重启 DSH |
203
203
  | 大仓里 `@` 搜不到真实文件(如根目录 `AGENTS.md`、`src/**`) | 2.4.8 起已修复:此前排除目录发生在截断之后,`node_modules/` 这类目录会吃光索引配额;现在排除由远端 `grep`/`-prune` 在截断前完成,并会在索引达上限时打 warn 提示 |
204
+ | 想加的远程目录还不存在,「添加工作区」里没法创建 | 2.4.9 起「目录选择器」底部有「📁 新建目录」(本地 / 远程 tab 均有):输入名字即可就地创建并自动进入 |
205
+ | 从局域网 / 另一台设备访问时插件文件能力全部报 403 | 2.4.10 起已修复:信任判定改用宿主 `ctx.webRuntime.trustedHosts`(与 `/api` 网关同源)。把访问地址加进 DSH 信任列表即可:启动时加 `--trusted-host <host[:port]>`(或经配对设备访问);未配置时行为与之前一致(仅本机 loopback) |
204
206
  | 安装时提示 `minimumReleaseAge` 或「No matching version」(刚发布) | npm 供应链新鲜度策略,等 1–5 分钟后重试即可 |
205
207
  | 命令卡住不返回 | 默认 120s 超时后自动丢弃会话;长时任务用 `timeoutMs: 0`,随时可用 `remote_ssh_kill` 强杀 |
206
208
  | 大文件读取被截断 | 单文件读取上限 4MB、下载池化路径约 6.29MB(更大自动回落一次性连接);用 `remote_ssh_exec` + `head`/`tail` 分段处理 |
package/README_EN.md CHANGED
@@ -46,7 +46,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
46
46
  **One command** (no token, API key or extra configuration needed):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.8
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.10
50
50
  ```
51
51
 
52
52
  **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
@@ -64,7 +64,7 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
64
64
  **Three steps**
65
65
 
66
66
  1. **Settings → Remote SSH** → Add a connection (host / port / user / key) → Click "Test Connection"; an existing `~/.ssh/config` can be imported in one click
67
- 2. **Add Workspace** → Choose "Select Remote Directory…" → Pick a connection → Browse and select a remote directory (it becomes a native DSH workspace)
67
+ 2. **Add Workspace** → Choose "Select Remote Directory…" → Pick a connection → Browse and select a remote directory (it becomes a native DSH workspace); if the directory does not exist yet, click "📁 New folder" to create it in place (both the local and remote tabs) — the picker then enters it automatically
68
68
  3. Inside that workspace session: the built-in **Files** tab shows remote files (edits save straight back to remote), and the **Terminal** tab auto-SSHes **into the workspace's remote directory** (key auth only)
69
69
 
70
70
  **Just ask the model** (connection params are auto-filled inside a remote-workspace session):
@@ -201,6 +201,8 @@ The plugin never patches DSH sources or injects into the profile dependency tree
201
201
  | Files tab tree root shows the mirror directory id (e.g. `wmu3sxe24jpvg`) | Fixed in 2.4.6: the root row now shows the **remote directory name** (e.g. `IB_Robot`) with the full remote path on hover; that label never passes through the `fs.*` routes, so the client renders the replacement |
202
202
  | `@` completion only finds the few files in the mirror | Fixed in 2.4.7: in a remote-workspace session `@` now lists remote files (60s index cache + 900ms query budget); if only mirror files show up, make sure 2.4.7 is installed and DSH restarted |
203
203
  | In a large repo `@` cannot find real files (e.g. root `AGENTS.md`, `src/**`) | Fixed in 2.4.8: exclusion used to run *after* truncation, so a `node_modules/` tree could exhaust the index quota; exclusion now happens remotely (`grep`/`-prune`) before truncation, and hitting the cap logs a warning |
204
+ | The remote directory you want does not exist yet and "Add Workspace" cannot create it | Fixed in 2.4.9: the directory picker has a "📁 New folder" button (both tabs) — type a name to create it in place and enter it automatically |
205
+ | Accessing from the LAN / another device makes every file capability return 403 | Fixed in 2.4.10: the trust fence now reads the host's `ctx.webRuntime.trustedHosts` (same source as the `/api` gateway). Add the address to the DSH trust list — start with `--trusted-host <host[:port]>`, or access through your paired remote-access setup; with nothing configured the behaviour is unchanged (loopback only) |
204
206
  | Install fails with `minimumReleaseAge` or "No matching version" right after a release | npm supply-chain freshness policy — retry after 1–5 minutes |
205
207
  | A command hangs forever | The 120s timeout discards the pooled session automatically; use `timeoutMs: 0` for long jobs and `remote_ssh_kill` at any time |
206
208
  | Large files are truncated | 4MB per read, ≈6.29MB on the pooled download path (larger files fall back to a one-shot connection); use `remote_ssh_exec` with `head`/`tail` to page through |
package/lib/client.js CHANGED
@@ -119,6 +119,8 @@ window.__ModuleLoader__.load({
119
119
  "term.errSpawn": "启动终端失败",
120
120
  "picker.dir": "目录",
121
121
  "picker.choose": "选择此目录",
122
+ "picker.errNameEmpty": "目录名不能为空",
123
+ "picker.errNameBad": "目录名不能包含 / 或 \\,也不能是 . 或 ..",
122
124
  "flow.localTitle": "选择本地目录",
123
125
  "flow.remoteTitle": "选择远程目录",
124
126
  "flow.localDir": "本地目录",
@@ -130,6 +132,7 @@ window.__ModuleLoader__.load({
130
132
  "flow.chooseRemote": "选择远程目录…",
131
133
  "flow.backLocal": "← 选择本地目录",
132
134
  "flow.errCreate": "创建远程工作区失败",
135
+ "flow.errMkdir": "新建目录失败",
133
136
  "settings.importSshConfig": "从 ~/.ssh/config 导入…",
134
137
  "settings.importTitle": "导入 ~/.ssh/config 主机",
135
138
  "settings.noneFound": "未在 ~/.ssh/config 中发现可用主机",
@@ -219,6 +222,8 @@ window.__ModuleLoader__.load({
219
222
  "term.errSpawn": "Failed to start terminal",
220
223
  "picker.dir": "Directory",
221
224
  "picker.choose": "Choose this directory",
225
+ "picker.errNameEmpty": "Folder name cannot be empty",
226
+ "picker.errNameBad": "Folder name cannot contain / or \\, nor be . or ..",
222
227
  "flow.localTitle": "Select Local Directory",
223
228
  "flow.remoteTitle": "Select Remote Directory",
224
229
  "flow.localDir": "Local directory",
@@ -230,6 +235,7 @@ window.__ModuleLoader__.load({
230
235
  "flow.chooseRemote": "Choose remote directory…",
231
236
  "flow.backLocal": "← Choose local directory",
232
237
  "flow.errCreate": "Failed to create remote workspace",
238
+ "flow.errMkdir": "Failed to create the folder",
233
239
  "settings.importSshConfig": "Import from ~/.ssh/config…",
234
240
  "settings.importTitle": "Import ~/.ssh/config hosts",
235
241
  "settings.noneFound": "No usable hosts found in ~/.ssh/config",
@@ -777,14 +783,36 @@ window.__ModuleLoader__.load({
777
783
  return parent;
778
784
  }
779
785
 
786
+ // 新建目录名校验(issue #11):返回 i18n 键,合法则返回 null。
787
+ // 只接受单个路径段——分隔符会让「在 path 下新建」变成任意位置写入。
788
+ function newDirNameError(name) {
789
+ var s = String(name == null ? "" : name).trim();
790
+ if (!s) return "picker.errNameEmpty";
791
+ if (s === "." || s === "..") return "picker.errNameBad";
792
+ if (s.indexOf("/") >= 0 || s.indexOf("\\") >= 0) return "picker.errNameBad";
793
+ return null;
794
+ }
795
+
796
+ // 把子目录名拼到基路径之后:沿用基路径的分隔符(Windows 反斜杠 / POSIX 斜杠),
797
+ // 并保留根路径语义("/" + name、"C:\" + name)。
798
+ function joinChild(base, name) {
799
+ var raw = String(base == null ? "" : base);
800
+ var b = raw.replace(/[\/\\]+$/, "");
801
+ if (!b) return (raw.charAt(0) === "/" ? "/" : "") + String(name);
802
+ var sep = (b.indexOf("\\") >= 0 && b.indexOf("/") < 0) || /^[A-Za-z]:$/.test(b) ? "\\" : "/";
803
+ return b + sep + String(name);
804
+ }
805
+
780
806
  // 通用目录选择器:listFn(path) 返回 {ok, path, entries:[{name,type,path}]}。
781
807
  // resetKey 变化(如远程切换连接)时从根目录重新加载。
808
+ // createFn(path, name)(可选):在当前目录下新建子目录,远程/本地由调用方分流(issue #11)。
782
809
  function DirPicker(props) {
783
810
  var t = useT();
784
811
  var [path, setPath] = React.useState("");
785
812
  var [entries, setEntries] = React.useState([]);
786
813
  var [err, setErr] = React.useState(null);
787
814
  var [loading, setLoading] = React.useState(false);
815
+ var [newDir, setNewDir] = React.useState(null); // { name, busy, error } | null
788
816
 
789
817
  var listFnRef = React.useRef(props.listFn);
790
818
  listFnRef.current = props.listFn;
@@ -810,6 +838,26 @@ window.__ModuleLoader__.load({
810
838
  load(path || undefined);
811
839
  }
812
840
 
841
+ // 在当前 path 下新建子目录(远程/本地由 createFn 分流),成功后进入该目录,
842
+ // 便于紧接着点「选择此目录」——对齐 VSCode「新建文件夹」的交互预期(issue #11)。
843
+ function submitNewDir() {
844
+ if (!newDir || !props.createFn) return;
845
+ var name = String(newDir.name || "").trim();
846
+ var invalid = newDirNameError(name);
847
+ if (invalid) { setNewDir({ name: name, busy: false, error: t(invalid) }); return; }
848
+ setNewDir({ name: name, busy: true, error: null });
849
+ Promise.resolve(props.createFn(path, name)).then(function (r) {
850
+ if (r && r.ok) {
851
+ setNewDir(null);
852
+ load((r && r.path) || joinChild(path, name));
853
+ } else {
854
+ setNewDir({ name: name, busy: false, error: (r && r.error) || t("common.errList") });
855
+ }
856
+ }).catch(function (e) {
857
+ setNewDir({ name: name, busy: false, error: String(e && e.message ? e.message : e) });
858
+ });
859
+ }
860
+
813
861
  // 路径栏:输入框(回车导航)+ 打开按钮(紧挨在右侧)+ 上级
814
862
  return h("div", null,
815
863
  h("div", { className: "rssh-pathbar" },
@@ -820,6 +868,16 @@ window.__ModuleLoader__.load({
820
868
  h("button", { className: "rssh-btn", onClick: open, disabled: loading }, t("common.open")),
821
869
  h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
822
870
  ),
871
+ // 新建目录输入行(点底部「新建目录」按钮后出现)
872
+ newDir ? h("div", { className: "rssh-pathbar", style: { marginTop: 6 } },
873
+ h("input", { className: "rssh-input", value: newDir.name, autoFocus: true,
874
+ placeholder: t("ops.newDirPh"),
875
+ onChange: function (e) { setNewDir({ name: e.target.value, busy: false, error: null }); },
876
+ onKeyDown: function (e) { if (e.key === "Enter") { e.preventDefault(); submitNewDir(); } } }),
877
+ h("button", { className: "rssh-btn", onClick: submitNewDir, disabled: newDir.busy }, newDir.busy ? t("common.processing") : t("common.save")),
878
+ h("button", { className: "rssh-btn", onClick: function () { setNewDir(null); }, disabled: newDir.busy }, t("common.close"))
879
+ ) : null,
880
+ newDir && newDir.error ? h("div", { className: "rssh-err" }, newDir.error) : null,
823
881
  loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
824
882
  err ? h("div", { className: "rssh-err" }, err) : null,
825
883
  // 当前路径下的文件树(仅目录可进入)
@@ -832,7 +890,10 @@ window.__ModuleLoader__.load({
832
890
  })
833
891
  ),
834
892
  h("div", { className: "rssh-actions", style: { marginTop: 8 } },
835
- h("button", { className: "rssh-btn", disabled: !path || props.disabled, onClick: function () { props.onChoose(path); } }, t("picker.choose"))
893
+ h("button", { className: "rssh-btn", disabled: !path || props.disabled, onClick: function () { props.onChoose(path); } }, t("picker.choose")),
894
+ props.createFn ? h("button", { className: "rssh-btn",
895
+ disabled: !path || loading || props.disabled || !!(newDir && newDir.busy),
896
+ onClick: function () { setNewDir({ name: "", busy: false, error: null }); } }, t("ops.newDir")) : null
836
897
  )
837
898
  );
838
899
  }
@@ -882,11 +943,29 @@ window.__ModuleLoader__.load({
882
943
  profiles.map(function (p) { return h("option", { key: p.id, value: p.id }, p.name); })
883
944
  )
884
945
  ) : null,
885
- // 目录选择器:远程需选好连接后才显示
946
+ // 目录选择器:远程需选好连接后才显示。
947
+ // createFn:在当前目录下新建子目录(远程走 SSH 的 mkdir -p,本地走宿主 mkdirLocal),
948
+ // 两个 tab 都提供——新建远程项目目录是本 issue 的痛点,本地顺手补齐(issue #11)。
886
949
  isRemote
887
- ? (profileId ? h(DirPicker, { resetKey: profileId, placeholder: t("flow.remoteDir"), listFn: function (p) { return remoteListFn(profileId, p); }, onChoose: chooseRemote, disabled: creating })
950
+ ? (profileId ? h(DirPicker, { resetKey: profileId, placeholder: t("flow.remoteDir"),
951
+ listFn: function (p) { return remoteListFn(profileId, p); },
952
+ createFn: function (p, name) {
953
+ var target = joinChild(p, name);
954
+ return api("mkdir", { profileId: profileId, path: target }).then(function (r) {
955
+ return (r && r.ok) ? { ok: true, path: target } : { ok: false, error: (r && r.error) || t("flow.errMkdir") };
956
+ });
957
+ },
958
+ onChoose: chooseRemote, disabled: creating })
888
959
  : h("div", { className: "rssh-pick-empty" }, t("common.selectProfile")))
889
- : h(DirPicker, { resetKey: "local", placeholder: t("flow.localDir"), listFn: function (p) { return localListFn(props.workspaces, p); }, onChoose: chooseLocal })
960
+ : h(DirPicker, { resetKey: "local", placeholder: t("flow.localDir"),
961
+ listFn: function (p) { return localListFn(props.workspaces, p); },
962
+ createFn: function (p, name) {
963
+ var target = joinChild(p, name);
964
+ return api("mkdirLocal", { path: target }).then(function (r) {
965
+ return (r && r.ok) ? { ok: true, path: target } : { ok: false, error: (r && r.error) || t("flow.errMkdir") };
966
+ });
967
+ },
968
+ onChoose: chooseLocal })
890
969
  )
891
970
  );
892
971
  }
package/lib/index.js CHANGED
@@ -1003,19 +1003,90 @@ function normExec(r) {
1003
1003
  // HTTP 工具
1004
1004
  // ---------------------------------------------------------------------------
1005
1005
 
1006
- function isTrusted(req, requireRequestedWith) {
1007
- const secFetchSite = req.headers["sec-fetch-site"];
1008
- if (secFetchSite === "cross-site") return false;
1006
+ // ---------------------------------------------------------------------------
1007
+ // 浏览器信任判定(DNS-rebinding / 跨站防线,**不是**认证)
1008
+ //
1009
+ // 与 /api 网关、better-sidebar 的 fence 同源:Host 头为 loopback,或命中宿主
1010
+ // ctx.webRuntime.trustedHosts(启动时采样的局域网 IP 字面量 + `--trusted-host`)。
1011
+ // 实现逐条对照 @deepseek-ai/dsh-client-connection 的 api-request-trust.ts
1012
+ // (better-sidebar 的 src/trust-fence.ts 是同一份拷贝,BSD-3-Clause —— 上游未导出这些
1013
+ // helper,故按同样方式在此复刻),差异只有:本插件额外要求 x-requested-with(PR #4 的
1014
+ // CSRF 加固),且把拒绝原因回传给调用方以便给出准确文案(issue #12:此前无论何种原因都
1015
+ // 报 "missing x-requested-with header",把排查方向带偏)。
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ /** Host 头 authority 的规范化 URL;不可解析时返回 undefined。 */
1019
+ function parseAuthority(authority) {
1020
+ try {
1021
+ return new URL("http://" + String(authority));
1022
+ } catch (e) {
1023
+ return undefined;
1024
+ }
1025
+ }
1026
+
1027
+ /** hostname 是否指向本机 loopback(localhost / [::1] / 127.x.x.x)。
1028
+ * 额外放行 0.0.0.0:它是本插件历史行为,且浏览器实际不会把它当目的地址发送。 */
1029
+ function isLoopbackHostname(hostname) {
1030
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "0.0.0.0") return true;
1031
+ const parts = String(hostname).split(".");
1032
+ return parts.length === 4
1033
+ && parts[0] === "127"
1034
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1035
+ }
1036
+
1037
+ /** authority 的规范形式:写了端口则 hostname:port,否则仅 hostname。 */
1038
+ function canonicalAuthority(entry, entryUrl) {
1039
+ const port = entryUrl.port !== "" ? entryUrl.port : new URL("https://" + String(entry)).port;
1040
+ return port === "" ? entryUrl.hostname : entryUrl.hostname + ":" + port;
1041
+ }
1042
+
1043
+ /** 请求 authority 是否命中 trustedHosts(条目带端口则精确比对 host,否则只比 hostname)。 */
1044
+ function isTrustedAuthority(hostUrl, trustedHosts) {
1045
+ return (trustedHosts || []).some((entry) => {
1046
+ const entryUrl = parseAuthority(entry);
1047
+ if (entryUrl === undefined) return false;
1048
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
1049
+ ? entryUrl.hostname === hostUrl.hostname
1050
+ : entryUrl.host === hostUrl.host;
1051
+ });
1052
+ }
1053
+
1054
+ /** 本插件额外要求的非简单头(PR #4 的 CSRF 加固)。 */
1055
+ function hasRequestedWithHeader(req) {
1056
+ return String(req.headers["x-requested-with"] || "").trim() === "XMLHttpRequest";
1057
+ }
1058
+
1059
+ /** 判定一次请求是否可信:返回 "ok" 或具体拒绝原因(供调用方给出准确文案)。 */
1060
+ function trustVerdict(req, trustedHosts) {
1009
1061
  const host = req.headers["host"];
1010
- if (!host) return false;
1011
- const hostname = String(host).split(":")[0].replace(/^\[/, "").replace(/\]$/, "");
1012
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0") {
1013
- // CSRF 加固(PR #4 的服务端半边):/remote-ssh/api/* 仅由本插件客户端调用,
1014
- // 其请求必带 x-requested-with;跨站 no-cors POST 无法携带非简单头,预检也会被拒。
1015
- if (requireRequestedWith && String(req.headers["x-requested-with"] || "").trim() !== "XMLHttpRequest") return false;
1016
- return true;
1062
+ if (!host) return "no-host";
1063
+ const hostUrl = parseAuthority(host);
1064
+ if (hostUrl === undefined) return "bad-host";
1065
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return "bad-host";
1066
+ if (req.headers["sec-fetch-site"] === "cross-site") return "cross-site";
1067
+ // Origin 围栏:浏览器带 Origin 时必须与本请求 hostname 一致(比较 hostname 而非 host ——
1068
+ // 部分 Chromium(Edge 151)对非默认端口的 loopback 页面会把 Origin 序列化成不带端口)。
1069
+ // 无 Origin 视为通过(Host 围栏已限定 authority);字面量 "null"(沙箱 iframe / file:)拒绝。
1070
+ const origin = req.headers["origin"];
1071
+ if (origin === undefined) return "ok";
1072
+ try {
1073
+ return new URL(origin).hostname === hostUrl.hostname ? "ok" : "bad-origin";
1074
+ } catch (e) {
1075
+ return "bad-origin";
1076
+ }
1077
+ }
1078
+
1079
+ /** 拒绝原因 → 面向用户/模型的文案(issue #12:区分「缺头」与「Host 不可信」)。 */
1080
+ function trustErrorMessage(reason) {
1081
+ switch (reason) {
1082
+ case "header": return "missing x-requested-with header";
1083
+ case "cross-site": return "cross-site request refused";
1084
+ case "bad-origin": return "origin does not match the request host";
1085
+ case "no-host": return "missing Host header";
1086
+ default:
1087
+ return "untrusted host: this deployment only serves loopback or hosts trusted by the DSH web runtime "
1088
+ + "(start DSH with --trusted-host <host[:port]>, or expose it through your remote-access setup)";
1017
1089
  }
1018
- return false;
1019
1090
  }
1020
1091
 
1021
1092
  async function readJsonBody(req) {
@@ -1285,6 +1356,38 @@ function apply(ctx, config) {
1285
1356
  const terminals = new Map();
1286
1357
  let nextTerminalId = 1;
1287
1358
 
1359
+ // ---- 宿主信任源(issue #12)----
1360
+ // ctx.webRuntime.trustedHosts 是 DSH 官方运行时能力:启动时采样的局域网 IP 字面量
1361
+ // 加 `--trusted-host` 指定的 authority,`/api` 网关的 fence 正以它为准。
1362
+ // 按请求实时读取(服务值可被替换,无需重启插件);服务缺失时保持 loopback-only,
1363
+ // 行为与 2.4.9 完全一致 —— 因此用软注入(ctx.inject)而非硬 inject:万一将来宿主
1364
+ // 改名/移除该服务,插件仍能挂载,只是远程访问退回本机可用。
1365
+ let webRuntimeFace = null;
1366
+ ctx.inject(["webRuntime"], (sctx) => {
1367
+ try { webRuntimeFace = (sctx && sctx.webRuntime) || (sctx && typeof sctx.get === "function" ? sctx.get("webRuntime") : null) || null; } catch (e) { webRuntimeFace = null; }
1368
+ });
1369
+ function trustedHostsNow() {
1370
+ try {
1371
+ const list = webRuntimeFace && webRuntimeFace.trustedHosts;
1372
+ return Array.isArray(list) ? list : [];
1373
+ } catch (e) {
1374
+ return [];
1375
+ }
1376
+ }
1377
+
1378
+ /** 一次请求的信任判定:返回 "ok" 或拒绝原因("header" / "bad-host" / …)。 */
1379
+ function requestTrust(req, requireRequestedWith) {
1380
+ const verdict = trustVerdict(req, trustedHostsNow());
1381
+ if (verdict !== "ok") return verdict;
1382
+ if (requireRequestedWith && !hasRequestedWithHeader(req)) return "header";
1383
+ return "ok";
1384
+ }
1385
+
1386
+ /** 统一的 403 输出(带准确原因,避免把 Host 不可信误报成缺头)。 */
1387
+ function denyRequest(res, reason) {
1388
+ writeJson(res, 403, { ok: false, error: { code: reason === "header" ? "csrf" : "forbidden", message: trustErrorMessage(reason) } });
1389
+ }
1390
+
1288
1391
  // ---- 持久 SSH 会话池(连接复用,避免每次操作都做完整 SSH 握手)----
1289
1392
  const sessions = new Map(); // profileKey -> CommandSession
1290
1393
  const SESSION_IDLE_MS = 10 * 60 * 1000;
@@ -1635,6 +1738,18 @@ function apply(ctx, config) {
1635
1738
  invalidateRemoteCaches(p, args.path, { subtree: false }); // 父目录列举变化
1636
1739
  return r;
1637
1740
  },
1741
+ /** 本地(宿主机器)新建目录:供「添加工作区」目录选择器的**本地** tab 创建目录用(issue #11)。
1742
+ * 远程侧走上面的 mkdir;本地侧此前没有任何入口,用户只能离开 DSH 去系统文件管理器建。 */
1743
+ mkdirLocal: async (args) => {
1744
+ const p = String((args && args.path) || "").trim();
1745
+ if (!p) return { ok: false, error: "path 为必填项" };
1746
+ try {
1747
+ await mkdir(p, { recursive: true });
1748
+ return { ok: true, path: p };
1749
+ } catch (e) {
1750
+ return { ok: false, error: "创建目录失败: " + String(e && e.message ? e.message : e) };
1751
+ }
1752
+ },
1638
1753
  deleteFile: async (args) => {
1639
1754
  const p = resolveProfile(args);
1640
1755
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
@@ -1745,7 +1860,9 @@ function apply(ctx, config) {
1745
1860
  handler: async (req, res) => {
1746
1861
  // CSRF 加固:/remote-ssh/api/* 仅由本插件客户端调用,强制校验其必带的
1747
1862
  // x-requested-with 头(跨站攻击者无法在 no-cors POST 中携带非简单头)。
1748
- if (!isTrusted(req, true)) { writeJson(res, 403, { ok: false, error: { code: "csrf", message: "missing x-requested-with header" } }); return; }
1863
+ // 信任判定走宿主 webRuntime.trustedHosts(issue #12),失败时回传具体原因。
1864
+ const apiTrust = requestTrust(req, true);
1865
+ if (apiTrust !== "ok") { denyRequest(res, apiTrust); return; }
1749
1866
  if (req.method !== "POST") { writeError(res, new Error("method not allowed"), 405); return; }
1750
1867
  const pathname = new URL(req.url || "/", "http://dsh.internal").pathname;
1751
1868
  if (!pathname.startsWith(API_BASE)) { writeError(res, new Error("not-found"), 404); return; }
@@ -2400,7 +2517,8 @@ function apply(ctx, config) {
2400
2517
  // webServer prefix 匹配要求 pathname 以 prefix+"/" 开头,但 fs.tree 用的是点分隔符,
2401
2518
  // 所以无法用 prefix 拦截,必须用 exact。
2402
2519
  async function interceptFsHandler(req, res, method) {
2403
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2520
+ const fsTrust = requestTrust(req, false);
2521
+ if (fsTrust !== "ok") { denyRequest(res, fsTrust); return; }
2404
2522
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2405
2523
  let payload;
2406
2524
  try { payload = await readJsonBody(req); }
@@ -2655,7 +2773,8 @@ function apply(ctx, config) {
2655
2773
  }
2656
2774
 
2657
2775
  async function interceptGitHandler(req, res, method) {
2658
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2776
+ const gitTrust = requestTrust(req, false);
2777
+ if (gitTrust !== "ok") { denyRequest(res, gitTrust); return; }
2659
2778
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2660
2779
  let payload;
2661
2780
  try { payload = await readJsonBody(req); }
@@ -2837,7 +2956,8 @@ function apply(ctx, config) {
2837
2956
  }
2838
2957
 
2839
2958
  async function handleRemoteUpload(req, res) {
2840
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2959
+ const upTrust = requestTrust(req, false);
2960
+ if (upTrust !== "ok") { denyRequest(res, upTrust); return; }
2841
2961
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2842
2962
  try {
2843
2963
  const url = new URL(req.url || "/", "http://dsh.internal");
@@ -2918,7 +3038,8 @@ function apply(ctx, config) {
2918
3038
  }
2919
3039
 
2920
3040
  async function handleSidebarFile(req, res) {
2921
- if (!isTrusted(req)) { res.writeHead(403); res.end("forbidden"); return; }
3041
+ const fileTrust = requestTrust(req, false);
3042
+ if (fileTrust !== "ok") { res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); res.end(trustErrorMessage(fileTrust)); return; }
2922
3043
  if (req.method !== "GET") { res.writeHead(405); res.end(); return; }
2923
3044
  try {
2924
3045
  const url = new URL(req.url || "/", "http://dsh.internal");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.4.8",
3
+ "version": "2.4.10",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",