@zhangfengshun/dsh-remote-ssh 2.4.9 → 2.4.11

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,39 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.4.11] — `fs.search` 返回契约对齐 better-sidebar:补上 `matches`(issue #13)
6
+ ### 修复
7
+ - **「文件」页签的「按文件名搜索」不再崩掉整块页签**(感谢 @Linhaojing 的契约考古与最小复现):better-sidebar 客户端契约是 `{ matches: string[], truncated }`(其注释原文:*matches are cwd-relative '/'-separated paths*,自 v0.13.0 引入搜索框起未变),并在渲染阶段直接读 `results.matches.length` / `results.matches.map(rel => …)`;而本插件的 `fs.search` 拦截只返回 `{ entries: [{path,isDir}], truncated }`,于是 `undefined.length` 抛 TypeError 被 `RenderBoundary` 兜住 → 整个页签变成错误条、搜索框与文件树一起消失。因为拦截是**无条件**的(不区分远程/本地会话),**本地工作区同样复现**,与 better-sidebar 版本无关。
8
+ - 两条分支统一经 `fsSearchResult()` 产出:`matches` **必定存在**且为 **cwd 相对、`/` 分隔**的字符串数组(与 better-sidebar 自带 `searchFiles` 的行为一致,结果行显示相对路径),`entries`(绝对路径 + `isDir`)保留作向前兼容;
9
+ - 本地分支:`matches` 由 `toPosixRelative(sessionCwd, entry.path)` 从绝对路径裁剪而来(Windows 盘符路径大小写不敏感、POSIX 保持大小写敏感、前缀相近的非子路径不误裁、非 ASCII 安全);
10
+ - **附带修复远程分支的路径 bug**:`remoteGlob` 的 `files` 是 `find` 以命令行起点拼出的**远端绝对路径**,此前却用 `join(mirrorBase, f)` 拼成了畸形镜像路径(如 `<镜像根>/home/user/proj/src/a.jl`),远程搜索结果的路径本来就是错的;现在 `remoteGlob` 新增 `opts.relative`(`-printf '%P\n'`)取相对路径,`entries` 再据此拼镜像绝对路径(`remote_ssh_glob` 工具仍走默认的 `%p`,行为不变)。
11
+ - README 兼容性表中原先笼统的 `fs.tree/read/write/search(4 端点契约)` 一行,已改为显式写明 `fs.search` 的 `{ matches, truncated }` 契约。
12
+ - **远程工作区的搜索不再一直「加载中…」**(同 issue 的实测跟进:崩溃修好后,在真实 OpenFOAM 工作区里搜索变成永久 loading)。旧命令有两个致命点:① `… | sort | head -n 500` 里的 `sort` 会缓冲**全部** `find` 输出后才吐第一行,`head` 的提前短路完全失效;② 没有深度/时间约束,而 `find` 是深度优先——巨型子目录(`processor*` / 数据目录)会吃光预算,连顶层文件都轮不到。实测该工作区:**旧命令 5 分钟零输出**(还占住池化 SSH 会话,把文件树/读写一起拖住)。现在:
13
+ - **浅层优先 + 有结果即返回**:第一趟 `-maxdepth 3`(3s 预算,实测冷 0.68s / 热 0.11s)——**只要有命中就立刻返回**,深挖转**后台预热缓存**(同一 query 的后续请求会拿到更全的结果);只有浅层**一无所获**时才同步等深挖趟(`-maxdepth 8`,5s 预算,实测会吃满)。此前条件是「浅层命中 < 200 就同步深挖」,而文件名片段极少命中 200 个 → 几乎每次查询都要多等一趟 ≤5s 的深挖(用户实测反馈「有点慢」),现已消除;两趟结果合并去重后本地排序(不再需要远端 `sort`)。实测同一工作区:**浅层 0.96s 返回 43 条命中**(旧命令 5 分钟 0 条);
14
+ - **遍历前剪噪声目录**:`REMOTE_SEARCH_SKIP_DIRS` = 本插件索引排除表 ∪ 上游 `SEARCH_SKIP_DIRS`(`.git` / `node_modules` / `dist` / `.next` / `.pnpm-store` / `.turbo` / …);
15
+ - **远端墙钟预算**:`timeout`(macOS 回退 `gtimeout`,都没有则退回无预算版本由 SSH 层兜底)到点杀掉 `find`,并把**已收集到的部分结果**照常返回 + `truncated: true`——宁可给部分结果,也不让 UI 无限转圈;
16
+ - **命中上限 200**(与上游 `DEFAULT_MAX_MATCHES` 一致)、**SSH 层 15s 超时**(不再占用池化会话 120s)、**同 query 30s 短缓存 + 并发合并**(连打键盘不会堆起一串 `find`),缓存挂 `cacheEpoch`(写/exec 后自动失效)。
17
+ - **`@文件名` 在某些远程项目里完全没有候选**(用户实测反馈:同一台机器上 DFS-Dev 正常、另一个项目全空;而单独输入 `@` 正常——因为 `@` 走目录列举、模糊查询走索引)。根因:索引在 git 仓库里首选 `git ls-files --cached --others --exclude-standard`,而 `--others` 需要**遍历整棵工作树**枚举未跟踪文件,在巨型项目上根本跑不完(实测该目录 **10s 被超时杀掉、零输出**;`git ls-files --cached` 反而只要 0.085s 且为 0 条——仓库里没有任何已跟踪文件)→ 20s 构建预算内拿不到条目 → 索引为空。现在三级降级,每级都有墙钟预算:
18
+ - ① 完整 git(6s)→ ② 仅索引 git(3s,恒定快)→ ③ 有界 `find`(`maxdepth 3` + 5s;原为 `maxdepth 5` 且无预算,实测该目录 5 层遍历 >180s 都跑不完);
19
+ - 实测同一项目:新命令 **921 条 / 6.68s**(其中 916 条含 `kOmega`、`node_modules` 零残留),三级合计 ≤14s < 20s 构建预算;
20
+ - **另外加了即时兜底**:模糊查询在索引尚未就绪(大仓库要 6.7s 才建好,而单次查询只等 900ms)时,**不再直接返回空列表**,而是用与侧栏搜索同一套有界 find(剪噪声 + 无 `sort` + `maxdepth 3` + 5s 预算,实测 **0.65s**)立刻给出候选;索引建好后自动切换到更全的索引结果。
21
+
22
+ ### 测试
23
+ - 新增 `tests/fs-search-contract.test.mjs`(**34 条断言**):`toPosixRelative`(POSIX / 尾部斜杠 / Windows 反斜杠 / 盘符大小写不敏感 / POSIX 大小写敏感 / 非子路径不误裁 / 空 base / null / 非 ASCII)、`fsSearchResult` 形状(`matches` 恒为字符串数组——**原崩溃点**、缺参数时仍为空数组而非 undefined、过滤空串与非字符串、`truncated` 归一化)、**契约模拟**(客户端 `.length` / `.map(rel)` 渲染相对路径 / 点击解析回 `entries.path`)、接线断言(两条分支、`remoteGlob` 的 `relative` 选项与 `%p` 默认值、不再有只回 `entries` 的旧分支)与 README 契约描述。
24
+ - 新增 `tests/remote-search.test.mjs`(**36 条断言**):两趟命令生成(`-maxdepth 3/8`、3s/5s 预算、无 `sort`、`%P`、`head 200`、`-prune`、`timeout`/`gtimeout` 探测与回退)、**延迟策略**(浅层有结果立即返回、后台预热写回缓存、浅层零命中才同步深挖、浅层满额不再深挖、预算到点返回部分结果 + `truncated`)、硬失败不深挖且不缓存、缓存命中与 `cacheEpoch` 失效、三个并发同 query 只跑一趟遍历、以及 `fs.search` 分支的接线。
25
+
26
+ ## [2.4.10] — 非 loopback 访问不再全线 403:信任判定改用宿主 webRuntime.trustedHosts(issue #12)
27
+ ### 修复
28
+ - **信任判定与 `/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 官方路由正常,说明是本插件单方面比宿主更严。
29
+ - 现在改为读宿主官方运行时能力 **`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,故按同样方式复刻并在注释中标注来源);
30
+ - **按请求实时读取** `trustedHosts`:用户改动信任列表无需重启插件;
31
+ - **软注入 `webRuntime`**(`ctx.inject(["webRuntime"])` 而非硬 `inject`):万一宿主将来改名/移除该服务,插件仍能挂载,只是远程访问退回 loopback-only,行为与 2.4.10 之前一致——**零回归**;
32
+ - 安全性不降低:`sec-fetch-site: cross-site` 与新增的 **Origin 围栏**(带 Origin 时必须与本请求 hostname 一致;`Origin: null` 拒绝)都在,Host 白名单依旧存在,只是信任源从硬编码换成宿主权威列表;用户未配置信任列表时 `trustedHosts` 只含 loopback,行为与之前完全相同。
33
+ - **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 也带准确原因。
34
+
35
+ ### 测试
36
+ - 新增 `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 处调用点、软注入、实时读取、错误码兼容)。
37
+
5
38
  ## [2.4.9] — 「添加工作区」目录选择器支持新建目录(issue #11)
6
39
  ### 新增
7
40
  - **「添加工作区」弹窗的目录选择器新增「新建目录」**(感谢 @Linhaojing 的定位与接线建议):此前本地 / 远程两个 tab 都只有「打开 / 上级 / 选择此目录」,要把**尚不存在**的目录加为工作区(例如远端起新项目 `~/lhj/new-project`)必须先跳出 DSH 用别的终端 `mkdir`。现在底部操作区多一个「📁 新建目录」按钮:点开输入行 → 填名字(回车即提交)→ 在当前 `path` 下创建 → **自动进入新目录**,紧接着点「选择此目录」即可成为工作区,全程不离开 DSH(对齐 VSCode「新建文件夹」的交互预期)。
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.9
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.11
50
50
  ```
51
51
 
52
52
  安装后**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
@@ -183,7 +183,7 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
183
183
  | --- | --- | --- |
184
184
  | DSH | 0.1.5-rc.1(DSH Desktop v2.0.9) | ✅ 主机服务 / settings / tools / slot / 上传下载拦截全部咬合 |
185
185
  | DSH | 0.1.2-rc.1 稳定线 | ✅(插件 2.3.x 时代基线) |
186
- | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree/read/write/search`(4 端点契约) |
186
+ | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree`/`fs.read`/`fs.write` + `fs.search`(`{ matches: cwd 相对 '/'-分隔路径, truncated }` 契约,2.4.11 起;此前只回 `entries` 会让「按文件名搜索」崩掉整块页签) |
187
187
  | dsh-better-sidebar | 0.19.x | ⚠️ 插件侧已适配 6 端点(含 `fs.rename`/`fs.remove`);但 0.19.0/0.19.1 自身在 DSH Desktop 上主机半边无法加载,需等上游修复(见[安装](#安装)的警告) |
188
188
  | 远程主机 sshd | 标准 OpenSSH(Linux / 超算 / Windows) | ✅ 密钥认证;密码认证需本机 `sshpass`(POSIX) |
189
189
 
@@ -202,6 +202,10 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
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
204
  | 想加的远程目录还不存在,「添加工作区」里没法创建 | 2.4.9 起「目录选择器」底部有「📁 新建目录」(本地 / 远程 tab 均有):输入名字即可就地创建并自动进入 |
205
+ | 从局域网 / 另一台设备访问时插件文件能力全部报 403 | 2.4.10 起已修复:信任判定改用宿主 `ctx.webRuntime.trustedHosts`(与 `/api` 网关同源)。把访问地址加进 DSH 信任列表即可:启动时加 `--trusted-host <host[:port]>`(或经配对设备访问);未配置时行为与之前一致(仅本机 loopback) |
206
+ | 「文件」页签的「按文件名搜索」一输入就报 `Cannot read properties of undefined (reading 'length')` | 2.4.11 起已修复:`fs.search` 拦截此前只返回 `entries`,而 better-sidebar 客户端契约是 `{ matches, truncated }`;现在补上 `matches`(cwd 相对、`/` 分隔,与上游自带实现一致)并保留 `entries` |
207
+ | 远程工作区里「按文件名搜索」一直转圈(大工作区) | 2.4.11 起已修复:改为浅层优先(`-maxdepth 3`,实测冷 0.68s / 热 0.11s)且**有命中就立即返回**(深挖转后台预热缓存,浅层零命中才同步等深挖 `-maxdepth 8`),遍历前剪噪声目录、去掉会阻塞短路的 `sort`,并加远端墙钟预算——到点返回**已收集的部分结果**并标记不完整。实测某 OpenFOAM 工作区:旧实现 5 分钟零输出 → 现在 **0.96s 返回 43 条** |
208
+ | 远程会话里 `@文件名` 没有候选,但单独输入 `@` 有 | 2.4.11 起已修复:模糊查询依赖索引,而索引首选 `git ls-files --cached --others`(`--others` 要遍历整棵工作树,巨型项目上跑不完 → 索引为空)。现在三级降级(完整 git 6s → 仅索引 git 3s → 有界 `find` `maxdepth 3` + 5s),并在索引未就绪时用有界 find 即时兜底(实测 0.65s),不再出现「全空」 |
205
209
  | 安装时提示 `minimumReleaseAge` 或「No matching version」(刚发布) | npm 供应链新鲜度策略,等 1–5 分钟后重试即可 |
206
210
  | 命令卡住不返回 | 默认 120s 超时后自动丢弃会话;长时任务用 `timeoutMs: 0`,随时可用 `remote_ssh_kill` 强杀 |
207
211
  | 大文件读取被截断 | 单文件读取上限 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.9
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.11
50
50
  ```
51
51
 
52
52
  **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
@@ -183,7 +183,7 @@ All SSH commands default to a **120-second** timeout (issue #5): a hung remote c
183
183
  | --- | --- | --- |
184
184
  | DSH | 0.1.5-rc.1 (DSH Desktop v2.0.9) | ✅ host services / settings / tools / slots / upload & download interception all compatible |
185
185
  | DSH | 0.1.2-rc.1 stable line | ✅ (the 2.3.x-era baseline) |
186
- | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree/read/write/search` (4-endpoint contract) |
186
+ | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree`/`fs.read`/`fs.write` + `fs.search` (the `{ matches: cwd-relative '/'-separated paths, truncated }` contract, since 2.4.11; returning only `entries` used to crash the whole Files tab on search) |
187
187
  | dsh-better-sidebar | 0.19.x | ⚠️ this plugin already supports the 6-endpoint contract (incl. `fs.rename`/`fs.remove`); 0.19.0/0.19.1 themselves cannot load their host half on DSH Desktop until upstream fixes it (see the warning under [Installation](#installation)) |
188
188
  | Remote sshd | standard OpenSSH (Linux / HPC / Windows) | ✅ key auth; password auth needs `sshpass` on the host (POSIX) |
189
189
 
@@ -202,6 +202,10 @@ The plugin never patches DSH sources or injects into the profile dependency tree
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
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) |
206
+ | The Files tab's "search by file name" crashes with `Cannot read properties of undefined (reading 'length')` | Fixed in 2.4.11: the `fs.search` interception returned only `entries`, while better-sidebar's client contract is `{ matches, truncated }`; it now provides `matches` (cwd-relative, `/`-separated, matching upstream's own implementation) and keeps `entries` |
207
+ | "Search by file name" spins forever in a remote workspace (large trees) | Fixed in 2.4.11: shallow-first (`-maxdepth 3`, measured 0.68s cold / 0.11s warm) **returning as soon as anything matches** (the deeper pass becomes a background cache warm-up and only runs synchronously when the shallow pass finds nothing), noise directories pruned before traversal, the short-circuit-blocking `sort` removed, and a remote wall-clock budget that returns the **partial results collected so far** and flags them as incomplete. Measured on an OpenFOAM workspace: 5 minutes with zero output before → **43 matches in 0.96s** now |
208
+ | In a remote session `@filename` shows no candidates, while a bare `@` works | Fixed in 2.4.11: fuzzy queries rely on the index, and the index preferred `git ls-files --cached --others` (`--others` must walk the whole working tree and never finishes on huge projects → empty index). It now degrades in three budgeted steps (full git 6s → index-only git 3s → bounded `find` `maxdepth 3` + 5s) and falls back to a bounded `find` (measured 0.65s) whenever the index is not ready yet, so candidates are never empty |
205
209
  | Install fails with `minimumReleaseAge` or "No matching version" right after a release | npm supply-chain freshness policy — retry after 1–5 minutes |
206
210
  | 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 |
207
211
  | 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/index.js CHANGED
@@ -877,9 +877,25 @@ async function remoteGrep(runner, p, pattern, path, opts) {
877
877
  return { ok: true, pattern: pattern, path: target, matches: matches, truncated: truncated, error: "" };
878
878
  }
879
879
 
880
+ /** 侧栏文件名搜索要剪掉的噪声目录:定义见下方 REF_EXCLUDED_DIRS 之后
881
+ * (模块级 const 不能引用尚未初始化的绑定 —— 曾因展开 REF_EXCLUDED_DIRS 触发 TDZ,
882
+ * 导致整个插件树加载失败,故这里只留说明,实体定义放在其依赖之后)。 */
883
+
880
884
  /**
881
885
  * 在远端按 glob 模式查找文件(find -name)。find 已递归;pattern 为 basename 通配
882
886
  * (如 .py 后缀),自动剥离前导双星号-斜杠与星号-斜杠以适配 POSIX find -name。
887
+ *
888
+ * 选项:
889
+ * - `relative`:用 `-printf '%P\n'` 输出**相对起始目录**的路径(issue #13:侧栏文件名搜索
890
+ * 要 cwd 相对路径;默认仍输出 find 原样的绝对路径,供 remote_ssh_glob 工具使用)。
891
+ * - `sorted !== false`:管道里加 `sort`。搜索路径必须关掉它 —— `sort` 会缓冲**全部** find
892
+ * 输出后才吐第一行,使 `head` 的提前短路完全失效(issue #13 实测:一个 OpenFOAM 工作区
893
+ * 5 分钟都跑不完,UI 一直「加载中…」,还占住池化会话阻塞其他操作)。
894
+ * - `pruneNoise`:遍历前剪掉 REMOTE_SEARCH_SKIP_DIRS。
895
+ * - `maxDepth`:`-maxdepth N`(浅层优先搜索用;不设则全深度)。
896
+ * - `timeBudgetSec`:远端墙钟预算(`timeout`/`gtimeout`,存在才用),到点杀掉 find 并返回
897
+ * **已收集到的部分结果** + truncated,避免把用户吊死在一次遍历上。
898
+ * - `timeoutMs`:SSH 层命令超时(默认沿用全局 120s)。
883
899
  */
884
900
  async function remoteGlob(runner, p, pattern, path, opts) {
885
901
  opts = opts || {};
@@ -887,10 +903,37 @@ async function remoteGlob(runner, p, pattern, path, opts) {
887
903
  let pat = String(pattern || "").trim();
888
904
  pat = pat.replace(/^(\*\*\/|\*\/)+/, "");
889
905
  const max = parseInt(String(opts.maxResults || 500), 10) || 500;
890
- const cmd = "find " + shellQuotePath(target) + " -name " + shellQuote(pat) + " -printf '%p\\n' 2>/dev/null | sort | head -n " + max;
891
- const r = await runner(p, cmd, undefined, MAX_BYTES);
892
- const files = String(r.stdout || "").split("\n").filter(function (s) { return !!s; });
893
- return { ok: true, pattern: pattern, path: target, files: files, truncated: !!r.truncated, error: "" };
906
+ const depth = parseInt(String(opts.maxDepth || 0), 10) || 0;
907
+ const fmt = opts.relative ? "%P" : "%p";
908
+ const prune = opts.pruneNoise
909
+ ? "\\( " + Array.from(REMOTE_SEARCH_SKIP_DIRS).map((n) => "-name " + shellQuote(n)).join(" -o ") + " \\) -prune -o "
910
+ : "";
911
+ const findCmd = "find " + shellQuotePath(target) + " " + (depth > 0 ? "-maxdepth " + depth + " " : "")
912
+ + prune + "-name " + shellQuote(pat) + " -printf '" + fmt + "\\n' 2>/dev/null";
913
+ const pipeline = findCmd + (opts.sorted === false ? "" : " | sort") + " | head -n " + max;
914
+ let cmd = pipeline;
915
+ const budget = parseInt(String(opts.timeBudgetSec || 0), 10) || 0;
916
+ if (budget > 0) {
917
+ // timeout 是 GNU coreutils(Linux 常见),macOS 需 gtimeout;都没有时退回无预算版本,
918
+ // 由 SSH 层超时兜底(此时不会拿到部分结果)。
919
+ cmd = [
920
+ "T=$(command -v timeout || command -v gtimeout || true)",
921
+ "if [ -n \"$T\" ]; then",
922
+ "\"$T\" " + budget + " " + pipeline,
923
+ "else",
924
+ pipeline,
925
+ "fi"
926
+ ].join("\n");
927
+ }
928
+ const r = await runner(p, cmd, undefined, MAX_BYTES, false, opts.timeoutMs);
929
+ const files = String(r && r.stdout ? r.stdout : "").split("\n").filter(function (s) { return !!s; });
930
+ // 预算到点(timeout → 124)或被 kill 时,stdout 里已有部分结果:照常返回并标记 truncated,
931
+ // 让用户看到「部分结果」而不是一个错误或永久加载。
932
+ if (r && r.ok === false && files.length === 0) {
933
+ return { ok: false, pattern: pattern, path: target, files: [], truncated: false, error: (r && r.error) || "glob failed" };
934
+ }
935
+ const budgetHit = !!(r && (r.ok === false || r.exitCode === 124 || r.timeout));
936
+ return { ok: true, pattern: pattern, path: target, files: files, truncated: !!((r && r.truncated) || budgetHit || files.length >= max), error: "" };
894
937
  }
895
938
 
896
939
  /**
@@ -902,6 +945,111 @@ function spawnOne(subprocess, argv, stdio, cwd) {
902
945
  return subprocess.spawn({ argv: argv, cwd: cwd || process.cwd(), stdio: stdio, graceMs: 60000 });
903
946
  }
904
947
 
948
+ /** 侧栏文件名搜索的预算与缓存(issue #13 实测:一个 OpenFOAM 工作区 `find` 5 分钟都跑不完,
949
+ * UI 一直「加载中…」,而且池化 SSH 会话被这条命令占住,其他操作一起卡)。
950
+ *
951
+ * 实测该工作区:顶层仅 12 个条目、命中集中在 depth 1–3(0.02–0.42s),depth 4 起耗时跳到 3s+
952
+ * 且不再有新命中——`find` 是深度优先,巨型子目录会吃光预算,连顶层文件都轮不到。
953
+ * 因此分两趟:先浅层(快、通常已够);**浅层一无所获才同步等深挖**,有结果时深挖转后台预热
954
+ * 缓存(否则常见查询都要多等一趟 ≤5s 的深挖,实测「有点慢」)。两趟都受远端墙钟预算约束。 */
955
+ const REF_SEARCH_MAX_MATCHES = 200; // 与上游 DEFAULT_MAX_MATCHES 一致
956
+ const REF_SEARCH_SHALLOW_DEPTH = 3; // 第一趟深度
957
+ const REF_SEARCH_SHALLOW_BUDGET_SEC = 3; // 第一趟墙钟预算
958
+ const REF_SEARCH_DEEP_DEPTH = 8; // 第二趟深度(仅在浅层未找满时)
959
+ const REF_SEARCH_DEEP_BUDGET_SEC = 5; // 第二趟墙钟预算
960
+ const REF_SEARCH_TIMEOUT_MS = 15000; // SSH 层超时(单趟预算 + 余量),避免占住池化会话
961
+ const REF_SEARCH_TTL_MS = 30000; // 相同 query 的短缓存(连打键盘/重复打开搜索框时省一次遍历)
962
+ const remoteSearchCache = new Map(); // key -> { at, epoch, result }
963
+ const remoteSearchInflight = new Map(); // key -> Promise(并发合并同一 query)
964
+
965
+ /** 远端文件名搜索:浅层优先 + 按需深挖 + 剪噪声目录 + 无 sort(head 可提前短路)+ 部分结果降级。 */
966
+ async function remoteSearch(runner, profile, remoteRoot, query) {
967
+ const pk = profileKey(profile);
968
+ const key = pk + "|" + String(remoteRoot || "~") + "|" + String(query || "");
969
+ const epoch = cacheEpoch(pk);
970
+ const hit = remoteSearchCache.get(key);
971
+ if (hit && hit.epoch === epoch && Date.now() - hit.at <= REF_SEARCH_TTL_MS) return hit.result;
972
+ let inflight = remoteSearchInflight.get(key);
973
+ if (!inflight) {
974
+ inflight = (async () => {
975
+ try {
976
+ const q = String(query || "");
977
+ const common = {
978
+ relative: true,
979
+ sorted: false,
980
+ pruneNoise: true,
981
+ maxResults: REF_SEARCH_MAX_MATCHES,
982
+ timeoutMs: REF_SEARCH_TIMEOUT_MS
983
+ };
984
+ const first = await remoteGlob(runner, profile, "*" + q + "*", remoteRoot, Object.assign({}, common, {
985
+ maxDepth: REF_SEARCH_SHALLOW_DEPTH,
986
+ timeBudgetSec: REF_SEARCH_SHALLOW_BUDGET_SEC
987
+ }));
988
+ const files = (first && first.files) || [];
989
+ let truncated = !!(first && first.truncated);
990
+ let error = first && first.ok ? "" : (first && first.error) || "search failed";
991
+ // 深挖策略(延迟关键):**有结果就立刻返回**,深挖转后台预热缓存。
992
+ // 此前条件是「浅层命中 < 200 就深挖」,而文件名片段极少能命中 200 个 → 几乎每次查询都要
993
+ // 再等一趟最多 5s 的深挖(实测浅层 0.96s + 深挖 ≤5s ≈ 最多 6s,用户反馈「有点慢」)。
994
+ // 现在:浅层一无所获时才同步等深挖(此时用户没别的可看);否则后台跑,同一 query 的
995
+ // 后续请求/重复打开搜索框会命中更全的缓存结果。
996
+ const deepOpts = Object.assign({}, common, {
997
+ maxDepth: REF_SEARCH_DEEP_DEPTH,
998
+ timeBudgetSec: REF_SEARCH_DEEP_BUDGET_SEC
999
+ });
1000
+ const warmDeepPass = () => {
1001
+ Promise.resolve()
1002
+ .then(() => remoteGlob(runner, profile, "*" + q + "*", remoteRoot, deepOpts))
1003
+ .then((deep) => {
1004
+ if (!deep || !deep.ok || !deep.files || deep.files.length === 0) return;
1005
+ if (cacheEpoch(pk) !== epoch) return; // 期间发生过写/exec:结果已过期,不写入
1006
+ const cur = remoteSearchCache.get(key);
1007
+ const merged = new Set((cur && cur.result && cur.result.files) || files);
1008
+ for (const f of deep.files) merged.add(f);
1009
+ remoteSearchCache.set(key, {
1010
+ at: Date.now(),
1011
+ epoch: epoch,
1012
+ result: {
1013
+ ok: true,
1014
+ pattern: "*" + q + "*",
1015
+ path: remoteRoot,
1016
+ files: Array.from(merged).slice(0, REF_SEARCH_MAX_MATCHES).sort(),
1017
+ truncated: !!(deep.truncated || merged.size >= REF_SEARCH_MAX_MATCHES),
1018
+ error: ""
1019
+ }
1020
+ });
1021
+ })
1022
+ .catch(() => {});
1023
+ };
1024
+ if (first && first.ok && files.length === 0) {
1025
+ // 浅层零命中:同步等深挖(用户此时没有可看的结果)
1026
+ const deep = await remoteGlob(runner, profile, "*" + q + "*", remoteRoot, deepOpts);
1027
+ const seen = new Set(files);
1028
+ for (const f of ((deep && deep.files) || [])) { if (!seen.has(f)) { seen.add(f); files.push(f); } }
1029
+ truncated = truncated || !!(deep && deep.truncated);
1030
+ if (deep && !deep.ok && files.length === 0) error = deep.error || error;
1031
+ } else if (first && first.ok && files.length > 0 && files.length < REF_SEARCH_MAX_MATCHES) {
1032
+ warmDeepPass(); // 后台预热:不增加本次响应延迟(浅层已满额时无需再深挖)
1033
+ }
1034
+ const result = {
1035
+ ok: files.length > 0 || !!(first && first.ok),
1036
+ pattern: "*" + q + "*",
1037
+ path: remoteRoot,
1038
+ files: files.slice(0, REF_SEARCH_MAX_MATCHES).sort(),
1039
+ truncated: truncated || files.length >= REF_SEARCH_MAX_MATCHES,
1040
+ error: files.length > 0 ? "" : error
1041
+ };
1042
+ if (result.ok) remoteSearchCache.set(key, { at: Date.now(), epoch, result: result });
1043
+ return result;
1044
+ } finally {
1045
+ remoteSearchInflight.delete(key);
1046
+ }
1047
+ })();
1048
+ remoteSearchInflight.set(key, inflight);
1049
+ }
1050
+ return await inflight;
1051
+ }
1052
+
905
1053
  function readCollected(handle, name) {
906
1054
  if (!handle || !handle.collected || !handle.collected[name]) return "";
907
1055
  const r = handle.collected[name].readFrom(0);
@@ -1003,19 +1151,90 @@ function normExec(r) {
1003
1151
  // HTTP 工具
1004
1152
  // ---------------------------------------------------------------------------
1005
1153
 
1006
- function isTrusted(req, requireRequestedWith) {
1007
- const secFetchSite = req.headers["sec-fetch-site"];
1008
- if (secFetchSite === "cross-site") return false;
1154
+ // ---------------------------------------------------------------------------
1155
+ // 浏览器信任判定(DNS-rebinding / 跨站防线,**不是**认证)
1156
+ //
1157
+ // 与 /api 网关、better-sidebar 的 fence 同源:Host 头为 loopback,或命中宿主
1158
+ // ctx.webRuntime.trustedHosts(启动时采样的局域网 IP 字面量 + `--trusted-host`)。
1159
+ // 实现逐条对照 @deepseek-ai/dsh-client-connection 的 api-request-trust.ts
1160
+ // (better-sidebar 的 src/trust-fence.ts 是同一份拷贝,BSD-3-Clause —— 上游未导出这些
1161
+ // helper,故按同样方式在此复刻),差异只有:本插件额外要求 x-requested-with(PR #4 的
1162
+ // CSRF 加固),且把拒绝原因回传给调用方以便给出准确文案(issue #12:此前无论何种原因都
1163
+ // 报 "missing x-requested-with header",把排查方向带偏)。
1164
+ // ---------------------------------------------------------------------------
1165
+
1166
+ /** Host 头 authority 的规范化 URL;不可解析时返回 undefined。 */
1167
+ function parseAuthority(authority) {
1168
+ try {
1169
+ return new URL("http://" + String(authority));
1170
+ } catch (e) {
1171
+ return undefined;
1172
+ }
1173
+ }
1174
+
1175
+ /** hostname 是否指向本机 loopback(localhost / [::1] / 127.x.x.x)。
1176
+ * 额外放行 0.0.0.0:它是本插件历史行为,且浏览器实际不会把它当目的地址发送。 */
1177
+ function isLoopbackHostname(hostname) {
1178
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "0.0.0.0") return true;
1179
+ const parts = String(hostname).split(".");
1180
+ return parts.length === 4
1181
+ && parts[0] === "127"
1182
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1183
+ }
1184
+
1185
+ /** authority 的规范形式:写了端口则 hostname:port,否则仅 hostname。 */
1186
+ function canonicalAuthority(entry, entryUrl) {
1187
+ const port = entryUrl.port !== "" ? entryUrl.port : new URL("https://" + String(entry)).port;
1188
+ return port === "" ? entryUrl.hostname : entryUrl.hostname + ":" + port;
1189
+ }
1190
+
1191
+ /** 请求 authority 是否命中 trustedHosts(条目带端口则精确比对 host,否则只比 hostname)。 */
1192
+ function isTrustedAuthority(hostUrl, trustedHosts) {
1193
+ return (trustedHosts || []).some((entry) => {
1194
+ const entryUrl = parseAuthority(entry);
1195
+ if (entryUrl === undefined) return false;
1196
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
1197
+ ? entryUrl.hostname === hostUrl.hostname
1198
+ : entryUrl.host === hostUrl.host;
1199
+ });
1200
+ }
1201
+
1202
+ /** 本插件额外要求的非简单头(PR #4 的 CSRF 加固)。 */
1203
+ function hasRequestedWithHeader(req) {
1204
+ return String(req.headers["x-requested-with"] || "").trim() === "XMLHttpRequest";
1205
+ }
1206
+
1207
+ /** 判定一次请求是否可信:返回 "ok" 或具体拒绝原因(供调用方给出准确文案)。 */
1208
+ function trustVerdict(req, trustedHosts) {
1009
1209
  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;
1210
+ if (!host) return "no-host";
1211
+ const hostUrl = parseAuthority(host);
1212
+ if (hostUrl === undefined) return "bad-host";
1213
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return "bad-host";
1214
+ if (req.headers["sec-fetch-site"] === "cross-site") return "cross-site";
1215
+ // Origin 围栏:浏览器带 Origin 时必须与本请求 hostname 一致(比较 hostname 而非 host ——
1216
+ // 部分 Chromium(Edge 151)对非默认端口的 loopback 页面会把 Origin 序列化成不带端口)。
1217
+ // 无 Origin 视为通过(Host 围栏已限定 authority);字面量 "null"(沙箱 iframe / file:)拒绝。
1218
+ const origin = req.headers["origin"];
1219
+ if (origin === undefined) return "ok";
1220
+ try {
1221
+ return new URL(origin).hostname === hostUrl.hostname ? "ok" : "bad-origin";
1222
+ } catch (e) {
1223
+ return "bad-origin";
1224
+ }
1225
+ }
1226
+
1227
+ /** 拒绝原因 → 面向用户/模型的文案(issue #12:区分「缺头」与「Host 不可信」)。 */
1228
+ function trustErrorMessage(reason) {
1229
+ switch (reason) {
1230
+ case "header": return "missing x-requested-with header";
1231
+ case "cross-site": return "cross-site request refused";
1232
+ case "bad-origin": return "origin does not match the request host";
1233
+ case "no-host": return "missing Host header";
1234
+ default:
1235
+ return "untrusted host: this deployment only serves loopback or hosts trusted by the DSH web runtime "
1236
+ + "(start DSH with --trusted-host <host[:port]>, or expose it through your remote-access setup)";
1017
1237
  }
1018
- return false;
1019
1238
  }
1020
1239
 
1021
1240
  async function readJsonBody(req) {
@@ -1136,14 +1355,26 @@ const REF_INDEX_TTL_MS = 60000;
1136
1355
  const REF_QUERY_BUDGET_MS = 900;
1137
1356
  /** 后台建索引的预算。 */
1138
1357
  const REF_INDEX_BUILD_TIMEOUT_MS = 20000;
1139
- /** git 仓库的 find 深度上限。 */
1140
- const REF_FIND_MAXDEPTH = 5;
1358
+ /** 索引回退 find 的深度上限(原为 5,在巨型目录上 5 层遍历会跑不完——实测 >180s)。 */
1359
+ const REF_INDEX_FIND_MAXDEPTH = 3;
1360
+ /** 远端各步的墙钟预算(秒):完整 git → 仅索引 git → find 回退,合计 14s < 上面的 20s 构建预算。 */
1361
+ const REF_INDEX_GIT_FULL_BUDGET_SEC = 6;
1362
+ const REF_INDEX_GIT_CACHED_BUDGET_SEC = 3;
1363
+ const REF_INDEX_FIND_BUDGET_SEC = 5;
1141
1364
  /** 排除目录:与 @deepseek-ai/dsh-file-reference-local 的 DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES 逐条一致。 */
1142
1365
  const REF_EXCLUDED_DIRS = new Set([
1143
1366
  ".git", "node_modules", "dist", "build", "out", "coverage", "target",
1144
1367
  ".next", ".nuxt", ".turbo", ".venv", "__pycache__", ".pytest_cache",
1145
1368
  ".mypy_cache", ".gradle"
1146
1369
  ]);
1370
+
1371
+ /** 侧栏文件名搜索要剪掉的噪声目录(上游 SEARCH_SKIP_DIRS 与本地索引排除表的并集)。
1372
+ * 在远端遍历前剪枝,避免把配额与时间花在依赖/构建产物上。
1373
+ * **必须定义在 REF_EXCLUDED_DIRS 之后**:模块级 const 的展开会在模块求值期立即读取该绑定。 */
1374
+ const REMOTE_SEARCH_SKIP_DIRS = new Set([
1375
+ ...REF_EXCLUDED_DIRS,
1376
+ ".pnpm-store", ".yarn", ".turbopack", ".output", ".cache", ".parcel-cache", ".umi", ".umi-production"
1377
+ ]);
1147
1378
  const refIndexCache = new Map(); // key = profileKey|root -> { at, epoch, entries }
1148
1379
  const refIndexBuilds = new Map(); // key -> Promise<entries>(同一 workspace 只建一次)
1149
1380
  const refTruncationWarned = new Set(); // 已就索引截断告警过的 workspace(避免每次重建都刷日志)
@@ -1254,27 +1485,77 @@ function refExcludeRegex() {
1254
1485
  return "(^|/)(" + alts + ")(/|$)";
1255
1486
  }
1256
1487
 
1257
- /** 远端建索引命令:git 仓库走 git ls-files(快且尊重 .gitignore),否则有界 find。
1488
+ /** 远端建索引命令:git 仓库优先(尊重 .gitignore),跑不完/为空时回退到有界 find。
1258
1489
  *
1259
- * 排除必须发生在 `head` **之前**(issue #10 实测反馈):`git ls-files --cached --others`
1490
+ * 排除必须发生在 `head` **之前**(issue #10 实测反馈):`git ls-files --cached --others`
1260
1491
  * 的输出**不是全局字典序**——未跟踪文件按 readdir 顺序先输出,`node_modules/` 这类目录
1261
1492
  * 可能占满前两万行,把 `head` 的配额吃光,导致 `AGENTS.md`、`src/**` 等真实文件被整段切掉
1262
1493
  * (实测仓库:node_modules 18,880 条 = 84.5%,`AGENTS.md` 落在第 20417 行)。
1263
- * 因此 git 分支在 head 前先 `grep -vE` 掉排除目录;find 分支本就用 `-prune` 在远端剪枝。
1494
+ * 每一步都要有**墙钟预算**(用户实测反馈:某个远程项目里 `@文件名` 完全没有候选,
1495
+ * 而 `@` 单独输入正常 —— 因为模糊查询走索引、目录列举不走):`git ls-files --others`
1496
+ * 需要遍历整棵工作树枚举未跟踪文件,在巨型目录上**根本跑不完**(实测 10s 被超时杀掉、
1497
+ * 零输出),于是索引为空。现在:完整 git(6s)→ 仅索引 git(3s,恒定快)→ 有界 find
1498
+ * (`maxdepth 3` + 5s,实测该目录 921 条 / 0.65s),三级合计 ≤14s < 20s 构建预算。
1264
1499
  * 客户端的 excludedSegment 仍保留作双保险(见 refParseIndexOutput)。 */
1265
1500
  function refIndexCommand(root) {
1266
1501
  const target = shellQuotePath(root || "~");
1267
1502
  const prune = Array.from(REF_EXCLUDED_DIRS).map((n) => "-name " + shellQuote(n)).join(" -o ");
1503
+ const cap = REF_MAX_ENTRIES + 1;
1504
+ const gitPipe = "| grep -vE '" + refExcludeRegex() + "' | head -n " + cap;
1505
+ const findFallback = "find . -mindepth 1 -maxdepth " + REF_INDEX_FIND_MAXDEPTH
1506
+ + " \\( " + prune + " \\) -prune -o -printf '%y\\t%P\\n' 2>/dev/null | head -n " + cap;
1268
1507
  return [
1269
1508
  "cd " + target + " 2>/dev/null || exit 3",
1509
+ "T=$(command -v timeout || command -v gtimeout || true)",
1510
+ "G=",
1270
1511
  "if [ -d .git ] && command -v git >/dev/null 2>&1; then",
1271
- "git ls-files --cached --others --exclude-standard 2>/dev/null | grep -vE '" + refExcludeRegex() + "' | head -n " + (REF_MAX_ENTRIES + 1) + " | sed 's/^/f\t/'",
1272
- "else",
1273
- "find . -mindepth 1 -maxdepth " + REF_FIND_MAXDEPTH + " \\( " + prune + " \\) -prune -o -printf '%y\t%P\\n' 2>/dev/null | head -n " + (REF_MAX_ENTRIES + 1),
1512
+ " if [ -n \"$T\" ]; then G=$(\"$T\" " + REF_INDEX_GIT_FULL_BUDGET_SEC + " git ls-files --cached --others --exclude-standard 2>/dev/null " + gitPipe + "); else G=$(git ls-files --cached --others --exclude-standard 2>/dev/null " + gitPipe + "); fi",
1513
+ " if [ -z \"$G\" ]; then",
1514
+ " if [ -n \"$T\" ]; then G=$(\"$T\" " + REF_INDEX_GIT_CACHED_BUDGET_SEC + " git ls-files --cached 2>/dev/null " + gitPipe + "); else G=$(git ls-files --cached 2>/dev/null " + gitPipe + "); fi",
1515
+ " fi",
1516
+ "fi",
1517
+ "if [ -n \"$G\" ]; then printf '%s\\n' \"$G\" | sed 's/^/f\t/'; else",
1518
+ " if [ -n \"$T\" ]; then \"$T\" " + REF_INDEX_FIND_BUDGET_SEC + " " + findFallback + "; else " + findFallback + "; fi",
1274
1519
  "fi"
1275
1520
  ].join("\n");
1276
1521
  }
1277
1522
 
1523
+ // ---------------------------------------------------------------------------
1524
+ // fs.search 的返回契约(issue #13)
1525
+ //
1526
+ // better-sidebar 客户端契约(v0.13.0 起至今未变,其 client-registry 注释原文:
1527
+ // "matches are cwd-relative '/'-separated paths"):
1528
+ // fs.search → { matches: string[], truncated: boolean }
1529
+ // 而本插件此前的两条分支只返回 { entries: [{path,isDir}], truncated } —— 客户端在渲染阶段
1530
+ // 读 results.matches.length / results.matches.map(...),undefined 直接抛 TypeError,
1531
+ // 被 RenderBoundary 兜住 → 整个「文件」页签变成错误条(本地会话也复现,因为拦截是无条件的)。
1532
+ // 现在统一经 fsSearchResult() 产出:matches 必须存在且为 cwd 相对、'/' 分隔的字符串数组;
1533
+ // entries(绝对路径 + isDir)保留作向前兼容。
1534
+ // ---------------------------------------------------------------------------
1535
+
1536
+ /** 把绝对路径裁成相对 base 的 POSIX 路径;不在 base 之下时回退为归一化后的绝对路径。 */
1537
+ function toPosixRelative(base, target) {
1538
+ const t = String(target == null ? "" : target).replace(/\\/g, "/");
1539
+ const b = String(base == null ? "" : base).replace(/\\/g, "/").replace(/\/+$/, "");
1540
+ if (!b) return t;
1541
+ // Windows 盘符路径大小写不敏感;POSIX 保持大小写敏感
1542
+ const fold = /^[a-z]:\//i.test(b);
1543
+ const tt = fold ? t.toLowerCase() : t;
1544
+ const bb = fold ? b.toLowerCase() : b;
1545
+ if (tt === bb) return "";
1546
+ if (tt.startsWith(bb + "/")) return t.slice(b.length + 1);
1547
+ return t;
1548
+ }
1549
+
1550
+ /** 组装 fs.search 的响应:matches 永远存在且为字符串数组(客户端会直接 .length / .map)。 */
1551
+ function fsSearchResult(matches, entries, truncated) {
1552
+ return {
1553
+ matches: Array.isArray(matches) ? matches.filter((m) => typeof m === "string" && m !== "") : [],
1554
+ entries: Array.isArray(entries) ? entries : [],
1555
+ truncated: !!truncated
1556
+ };
1557
+ }
1558
+
1278
1559
  // ---------------------------------------------------------------------------
1279
1560
  // apply
1280
1561
  // ---------------------------------------------------------------------------
@@ -1285,6 +1566,38 @@ function apply(ctx, config) {
1285
1566
  const terminals = new Map();
1286
1567
  let nextTerminalId = 1;
1287
1568
 
1569
+ // ---- 宿主信任源(issue #12)----
1570
+ // ctx.webRuntime.trustedHosts 是 DSH 官方运行时能力:启动时采样的局域网 IP 字面量
1571
+ // 加 `--trusted-host` 指定的 authority,`/api` 网关的 fence 正以它为准。
1572
+ // 按请求实时读取(服务值可被替换,无需重启插件);服务缺失时保持 loopback-only,
1573
+ // 行为与 2.4.9 完全一致 —— 因此用软注入(ctx.inject)而非硬 inject:万一将来宿主
1574
+ // 改名/移除该服务,插件仍能挂载,只是远程访问退回本机可用。
1575
+ let webRuntimeFace = null;
1576
+ ctx.inject(["webRuntime"], (sctx) => {
1577
+ try { webRuntimeFace = (sctx && sctx.webRuntime) || (sctx && typeof sctx.get === "function" ? sctx.get("webRuntime") : null) || null; } catch (e) { webRuntimeFace = null; }
1578
+ });
1579
+ function trustedHostsNow() {
1580
+ try {
1581
+ const list = webRuntimeFace && webRuntimeFace.trustedHosts;
1582
+ return Array.isArray(list) ? list : [];
1583
+ } catch (e) {
1584
+ return [];
1585
+ }
1586
+ }
1587
+
1588
+ /** 一次请求的信任判定:返回 "ok" 或拒绝原因("header" / "bad-host" / …)。 */
1589
+ function requestTrust(req, requireRequestedWith) {
1590
+ const verdict = trustVerdict(req, trustedHostsNow());
1591
+ if (verdict !== "ok") return verdict;
1592
+ if (requireRequestedWith && !hasRequestedWithHeader(req)) return "header";
1593
+ return "ok";
1594
+ }
1595
+
1596
+ /** 统一的 403 输出(带准确原因,避免把 Host 不可信误报成缺头)。 */
1597
+ function denyRequest(res, reason) {
1598
+ writeJson(res, 403, { ok: false, error: { code: reason === "header" ? "csrf" : "forbidden", message: trustErrorMessage(reason) } });
1599
+ }
1600
+
1288
1601
  // ---- 持久 SSH 会话池(连接复用,避免每次操作都做完整 SSH 握手)----
1289
1602
  const sessions = new Map(); // profileKey -> CommandSession
1290
1603
  const SESSION_IDLE_MS = 10 * 60 * 1000;
@@ -1757,7 +2070,9 @@ function apply(ctx, config) {
1757
2070
  handler: async (req, res) => {
1758
2071
  // CSRF 加固:/remote-ssh/api/* 仅由本插件客户端调用,强制校验其必带的
1759
2072
  // x-requested-with 头(跨站攻击者无法在 no-cors POST 中携带非简单头)。
1760
- if (!isTrusted(req, true)) { writeJson(res, 403, { ok: false, error: { code: "csrf", message: "missing x-requested-with header" } }); return; }
2073
+ // 信任判定走宿主 webRuntime.trustedHosts(issue #12),失败时回传具体原因。
2074
+ const apiTrust = requestTrust(req, true);
2075
+ if (apiTrust !== "ok") { denyRequest(res, apiTrust); return; }
1761
2076
  if (req.method !== "POST") { writeError(res, new Error("method not allowed"), 405); return; }
1762
2077
  const pathname = new URL(req.url || "/", "http://dsh.internal").pathname;
1763
2078
  if (!pathname.startsWith(API_BASE)) { writeError(res, new Error("not-found"), 404); return; }
@@ -2412,7 +2727,8 @@ function apply(ctx, config) {
2412
2727
  // webServer prefix 匹配要求 pathname 以 prefix+"/" 开头,但 fs.tree 用的是点分隔符,
2413
2728
  // 所以无法用 prefix 拦截,必须用 exact。
2414
2729
  async function interceptFsHandler(req, res, method) {
2415
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2730
+ const fsTrust = requestTrust(req, false);
2731
+ if (fsTrust !== "ok") { denyRequest(res, fsTrust); return; }
2416
2732
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2417
2733
  let payload;
2418
2734
  try { payload = await readJsonBody(req); }
@@ -2456,11 +2772,16 @@ function apply(ctx, config) {
2456
2772
  const rp = localToRemote(localPath, remoteBase, remoteInfo.remotePath);
2457
2773
  result = await remoteWriteFile(runPooled, profile, rp, payload.content || "");
2458
2774
  } else if (method === "fs.search") {
2775
+ // remoteSearch:相对路径(%P)+ 剪噪声目录 + 无 sort(head 提前短路)+ 8s 远端预算
2776
+ // + 15s SSH 超时 + 同 query 合并/短缓存(issue #13:大工作区此前会一直「加载中…」)。
2459
2777
  const rp = remoteInfo.remotePath;
2460
- const r = await remoteGlob(runPooled, profile, "*" + (payload.query || "") + "*", rp);
2461
- result = { entries: (r.files || []).map(function (f) {
2462
- return { path: join(remoteBase, f), isDir: false };
2463
- }), truncated: !!r.truncated };
2778
+ const r = await remoteSearch(runPooled, profile, rp, payload.query || "");
2779
+ const matches = (r.files || []).map(function (f) {
2780
+ return String(f).replace(/\\/g, "/").replace(/^\.\//, "");
2781
+ }).filter(function (f) { return f !== ""; });
2782
+ result = fsSearchResult(matches, matches.map(function (rel) {
2783
+ return { path: join(remoteBase, rel), isDir: false };
2784
+ }), r.truncated);
2464
2785
  } else if (method === "fs.rename") {
2465
2786
  // better-sidebar 0.19 新增端点:重命名必须落在远端,绝不能让本地镜像目录被改。
2466
2787
  const localPath = payload.path || "";
@@ -2514,7 +2835,11 @@ function apply(ctx, config) {
2514
2835
  } else if (method === "fs.write") {
2515
2836
  result = await localWriteFile(payload.path, payload.content || "");
2516
2837
  } else if (method === "fs.search") {
2517
- result = await localSearchFiles(sessionCwd, payload.query || "");
2838
+ // matches 必须是 cwd 相对、'/' 分隔的路径(better-sidebar 客户端直接 .map 渲染并据此打开);
2839
+ // entries 保留绝对路径(本插件 fs.read 拦截据此映射回远端)。
2840
+ const r = await localSearchFiles(sessionCwd, payload.query || "");
2841
+ const entries = (r && r.entries) || [];
2842
+ result = fsSearchResult(entries.map(function (e) { return toPosixRelative(sessionCwd, e.path); }), entries, r && r.truncated);
2518
2843
  } else if (method === "fs.rename") {
2519
2844
  result = await localRenameEntry(payload.path, String(payload.name || ""));
2520
2845
  } else if (method === "fs.remove") {
@@ -2667,7 +2992,8 @@ function apply(ctx, config) {
2667
2992
  }
2668
2993
 
2669
2994
  async function interceptGitHandler(req, res, method) {
2670
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2995
+ const gitTrust = requestTrust(req, false);
2996
+ if (gitTrust !== "ok") { denyRequest(res, gitTrust); return; }
2671
2997
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2672
2998
  let payload;
2673
2999
  try { payload = await readJsonBody(req); }
@@ -2849,7 +3175,8 @@ function apply(ctx, config) {
2849
3175
  }
2850
3176
 
2851
3177
  async function handleRemoteUpload(req, res) {
2852
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
3178
+ const upTrust = requestTrust(req, false);
3179
+ if (upTrust !== "ok") { denyRequest(res, upTrust); return; }
2853
3180
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2854
3181
  try {
2855
3182
  const url = new URL(req.url || "/", "http://dsh.internal");
@@ -2930,7 +3257,8 @@ function apply(ctx, config) {
2930
3257
  }
2931
3258
 
2932
3259
  async function handleSidebarFile(req, res) {
2933
- if (!isTrusted(req)) { res.writeHead(403); res.end("forbidden"); return; }
3260
+ const fileTrust = requestTrust(req, false);
3261
+ if (fileTrust !== "ok") { res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); res.end(trustErrorMessage(fileTrust)); return; }
2934
3262
  if (req.method !== "GET") { res.writeHead(405); res.end(); return; }
2935
3263
  try {
2936
3264
  const url = new URL(req.url || "/", "http://dsh.internal");
@@ -3071,9 +3399,28 @@ function apply(ctx, config) {
3071
3399
  return await remoteRefListDirectory(profile, root, split.directory, split.fragment);
3072
3400
  }
3073
3401
  const entries = await refIndexFor(profile, root);
3074
- if (!entries.length) return [];
3075
- const visible = entries.filter((e) => refVisibleForGlobalQuery(e.path, split.fragment));
3076
- return refRankCandidates(visible, split.fragment, REF_MAX_RESULTS);
3402
+ if (entries.length) {
3403
+ const visible = entries.filter((e) => refVisibleForGlobalQuery(e.path, split.fragment));
3404
+ return refRankCandidates(visible, split.fragment, REF_MAX_RESULTS);
3405
+ }
3406
+ // 索引尚未就绪时不能直接给空列表 —— 那正是「@文件名 没有候选、而 @ 单独输入正常」的观感
3407
+ // (用户实测反馈:某个远程项目里模糊查询全空)。原因:大仓库里索引的 git 那一步会吃满预算
3408
+ // (实测 6.7s 才建好),而单次查询只等 REF_QUERY_BUDGET_MS=900ms。
3409
+ // 这里用与侧栏搜索同一套**有界 find** 兜底:剪噪声目录 + 无 sort + 浅层深度 + 墙钟预算(实测 0.65s)。
3410
+ const r = await remoteGlob(runPooled, profile, "*" + split.fragment + "*", root, {
3411
+ relative: true,
3412
+ sorted: false,
3413
+ pruneNoise: true,
3414
+ maxResults: REF_MAX_RESULTS,
3415
+ maxDepth: REF_INDEX_FIND_MAXDEPTH,
3416
+ timeBudgetSec: REF_INDEX_FIND_BUDGET_SEC,
3417
+ timeoutMs: 10000
3418
+ });
3419
+ if (!r || !r.files || !r.files.length) return [];
3420
+ const quick = r.files.map(function (f) {
3421
+ return { path: String(f).replace(/\\/g, "/").replace(/^\.\//, ""), kind: "file" };
3422
+ }).filter(function (e) { return e.path !== "" && refVisibleForGlobalQuery(e.path, split.fragment); });
3423
+ return refRankCandidates(quick, split.fragment, REF_MAX_RESULTS);
3077
3424
  }
3078
3425
 
3079
3426
  /** 该 agent 是远程工作区会话则返回远端候选,否则返回 null(调用方委托原实现)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.4.9",
3
+ "version": "2.4.11",
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",