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 +10 -8
- package/agents/agent-manifest.json +6 -6
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/lib/client.js +60 -24
- package/lib/file-references.js +20 -8
- package/lib/fs.js +138 -39
- package/lib/index.js +2367 -491
- package/lib/types/src/config.d.ts +76 -0
- package/lib/types/src/host/app/backend.d.ts +35 -2
- package/lib/types/src/host/domain/roots/session-roots.d.ts +23 -0
- package/lib/types/src/host/infra/transport/forward.d.ts +75 -0
- package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +5 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +26 -0
- package/lib/types/src/host/integration/fs/fs-provider.d.ts +18 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,11 +45,11 @@ shell:
|
|
|
45
45
|
|
|
46
46
|
| 平台 | 字节 | sha256 |
|
|
47
47
|
|---|---|---|
|
|
48
|
-
| linux-amd64 | 4,
|
|
49
|
-
| linux-arm64 | 4,653,216 | `
|
|
48
|
+
| linux-amd64 | 4,927,648 | `4f47307e4927e39972ebad9c2f25b3676e990ff1335b72ee33c3e8ec25a1e2cf` |
|
|
49
|
+
| linux-arm64 | 4,653,216 | `9c1895ca3310a8fe5aed13a281cbdd98fe95928c24848ba58fea509b5f7341e6` |
|
|
50
50
|
|
|
51
|
-
- 代理版本:`
|
|
52
|
-
- 源码提交:`
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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-
|
|
4
|
-
"agentVersion": "
|
|
5
|
-
"sourceCommit": "
|
|
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": "
|
|
11
|
-
"bytes":
|
|
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": "
|
|
16
|
+
"sha256": "9c1895ca3310a8fe5aed13a281cbdd98fe95928c24848ba58fea509b5f7341e6",
|
|
17
17
|
"bytes": 4653216
|
|
18
18
|
}
|
|
19
19
|
]
|
|
Binary file
|
|
Binary file
|
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
|
-
// --
|
|
3223
|
-
//
|
|
3224
|
-
//
|
|
3225
|
-
//
|
|
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)
|
|
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
|
-
...
|
|
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) => {
|
package/lib/file-references.js
CHANGED
|
@@ -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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
/**
|
|
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
|
|
1264
|
-
|
|
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");
|