dsh-extra-folders 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.en.md +131 -0
- package/README.md +130 -2
- package/assets/approval.png +0 -0
- package/assets/dialog.png +0 -0
- package/assets/menu.png +0 -0
- package/client.js +447 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +5 -0
- package/index.js +350 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kingOfSoySauce
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# dsh-extra-folders
|
|
2
|
+
|
|
3
|
+
When a project spans several folders, or you temporarily need to write outside the workspace, add those folders to the workspace. Sessions and subagents can then write them directly, without repeated approvals and without granting full access.
|
|
4
|
+
|
|
5
|
+
Before adding a folder, writing outside the workspace triggers an approval:
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
| Workspace menu | Dialog |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
|  |  |
|
|
12
|
+
|
|
13
|
+
## Install and update
|
|
14
|
+
|
|
15
|
+
DSH 0.2.2-alpha.3 or later.
|
|
16
|
+
|
|
17
|
+
### Option 1: Add from the UI
|
|
18
|
+
|
|
19
|
+
**Settings → Plugins → Add plugin**
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
dsh-extra-folders@latest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Option 2: Command line
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
dsh plugin --profile desktop add dsh-extra-folders@latest
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Option 3: Ask an agent for help
|
|
32
|
+
|
|
33
|
+
<details>
|
|
34
|
+
<summary><strong>Expand the install prompt</strong></summary>
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
Please install dsh-extra-folders into the current profile:
|
|
38
|
+
npm package dsh-extra-folders@latest (source https://github.com/kingOfSoySauce/dsh-extra-folders)
|
|
39
|
+
|
|
40
|
+
First, confirm three things in read-only mode: whether the host is Desktop or Web, the current profile name, and whether the dsh version is >= 0.2.2-alpha.3.
|
|
41
|
+
If the version is too low, tell me and do not try to install.
|
|
42
|
+
|
|
43
|
+
On Desktop, prefer Plugins → Add plugin and paste
|
|
44
|
+
dsh-extra-folders@latest
|
|
45
|
+
If the current host provides plugin management tools, you can also run
|
|
46
|
+
dsh plugin --profile <profile> add dsh-extra-folders@latest.
|
|
47
|
+
|
|
48
|
+
After install, check that the plugin row is enabled. If a restart is needed, tell me and I will restart it myself.
|
|
49
|
+
Do not edit the profile's package.json by hand, and do not touch other plugins.
|
|
50
|
+
When it is installed, take me to the more menu of a sidebar workspace row and confirm that it contains "Add folder".
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
</details>
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
<summary><strong>Expand the update prompt</strong></summary>
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
Please update dsh-extra-folders to the latest version:
|
|
60
|
+
npm package dsh-extra-folders@latest (source https://github.com/kingOfSoySauce/dsh-extra-folders)
|
|
61
|
+
|
|
62
|
+
First, confirm four things in read-only mode: whether the host is Desktop or Web, the current profile name, the installed version, and the latest version on npm.
|
|
63
|
+
If it is already the latest, tell me and do not reinstall.
|
|
64
|
+
|
|
65
|
+
On Desktop, prefer Plugins → Add plugin and add
|
|
66
|
+
dsh-extra-folders@latest
|
|
67
|
+
If the current host provides plugin management tools, you can also run
|
|
68
|
+
dsh plugin --profile <profile> add dsh-extra-folders@latest.
|
|
69
|
+
|
|
70
|
+
After the update, check that the plugin row is enabled. If a restart is needed, tell me and I will restart it myself.
|
|
71
|
+
Do not edit the profile's package.json by hand, and do not touch other plugins.
|
|
72
|
+
When it is updated, take me to the more menu of a sidebar workspace row and confirm that "Add folder" is there and the folders I added before are still listed.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
</details>
|
|
76
|
+
|
|
77
|
+
Whichever option you use, if the version still looks old after updating, turn the plugin row off and on in Plugins.
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Sidebar workspace row → the more button at the end of the row → **Add folder**.
|
|
82
|
+
|
|
83
|
+
The first row in the dialog is the workspace itself. It is always writable and cannot be removed. The rows below are the folders you added. You can remove each one on its own. Adding and removing take effect right away. No refresh, and no need to wait for the next session.
|
|
84
|
+
|
|
85
|
+
The grant follows the workspace. Subagents are covered too. Sessions in other workspaces do not see these folders.
|
|
86
|
+
|
|
87
|
+
## Strengths
|
|
88
|
+
|
|
89
|
+
- **Native UI**: the dialog and buttons belong to the host, opened from the workspace row menu. Colors and fonts follow the host, so appearance plugins are naturally compatible.
|
|
90
|
+
- **No new tools**: the model sees the same toolset as before, and prefix-cache hits are unaffected.
|
|
91
|
+
- **No prompt text**: the plugin writes nothing into the context. DSH's own writable-directories note is updated at the end of the session history when folders change; earlier prefixes stay put.
|
|
92
|
+
- **No kernel patch**: the filesystem and the sandbox are untouched. One resolve method is wrapped, and disabling the plugin restores stock behavior.
|
|
93
|
+
- **Scoped grant**: grants are stored per workspace; other workspaces do not see these folders. Folders can only be added or removed from the UI, and the model has no way to widen its own access.
|
|
94
|
+
- **Zero dependencies**: one host file, one client file, no build step, no third-party packages.
|
|
95
|
+
|
|
96
|
+
How grants work, where state lives, and what does not apply: see [Design notes](https://github.com/kingOfSoySauce/dsh-extra-folders/blob/main/DESIGN.en.md).
|
|
97
|
+
|
|
98
|
+
## Compatibility
|
|
99
|
+
|
|
100
|
+
| Environment | Status |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| macOS desktop 0.2.2-rc.1 | Verified |
|
|
103
|
+
| DSH 0.2.2-alpha.3 and later | Supported |
|
|
104
|
+
| DSH 0.2.1 and earlier | Not supported. The kernel has no `additionalWritableRoots` and no workspace menu service |
|
|
105
|
+
| Web (`dsh web`) | Same code as desktop. The folder picker is replaced by in-app browsing. Not tested |
|
|
106
|
+
| Windows | Applies to the file tools only. The sandboxed shell is not affected. This limit comes from DSH. Not tested |
|
|
107
|
+
|
|
108
|
+
The kernel is checked when the host starts. If the kernel is not supported, the plugin writes one clear log line and stops.
|
|
109
|
+
|
|
110
|
+
## Uninstall
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
dsh plugin --profile desktop remove dsh-extra-folders
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
You can also remove it directly in Plugins.
|
|
117
|
+
|
|
118
|
+
## Troubleshooting
|
|
119
|
+
|
|
120
|
+
| Symptom | What to do |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| No "Add folder" in the menu | Check that the plugin row is on and refresh the page once. dsh below 0.2.2-alpha.3 does not have this item |
|
|
123
|
+
| I added a folder but it still asks for approval | Check whether the session permission is `read-only`. Attached folders do not apply in that mode |
|
|
124
|
+
| An old session still asks for approval, or asks first whether to request it | Start a new session. The approval habit from earlier turns carries over. A new grant does not rewrite the pattern already set in that conversation |
|
|
125
|
+
| I picked a folder but it was not added | The plugin only accepts real directories. A symlink is rejected, so pick the path it points to |
|
|
126
|
+
| bash cannot write on Windows | DSH only grants attached folders to the file tools. This is a DSH limit and the plugin cannot change it |
|
|
127
|
+
| On Web, the dialog opens but there is no system picker | On Web, in-app browsing replaces the system dialog |
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DSH工作区添加文件夹插件
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
项目跨了几个文件夹,或者临时要访问工作区外的文件时,把文件夹加进工作区。之后工作区里的会话和子 agent 都能直接写,不用再逐次点授权,也不用放开全部权限。
|
|
4
|
+
|
|
5
|
+
加文件夹之前,写工作区外的文件会触发审批:
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
| 工作区菜单 | 对话框 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
|  |  |
|
|
12
|
+
|
|
13
|
+
## 安装、更新
|
|
14
|
+
|
|
15
|
+
DSH 0.2.2-alpha.3 及以上。
|
|
16
|
+
|
|
17
|
+
### 方式一:界面添加
|
|
18
|
+
|
|
19
|
+
**插件 → 添加插件**
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
dsh-extra-folders@latest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 方式二:命令行
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
dsh plugin --profile desktop add dsh-extra-folders@latest
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 方式三:Agent 帮忙
|
|
32
|
+
|
|
33
|
+
<details>
|
|
34
|
+
<summary><strong>展开安装提示词</strong></summary>
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
请帮我把 dsh-extra-folders 装到当前 profile:
|
|
38
|
+
npm 包 dsh-extra-folders@latest(源码 https://github.com/kingOfSoySauce/dsh-extra-folders)
|
|
39
|
+
|
|
40
|
+
先只读确认三件事:宿主是桌面端还是 Web、当前 profile 名、dsh 版本是否 >= 0.2.2-alpha.3。
|
|
41
|
+
版本不够就直接告诉我,不要尝试安装。
|
|
42
|
+
|
|
43
|
+
桌面端优先用 Plugins → Add plugin,粘贴
|
|
44
|
+
dsh-extra-folders@latest
|
|
45
|
+
如果当前宿主提供了插件管理工具,也可以执行
|
|
46
|
+
dsh plugin --profile <profile> add dsh-extra-folders@latest。
|
|
47
|
+
|
|
48
|
+
装完核对插件行是启用状态。需要重启时告诉我,由我自己重启。
|
|
49
|
+
不要手改 profile 的 package.json,不要动其他插件。
|
|
50
|
+
装好后带我到侧栏工作区行的更多菜单,确认里面有「添加文件夹」。
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
</details>
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
<summary><strong>展开更新提示词</strong></summary>
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
请帮我把 dsh-extra-folders 更新到最新版:
|
|
60
|
+
npm 包 dsh-extra-folders@latest(源码 https://github.com/kingOfSoySauce/dsh-extra-folders)
|
|
61
|
+
|
|
62
|
+
先只读确认四件事:宿主是桌面端还是 Web、当前 profile 名、已装版本、npm 上的最新版本。
|
|
63
|
+
已经是最新就直接告诉我,不要重装。
|
|
64
|
+
|
|
65
|
+
桌面端优先用 Plugins → Add plugin 重新添加
|
|
66
|
+
dsh-extra-folders@latest
|
|
67
|
+
如果当前宿主提供了插件管理工具,也可以执行
|
|
68
|
+
dsh plugin --profile <profile> add dsh-extra-folders@latest。
|
|
69
|
+
|
|
70
|
+
更新完核对插件行是启用状态。需要重启时告诉我,由我自己重启。
|
|
71
|
+
不要手改 profile 的 package.json,不要动其他插件。
|
|
72
|
+
更新后带我到侧栏工作区行的更多菜单,确认「添加文件夹」还在,之前加的文件夹也还在。
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
</details>
|
|
76
|
+
|
|
77
|
+
不管用哪种方式,更新后如果版本号没变,在 Plugins 里把插件行关掉再打开。
|
|
78
|
+
|
|
79
|
+
## 使用
|
|
80
|
+
|
|
81
|
+
侧栏工作区行 → 行尾的更多按钮 → **添加文件夹**。
|
|
82
|
+
|
|
83
|
+
弹框第一行是工作区本身,它一直可写,不能移除。下面是你添加的文件夹,可以单独移除。添加和移除都立即生效,不用刷新,也不用等下一个会话。
|
|
84
|
+
|
|
85
|
+
授权跟着工作区走。子 agent 也算在内,其他工作区的会话看不到这些文件夹。
|
|
86
|
+
|
|
87
|
+
## 优点
|
|
88
|
+
|
|
89
|
+
- **原生界面**:弹框和按钮都是官方的,入口在工作区行的更多菜单里。颜色、字体这些全跟宿主走,天然兼容各种外观插件。
|
|
90
|
+
- **不加工具**:模型看到的工具和没装时一样,不影响前缀缓存命中。
|
|
91
|
+
- **零提示词**:插件不往上下文里写东西。提示词和工具说明都没有,连进度提示也不写。DSH 自己的策略说明会跟着目录更新末尾那一条,前面的前缀不动。
|
|
92
|
+
- **不换内核**:没换文件系统,也没换沙箱。只包了权限解析那一个方法,插件一关就回到原生行为。
|
|
93
|
+
- **范围可控**:授权按工作区存,别的工作区看不到;加和删只能从界面点,模型没有扩权入口。
|
|
94
|
+
- **零依赖**:宿主一个文件,界面一个文件,不用构建,也没有第三方包。
|
|
95
|
+
|
|
96
|
+
怎么授权、状态放哪,见 [设计说明](https://github.com/kingOfSoySauce/dsh-extra-folders/blob/main/DESIGN.md)。不适用的场景也写在那里。
|
|
97
|
+
|
|
98
|
+
## 兼容性
|
|
99
|
+
|
|
100
|
+
| 环境 | 状态 |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| macOS 桌面端 0.2.2-rc.1 | 已验证 |
|
|
103
|
+
| DSH 0.2.2-alpha.3 及以上 | 支持 |
|
|
104
|
+
| DSH 0.2.1 及更早 | 不支持。内核没有 `additionalWritableRoots`,也没有工作区菜单服务 |
|
|
105
|
+
| Web(`dsh web`) | 代码与桌面端相同,文件夹选择器换成应用内浏览。未实测 |
|
|
106
|
+
| Windows | 只对文件工具生效,沙箱里的 shell 不受影响。这条限制来自 DSH 本身,未实测 |
|
|
107
|
+
|
|
108
|
+
宿主启动时会检查内核。不支持就写一条明确日志并退出。
|
|
109
|
+
|
|
110
|
+
## 卸载
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
dsh plugin --profile desktop remove dsh-extra-folders
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
也可以在 Plugins 里直接移除。
|
|
117
|
+
|
|
118
|
+
## 遇到问题
|
|
119
|
+
|
|
120
|
+
| 现象 | 处理 |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| 菜单里没有「添加文件夹」 | 确认插件行开着,刷新一次页面;dsh 低于 0.2.2-alpha.3 不会有这一项 |
|
|
123
|
+
| 加了文件夹还是弹授权 | 看会话权限是不是 `read-only`,这个模式下附加文件夹不生效 |
|
|
124
|
+
| 老会话还在弹授权,或者先问一句要不要申请 | 新开一个会话。之前几轮的审批习惯会延续下来,新加的授权不会改写已经形成的对话惯性 |
|
|
125
|
+
| 选了文件夹却没加上 | 插件只收真实目录,选了软链接会被拒绝,选它指向的路径 |
|
|
126
|
+
| Windows 上 bash 写不进去 | DSH 只给文件工具放行附加目录,这是 DSH 的限制,插件改不了 |
|
|
127
|
+
| Web 端点开但没有系统选择器 | Web 端由应用内浏览代替系统对话框 |
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
Binary file
|
|
Binary file
|
package/assets/menu.png
ADDED
|
Binary file
|
package/client.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-extra-folders',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
/** The official controls: Modal and Button are seed modules every plugin may require. */
|
|
7
|
+
let primitives
|
|
8
|
+
try {
|
|
9
|
+
primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
10
|
+
} catch {
|
|
11
|
+
primitives = undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ROUTE = '/extra-folders'
|
|
15
|
+
const DIALOG_CLASS = 'wxf-dialog'
|
|
16
|
+
const ICON_KEY = 'workspace-extra-folders.folder'
|
|
17
|
+
/** After 打开文件夹 and 在 Finder 中显示, before 重命名 (100) and 删除 (300). */
|
|
18
|
+
const MENU_ORDER = 150
|
|
19
|
+
|
|
20
|
+
const COPY = {
|
|
21
|
+
zh: {
|
|
22
|
+
menu: '添加文件夹',
|
|
23
|
+
title: '工作区添加文件夹',
|
|
24
|
+
add: '添加文件夹',
|
|
25
|
+
cancel: '取消',
|
|
26
|
+
close: '关闭',
|
|
27
|
+
empty: '还没有文件夹',
|
|
28
|
+
remove: '移除',
|
|
29
|
+
missing: '文件夹已不存在',
|
|
30
|
+
failed: '没保存成功,再试一次',
|
|
31
|
+
pickerUnavailable: '这里打不开系统文件夹选择器',
|
|
32
|
+
root: '工作区',
|
|
33
|
+
codes: {
|
|
34
|
+
notWorkspace: '这个目录不是侧栏里的工作区',
|
|
35
|
+
duplicate: '这个文件夹已经加过了',
|
|
36
|
+
notDirectory: '选中的不是文件夹',
|
|
37
|
+
missing: '文件夹不见了,可能刚被删掉',
|
|
38
|
+
symlink: '这是软链接,请选它指向的真实路径',
|
|
39
|
+
self: '这就是工作区本身',
|
|
40
|
+
unknown: '没保存成功,再试一次'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
en: {
|
|
44
|
+
menu: 'Add folder',
|
|
45
|
+
title: 'Add workspace folders',
|
|
46
|
+
add: 'Add folder',
|
|
47
|
+
cancel: 'Cancel',
|
|
48
|
+
close: 'Close',
|
|
49
|
+
empty: 'No folders yet',
|
|
50
|
+
remove: 'Remove',
|
|
51
|
+
missing: 'Folder is gone',
|
|
52
|
+
failed: "Didn't save. Try again",
|
|
53
|
+
pickerUnavailable: 'The system folder picker is not available here',
|
|
54
|
+
root: 'Workspace',
|
|
55
|
+
codes: {
|
|
56
|
+
notWorkspace: 'That directory is not a workspace in the sidebar',
|
|
57
|
+
duplicate: 'That folder is already attached',
|
|
58
|
+
notDirectory: 'That is not a folder',
|
|
59
|
+
missing: 'That folder is gone',
|
|
60
|
+
symlink: 'That is a symlink. Pick the real path it points to.',
|
|
61
|
+
self: 'That is the workspace itself',
|
|
62
|
+
unknown: "Didn't save. Try again"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Captured Client context so components can reach services lazily. */
|
|
68
|
+
let pluginContext
|
|
69
|
+
|
|
70
|
+
/** One dictionary per render, picked from the live locale. Never mixed. */
|
|
71
|
+
function copy() {
|
|
72
|
+
try {
|
|
73
|
+
const snapshot = pluginContext?.get?.('locale')?.getLocale?.()
|
|
74
|
+
const id = typeof snapshot?.active === 'string' ? snapshot.active : snapshot?.id
|
|
75
|
+
return COPY[typeof id === 'string' && id.startsWith('zh') ? 'zh' : 'en'] ?? COPY.en
|
|
76
|
+
} catch {
|
|
77
|
+
return COPY.en
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One translated sentence per failure code. */
|
|
82
|
+
function describe(failure) {
|
|
83
|
+
const dict = copy()
|
|
84
|
+
const code = typeof failure?.code === 'string' ? failure.code : 'unknown'
|
|
85
|
+
return dict.codes[code] ?? dict.failed
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function callRoute(endpoint, payload) {
|
|
89
|
+
if (typeof location === 'undefined') throw new Error('the workspace route needs the browser GUI')
|
|
90
|
+
const url = new URL(`${ROUTE}/${endpoint}`, location.href).toString()
|
|
91
|
+
const response = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
credentials: 'same-origin',
|
|
94
|
+
headers: { 'content-type': 'application/json' },
|
|
95
|
+
body: JSON.stringify(payload ?? {})
|
|
96
|
+
})
|
|
97
|
+
const text = await response.text()
|
|
98
|
+
let data
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(text)
|
|
101
|
+
} catch {
|
|
102
|
+
data = undefined
|
|
103
|
+
}
|
|
104
|
+
const code = typeof data?.error === 'string' ? data.error : 'unknown'
|
|
105
|
+
if (!response.ok || typeof data?.error === 'string') {
|
|
106
|
+
const failure = new Error(code)
|
|
107
|
+
failure.code = code
|
|
108
|
+
throw failure
|
|
109
|
+
}
|
|
110
|
+
return data
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -- one open dialog at a time -----------------------------------------
|
|
114
|
+
let openRequest = null
|
|
115
|
+
const listeners = new Set()
|
|
116
|
+
function publish(next) {
|
|
117
|
+
openRequest = next
|
|
118
|
+
for (const listener of [...listeners]) {
|
|
119
|
+
try {
|
|
120
|
+
listener()
|
|
121
|
+
} catch {
|
|
122
|
+
// one broken listener must not block the others
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function subscribe(listener) {
|
|
127
|
+
listeners.add(listener)
|
|
128
|
+
return () => {
|
|
129
|
+
listeners.delete(listener)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function useRequest() {
|
|
133
|
+
return React.useSyncExternalStore(subscribe, () => openRequest, () => openRequest)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Rows copy the models page: one bordered card per entry, actions at the right edge.
|
|
137
|
+
const styles = {
|
|
138
|
+
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
|
139
|
+
row: {
|
|
140
|
+
display: 'flex',
|
|
141
|
+
alignItems: 'center',
|
|
142
|
+
gap: '12px',
|
|
143
|
+
minHeight: '52px',
|
|
144
|
+
padding: '0 16px',
|
|
145
|
+
borderRadius: '12px',
|
|
146
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
147
|
+
background: 'transparent'
|
|
148
|
+
},
|
|
149
|
+
rowPath: { flex: '1 1 auto', minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', direction: 'ltr', fontSize: '14px' },
|
|
150
|
+
remove: {
|
|
151
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
152
|
+
background: 'transparent',
|
|
153
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
154
|
+
borderRadius: '8px',
|
|
155
|
+
padding: '4px 12px',
|
|
156
|
+
cursor: 'pointer',
|
|
157
|
+
font: 'inherit',
|
|
158
|
+
fontSize: '13px'
|
|
159
|
+
},
|
|
160
|
+
tag: { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', flex: '0 0 auto' },
|
|
161
|
+
missing: { color: 'var(--dsw-alias-state-warn-primary)', fontSize: '12px' },
|
|
162
|
+
error: { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', lineHeight: '1.5' },
|
|
163
|
+
addRow: {
|
|
164
|
+
display: 'flex',
|
|
165
|
+
alignItems: 'center',
|
|
166
|
+
justifyContent: 'center',
|
|
167
|
+
gap: '8px',
|
|
168
|
+
minHeight: '52px',
|
|
169
|
+
width: '100%',
|
|
170
|
+
borderRadius: '12px',
|
|
171
|
+
border: '1px dashed var(--dsw-alias-border-l2)',
|
|
172
|
+
background: 'transparent',
|
|
173
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
174
|
+
cursor: 'pointer',
|
|
175
|
+
font: 'inherit',
|
|
176
|
+
fontSize: '14px'
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function FolderGlyph() {
|
|
181
|
+
return h('svg', {
|
|
182
|
+
width: 14,
|
|
183
|
+
height: 14,
|
|
184
|
+
viewBox: '0 0 16 16',
|
|
185
|
+
fill: 'none',
|
|
186
|
+
stroke: 'currentColor',
|
|
187
|
+
strokeWidth: 1.3,
|
|
188
|
+
strokeLinecap: 'round',
|
|
189
|
+
strokeLinejoin: 'round',
|
|
190
|
+
'aria-hidden': true
|
|
191
|
+
}, [
|
|
192
|
+
h('path', { key: 'folder', d: 'M1.5 3.5C1.5 2.94772 1.94772 2.5 2.5 2.5H6.5L8 4.5H13.5C14.0523 4.5 14.5 4.94772 14.5 5.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V3.5Z' }),
|
|
193
|
+
h('path', { key: 'plus', d: 'M8 7.5V11.5M6 9.5H10' })
|
|
194
|
+
])
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One workspace's attached folders, rendered inside the official Modal. */
|
|
198
|
+
function FolderDialog({ request, onClose }) {
|
|
199
|
+
const text = copy()
|
|
200
|
+
const workspace = typeof request.path === 'string' ? request.path : ''
|
|
201
|
+
const title = typeof request.title === 'string' && request.title !== '' ? request.title : workspace
|
|
202
|
+
const [folders, setFolders] = React.useState([])
|
|
203
|
+
const [missing, setMissing] = React.useState([])
|
|
204
|
+
const [busy, setBusy] = React.useState(false)
|
|
205
|
+
const [error, setError] = React.useState('')
|
|
206
|
+
|
|
207
|
+
const load = React.useCallback(async () => {
|
|
208
|
+
const data = await callRoute('list', { workspace })
|
|
209
|
+
setFolders(Array.isArray(data?.folders) ? data.folders : [])
|
|
210
|
+
setMissing(Array.isArray(data?.missing) ? data.missing : [])
|
|
211
|
+
}, [workspace])
|
|
212
|
+
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
load().catch((failure) => setError(describe(failure)))
|
|
215
|
+
}, [load])
|
|
216
|
+
|
|
217
|
+
const add = async () => {
|
|
218
|
+
setError('')
|
|
219
|
+
const ui = pluginContext?.get?.('uiWorkspace')
|
|
220
|
+
if (typeof ui?.pickDirectory !== 'function') {
|
|
221
|
+
setError(text.pickerUnavailable)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
let picked
|
|
225
|
+
try {
|
|
226
|
+
picked = await ui.pickDirectory()
|
|
227
|
+
} catch (failure) {
|
|
228
|
+
setError(describe(failure))
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (typeof picked !== 'string' || picked === '') return
|
|
232
|
+
setBusy(true)
|
|
233
|
+
try {
|
|
234
|
+
await callRoute('add', { workspace, path: picked })
|
|
235
|
+
await load()
|
|
236
|
+
} catch (failure) {
|
|
237
|
+
setError(describe(failure))
|
|
238
|
+
} finally {
|
|
239
|
+
setBusy(false)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const remove = async (folder) => {
|
|
244
|
+
setError('')
|
|
245
|
+
setBusy(true)
|
|
246
|
+
try {
|
|
247
|
+
await callRoute('remove', { workspace, path: folder })
|
|
248
|
+
await load()
|
|
249
|
+
} catch (failure) {
|
|
250
|
+
setError(describe(failure))
|
|
251
|
+
} finally {
|
|
252
|
+
setBusy(false)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The workspace itself always comes first and cannot be removed. */
|
|
257
|
+
const rows = [
|
|
258
|
+
{ path: workspace, removable: false },
|
|
259
|
+
...folders.map((folder) => ({ path: folder, removable: true }))
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
const body = h('div', { style: styles.list },
|
|
263
|
+
rows.map((row) => h('div', { key: row.path, style: styles.row }, [
|
|
264
|
+
h('span', { key: 'path', style: styles.rowPath, title: row.path }, row.path),
|
|
265
|
+
row.removable ? null : h('span', { key: 'tag', style: styles.tag }, text.root),
|
|
266
|
+
row.removable && missing.includes(row.path) ? h('span', { key: 'missing', style: styles.missing }, text.missing) : null,
|
|
267
|
+
row.removable
|
|
268
|
+
? h('button', {
|
|
269
|
+
key: 'remove',
|
|
270
|
+
type: 'button',
|
|
271
|
+
style: styles.remove,
|
|
272
|
+
disabled: busy,
|
|
273
|
+
onClick: () => {
|
|
274
|
+
void remove(row.path)
|
|
275
|
+
}
|
|
276
|
+
}, text.remove)
|
|
277
|
+
: null
|
|
278
|
+
])))
|
|
279
|
+
|
|
280
|
+
const Modal = primitives?.Modal
|
|
281
|
+
const Button = primitives?.Button
|
|
282
|
+
/** forwardRef and memo components are objects, so never test `typeof x === 'function'` here. */
|
|
283
|
+
const official = Modal !== undefined && Modal !== null && Button !== undefined && Button !== null
|
|
284
|
+
const addRow = h('button', {
|
|
285
|
+
key: 'add',
|
|
286
|
+
type: 'button',
|
|
287
|
+
style: { ...styles.addRow, opacity: busy ? 0.6 : 1 },
|
|
288
|
+
disabled: busy,
|
|
289
|
+
onClick: () => {
|
|
290
|
+
void add()
|
|
291
|
+
}
|
|
292
|
+
}, [
|
|
293
|
+
h('svg', { key: 'plus', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', 'aria-hidden': true }, [
|
|
294
|
+
h('path', { key: 'h', d: 'M8 3.5v9' }),
|
|
295
|
+
h('path', { key: 'v', d: 'M3.5 8h9' })
|
|
296
|
+
]),
|
|
297
|
+
h('span', { key: 'label' }, text.add)
|
|
298
|
+
])
|
|
299
|
+
|
|
300
|
+
if (official) {
|
|
301
|
+
return h(Modal, {
|
|
302
|
+
open: true,
|
|
303
|
+
onClose,
|
|
304
|
+
closeLabel: text.close,
|
|
305
|
+
title: text.title,
|
|
306
|
+
className: DIALOG_CLASS,
|
|
307
|
+
// Both actions stay right-aligned inside the Modal's own footer container,
|
|
308
|
+
// the way the rename and delete dialogs place theirs.
|
|
309
|
+
footer: h(React.Fragment, null,
|
|
310
|
+
h(Button, { key: 'close', variant: 'outline', onClick: onClose }, text.close),
|
|
311
|
+
h(Button, { key: 'add', variant: 'primary', disabled: busy, onClick: () => { void add() } }, text.add)),
|
|
312
|
+
children: h('div', { style: { display: 'flex', flexDirection: 'column', gap: '16px' } }, [
|
|
313
|
+
body,
|
|
314
|
+
error === '' ? null : h('div', { key: 'error', role: 'alert', style: styles.error }, error)
|
|
315
|
+
])
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Older deployments without the primitives seed keep a usable panel.
|
|
320
|
+
return h('div', {
|
|
321
|
+
role: 'dialog',
|
|
322
|
+
'aria-modal': 'true',
|
|
323
|
+
'aria-label': text.title,
|
|
324
|
+
style: {
|
|
325
|
+
position: 'fixed',
|
|
326
|
+
inset: '0',
|
|
327
|
+
display: 'flex',
|
|
328
|
+
alignItems: 'center',
|
|
329
|
+
justifyContent: 'center',
|
|
330
|
+
background: 'color-mix(in srgb, var(--dsw-alias-bg-base) 55%, transparent)',
|
|
331
|
+
zIndex: 60
|
|
332
|
+
}
|
|
333
|
+
}, h('div', {
|
|
334
|
+
style: {
|
|
335
|
+
width: 'min(520px, calc(100vw - 32px))',
|
|
336
|
+
display: 'flex',
|
|
337
|
+
flexDirection: 'column',
|
|
338
|
+
gap: '12px',
|
|
339
|
+
padding: '20px',
|
|
340
|
+
borderRadius: '16px',
|
|
341
|
+
background: 'var(--dsw-alias-bg-overlay)',
|
|
342
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
343
|
+
color: 'var(--dsw-alias-label-primary)'
|
|
344
|
+
}
|
|
345
|
+
}, [
|
|
346
|
+
body,
|
|
347
|
+
error === '' ? null : h('div', { key: 'error', style: styles.error }, error),
|
|
348
|
+
addRow,
|
|
349
|
+
h('button', {
|
|
350
|
+
key: 'close',
|
|
351
|
+
type: 'button',
|
|
352
|
+
onClick: onClose,
|
|
353
|
+
style: { alignSelf: 'flex-end', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-primary)', borderRadius: '10px', padding: '6px 14px', cursor: 'pointer', font: 'inherit' }
|
|
354
|
+
}, text.close)
|
|
355
|
+
]))
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A broken dialog must not take the overlay layer with it. */
|
|
359
|
+
class OverlayBoundary extends React.Component {
|
|
360
|
+
constructor(props) {
|
|
361
|
+
super(props)
|
|
362
|
+
this.state = { failed: false }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
static getDerivedStateFromError() {
|
|
366
|
+
return { failed: true }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
componentDidCatch(error) {
|
|
370
|
+
console.error('dsh-extra-folders: dialog failed', error)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
render() {
|
|
374
|
+
return this.state.failed ? null : this.props.children
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function OverlayHost() {
|
|
379
|
+
const request = useRequest()
|
|
380
|
+
if (request === null) return null
|
|
381
|
+
return h(OverlayBoundary, null, h(FolderDialog, {
|
|
382
|
+
key: request.path,
|
|
383
|
+
request,
|
|
384
|
+
onClose: () => publish(null)
|
|
385
|
+
}))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The workspace row's own more menu: the action list this plugin contributes.
|
|
390
|
+
* The menu owns rendering, so an action needs a label, an optional icon key,
|
|
391
|
+
* and what to run.
|
|
392
|
+
*/
|
|
393
|
+
function registerWorkspaceAction(ctx) {
|
|
394
|
+
ctx.inject(['contextMenus'], (scope) => {
|
|
395
|
+
scope.effect(() => scope.contextMenus.register({
|
|
396
|
+
id: 'workspace-extra-folders.attach',
|
|
397
|
+
target: 'workspace',
|
|
398
|
+
order: MENU_ORDER,
|
|
399
|
+
observe: () => {
|
|
400
|
+
const locale = ctx.get('locale')
|
|
401
|
+
return locale !== undefined && typeof locale.subscribe === 'function' ? [locale] : []
|
|
402
|
+
},
|
|
403
|
+
resolve: (target) => [{
|
|
404
|
+
id: 'attach-folders',
|
|
405
|
+
label: copy().menu,
|
|
406
|
+
icon: { key: ICON_KEY },
|
|
407
|
+
run: () => {
|
|
408
|
+
publish({ path: target?.path, title: target?.displayTitle })
|
|
409
|
+
}
|
|
410
|
+
}]
|
|
411
|
+
}), 'dsh-extra-folders: workspace menu action')
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
inject: ['slots'],
|
|
417
|
+
apply(ctx) {
|
|
418
|
+
pluginContext = ctx
|
|
419
|
+
try {
|
|
420
|
+
ctx.effect(() => {
|
|
421
|
+
const style = document.createElement('style')
|
|
422
|
+
style.dataset.plugin = 'workspace-extra-folders'
|
|
423
|
+
style.textContent = `.${DIALOG_CLASS}{width:max-content;min-width:min(380px,calc(100vw - 32px));max-width:min(680px,calc(100vw - 32px))}`
|
|
424
|
+
document.head.append(style)
|
|
425
|
+
return () => style.remove()
|
|
426
|
+
}, 'dsh-extra-folders: dialog width')
|
|
427
|
+
|
|
428
|
+
ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({
|
|
429
|
+
name: 'contextMenu.icon',
|
|
430
|
+
key: ICON_KEY
|
|
431
|
+
}, FolderGlyph))
|
|
432
|
+
|
|
433
|
+
registerWorkspaceAction(ctx)
|
|
434
|
+
|
|
435
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
436
|
+
name: 'shell.overlay',
|
|
437
|
+
id: 'workspace-extra-folders.dialog',
|
|
438
|
+
order: 20
|
|
439
|
+
}, OverlayHost))
|
|
440
|
+
} catch (error) {
|
|
441
|
+
// A deployment without this plugin's slots must keep its boot.
|
|
442
|
+
console.error('dsh-extra-folders: client half could not install', error)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Workspace extra folders">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
|
|
3
|
+
<path d="M14 22.5c0-1.38 1.12-2.5 2.5-2.5h10l4 4h17c1.38 0 2.5 1.12 2.5 2.5v18c0 1.38-1.12 2.5-2.5 2.5h-31A2.5 2.5 0 0 1 14 44.5v-22Z" fill="#ffffff" fill-opacity="0.92"/>
|
|
4
|
+
<path d="M32 31v10M27 36h10" stroke="#1f6feb" stroke-width="3.2" stroke-linecap="round"/>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace extra folders — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Attach additional writable directories to ONE workspace. The list is durable
|
|
5
|
+
* per canonical workspace path, is managed only from the Web UI (the model has
|
|
6
|
+
* no write path), and is granted per capability call by wrapping
|
|
7
|
+
* `ctx.sandboxPolicy.resolve()`: the resolved policy carries the calling
|
|
8
|
+
* Session's workspace folders in `additionalWritableRoots`, which the shared
|
|
9
|
+
* `writableRoots()` derivation in `@deepseek-ai/dsh-sandbox` turns into the
|
|
10
|
+
* allow-list used by BOTH the filesystem fence and the Seatbelt/bwrap/Landlock
|
|
11
|
+
* profiles. Sessions in other workspaces never see them.
|
|
12
|
+
*
|
|
13
|
+
* The UI talks to this half over a loopback route fenced by
|
|
14
|
+
* `ctx.connection.requestRejection()`, the same authenticated-peer check the
|
|
15
|
+
* `/api` routes use: a local process without a live browser Peer is refused.
|
|
16
|
+
*
|
|
17
|
+
* Failures travel back as stable CODES, never as English sentences: the Client
|
|
18
|
+
* half owns every user-visible sentence, so one workspace's error text cannot
|
|
19
|
+
* arrive in the wrong language.
|
|
20
|
+
*
|
|
21
|
+
* The plugin contributes NO prompt text of its own. DSH's sandbox-policy note
|
|
22
|
+
* already renders `Additional writable directories: [...]` from the resolved
|
|
23
|
+
* policy, so a second note would only duplicate tokens. The policy note is
|
|
24
|
+
* projected as a tail entry of the session history and de-duplicated while the
|
|
25
|
+
* list holds, so adding or removing a folder changes the tail only. It never
|
|
26
|
+
* rewrites the system prompt or an earlier prefix, which keeps the provider's
|
|
27
|
+
* prefix cache intact.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-extra-folders
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs'
|
|
33
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
34
|
+
import { homedir } from 'node:os'
|
|
35
|
+
import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path'
|
|
36
|
+
|
|
37
|
+
/** Cordis plugin name. */
|
|
38
|
+
export const name = 'workspace-extra-folders'
|
|
39
|
+
|
|
40
|
+
/** Loopback JSON route the Client half calls. */
|
|
41
|
+
const ROUTE = '/extra-folders'
|
|
42
|
+
/** Durable state format. */
|
|
43
|
+
const STATE_VERSION = 1
|
|
44
|
+
/** Bound on a request body; the route only carries two paths. */
|
|
45
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
46
|
+
|
|
47
|
+
/** The Harness home that owns plugin state. */
|
|
48
|
+
function dshHome() {
|
|
49
|
+
const configured = process.env.DSH_HOME
|
|
50
|
+
return typeof configured === 'string' && configured !== '' ? configured : join(homedir(), '.dsh')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An error carrying a code the Client half translates. */
|
|
54
|
+
function fail(code) {
|
|
55
|
+
const error = new Error(code)
|
|
56
|
+
error.code = code
|
|
57
|
+
return error
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Canonical identity of a path, tolerant of a path that disappeared. */
|
|
61
|
+
function canonical(path) {
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync(path)
|
|
64
|
+
} catch {
|
|
65
|
+
return resolvePath(path)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether a path is an existing directory. */
|
|
70
|
+
function isDirectory(target) {
|
|
71
|
+
try {
|
|
72
|
+
return statSync(target).isDirectory()
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate a directory grant the way the sandbox will: it must exist, be a
|
|
80
|
+
* directory, and be spelled as its own physical path. `additionalRoot()` in the
|
|
81
|
+
* sandbox rejects symlinked grants, so accepting one here would only fail later,
|
|
82
|
+
* on every confined call.
|
|
83
|
+
* @param target - candidate directory from the folder picker.
|
|
84
|
+
* @returns the physical path to store.
|
|
85
|
+
*/
|
|
86
|
+
function physicalDirectory(target) {
|
|
87
|
+
let stats
|
|
88
|
+
try {
|
|
89
|
+
stats = statSync(target)
|
|
90
|
+
} catch {
|
|
91
|
+
throw fail('missing')
|
|
92
|
+
}
|
|
93
|
+
if (!stats.isDirectory()) throw fail('notDirectory')
|
|
94
|
+
const physical = realpathSync(target)
|
|
95
|
+
if (physical !== resolvePath(target)) throw fail('symlink')
|
|
96
|
+
return physical
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The durable per-workspace folder lists plus their write path.
|
|
101
|
+
* @param ctx - plugin context, used for diagnostics only.
|
|
102
|
+
*/
|
|
103
|
+
function createStore(ctx) {
|
|
104
|
+
const file = join(dshHome(), 'extra-folders.json')
|
|
105
|
+
let workspaces = read()
|
|
106
|
+
|
|
107
|
+
function read() {
|
|
108
|
+
/** The pre-rename state file, read when the current one does not exist. */
|
|
109
|
+
const legacyFile = join(dshHome(), 'workspace-extra-folders.json')
|
|
110
|
+
for (const candidate of [file, legacyFile]) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(readFileSync(candidate, 'utf8'))
|
|
113
|
+
const source = parsed?.version === STATE_VERSION && typeof parsed?.workspaces === 'object' && parsed.workspaces !== null
|
|
114
|
+
? parsed.workspaces
|
|
115
|
+
: {}
|
|
116
|
+
const result = {}
|
|
117
|
+
for (const [workspace, list] of Object.entries(source)) {
|
|
118
|
+
if (workspace === '' || !Array.isArray(list)) continue
|
|
119
|
+
const folders = [...new Set(list.filter((entry) => typeof entry === 'string' && entry !== ''))]
|
|
120
|
+
if (folders.length > 0) result[workspace] = folders
|
|
121
|
+
}
|
|
122
|
+
return result
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error?.code !== 'ENOENT') ctx.logger?.warn?.(`dsh-extra-folders: cannot read ${candidate}: ${String(error)}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function persist() {
|
|
131
|
+
await mkdir(dirname(file), { recursive: true })
|
|
132
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
133
|
+
await writeFile(temporary, `${JSON.stringify({ version: STATE_VERSION, workspaces }, null, 2)}\n`, 'utf8')
|
|
134
|
+
await rename(temporary, file)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const foldersFor = (workspace) => (Array.isArray(workspaces[workspace]) ? [...workspaces[workspace]] : [])
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
list: foldersFor,
|
|
141
|
+
existing: (workspace) => foldersFor(workspace).filter(isDirectory),
|
|
142
|
+
missing: (workspace) => foldersFor(workspace).filter((folder) => !isDirectory(folder)),
|
|
143
|
+
async add(workspace, target) {
|
|
144
|
+
const physical = physicalDirectory(target)
|
|
145
|
+
if (physical === workspace) throw fail('self')
|
|
146
|
+
const current = foldersFor(workspace)
|
|
147
|
+
if (current.includes(physical)) throw fail('duplicate')
|
|
148
|
+
workspaces = { ...workspaces, [workspace]: [...current, physical] }
|
|
149
|
+
await persist()
|
|
150
|
+
return physical
|
|
151
|
+
},
|
|
152
|
+
async remove(workspace, target) {
|
|
153
|
+
const absolute = canonical(target)
|
|
154
|
+
const current = foldersFor(workspace)
|
|
155
|
+
const index = current.indexOf(absolute) !== -1 ? current.indexOf(absolute) : current.indexOf(target)
|
|
156
|
+
if (index === -1) throw fail('unknown')
|
|
157
|
+
const next = current.filter((_, position) => position !== index)
|
|
158
|
+
const updated = { ...workspaces }
|
|
159
|
+
if (next.length === 0) delete updated[workspace]
|
|
160
|
+
else updated[workspace] = next
|
|
161
|
+
workspaces = updated
|
|
162
|
+
await persist()
|
|
163
|
+
return absolute
|
|
164
|
+
},
|
|
165
|
+
count: () => Object.keys(workspaces).length,
|
|
166
|
+
stateFile: file
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One workspace's attached folders, plus the ones whose directory vanished. */
|
|
171
|
+
function summarize(store, workspace) {
|
|
172
|
+
return { workspace, folders: store.list(workspace), missing: store.missing(workspace) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extend the shared sandbox policy per call. Every enforcing family (filesystem
|
|
177
|
+
* fence, one-shot bash, terminal) resolves its policy through this one service,
|
|
178
|
+
* so this wrapper is the whole grant: folders are merged into
|
|
179
|
+
* `additionalWritableRoots` only for a Session whose immutable cwd is that
|
|
180
|
+
* workspace, and only while the folders still exist on disk.
|
|
181
|
+
* @param policy - the mounted `sandboxPolicy` service.
|
|
182
|
+
* @param existing - folders that currently exist for one canonical workspace.
|
|
183
|
+
* @returns a disposer restoring the original method.
|
|
184
|
+
*/
|
|
185
|
+
function installGrant(policy, existing) {
|
|
186
|
+
if (policy === null || typeof policy !== 'object' || typeof policy.resolve !== 'function') return () => {}
|
|
187
|
+
const original = policy.resolve
|
|
188
|
+
const ownDescriptor = Object.getOwnPropertyDescriptor(policy, 'resolve')
|
|
189
|
+
const resolveWithFolders = function resolveWithWorkspaceFolders(request = {}) {
|
|
190
|
+
const resolved = original.call(this, request)
|
|
191
|
+
const cwd = request?.session?.header?.cwd
|
|
192
|
+
if (typeof cwd !== 'string' || cwd === '') return resolved
|
|
193
|
+
const folders = existing(canonical(cwd))
|
|
194
|
+
if (folders.length === 0) return resolved
|
|
195
|
+
const configured = resolved.additionalWritableRoots ?? []
|
|
196
|
+
const extras = folders.filter((folder) => !configured.includes(folder))
|
|
197
|
+
if (extras.length === 0) return resolved
|
|
198
|
+
return { ...resolved, additionalWritableRoots: Object.freeze([...configured, ...extras]) }
|
|
199
|
+
}
|
|
200
|
+
policy.resolve = resolveWithFolders
|
|
201
|
+
return () => {
|
|
202
|
+
if (policy.resolve !== resolveWithFolders) return
|
|
203
|
+
if (ownDescriptor !== undefined) Object.defineProperty(policy, 'resolve', ownDescriptor)
|
|
204
|
+
else delete policy.resolve
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Whether a path is a workspace the sidebar lists (tolerant of a missing registry). */
|
|
209
|
+
function isRegisteredWorkspace(ctx, workspace) {
|
|
210
|
+
const registry = ctx.get('workspaceRegistry')
|
|
211
|
+
if (registry === undefined || typeof registry.list !== 'function') return true
|
|
212
|
+
try {
|
|
213
|
+
const wanted = canonical(workspace)
|
|
214
|
+
return registry.list().some((entry) => canonical(entry.path) === wanted)
|
|
215
|
+
} catch {
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Read one bounded JSON body. */
|
|
221
|
+
function readJsonBody(request) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const chunks = []
|
|
224
|
+
let size = 0
|
|
225
|
+
request.on('data', (chunk) => {
|
|
226
|
+
size += chunk.length
|
|
227
|
+
if (size > MAX_BODY_BYTES) {
|
|
228
|
+
reject(fail('tooLarge'))
|
|
229
|
+
request.destroy()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
chunks.push(chunk)
|
|
233
|
+
})
|
|
234
|
+
request.on('end', () => {
|
|
235
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
236
|
+
if (text === '') {
|
|
237
|
+
resolve({})
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
resolve(JSON.parse(text))
|
|
242
|
+
} catch {
|
|
243
|
+
reject(fail('badBody'))
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
request.on('error', reject)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Write one JSON response. */
|
|
251
|
+
function sendJson(response, status, value) {
|
|
252
|
+
const body = JSON.stringify(value)
|
|
253
|
+
response.writeHead(status, {
|
|
254
|
+
'content-type': 'application/json; charset=utf-8',
|
|
255
|
+
'content-length': Buffer.byteLength(body),
|
|
256
|
+
'cache-control': 'no-store'
|
|
257
|
+
})
|
|
258
|
+
response.end(body)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Register every Host contribution.
|
|
263
|
+
* @param ctx - Host plugin context.
|
|
264
|
+
*/
|
|
265
|
+
export function apply(ctx) {
|
|
266
|
+
const store = createStore(ctx)
|
|
267
|
+
|
|
268
|
+
// 0.2.2-alpha.2 added `additionalWritableRoots`; earlier lines cannot honor an extra
|
|
269
|
+
// root at all. Say so instead of installing a menu that grants nothing.
|
|
270
|
+
const policy = ctx.get('sandboxPolicy')
|
|
271
|
+
if (policy !== undefined && !('additionalWritableRoots' in policy)) {
|
|
272
|
+
ctx.logger?.error?.(
|
|
273
|
+
'dsh-extra-folders: this DSH build has no additionalWritableRoots on its sandbox policy, '
|
|
274
|
+
+ 'so extra folders cannot be granted. Requires DSH >= 0.2.2-alpha.3.'
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
ctx.inject(['sandboxPolicy'], (scope) => {
|
|
280
|
+
scope.effect(() => installGrant(scope.sandboxPolicy, (workspace) => store.existing(workspace)), 'dsh-extra-folders: sandbox grant')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
ctx.inject(['webServer'], (scope) => {
|
|
284
|
+
scope.effect(() => scope.webServer.register({
|
|
285
|
+
kind: 'prefix',
|
|
286
|
+
path: ROUTE,
|
|
287
|
+
handler: (request, response) => {
|
|
288
|
+
void (async () => {
|
|
289
|
+
const connection = ctx.get('connection')
|
|
290
|
+
if (connection !== undefined && typeof connection.admit === 'function') {
|
|
291
|
+
const admission = connection.admit(request)
|
|
292
|
+
if (admission !== null && typeof admission === 'object' && 'rejection' in admission) {
|
|
293
|
+
response.writeHead(admission.rejection)
|
|
294
|
+
response.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (request.method !== 'POST') {
|
|
299
|
+
sendJson(response, 405, { error: 'unknown' })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
let payload
|
|
303
|
+
try {
|
|
304
|
+
payload = await readJsonBody(request)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
sendJson(response, 400, { error: error?.code ?? 'unknown' })
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
const endpoint = new URL(request.url ?? '/', 'http://127.0.0.1').pathname.slice(ROUTE.length + 1)
|
|
310
|
+
const workspaceInput = typeof payload?.workspace === 'string' ? payload.workspace : ''
|
|
311
|
+
if (workspaceInput === '' || !isAbsolute(workspaceInput)) {
|
|
312
|
+
sendJson(response, 400, { error: 'notWorkspace' })
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
const workspace = canonical(workspaceInput)
|
|
316
|
+
if (!isDirectory(workspace) || !isRegisteredWorkspace(ctx, workspace)) {
|
|
317
|
+
sendJson(response, 409, { error: 'notWorkspace' })
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
if (endpoint === 'list') {
|
|
322
|
+
sendJson(response, 200, summarize(store, workspace))
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
if (endpoint !== 'add' && endpoint !== 'remove') {
|
|
326
|
+
sendJson(response, 404, { error: 'unknown' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const target = typeof payload?.path === 'string' ? payload.path.trim() : ''
|
|
330
|
+
if (target === '') {
|
|
331
|
+
sendJson(response, 400, { error: 'missing' })
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
if (endpoint === 'add') {
|
|
335
|
+
const added = await store.add(workspace, target)
|
|
336
|
+
sendJson(response, 200, { ...summarize(store, workspace), added })
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const removed = await store.remove(workspace, target)
|
|
340
|
+
sendJson(response, 200, { ...summarize(store, workspace), removed })
|
|
341
|
+
} catch (error) {
|
|
342
|
+
sendJson(response, 400, { error: error?.code ?? 'unknown' })
|
|
343
|
+
}
|
|
344
|
+
})()
|
|
345
|
+
}
|
|
346
|
+
}), 'dsh-extra-folders: loopback route')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
ctx.logger?.info?.(`dsh-extra-folders: ${store.count()} workspace(s) carry attached folders (${store.stateFile})`)
|
|
350
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Workspace folders",
|
|
4
|
+
"description": "A workspace has one writable directory by default, so writing anywhere else asks for approval again and again. This plugin adds more directories from the workspace row's menu. Attached directories are writable for that workspace's sessions by both the file tools and the shell. Sessions in other workspaces don't get that grant."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-extra-folders",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Attach extra writable folders to one workspace, granted per session, not profile-wide.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"workspace",
|
|
10
|
+
"multi-root",
|
|
11
|
+
"folders",
|
|
12
|
+
"writable",
|
|
13
|
+
"sandbox",
|
|
14
|
+
"permissions"
|
|
15
|
+
],
|
|
16
|
+
"author": "kingOfSoySauce",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/kingOfSoySauce/dsh-extra-folders.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/kingOfSoySauce/dsh-extra-folders#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/kingOfSoySauce/dsh-extra-folders/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "index.js",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./index.js",
|
|
30
|
+
"./client": "./client.js",
|
|
31
|
+
"./locale/*.json": "./locale/*.json",
|
|
32
|
+
"./icon": "./icon.svg"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"index.js",
|
|
36
|
+
"client.js",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"locale/*.json",
|
|
39
|
+
"icon.svg",
|
|
40
|
+
"assets/approval.png",
|
|
41
|
+
"assets/menu.png",
|
|
42
|
+
"assets/dialog.png",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"dsh": ">=0.2.2-alpha.3 <0.3"
|
|
48
|
+
},
|
|
49
|
+
"dsh": {
|
|
50
|
+
"bundle": {
|
|
51
|
+
"patch": "./cordis.patch.yml"
|
|
52
|
+
},
|
|
53
|
+
"client": {
|
|
54
|
+
"platform": "web",
|
|
55
|
+
"immediately": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|