dsh-multi-folder 0.1.2 → 0.1.4
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 +16 -3
- package/README.zh.md +14 -1
- package/docs/design.md +38 -6
- package/lib/index.js +196 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://nodejs.org/)
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-multi-folder)
|
|
10
|
+
[](https://github.com/AngelosZou/dsh-multi-folder/issues)
|
|
11
|
+
[](https://awesome-dsh-plugin.com)
|
|
9
12
|
|
|
10
13
|
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin bundle that gives one project (workspace) a set of **secondary working directories**:
|
|
11
14
|
|
|
@@ -13,8 +16,7 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin bun
|
|
|
13
16
|
- Under **Workspace Write** mode the agent gains the **same read / write / edit / execute permissions** on the configured secondary directories as on the primary workspace — enforced by re-rooting the session's own sandbox policy, so every mode keeps its semantics (`read-only` still denies, `workspace-write` allows, `danger-full-access` allows).
|
|
14
17
|
- The directory list is **injected into the system prompt** and re-rendered per session assembly.
|
|
15
18
|
- Configuration changes notify the agent through a **non-interrupting message queue** — delivered at the next message boundary (user send or tool-call end), and **only when the directory set actually changed**.
|
|
16
|
-
- Configurable **before the session starts**: the session-creation page (new-session screen) offers a Multi-folder entry
|
|
17
|
-
- **Localized UI.** The button, panel, and creation-page entries follow the DSH locale (the browser language or the Language setting in Settings): "Multi-folder" in English, 「多工作目录」 in Chinese.
|
|
19
|
+
- Configurable **before the session starts**: the session-creation page (new-session screen) offers a Multi-folder entry that reads and edits the same per-workspace configuration through a **sessionless remote API** (`multiFolder/*` endpoints) — no session id required.
|
|
18
20
|
- **No new tools.** Everything is a framework-level change (tool-pipeline interception) plus a UI-level change (a session-scoped header entry).
|
|
19
21
|
|
|
20
22
|
## Requirements
|
|
@@ -34,7 +36,7 @@ Then **restart the DSH backend** (host composition loads at process start) and *
|
|
|
34
36
|
|
|
35
37
|
## Usage
|
|
36
38
|
|
|
37
|
-
A Multi-folder button
|
|
39
|
+
A Multi-folder button appears in the session header, and a second entry appears on the **session-creation page** (fixed launcher in the bottom-right corner while the new-session screen is shown; an inline chip beside the workspace picker once the upstream `conversation.hero.workspaceExtras` slot is available). The panel lets you:
|
|
38
40
|
|
|
39
41
|
| Action | Behavior |
|
|
40
42
|
| ------ | -------- |
|
|
@@ -54,6 +56,17 @@ Equivalent slash command for the user:
|
|
|
54
56
|
|
|
55
57
|
The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `write` / `edit` / `pwsh` / `bash` are intercepted and re-rooted automatically when the target path (or `workdir`) falls inside a configured secondary directory.
|
|
56
58
|
|
|
59
|
+
## Permission model
|
|
60
|
+
|
|
61
|
+
Each confined command runs under **exactly ONE writable root** — the workspace root the call is re-rooted to (the Windows ACL runner grants a single workspace write SID per process tree). Consequences:
|
|
62
|
+
|
|
63
|
+
- A command whose cwd stays the **primary workspace cannot create files inside a secondary directory**. `git -C <secondary> commit`, `cd <secondary>` inside a script, `git clone <url> <secondary>`, or absolute-path writes all fail with an OS-level `Permission denied` (e.g. `fatal: Unable to create '.../.git/index.lock': Permission denied`).
|
|
64
|
+
- Symmetrically, a command re-rooted to a secondary directory cannot write to the **primary workspace** (or another secondary directory) in the same invocation.
|
|
65
|
+
- **Rule for file-creating commands: set `workdir` to the directory the command writes into.** For git, run the command from inside the repository (pass `workdir` pointing at it) instead of using `git -C` from the primary workspace.
|
|
66
|
+
- Reads are unrestricted and need no `workdir`.
|
|
67
|
+
|
|
68
|
+
When a shell run ends in such a denial and references a configured secondary directory, the plugin attaches a short diagnostic hint to the tool result explaining the workdir fix.
|
|
69
|
+
|
|
57
70
|
## How it works
|
|
58
71
|
|
|
59
72
|
- **Interception** — a listener on the `tools/execute` around-dispatch waterfall short-circuits `write` / `edit` / `pwsh` / `bash` calls whose resolved path (or `workdir`) lands inside a configured secondary directory, and executes them with the session's standing sandbox policy **re-rooted to that directory** (`{ ...standingPolicy, workspaceRoot: secondaryDir }`). The mode itself is untouched, which is what gives every sandbox mode its identical primary-workspace semantics for free. Paths are canonicalized through `fs.resolve` + `processPath` before matching, so `..`, symlinks, and case differences behave correctly.
|
package/README.zh.md
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://nodejs.org/)
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-multi-folder)
|
|
10
|
+
[](https://github.com/AngelosZou/dsh-multi-folder/issues)
|
|
11
|
+
[](https://awesome-dsh-plugin.com)
|
|
9
12
|
|
|
10
13
|
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件 bundle,为一个 Project(工作区)提供一组**副工作目录**:
|
|
11
14
|
|
|
@@ -14,7 +17,6 @@
|
|
|
14
17
|
- 目录列表**注入系统提示词**,每次组装按会话求值;
|
|
15
18
|
- 配置变更通过**不打断的消息队列**通知 Agent——在下一次消息边界(用户发送或工具调用结束)送达,且**仅在目录集合实际变化时**发送;
|
|
16
19
|
- **会话开始前即可配置**:会话创建页(新会话界面)提供「多工作目录」入口(英文界面显示 "Multi-folder"),通过**无会话远程 API**(`multiFolder/*` 端点)读写同一份 per-workspace 配置——无需 session id;
|
|
17
|
-
- **界面本地化**:按钮、面板与创建页入口跟随 DSH 的语言设置(浏览器语言或设置中的 Language 选项):英文界面显示 "Multi-folder",中文界面显示「多工作目录」。
|
|
18
20
|
- **不新增任何工具**:改动全部位于框架级(工具流水线拦截)与 UI 级(会话级头部入口)。
|
|
19
21
|
|
|
20
22
|
## 环境要求
|
|
@@ -54,6 +56,17 @@ dsh plugin --profile web add dsh-multi-folder
|
|
|
54
56
|
|
|
55
57
|
Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write` / `edit` / `pwsh` / `bash` 在路径(或 `workdir`)落入副目录时自动拦截并以该目录为沙箱根执行。
|
|
56
58
|
|
|
59
|
+
## 权限模型
|
|
60
|
+
|
|
61
|
+
每条受沙箱约束的命令只拥有**唯一一个可写根**——即本次调用被换根到的那个目录(Windows ACL runner 为每个进程树只授予一个工作区写 SID)。由此:
|
|
62
|
+
|
|
63
|
+
- cwd 停留在**主工作区**的命令**不能在副目录创建文件**。`git -C <副目录> commit`、脚本内 `cd <副目录>`、`git clone <url> <副目录>`、按绝对路径写文件等都会以操作系统级 `Permission denied` 失败(例如 `fatal: Unable to create '.../.git/index.lock': Permission denied`)。
|
|
64
|
+
- 对称地,被换根到副目录的命令在同一次调用中也**不能写主工作区**(或另一个副目录)。
|
|
65
|
+
- **创建文件的命令必须把 `workdir` 设为它要写入的目录。** 对 git 而言,请进入仓库目录执行(`workdir` 指向该仓库),而不是从主工作区用 `git -C`。
|
|
66
|
+
- 读操作不受限制,无需 `workdir`。
|
|
67
|
+
|
|
68
|
+
当 shell 命令以这类拒绝失败且命令引用了已配置的副目录时,插件会在工具结果后附带一条简短的诊断提示,说明 workdir 的修正方式。
|
|
69
|
+
|
|
57
70
|
## 工作原理
|
|
58
71
|
|
|
59
72
|
- **拦截**——监听 `tools/execute` 环绕分派瀑布,对解析路径(或 `workdir`)落在副目录内的 `write` / `edit` / `pwsh` / `bash` 调用短路,并以**换根后的会话站立策略**(`{ ...standingPolicy, workspaceRoot: secondaryDir }`)执行。模式本身不变,因此各种沙箱模式与主工作区的语义天然一致。匹配前先经 `fs.resolve` + `processPath` 规范化,`..`、符号链接与大小写差异均正确处理。
|
package/docs/design.md
CHANGED
|
@@ -35,13 +35,19 @@ A listener on the `tools/execute` around-dispatch waterfall handles `write`, `ed
|
|
|
35
35
|
4. If the canonical path is inside a configured secondary directory, execute the
|
|
36
36
|
operation directly with `{ ...standingPolicy, workspaceRoot: <secondary dir> }`:
|
|
37
37
|
- `write`/`edit` → `fs.writeText` / `fs.editText`;
|
|
38
|
-
- `pwsh`/`bash
|
|
39
|
-
`shell.run`, with the canonical workdir so the confinement
|
|
40
|
-
cwd agree exactly
|
|
38
|
+
- `pwsh`/`bash`, foreground → `shell.resolve({ command, workdir, dshEnv,
|
|
39
|
+
sandboxPolicy })` + `shell.run`, with the canonical workdir so the confinement
|
|
40
|
+
root and the process cwd agree exactly;
|
|
41
|
+
- `pwsh`/`bash`, background (`run_in_background: true`) → the same re-rooted
|
|
42
|
+
request registered through the generic jobs runtime (`ctx.jobs`) exactly like
|
|
43
|
+
the shipped shell tools (`kind` = tool name, `owner` = calling agent, streamed
|
|
44
|
+
reads shaped for `job_output` with sandbox markers, terminal outcome in the
|
|
45
|
+
`completed`/`killed` vocabulary). A caller-aborted call falls through to the
|
|
46
|
+
default pipeline, which raises the canonical abort error.
|
|
41
47
|
The result carries the same canonical value/content shapes as the shipped tools, so
|
|
42
48
|
downstream presentation keeps working.
|
|
43
49
|
5. Anything else — unknown tools, paths outside every secondary directory, escalation
|
|
44
|
-
arguments (`sandbox_permissions`),
|
|
50
|
+
arguments (`sandbox_permissions`), missing optional services (`shell`, `jobs`),
|
|
45
51
|
or any error — falls through to `next()` and the default pipeline.
|
|
46
52
|
|
|
47
53
|
**Why mode parity is free:** the mode field of the standing policy is never touched.
|
|
@@ -200,14 +206,40 @@ window.__ModuleLoader__.load({
|
|
|
200
206
|
|
|
201
207
|
## Known limitations
|
|
202
208
|
|
|
209
|
+
- Each confined command runs under exactly ONE writable root: the Windows ACL
|
|
210
|
+
runner grants a single workspace write SID per process tree (`--write-sid`
|
|
211
|
+
must match `--workspace`), and re-rooting replaces the root. A command whose
|
|
212
|
+
cwd stays the primary workspace therefore cannot create files inside a
|
|
213
|
+
secondary directory — `git -C <secondary> commit`, `cd <secondary>` inside a
|
|
214
|
+
script, `git clone <url> <secondary>`, and absolute-path writes fail with an
|
|
215
|
+
OS-level `Permission denied` (`fatal: Unable to create '.../.git/index.lock':
|
|
216
|
+
Permission denied`) that carries no sandbox marker. Symmetrically, a command
|
|
217
|
+
re-rooted to a secondary directory cannot write the primary workspace in the
|
|
218
|
+
same invocation. The injected prompt states the workdir rule, and a
|
|
219
|
+
`tools/post-execute` heuristic attaches a workdir-fix hint when a failed
|
|
220
|
+
`pwsh`/`bash` run both mentions a configured secondary directory and ends in
|
|
221
|
+
a denial (`permission denied` / `access … denied` / `is denied` / `eacces`;
|
|
222
|
+
the plugin's own `[sandbox: …]` marker lines are excluded from the scan).
|
|
223
|
+
Lifting this to real multi-root confinement needs an upstream change
|
|
224
|
+
(`SandboxExecutionPolicy` carrying extra write roots and the ACL runner
|
|
225
|
+
accepting several workspace write SIDs).
|
|
203
226
|
- Intercepted secondary-directory writes bypass the fs observation policy: they emit
|
|
204
227
|
no `fs/observed` event and do not participate in the `fs/write-intent` intent guard.
|
|
205
228
|
This is deliberate — secondary directories sit outside the primary workspace's
|
|
206
229
|
observation domain.
|
|
207
230
|
- `presentationMeta` is not computed on the short-circuit path; tool cards fall back to
|
|
208
231
|
their default presentation.
|
|
209
|
-
- `
|
|
210
|
-
|
|
232
|
+
- `sandbox_permissions` escalation on `pwsh`/`bash` calls in secondary directories is
|
|
233
|
+
passed through to the default pipeline, which re-roots the escalated run at the
|
|
234
|
+
PRIMARY workspace — escalation never widens a secondary root. (Background runs are
|
|
235
|
+
NOT passed through: they register with `ctx.jobs` under the same re-rooted policy
|
|
236
|
+
as foreground runs.)
|
|
237
|
+
- The interceptor registers a background `pwsh`/`bash` job whenever `ctx.jobs` is
|
|
238
|
+
available; it cannot read the shipped shell tools' per-tool
|
|
239
|
+
`enableRunInBackground: false` config, so a deployment that disables background
|
|
240
|
+
execution would still serve secondary-dir background jobs. Deployments that
|
|
241
|
+
disable background execution should also disable this plugin's shell interception
|
|
242
|
+
or accept that exception.
|
|
211
243
|
- The `/multi-folder` command lifecycle rows (`command/run`, `command/done`) are
|
|
212
244
|
visible in the conversation UI by framework design; they are log-only and never
|
|
213
245
|
reach the model. Workspace-mode (session-creation page) operations avoid them
|
package/lib/index.js
CHANGED
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
* here with the session's standing sandbox policy re-rooted to that
|
|
17
17
|
* directory — identical semantics to the primary workspace in every mode
|
|
18
18
|
* (read-only denies, workspace-write allows, danger-full-access allows).
|
|
19
|
-
*
|
|
19
|
+
* Background shell runs (`run_in_background: true`) register with the
|
|
20
|
+
* generic jobs runtime (`ctx.jobs`) under the same re-rooted policy,
|
|
21
|
+
* mirroring the shipped pwsh/bash tools so `job_output` / `job_kill` and
|
|
22
|
+
* finish notices keep working. Reads (read/glob/grep) are unfenced and
|
|
23
|
+
* already work.
|
|
20
24
|
* 3. Prompt injection: one ordered system-prompt section rendered per
|
|
21
25
|
* assembly from the configured directories of the assembling session.
|
|
22
26
|
* 4. Non-interrupting change notification: configuration changes made via
|
|
@@ -35,6 +39,12 @@
|
|
|
35
39
|
* directly. The `/multi-folder` command and the remote methods share
|
|
36
40
|
* one core so validation, canonicalization, and the config guard are
|
|
37
41
|
* identical on both channels.
|
|
42
|
+
* 7. Failure diagnosis: a `pwsh`/`bash` run that ends in an OS-level
|
|
43
|
+
* `Permission denied` touching a secondary working directory (the ACL
|
|
44
|
+
* runner confines each process tree to ONE writable root, so `git -C
|
|
45
|
+
* <secondary>` launched from the primary workspace cannot write the
|
|
46
|
+
* repo) gets a workdir-fix hint attached as an additional context at
|
|
47
|
+
* the `tools/post-execute` boundary.
|
|
38
48
|
*/
|
|
39
49
|
|
|
40
50
|
import { join } from 'node:path'
|
|
@@ -317,6 +327,100 @@ export function apply(ctx) {
|
|
|
317
327
|
return text
|
|
318
328
|
}
|
|
319
329
|
|
|
330
|
+
// -------------------------------------------- failure diagnosis (post-exec)
|
|
331
|
+
// The Windows ACL runner confines each process tree to exactly ONE writable
|
|
332
|
+
// workspace root (a single `--write-sid` that must match `--workspace`). A
|
|
333
|
+
// command that creates files inside a secondary directory while its cwd is
|
|
334
|
+
// confined elsewhere therefore fails with an OS-level `Permission denied`
|
|
335
|
+
// (git: `fatal: Unable to create '.../.git/index.lock': Permission denied`)
|
|
336
|
+
// and carries NO sandbox marker — the sandbox worked as designed. These
|
|
337
|
+
// helpers surface the workdir fix at the next tool-call boundary instead.
|
|
338
|
+
|
|
339
|
+
const DENIAL_MARK = /permission denied|access(?: is)? denied|eacces|is denied/i
|
|
340
|
+
|
|
341
|
+
const flattenResultText = (result) => {
|
|
342
|
+
const parts = []
|
|
343
|
+
if (result && Array.isArray(result.content)) {
|
|
344
|
+
for (const block of result.content) {
|
|
345
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const value = result && result.value
|
|
349
|
+
if (value && typeof value === 'object') {
|
|
350
|
+
for (const stream of [value.stdout, value.stderr]) {
|
|
351
|
+
if (stream && typeof stream.text === 'string') parts.push(stream.text)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// The plugin's own `[sandbox: ...]` markers describe runner-level denials,
|
|
355
|
+
// not path-level EACCES; keep them out of the scan.
|
|
356
|
+
return parts
|
|
357
|
+
.join('\n')
|
|
358
|
+
.split('\n')
|
|
359
|
+
.filter((line) => !line.startsWith('[sandbox:'))
|
|
360
|
+
.join('\n')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The configured secondary dir a call's workdir lands in, or null. */
|
|
364
|
+
const workdirHitFor = async (exec) => {
|
|
365
|
+
if (!exec || !exec.agent || !exec.agent.session || !exec.agent.session.header) return null
|
|
366
|
+
const primary = exec.agent.session.header.cwd
|
|
367
|
+
if (typeof primary !== 'string' || primary.length === 0) return null
|
|
368
|
+
const dirs = dirsForSync(primary)
|
|
369
|
+
if (dirs === null) return null
|
|
370
|
+
const args = exec.arguments || {}
|
|
371
|
+
const rawWorkdir = typeof args.workdir === 'string' ? args.workdir : null
|
|
372
|
+
const joined =
|
|
373
|
+
rawWorkdir === null
|
|
374
|
+
? String(primary)
|
|
375
|
+
: isAbsolute(rawWorkdir)
|
|
376
|
+
? rawWorkdir
|
|
377
|
+
: String(primary).replace(/[\\/]+$/, '') + '/' + rawWorkdir
|
|
378
|
+
const target = await fs.resolve(joined, { cwd: primary })
|
|
379
|
+
const abs = fs.processPath(target)
|
|
380
|
+
return longestRootFirst(dirs).find((d) => pathInside(abs, d)) ?? null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A user-visible diagnostic for a shell run that ended in an OS-level
|
|
385
|
+
* permission denial touching a secondary working directory. Returns the
|
|
386
|
+
* hint text or undefined. Never throws — a hint failure must never touch
|
|
387
|
+
* the tool pipeline.
|
|
388
|
+
*/
|
|
389
|
+
const permissionHint = async (exec, result) => {
|
|
390
|
+
try {
|
|
391
|
+
if (!exec || (exec.name !== 'pwsh' && exec.name !== 'bash')) return undefined
|
|
392
|
+
if (!exec.agent || !exec.agent.session || !exec.agent.session.header) return undefined
|
|
393
|
+
const exitCode =
|
|
394
|
+
result && result.value && typeof result.value.exitCode === 'number' ? result.value.exitCode : null
|
|
395
|
+
if (exitCode !== null && exitCode === 0) return undefined
|
|
396
|
+
if (!DENIAL_MARK.test(flattenResultText(result))) return undefined
|
|
397
|
+
const primary = exec.agent.session.header.cwd
|
|
398
|
+
if (typeof primary !== 'string' || primary.length === 0) return undefined
|
|
399
|
+
const dirs = dirsForSync(primary)
|
|
400
|
+
if (dirs === null) return undefined
|
|
401
|
+
const reRooted = await workdirHitFor(exec)
|
|
402
|
+
if (reRooted !== null) {
|
|
403
|
+
return (
|
|
404
|
+
'This command ran confined to the secondary working directory "' +
|
|
405
|
+
reRooted +
|
|
406
|
+
'", so writes OUTSIDE that directory (for example to the primary workspace or another secondary directory) were denied at the OS level. ' +
|
|
407
|
+
"Split the work into per-directory commands and set each command's `workdir` to the directory it writes into."
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
const cmdNorm = wsKey(String((exec.arguments || {}).command || ''))
|
|
411
|
+
const referenced = longestRootFirst(dirs).find((d) => cmdNorm.includes(wsKey(d)))
|
|
412
|
+
if (referenced === undefined) return undefined
|
|
413
|
+
return (
|
|
414
|
+
'This command ran with its cwd confined to the primary workspace, so creating files inside the secondary working directory "' +
|
|
415
|
+
referenced +
|
|
416
|
+
'" was denied at the OS level — each command can write inside only ONE root. ' +
|
|
417
|
+
'Re-run it with `workdir` set to that directory; for git, run the command from inside the repository instead of using `git -C` from the primary workspace.'
|
|
418
|
+
)
|
|
419
|
+
} catch {
|
|
420
|
+
return undefined
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
320
424
|
// ------------------------------------------------------ prompt injection
|
|
321
425
|
systemPrompt.section({
|
|
322
426
|
name: SECTION_NAME,
|
|
@@ -332,8 +436,12 @@ export function apply(ctx) {
|
|
|
332
436
|
return (
|
|
333
437
|
'Secondary working directories are available in this session (dsh-multi-folder plugin):\n' +
|
|
334
438
|
dirs.map((d) => '- ' + d).join('\n') +
|
|
335
|
-
'\nYou have the SAME read/write/edit and command-execution permissions on these directories as on the primary workspace under the current sandbox mode
|
|
336
|
-
'
|
|
439
|
+
'\nYou have the SAME read/write/edit and command-execution permissions on these directories as on the primary workspace under the current sandbox mode, ' +
|
|
440
|
+
'but each command can write inside only ONE root — the directory its workdir resolves to. ' +
|
|
441
|
+
'A command whose cwd stays the primary workspace CANNOT create files inside a secondary directory. ' +
|
|
442
|
+
'For shell tools, pass `workdir` pointing inside one of these directories — foreground and background (`run_in_background`) runs alike. ' +
|
|
443
|
+
'File-creating commands, git included, MUST set `workdir` to the secondary directory: do not run `git -C <secondary>` or `cd <secondary>` inside a command launched from the primary workspace. ' +
|
|
444
|
+
'Reads from these directories work without `workdir`. The primary workspace remains the default working directory.'
|
|
337
445
|
)
|
|
338
446
|
},
|
|
339
447
|
})
|
|
@@ -353,17 +461,21 @@ export function apply(ctx) {
|
|
|
353
461
|
// --------------------------------------- notification (tool-call boundary)
|
|
354
462
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
355
463
|
const decision = await next()
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
464
|
+
const extras = []
|
|
465
|
+
const notice = takeNotice(exec.agent)
|
|
466
|
+
if (notice !== undefined) extras.push(notice)
|
|
467
|
+
const hint = await permissionHint(exec, result)
|
|
468
|
+
if (hint !== undefined) extras.push(hint)
|
|
469
|
+
if (extras.length === 0) return decision
|
|
470
|
+
const msgs = extras.map((text) => noticeMessage(text))
|
|
359
471
|
if (decision.kind === 'block') {
|
|
360
472
|
return {
|
|
361
473
|
kind: 'block',
|
|
362
474
|
feedback: decision.feedback,
|
|
363
|
-
additionalContexts: [
|
|
475
|
+
additionalContexts: [...msgs, ...(decision.additionalContexts || [])],
|
|
364
476
|
}
|
|
365
477
|
}
|
|
366
|
-
return { ...decision, additionalContexts: [
|
|
478
|
+
return { ...decision, additionalContexts: [...msgs, ...(decision.additionalContexts || [])] }
|
|
367
479
|
})
|
|
368
480
|
|
|
369
481
|
// ------------------------------------------------- tool-pipeline intercept
|
|
@@ -383,6 +495,49 @@ export function apply(ctx) {
|
|
|
383
495
|
return text
|
|
384
496
|
}
|
|
385
497
|
|
|
498
|
+
/** Terminal outcome for a background process, in the jobs-registry vocabulary. */
|
|
499
|
+
const processOutcome = (proc) => {
|
|
500
|
+
if (proc.status === 'killed') {
|
|
501
|
+
return {
|
|
502
|
+
status: 'killed',
|
|
503
|
+
detail: proc.signal !== null && proc.signal !== undefined ? 'signal: ' + proc.signal : 'killed before exit',
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
status: 'completed',
|
|
508
|
+
detail: 'exit code: ' + (proc.exitCode === undefined || proc.exitCode === null ? 0 : proc.exitCode),
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* One consuming background read, shaped for `job_output`: the raw delta plus
|
|
514
|
+
* loss/spill notices and sandbox markers, mirroring the shipped pwsh/bash
|
|
515
|
+
* tools' background rendering. No escalation hint is appended — escalation
|
|
516
|
+
* calls stay on the default pipeline, which re-roots at the primary
|
|
517
|
+
* workspace, so this job can never receive a wider policy.
|
|
518
|
+
*/
|
|
519
|
+
const renderProcessRead = (read, sandbox) => {
|
|
520
|
+
const notices = []
|
|
521
|
+
if (read.lossy) {
|
|
522
|
+
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== undefined)
|
|
523
|
+
notices.push(
|
|
524
|
+
'[some output was dropped from memory; full output: ' +
|
|
525
|
+
(paths.length > 0 ? paths.join(', ') : '(unavailable)') +
|
|
526
|
+
']',
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
if (sandbox && sandbox.runnerFailed) {
|
|
530
|
+
notices.push(
|
|
531
|
+
'[sandbox: the sandbox runner itself failed under ' + sandbox.mode +
|
|
532
|
+
' mode — the command did not run; this is a sandbox problem, not a command failure]',
|
|
533
|
+
)
|
|
534
|
+
} else if (sandbox && sandbox.denied) {
|
|
535
|
+
notices.push('[sandbox: file access denied under ' + sandbox.mode + ' mode]')
|
|
536
|
+
}
|
|
537
|
+
if (notices.length === 0) return read.delta
|
|
538
|
+
return read.delta + (read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : '') + notices.join('\n')
|
|
539
|
+
}
|
|
540
|
+
|
|
386
541
|
ctx.on('tools/execute', async (exec, next) => {
|
|
387
542
|
if (exec.agent && exec.agent.session && exec.agent.session.header) {
|
|
388
543
|
hydrate(exec.agent.session.header.cwd)
|
|
@@ -459,7 +614,6 @@ export function apply(ctx) {
|
|
|
459
614
|
if (exec.name === 'pwsh' || exec.name === 'bash') {
|
|
460
615
|
const shell = ctx.get('shell')
|
|
461
616
|
if (shell === undefined) return next()
|
|
462
|
-
if (args && args.run_in_background === true) return next()
|
|
463
617
|
const dirs = dirsForSync(primary)
|
|
464
618
|
if (dirs === null) return next()
|
|
465
619
|
const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
|
|
@@ -483,6 +637,39 @@ export function apply(ctx) {
|
|
|
483
637
|
...(shellEnv !== undefined ? { dshEnv: shellEnv.collect(exec) } : {}),
|
|
484
638
|
sandboxPolicy: policy,
|
|
485
639
|
}
|
|
640
|
+
|
|
641
|
+
// Background runs get the SAME re-rooted policy as foreground runs.
|
|
642
|
+
// They register with the generic jobs runtime (`ctx.jobs`) exactly
|
|
643
|
+
// like the shipped pwsh/bash tools do, so `job_output` / `job_kill`
|
|
644
|
+
// and the finish notice keep working for the intercepted job. A
|
|
645
|
+
// background process outlives the tool call, so no caller signal is
|
|
646
|
+
// forwarded; `shell.start` ignores `timeoutMs` by design.
|
|
647
|
+
if (args && args.run_in_background === true) {
|
|
648
|
+
// An aborted call belongs to the default pipeline, which raises the
|
|
649
|
+
// canonical abort error before anything starts.
|
|
650
|
+
if (exec.signal && exec.signal.aborted) return next()
|
|
651
|
+
const jobs = ctx.get('jobs')
|
|
652
|
+
if (jobs === undefined) return next()
|
|
653
|
+
const jobId = jobs.start({
|
|
654
|
+
kind: exec.name,
|
|
655
|
+
label: String(args.command),
|
|
656
|
+
...(exec.agent ? { owner: exec.agent } : {}),
|
|
657
|
+
run: () => {
|
|
658
|
+
const proc = shell.start(shell.resolve(request))
|
|
659
|
+
return {
|
|
660
|
+
cancel: () => void proc.kill(),
|
|
661
|
+
done: proc.done.then(() => processOutcome(proc)),
|
|
662
|
+
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox),
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
})
|
|
666
|
+
return {
|
|
667
|
+
isError: false,
|
|
668
|
+
value: { kind: 'background', jobId },
|
|
669
|
+
content: [{ type: 'text', text: 'started background job ' + jobId }],
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
486
673
|
const result = await shell.run(shell.resolve({ ...request, signal: exec.signal }))
|
|
487
674
|
if (result.aborted) {
|
|
488
675
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary. Configurable from the session header AND from the session-creation page (before the first message) through a sessionless multiFolder remote API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|