dsh-over-ssh 0.1.1 → 0.2.0

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 CHANGED
@@ -45,11 +45,11 @@ shell:
45
45
 
46
46
  | 平台 | 字节 | sha256 |
47
47
  |---|---|---|
48
- | linux-amd64 | 4,907,168 | `08998e6f51bc1a3759a966e96263c380f81f4281f2a50b0257b61f12b1341c3b` |
49
- | linux-arm64 | 4,653,216 | `0e19b3e77590f233093c09a7adc20487d721407bf805a9f879a756c9f03b9a11` |
48
+ | linux-amd64 | 4,927,648 | `4f47307e4927e39972ebad9c2f25b3676e990ff1335b72ee33c3e8ec25a1e2cf` |
49
+ | linux-arm64 | 4,653,216 | `9c1895ca3310a8fe5aed13a281cbdd98fe95928c24848ba58fea509b5f7341e6` |
50
50
 
51
- - 代理版本:`1.7.0-0ae8c02`
52
- - 源码提交:`0ae8c02`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
51
+ - 代理版本:`0.2.0-2dc1feb`
52
+ - 源码提交:`2dc1feb`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
53
53
  - 清单原文:包内 `agents/agent-manifest.json`
54
54
  - 核对:`sha256sum agents/<平台>/dsh-over-ssh-agent` 与上表比对;按上面的提交检出 Go 仓后
55
55
  `scripts/check.sh` 可重建同样的产物。
@@ -61,7 +61,7 @@ shell:
61
61
  - **传输层自研**,不使用核心 `packages/ssh/*` 家族。原因见设计文档 §3.1:官方实现的 `BatchMode=yes` 让密码认证不可能、远端必须预装 Node + helper、`ctx.ssh` 单例、且其 Web 集成需单独实现(preset 的 `isolate` 子树覆盖不到 `agent.ctx`)。
62
62
  - **文件按路径路由,进程不路由**:核心 provider 的公开方法收不到 agent/session 身份,因此 fs 用自研路由 provider 支持"本地 + 远端多根",而 bash/终端保持单一执行世界,远端命令走显式入口。
63
63
  - **远端零安装(Tier 1,地板)**:只依赖远端已有的 POSIX 工具(`sh`/`base64`/`stat`/`find`/可选 `git`/`rg`),连上后做一次能力探测并按能力降级。
64
- - **可选 Go 代理(Tier 2,增强)**:独立仓库 `../dsh-over-ssh-agent`,纯静态单文件(随包产物的体积与摘要以 `agents/agent-manifest.json` 的 `bytes`/`sha256` 为准,交叉编译矩阵见 `../dsh-over-ssh-agent/doc/05-benchmarks/baseline.md`;零第三方依赖),经 ssh stdio 通信(不监听端口),补齐原生监听/索引/长任务/远端 LSP/Landlock 围栏。决策见 `doc/03-decisions/0005-optional-go-remote-agent.md`;代理不可用时一律回退 Tier 1。
64
+ - **可选 Go 代理(Tier 2,增强)**:独立仓库 `../dsh-over-ssh-agent`,纯静态单文件(随包产物的体积与摘要以 `agents/agent-manifest.json` 的 `bytes`/`sha256` 为准,交叉编译矩阵见 `../dsh-over-ssh-agent/doc/05-benchmarks/baseline.md`;第三方依赖 4 个,全部经 ADR-0008 授权),经 ssh 通信(stdio 或常驻 daemon 的 unix socket,**不监听 TCP 端口**),补齐原生监听/索引/长任务/远端 LSP/Landlock 围栏。决策见 `doc/03-decisions/0005-optional-go-remote-agent.md`;代理不可用时一律回退 Tier 1。
65
65
  - **默认尝试部署**(2026-10-13 起,审计 0040):连接的默认传输是 `agent`,首次连接会把代理装到远端 `$HOME/.cache/dsh-over-ssh/dsh-over-ssh-agent`(约 4.7 MiB / 4,907,168 B,以 `agents/agent-manifest.json` 为准,`0700`),失败自动回退 Tier 1。想避开远端写入就显式写 `transport: alias`。
66
66
  - **密码认证同样可用**:代理经系统 ssh 启动,而系统 ssh 靠 `SSH_ASKPASS` 助手取密码(秘密只经环境变量传递,不落盘)。此前"密码认证无法用代理"的说法是把本插件硬写 `BatchMode=yes` 误当成了 SSH 的限制。
67
67
  - 随包只带 `linux-amd64` 与 `linux-arm64` 两个产物;其它平台的远端会正常降级到 Tier 1。
@@ -71,13 +71,15 @@ shell:
71
71
 
72
72
  ```
73
73
  src/
74
+ index.ts fs.ts file-references.ts # 三个 DSH 入口(组合根)
74
75
  host/
75
76
  domain/ # 纯 TS、零框架依赖、100% 单测覆盖
76
- app/ # 应用层用例(依赖抽象)
77
+ app/ # 应用层用例(根路由、传输服务、状态)
77
78
  infra/ # 传输/后端/缓存/持久化实现
78
79
  integration/ # 与核心对接:fs provider、fileReferences、tools、Remote API、终端 wrapper
79
- client/ # web client 半(React + TS + CSS Modules)
80
- tests/ # 每个模块一个独立测试文件
80
+ client/ # web client 半(React + TS + CSS Modules)——与 host/ 是兄弟目录
81
+ shared/ # 两半共用的纯工具(路由常量、根键等)
82
+ tests/ # 镜像 src/ 的测试树(每模块一个 *.test.ts;根级只留 architecture/config 两个横切用例)
81
83
  ```
82
84
 
83
85
  ## 测试
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-11-02T16:02:32.695Z",
4
- "agentVersion": "1.7.0-0ae8c02",
5
- "sourceCommit": "0ae8c02",
3
+ "generatedAt": "2026-11-04T23:44:48.795Z",
4
+ "agentVersion": "0.2.0-2dc1feb",
5
+ "sourceCommit": "2dc1feb",
6
6
  "artifacts": [
7
7
  {
8
8
  "platform": "linux-amd64",
9
9
  "path": "linux-amd64/dsh-over-ssh-agent",
10
- "sha256": "08998e6f51bc1a3759a966e96263c380f81f4281f2a50b0257b61f12b1341c3b",
11
- "bytes": 4907168
10
+ "sha256": "4f47307e4927e39972ebad9c2f25b3676e990ff1335b72ee33c3e8ec25a1e2cf",
11
+ "bytes": 4927648
12
12
  },
13
13
  {
14
14
  "platform": "linux-arm64",
15
15
  "path": "linux-arm64/dsh-over-ssh-agent",
16
- "sha256": "0e19b3e77590f233093c09a7adc20487d721407bf805a9f879a756c9f03b9a11",
16
+ "sha256": "9c1895ca3310a8fe5aed13a281cbdd98fe95928c24848ba58fea509b5f7341e6",
17
17
  "bytes": 4653216
18
18
  }
19
19
  ]
package/lib/client.js CHANGED
@@ -43,9 +43,10 @@ var OVER_SSH_DICTS = {
43
43
  rootsTitle: "\u8FDC\u7A0B\u5DE5\u4F5C\u533A",
44
44
  // 本页只列插件已登记的根、并可移除:「增」在审计 0027 已移到工作区目录流,旧文案的
45
45
  // 「在这里增删…」是错的。作用域那句(不会自动出现在会话的目录列表里)是审计 0056 的护栏,保留。
46
- rootsDescription: "\u8FD9\u91CC\u53EA\u5217\u51FA\u63D2\u4EF6\u5DF2\u767B\u8BB0\u7684\u6839\uFF0C\u5E76\u53EF\u4EE5\u79FB\u9664\uFF1B\u8981\u6DFB\u52A0\u76EE\u5F55\u8BF7\u5728\u5DE5\u4F5C\u533A\u7684\u76EE\u5F55\u6D41\u91CC\u505A\u3002\u8FD9\u91CC\u767B\u8BB0\u7684\u6839\u4E0D\u4F1A\u81EA\u52A8\u51FA\u73B0\u5728\u4F1A\u8BDD\u7684\u76EE\u5F55\u5217\u8868\u91CC\uFF1A\u4F1A\u8BDD\u8981\u7528\u5B83\uFF0C\u9700\u5728\u4F1A\u8BDD\u91CC\u70B9\u300C\u6DFB\u52A0\u76EE\u5F55\u300D\u3002profile \u91CC\u58F0\u660E\u7684\u6839\u53EA\u8BFB\u663E\u793A\uFF1A\u5220\u6389\u5B83\u91CD\u542F\u540E\u8FD8\u4F1A\u56DE\u6765\u3002",
46
+ rootsDescription: "\u8FD9\u91CC\u53EA\u5217\u51FA\u63D2\u4EF6\u5DF2\u767B\u8BB0\u7684\u6839\uFF0C\u5E76\u53EF\u4EE5\u79FB\u9664\uFF1B\u8981\u6DFB\u52A0\u76EE\u5F55\u8BF7\u5728\u5DE5\u4F5C\u533A\u7684\u76EE\u5F55\u6D41\u91CC\u505A\u3002\u8FD9\u91CC\u767B\u8BB0\u7684\u6839\u4E0D\u4F1A\u81EA\u52A8\u51FA\u73B0\u5728\u4F1A\u8BDD\u7684\u76EE\u5F55\u5217\u8868\u91CC\uFF1A\u4F1A\u8BDD\u8981\u7528\u5B83\uFF0C\u9700\u5728\u4F1A\u8BDD\u91CC\u70B9\u300C\u6DFB\u52A0\u76EE\u5F55\u300D\u3002\u6A21\u578B\u5DE5\u5177\u53EA\u8BA4\u300C\u843D\u5728\u672C\u4F1A\u8BDD\u5DE5\u4F5C\u533A\u5185\u7684\u6839\u300D\u4E0E\u300C\u951A\u70B9\u5C31\u662F\u672C\u4F1A\u8BDD\u5DE5\u4F5C\u533A\u7684\u8FDC\u7AEF\u6839\u300D\uFF1B\u5176\u4F59\u6839\u5728\u88C5\u5165\u4F1A\u8BDD\u4E4B\u524D\u4F1A\u62A5\u300C\u4F1A\u8BDD\u9694\u79BB\u300D\u3002profile \u91CC\u58F0\u660E\u7684\u6839\u53EA\u8BFB\u663E\u793A\uFF1A\u5220\u6389\u5B83\u91CD\u542F\u540E\u8FD8\u4F1A\u56DE\u6765\u3002",
47
47
  rootsAdvancedSummary: "\u9AD8\u7EA7\uFF1A\u6839\u8868\uFF08\u8BCA\u65AD\uFF09",
48
48
  rootsAdvancedNote: "\u8FD9\u662F\u63D2\u4EF6\u7684\u6839\u8DEF\u7531\u8868\uFF1A\u6BCF\u6761 = \u4E00\u4E2A\u8FDC\u7AEF\u76EE\u5F55 + \u63D2\u4EF6\u4E3A\u5B83\u5EFA\u7684\u672C\u5730\u951A\u70B9\u76EE\u5F55\u3002\u5220\u6389\u67D0\u6761\u540E\uFF0C\u6B63\u5728\u4F7F\u7528\u8BE5\u951A\u70B9\u7684\u5DE5\u4F5C\u533A\u91CC remote_* \u5DE5\u5177\u4F1A\u7ACB\u523B\u62A5\u300C\u4E0D\u5728\u4EFB\u4F55\u8FDC\u7AEF\u6839\u5185\u300D\uFF1B\u5DE5\u4F5C\u533A\u672C\u8EAB\u4ECD\u5728\u4FA7\u680F\u3002",
49
+ rootsScopeNoSession: "\u6CA1\u6709\u4F1A\u8BDD\u4F5C\u7528\u57DF\uFF1A\u6A21\u578B\u53EA\u5BF9\u843D\u5728\u4F1A\u8BDD\u5DE5\u4F5C\u533A\u5185\u7684\u6839\u53EF\u8FBE\uFF1B\u5176\u4F59\u9700\u5728\u4F1A\u8BDD\u91CC\u70B9\u300C\u6DFB\u52A0\u76EE\u5F55\u300D",
49
50
  rootsEmpty: "\u8FD8\u6CA1\u6709\u914D\u7F6E\u4EFB\u4F55\u6839\u3002",
50
51
  rootsPending: "\u6B63\u5728\u4E0E\u5BBF\u4E3B\u901A\u4FE1\u2026",
51
52
  rootsAuthPassword: "\u7528\u6237\u540D + \u5BC6\u7801",
@@ -167,7 +168,6 @@ var OVER_SSH_DICTS = {
167
168
  rootsImportFailed: "\u5BFC\u5165\u5931\u8D25\uFF1A{message}",
168
169
  transferTitle: "\u4F20\u8F93\u8FDB\u5EA6",
169
170
  transferDescription: "\u5F53\u524D\u4E0E\u6700\u8FD1\u7684\u4F20\u8F93\uFF1A\u901F\u7387\u3001\u5DF2\u5B8C\u6210\u5B57\u8282\u3001\u5269\u4F59\u65F6\u95F4\u3002\u5931\u8D25\u9879\u7559\u5728\u4E0B\u9762\u3002",
170
- remoteFilesHint: "\u8FDC\u7AEF\u6587\u4EF6\u901A\u8FC7\u5BF9\u8BDD\u8BBF\u95EE\uFF1A@ \u8DEF\u5F84\uFF0C\u6216\u8BA9\u6211\u5217\u76EE\u5F55\u3002",
171
171
  transferEmpty: "\u5F53\u524D\u6CA1\u6709\u4F20\u8F93\u3002",
172
172
  transferLoading: "\u6B63\u5728\u8BFB\u53D6\u4F20\u8F93\u72B6\u6001\u2026",
173
173
  transferRefresh: "\u5237\u65B0",
@@ -283,9 +283,10 @@ var OVER_SSH_DICTS = {
283
283
  rootsTitle: "Remote workspaces",
284
284
  // Same correction as the Chinese copy: this page can only remove rows (adding moved to the workspace
285
285
  // directory flow, audit 0027). The scope sentence is the audit-0056 guard and stays.
286
- rootsDescription: "This page only lists the roots the plugin has registered, and lets you remove them; to add a directory, use the workspace directory flow. A root registered here does not appear in a session's directory list automatically: to use it, add it from the session first. Roots declared in the profile are read-only here: deleting one would bring it back after a restart.",
286
+ rootsDescription: "This page only lists the roots the plugin has registered, and lets you remove them; to add a directory, use the workspace directory flow. A root registered here does not appear in a session's directory list automatically: to use it, add it from the session first. The model reaches only roots that fall inside the session workspace (or a remote root whose anchor is that workspace); every other root reports session isolation until a session takes it in. Roots declared in the profile are read-only here: deleting one would bring it back after a restart.",
287
287
  rootsAdvancedSummary: "Advanced: root table (diagnostic)",
288
288
  rootsAdvancedNote: "This is the plugin's root routing table: each row is a remote directory plus the local anchor directory the plugin created for it. Deleting a row makes the remote_* tools fail immediately in any workspace that already uses that anchor; the workspace itself stays in the sidebar.",
289
+ rootsScopeNoSession: "No session scope: the model reaches only roots inside the session workspace; add it from the session otherwise",
289
290
  rootsEmpty: "No root is configured yet.",
290
291
  rootsPending: "Talking to the host\u2026",
291
292
  rootsAuthPassword: "Username + password",
@@ -402,7 +403,6 @@ var OVER_SSH_DICTS = {
402
403
  rootsImportFailed: "Import failed: {message}",
403
404
  transferTitle: "Transfers",
404
405
  transferDescription: "Current and recent transfers: rate, bytes done, time left. Failures stay listed below.",
405
- remoteFilesHint: "Remote files are reached through the conversation: @ a path, or ask me to list a directory.",
406
406
  transferEmpty: "Nothing is transferring.",
407
407
  transferLoading: "Reading transfer state\u2026",
408
408
  transferRefresh: "Refresh",
@@ -1785,6 +1785,9 @@ function buildRootsView(input) {
1785
1785
  title: row.label ?? row.path,
1786
1786
  subtitle: row.location === "remote" ? `${row.connectionId ?? "?"}:${row.path}` : row.path,
1787
1787
  badgeKey: row.location === "remote" ? "rootsBadgeRemote" : "rootsBadgeLocal",
1788
+ // 只有"用户在设置页登记、且没有任何会话作用域"的行才对 AI 不可用;profile 里的根是部署级共享根,
1789
+ // 不属于这个提示的适用范围。
1790
+ ...row.source === "user" && (row.workspaces?.length ?? 0) === 0 && (row.sessions?.length ?? 0) === 0 ? { scopeKey: "rootsScopeNoSession" } : {},
1788
1791
  // Only a root the panel owns can be deleted: a config root comes back after a restart, so offering a
1789
1792
  // delete button for it would be a lie the user cannot see. A root that is both declared and added is
1790
1793
  // deletable — the file entry goes away, which is what the user asked for.
@@ -2446,6 +2449,17 @@ function createTransferProgress(options) {
2446
2449
  listeners.clear();
2447
2450
  void running;
2448
2451
  },
2452
+ /**
2453
+ * Re-arm after a `dispose()`.
2454
+ *
2455
+ * React's development replay runs an effect's cleanup and then the effect again **on the same controller**
2456
+ * (the instance is kept by `useMemo`). Without `activate()` the second mount's `start()` was silently
2457
+ * ignored — `dispose()` set `disposed = true` and nothing cleared it — so the transfer panel never polled
2458
+ * again for the rest of the session. Every other controller in this half already has this method.
2459
+ */
2460
+ activate: () => {
2461
+ disposed = false;
2462
+ },
2449
2463
  requests: () => issued
2450
2464
  };
2451
2465
  }
@@ -2456,6 +2470,7 @@ function TransferProgressSection({ copy }) {
2456
2470
  const progress = (0, import_react4.useMemo)(() => createTransferProgress({ io }), [io]);
2457
2471
  const panel = (0, import_react4.useSyncExternalStore)(progress.subscribe, progress.getState);
2458
2472
  (0, import_react4.useEffect)(() => {
2473
+ progress.activate();
2459
2474
  progress.start();
2460
2475
  return () => progress.dispose();
2461
2476
  }, [progress]);
@@ -2766,6 +2781,7 @@ function renderRow(row, copy, editable, onRemove) {
2766
2781
  (0, import_react5.createElement)("span", { className: CLASS.rowTitle }, row.title),
2767
2782
  (0, import_react5.createElement)("code", { className: CLASS.badge }, resolveMessage2(copy, { key: row.badgeKey })),
2768
2783
  (0, import_react5.createElement)("span", { className: CLASS.rowMeta }, row.subtitle),
2784
+ row.scopeKey === void 0 ? null : (0, import_react5.createElement)("span", { className: CLASS.hint }, resolveMessage2(copy, { key: row.scopeKey })),
2769
2785
  // The one and only source of "may this be deleted": the view model's `removable`. A per-row destructive
2770
2786
  // action is the `ghost` variant, so the row stays quiet until the operator means it.
2771
2787
  row.removable ? (0, import_react5.createElement)(
@@ -3219,12 +3235,10 @@ function RootsPanelSection({
3219
3235
  Button({ variant: "secondary", disabled: !editable, onClick: () => actions.onImportOpen(), children: copy.rootsImportOpen })
3220
3236
  ),
3221
3237
  SshConfigImportDialog({ copy, state, actions, idPrefix }),
3222
- // -- remote files are reached through the conversation (audit 0085) -------
3223
- // There is no tree here, and no button that opens one. A file manager inside settings was the wrong
3224
- // artifact: it duplicated the agent's own access, wrote to the remote machine with no audit trail, and
3225
- // competed with the conversation as a second source of truth. The sentence says where the capability
3226
- // actually lives; the tools behind it (`@` a path, or asking for a listing) are the agent's.
3227
- (0, import_react5.createElement)("p", { className: CLASS.description }, copy.remoteFilesHint),
3238
+ // -- no file tree, no entry point (audit 0085) ---------------------------
3239
+ // Settings used to carry a sentence pointing at the conversation. It was a signpost for a capability
3240
+ // that deliberately has no UI; the decision still stands (remote files are the agent's), but the
3241
+ // signpost is gone — the settings section is not the place to teach it.
3228
3242
  // -- transfer progress (FR-2.6.4) ----------------------------------------
3229
3243
  transfers ?? null,
3230
3244
  // -- edit one saved connection (a dialog, not a block in this page) -------
@@ -4598,11 +4612,12 @@ function postFailureReason(parsed, status, path) {
4598
4612
  const error = parsed?.["error"];
4599
4613
  return typeof error === "string" ? error : transportFailureReason(status, path);
4600
4614
  }
4601
- async function postRootsJson(fetchImpl, path, body) {
4615
+ async function postRootsJson(fetchImpl, path, body, signal) {
4602
4616
  try {
4603
4617
  const response = await fetchImpl(path, {
4604
4618
  method: "POST",
4605
4619
  headers: { "content-type": "application/json" },
4620
+ ...signal === void 0 ? {} : { signal },
4606
4621
  body: JSON.stringify(body)
4607
4622
  });
4608
4623
  let parsed;
@@ -4626,10 +4641,10 @@ function createDirectoryBrowserIo(options) {
4626
4641
  const result = await rootsApi.load();
4627
4642
  return result.ok ? { ok: true, value: result.value.connections } : { ok: false, error: result.error };
4628
4643
  };
4629
- const listLocal = async (target) => {
4644
+ const listLocal = async (target, signal) => {
4630
4645
  const body = { action: "list-local" };
4631
4646
  if (target !== void 0 && target !== "") body["path"] = target;
4632
- const result = await postRootsJson(options.fetchImpl, path, body);
4647
+ const result = await postRootsJson(options.fetchImpl, path, body, signal);
4633
4648
  if (!result.ok) return result;
4634
4649
  const answer = result.value;
4635
4650
  const answerPath = answer["path"];
@@ -4650,9 +4665,9 @@ function createDirectoryBrowserIo(options) {
4650
4665
  }
4651
4666
  return { ok: true, value: { path: answerPath, crumbs, entries, truncated: answer["truncated"] === true } };
4652
4667
  };
4653
- const listRemote = async (input) => {
4668
+ const listRemote = async (input, signal) => {
4654
4669
  if (input.connectionId === "") return { ok: false, error: NO_CONNECTION_ERROR };
4655
- const result = await postRootsJson(options.fetchImpl, path, { action: "list-remote", connectionId: input.connectionId, path: input.path });
4670
+ const result = await postRootsJson(options.fetchImpl, path, { action: "list-remote", connectionId: input.connectionId, path: input.path }, signal);
4656
4671
  if (!result.ok) return result;
4657
4672
  const raw = result.value["entries"];
4658
4673
  const entries = [];
@@ -4666,9 +4681,9 @@ function createDirectoryBrowserIo(options) {
4666
4681
  const answerPath = typeof result.value["path"] === "string" && result.value["path"] !== "" ? result.value["path"] : input.path;
4667
4682
  return { ok: true, value: { path: answerPath, entries, truncated: result.value["truncated"] === true } };
4668
4683
  };
4669
- const loadGitStatus = async (input) => {
4684
+ const loadGitStatus = async (input, signal) => {
4670
4685
  if (input.connectionId === "" || input.path === "") return { ok: false, error: NO_CONNECTION_ERROR };
4671
- const result = await postRootsJson(options.fetchImpl, GIT_STATUS_API_PATH, { connectionId: input.connectionId, path: input.path });
4686
+ const result = await postRootsJson(options.fetchImpl, GIT_STATUS_API_PATH, { connectionId: input.connectionId, path: input.path }, signal);
4672
4687
  if (!result.ok) return result;
4673
4688
  return { ok: true, value: parseGitStatusView(result.value) };
4674
4689
  };
@@ -4750,11 +4765,14 @@ function directoryBrowserReducer(state, action) {
4750
4765
  }))
4751
4766
  };
4752
4767
  case "git-loaded": {
4753
- if (action.view.available !== true) return { ...withoutOptional(state) };
4768
+ if (action.view.available !== true) {
4769
+ const { git: _drop, ...rest } = state;
4770
+ return rest;
4771
+ }
4754
4772
  const changes = {};
4755
4773
  for (const change of action.view.changes) changes[change.path] = change.status;
4756
4774
  return {
4757
- ...withoutOptional(state),
4775
+ ...state,
4758
4776
  git: {
4759
4777
  root: action.view.root ?? "",
4760
4778
  directory: action.view.directory ?? "",
@@ -4827,6 +4845,13 @@ function createDirectoryBrowser(deps) {
4827
4845
  };
4828
4846
  const listeners = /* @__PURE__ */ new Set();
4829
4847
  let token = 0;
4848
+ let inFlight;
4849
+ const beginRequest = () => {
4850
+ inFlight?.abort();
4851
+ const controller = new AbortController();
4852
+ inFlight = controller;
4853
+ return controller.signal;
4854
+ };
4830
4855
  let gitToken = 0;
4831
4856
  let disposed = false;
4832
4857
  const dispatch = (action) => {
@@ -4837,20 +4862,21 @@ function createDirectoryBrowser(deps) {
4837
4862
  };
4838
4863
  const current = (issued) => !disposed && issued === token;
4839
4864
  const currentGit = (issued) => !disposed && issued === gitToken;
4840
- const loadGit = async (connectionId, path) => {
4865
+ const loadGit = async (connectionId, path, signal) => {
4841
4866
  const load = deps.io.loadGitStatus;
4842
4867
  if (load === void 0 || connectionId === "" || path === "") return;
4843
4868
  const issued = ++gitToken;
4844
- const result = await load({ connectionId, path });
4869
+ const result = await load({ connectionId, path }, signal);
4845
4870
  if (!currentGit(issued)) return;
4846
4871
  if (!result.ok) return;
4847
4872
  dispatch({ type: "git-loaded", view: result.value });
4848
4873
  };
4849
4874
  const readLocal = async (path) => {
4875
+ const signal = beginRequest();
4850
4876
  const issued = ++token;
4851
4877
  gitToken += 1;
4852
4878
  dispatch({ type: "target", path: path ?? "" });
4853
- const result = await deps.io.listLocal(path === void 0 || path === "" ? void 0 : path);
4879
+ const result = await deps.io.listLocal(path === void 0 || path === "" ? void 0 : path, signal);
4854
4880
  if (!current(issued)) return;
4855
4881
  if (!result.ok) {
4856
4882
  dispatch({ type: "failed", error: result.error });
@@ -4859,17 +4885,18 @@ function createDirectoryBrowser(deps) {
4859
4885
  dispatch({ type: "local-loaded", listing: result.value });
4860
4886
  };
4861
4887
  const readRemote = async (connectionId, path) => {
4888
+ const signal = beginRequest();
4862
4889
  const issued = ++token;
4863
4890
  gitToken += 1;
4864
4891
  dispatch({ type: "target", path });
4865
- const result = await deps.io.listRemote({ connectionId, path });
4892
+ const result = await deps.io.listRemote({ connectionId, path }, signal);
4866
4893
  if (!current(issued)) return;
4867
4894
  if (!result.ok) {
4868
4895
  dispatch({ type: "failed", error: result.error });
4869
4896
  return;
4870
4897
  }
4871
4898
  dispatch({ type: "remote-loaded", path, listing: result.value });
4872
- void loadGit(connectionId, result.value.path);
4899
+ void loadGit(connectionId, result.value.path, signal);
4873
4900
  };
4874
4901
  const ensureConnections = async () => {
4875
4902
  if (state.connections.length > 0) return true;
@@ -4942,6 +4969,8 @@ function createDirectoryBrowser(deps) {
4942
4969
  },
4943
4970
  dispose: () => {
4944
4971
  disposed = true;
4972
+ inFlight?.abort();
4973
+ inFlight = void 0;
4945
4974
  listeners.clear();
4946
4975
  }
4947
4976
  };
@@ -5212,6 +5241,9 @@ function createSessionDirectoryIo(options) {
5212
5241
  const loadRoots = async (workspacePath, sessionId) => {
5213
5242
  const result = await rootsApi.load();
5214
5243
  if (!result.ok) return result;
5244
+ if (result.value.warnings.includes("roots-invalid-answer")) {
5245
+ return { ok: false, error: "roots-invalid-answer" };
5246
+ }
5215
5247
  const all = result.value.roots;
5216
5248
  if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: [] };
5217
5249
  return {
@@ -5390,6 +5422,10 @@ function createSessionDirectory(deps) {
5390
5422
  dispatch({ type: "roots-failed", reason: result.error });
5391
5423
  return;
5392
5424
  }
5425
+ if (state.workspacePath !== workspacePath || state.sessionId !== sessionId) {
5426
+ await refreshRoots();
5427
+ return;
5428
+ }
5393
5429
  dispatch({ type: "root-removed", key });
5394
5430
  };
5395
5431
  const commit = async (path, connectionId) => {
@@ -49,14 +49,26 @@ var DEFAULTS = {
49
49
  function rankMatches(paths, query, limit) {
50
50
  const needle = query.trim().toLowerCase();
51
51
  if (needle === "") {
52
- const order = [];
53
- for (let index = 0; index < paths.length; index += 1) order.push(index);
54
- const depths = new Int32Array(paths.length);
55
- for (let index = 0; index < paths.length; index += 1) depths[index] = depth(paths[index]);
56
- order.sort(
57
- (left, right) => depths[left] - depths[right] || (paths[left] < paths[right] ? -1 : 1)
58
- );
59
- return order.slice(0, limit).map((index) => paths[index]);
52
+ const top = [];
53
+ const better = (left, right) => {
54
+ const leftDepth = depth(paths[left]);
55
+ const rightDepth = depth(paths[right]);
56
+ if (leftDepth !== rightDepth) return leftDepth < rightDepth;
57
+ return paths[left] < paths[right];
58
+ };
59
+ for (let index = 0; index < paths.length; index += 1) {
60
+ if (top.length >= limit && !better(index, top[top.length - 1])) continue;
61
+ let low = 0;
62
+ let high = top.length;
63
+ while (low < high) {
64
+ const middle = low + high >> 1;
65
+ if (better(index, top[middle])) high = middle;
66
+ else low = middle + 1;
67
+ }
68
+ top.splice(low, 0, index);
69
+ if (top.length > limit) top.pop();
70
+ }
71
+ return top.map((index) => paths[index]);
60
72
  }
61
73
  const scored = [];
62
74
  for (const path of paths) {
package/lib/fs.js CHANGED
@@ -381,6 +381,9 @@ function normalizePosixPath(path) {
381
381
 
382
382
  // src/host/domain/path-grammar.ts
383
383
  function normalizeRemotePath(path) {
384
+ if (/[\u0000-\u001f]/.test(path)) {
385
+ throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26", { path: JSON.stringify(path) });
386
+ }
384
387
  const normalized = normalizePosixPath(path);
385
388
  if (normalized !== void 0) return normalized;
386
389
  if (path === "") throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A", {});
@@ -542,6 +545,18 @@ function relativeDirectory(root, path) {
542
545
  function normalizeLocalPath2(path, options = {}) {
543
546
  return normalizeLocalPath(path, { caseInsensitive: options.caseInsensitive ?? process.platform === "win32" });
544
547
  }
548
+ function localPathContains(root, candidate, options = {}) {
549
+ const normalizedRoot = normalizeLocalPath2(root, options);
550
+ const normalizedCandidate = normalizeLocalPath2(candidate, options);
551
+ if (normalizedRoot === "/" || /^[a-z]:\/$/.test(normalizedRoot)) {
552
+ return normalizedCandidate.startsWith(normalizedRoot);
553
+ }
554
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
555
+ }
556
+
557
+ // src/host/app/roots/user-roots.ts
558
+ var USER_ROOTS_FILE_NAME = "user-roots.json";
559
+ var USER_ROOTS_LOCK_FILE_NAME = `${USER_ROOTS_FILE_NAME}.lock`;
545
560
 
546
561
  // src/shared/routes.ts
547
562
  function asRouteHost(value) {
@@ -823,6 +838,92 @@ function walkAncestors(ctx, sessionId) {
823
838
  }
824
839
  return { ancestors, settled };
825
840
  }
841
+ function readSessionCwd(ctx, sessionId) {
842
+ const cached = sessionCwdCache.get(sessionId);
843
+ if (cached !== void 0) {
844
+ sessionCwdCache.delete(sessionId);
845
+ sessionCwdCache.set(sessionId, cached);
846
+ return cached.value === "" ? void 0 : cached.value;
847
+ }
848
+ const found = walkSessionCwd(ctx, sessionId);
849
+ if (found.settled) {
850
+ sessionCwdCache.set(sessionId, { value: found.cwd ?? "" });
851
+ while (sessionCwdCache.size > SESSION_CWD_CACHE_MAX) {
852
+ const oldest = sessionCwdCache.keys().next();
853
+ if (oldest.done === true) break;
854
+ sessionCwdCache.delete(oldest.value);
855
+ }
856
+ }
857
+ return found.cwd;
858
+ }
859
+ function readSessionCwds(ctx) {
860
+ let list;
861
+ try {
862
+ const sessions = ctx.get("sessions");
863
+ list = sessions?.list?.();
864
+ } catch {
865
+ return [];
866
+ }
867
+ if (!Array.isArray(list)) return [];
868
+ const cwds = [];
869
+ for (const session of list) {
870
+ const cwd = session?.header?.cwd;
871
+ if (typeof cwd === "string" && cwd !== "") cwds.push(cwd);
872
+ }
873
+ return cwds;
874
+ }
875
+ var sessionCwdCache = /* @__PURE__ */ new Map();
876
+ var SESSION_CWD_CACHE_MAX = 256;
877
+ function walkSessionCwd(ctx, sessionId) {
878
+ let list;
879
+ try {
880
+ const sessions = ctx.get("sessions");
881
+ list = sessions?.list?.();
882
+ } catch {
883
+ return { cwd: void 0, settled: false };
884
+ }
885
+ if (!Array.isArray(list)) return { cwd: void 0, settled: false };
886
+ for (const session of list) {
887
+ if (session?.header?.id !== sessionId) continue;
888
+ const cwd = session.header.cwd;
889
+ return { cwd: typeof cwd === "string" && cwd !== "" ? cwd : void 0, settled: true };
890
+ }
891
+ return { cwd: void 0, settled: false };
892
+ }
893
+
894
+ // src/host/domain/roots/session-roots.ts
895
+ function workspaceWriteArea(root, workspaceRoot, cwd, identity = {}) {
896
+ const sessions = root.sessions ?? [];
897
+ if (sessions.length > 0 && identity.sessionId !== void 0 && identity.sessionId !== "") {
898
+ if (addedBySession(sessions, identity)) return "whole-root";
899
+ } else if (sessions.length === 0 && cwd !== void 0 && cwd !== "") {
900
+ if ((root.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, cwd))) return "whole-root";
901
+ }
902
+ if (workspaceRoot === void 0 || workspaceRoot === "") return "deny";
903
+ if (root.location === "remote") {
904
+ const anchor = root.anchorPath;
905
+ if (anchor === void 0 || anchor === "") return "deny";
906
+ if (sameWorkspacePath(anchor, workspaceRoot) || localPathContains(anchor, workspaceRoot)) return "whole-root";
907
+ if (localPathContains(workspaceRoot, anchor)) return "whole-root";
908
+ return "deny";
909
+ }
910
+ if (localPathContains(root.path, workspaceRoot)) return "workspace-only";
911
+ if (cwd !== void 0 && cwd !== "" && sameWorkspacePath(root.path, cwd)) return "workspace-only";
912
+ return "deny";
913
+ }
914
+ function addedBySession(sessions, identity) {
915
+ const sessionId = identity.sessionId;
916
+ if (sessionId === void 0 || sessionId === "") return false;
917
+ if (sessions.includes(sessionId)) return true;
918
+ return (identity.ancestors ?? []).some((ancestor) => sessions.includes(ancestor));
919
+ }
920
+ function sameWorkspacePath(left, right) {
921
+ const fold = (value) => {
922
+ const normalized = normalizeLocalPath2(value, { caseInsensitive: false }).replace(/\/+/g, "/");
923
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
924
+ };
925
+ return fold(left) === fold(right);
926
+ }
826
927
 
827
928
  // src/host/integration/fs/fs-provider.ts
828
929
  var MAX_WHOLE_READ_BYTES = 16 * 1024 * 1024;
@@ -1051,7 +1152,7 @@ var RoutedFsProvider = class extends FileSystem {
1051
1152
  }
1052
1153
  const guard = expected?.kind === "replaceIfVersion" ? expected.version : void 0;
1053
1154
  const outcome = await this.call(
1054
- () => routed.backend.writeText(routed.absolute, content, guard, signal)
1155
+ () => routed.backend.writeText(routed.absolute, content, guard, signal, sandboxPolicy)
1055
1156
  ).catch((error) => {
1056
1157
  throw mapWriteError(error, routed.displayPath);
1057
1158
  });
@@ -1096,7 +1197,8 @@ var RoutedFsProvider = class extends FileSystem {
1096
1197
  edit.newString,
1097
1198
  edit.replaceAll === true,
1098
1199
  chunk.version,
1099
- signal
1200
+ signal,
1201
+ sandboxPolicy
1100
1202
  )
1101
1203
  );
1102
1204
  return { version: FsVersion(native.version), before, after };
@@ -1107,7 +1209,7 @@ var RoutedFsProvider = class extends FileSystem {
1107
1209
  }
1108
1210
  }
1109
1211
  const outcome = await this.call(
1110
- () => routed.backend.writeText(routed.absolute, restoreLineEndings(after, lineEndings), chunk.version, signal)
1212
+ () => routed.backend.writeText(routed.absolute, restoreLineEndings(after, lineEndings), chunk.version, signal, sandboxPolicy)
1111
1213
  ).catch((error) => {
1112
1214
  throw mapWriteError(error, routed.displayPath);
1113
1215
  });
@@ -1236,7 +1338,24 @@ var RoutedFsProvider = class extends FileSystem {
1236
1338
  throw isSshDevError(error) ? toFsError(error) : error;
1237
1339
  }
1238
1340
  }
1239
- /** Enforce the per-call policy and the root's own writability. */
1341
+ /**
1342
+ * Enforce the per-call policy and the root's own writability.
1343
+ *
1344
+ * Three gates, in order:
1345
+ * 1. `read-only` denies every mutation (the platform's own contract);
1346
+ * 2. a root registered `writable: false` denies;
1347
+ * 3. under **`workspace-write`**, the target must be inside the session's workspace — where "workspace"
1348
+ * includes the directories **this session added** (product decision 2026-11-04: 「添加到会话的目录」=
1349
+ * 该目录临时成为 workspace 的一部分). Before that decision the plugin admitted every writable root it
1350
+ * could route, which made the mode wider than the platform's own `fs-sandbox` fence
1351
+ * (`workspace-write` = "canonicalizes under `workspaceRoot` or the platform temp area").
1352
+ *
1353
+ * A call without a per-call policy keeps the historical behaviour (the mode comes from
1354
+ * {@link sandboxMode}); the area rule needs `workspaceRoot`, which only the policy carries.
1355
+ *
1356
+ * @param routed - the resolved target.
1357
+ * @param policy - the per-call sandbox policy the tool layer resolved.
1358
+ */
1240
1359
  assertWritable(routed, policy) {
1241
1360
  const mode = policy?.mode ?? this.sandboxMode;
1242
1361
  if (mode === "read-only") {
@@ -1245,6 +1364,19 @@ var RoutedFsProvider = class extends FileSystem {
1245
1364
  if (routed.root.writable === false) {
1246
1365
  throw platformFsError(`the root of "${routed.displayPath}" is registered read-only`, "FS_SANDBOX_DENIED");
1247
1366
  }
1367
+ if (mode !== "workspace-write" || policy === void 0) return;
1368
+ const sessionId = currentSessionId();
1369
+ const identity = sessionId === void 0 || sessionId === "" ? {} : { sessionId, ancestors: this.sessionAncestors(sessionId) };
1370
+ const cwd = sessionId === void 0 || sessionId === "" ? void 0 : this.sessionWorkspaceCwdOf(sessionId);
1371
+ const area = workspaceWriteArea(routed.root, policy.workspaceRoot, cwd, identity);
1372
+ if (area === "whole-root") return;
1373
+ if (area === "workspace-only" && routed.root.location === "local" && localPathContains(policy.workspaceRoot, routed.absolute, { caseInsensitive: process.platform === "win32" })) {
1374
+ return;
1375
+ }
1376
+ throw platformFsError(
1377
+ `sandbox denies writing "${routed.displayPath}": the path is outside this session's workspace (its workspace, or a directory this session added with \u300C\u6DFB\u52A0\u76EE\u5F55\u300D)`,
1378
+ "FS_SANDBOX_DENIED"
1379
+ );
1248
1380
  }
1249
1381
  /**
1250
1382
  * Previous text content, when it is readable text inside the cap; `undefined` otherwise.
@@ -1260,9 +1392,8 @@ var RoutedFsProvider = class extends FileSystem {
1260
1392
  return void 0;
1261
1393
  });
1262
1394
  if (chunk === void 0 || chunk.truncated) return void 0;
1263
- const bytes = Buffer.from(chunk.text, "utf8");
1264
- const sniff = bytes.subarray(0, BINARY_SNIFF_BYTES);
1265
- if (sniff.includes(0)) return void 0;
1395
+ const nul = chunk.text.indexOf("\0");
1396
+ if (nul !== -1 && nul < BINARY_SNIFF_BYTES) return void 0;
1266
1397
  return chunk.text;
1267
1398
  }
1268
1399
  /** Run a backend call, translating domain errors into core codes. */
@@ -1352,38 +1483,6 @@ function sessionSyncSignature(cwds, registry) {
1352
1483
  const roots = registry.sessionWorkspaceRecords.map((record) => record.key).join("\0");
1353
1484
  return `${cwds.join("\0")}${roots}`;
1354
1485
  }
1355
- function readSessionCwd(ctx, sessionId) {
1356
- let list;
1357
- try {
1358
- const sessions = ctx.get("sessions");
1359
- list = sessions?.list?.();
1360
- } catch {
1361
- return void 0;
1362
- }
1363
- if (!Array.isArray(list)) return void 0;
1364
- for (const session of list) {
1365
- if (session?.header?.id !== sessionId) continue;
1366
- const cwd = session.header.cwd;
1367
- return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
1368
- }
1369
- return void 0;
1370
- }
1371
- function readSessionCwds(ctx) {
1372
- let list;
1373
- try {
1374
- const sessions = ctx.get("sessions");
1375
- list = sessions?.list?.();
1376
- } catch {
1377
- return [];
1378
- }
1379
- if (!Array.isArray(list)) return [];
1380
- const cwds = [];
1381
- for (const session of list) {
1382
- const cwd = session?.header?.cwd;
1383
- if (typeof cwd === "string" && cwd !== "") cwds.push(cwd);
1384
- }
1385
- return cwds;
1386
- }
1387
1486
  function assertText(displayPath, bytes) {
1388
1487
  if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {
1389
1488
  throw platformFsError(`cannot read "${displayPath}": not a UTF-8 text file`, "FS_NOT_TEXT");