dsh-multi-folder 0.2.0 → 0.2.2
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/docs/design.md +24 -8
- package/lib/client.js +28 -4
- package/lib/index.js +40 -6
- package/package.json +5 -1
package/docs/design.md
CHANGED
|
@@ -77,7 +77,17 @@ at apply time can yield `undefined` when the provider row activates later. There
|
|
|
77
77
|
handler and the `multiFolder/*` remote endpoints) with an explicit
|
|
78
78
|
`workspace-write` policy rooted at the config directory.
|
|
79
79
|
- A per-process cache keyed by normalized workspace path hydrates lazily (on
|
|
80
|
-
`agent/created`, `agent/pre-step`, and `tools/execute`).
|
|
80
|
+
`agent/created`, `agent/pre-step`, and `tools/execute`). On the interception
|
|
81
|
+
path hydration is **awaited**, not fire-and-forget: a first call that landed
|
|
82
|
+
before the config read resolved would otherwise see an empty cache, fall
|
|
83
|
+
through to the default pipeline, and be fenced against the PRIMARY workspace
|
|
84
|
+
root — a spurious `[sandbox: file access denied under workspace-write mode]`
|
|
85
|
+
for a secondary-directory mutation. `loadDirs` caches, so only the first call
|
|
86
|
+
pays the read. Because `sandbox-policy` realpath-canonicalizes the policy
|
|
87
|
+
workspace root while hydration is keyed by the session cwd **as spelled in
|
|
88
|
+
the header**, a workspace reached through a symlinked/junctioned ancestor can
|
|
89
|
+
spell the two differently; the interception consults BOTH keys (and the
|
|
90
|
+
config guard checks both config-path spellings) before falling through.
|
|
81
91
|
- One shared **core** (`coreList` / `coreAdd` / `coreRemove` / `coreSet`)
|
|
82
92
|
implements validation, canonicalization, sanitization, cache write-through,
|
|
83
93
|
and persistence. The command channel and the remote channel both call it, so
|
|
@@ -200,9 +210,13 @@ window.__ModuleLoader__.load({
|
|
|
200
210
|
renders directly ABOVE the composer card, in the same band as the
|
|
201
211
|
git-branch chip. The entry receives the dock owner share (`{ session,
|
|
202
212
|
input }`) plus the standard `useSessions` / `useWorkspaces` selector hooks,
|
|
203
|
-
so the hero phase
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
so the hero phase and the target workspace come from framework props instead
|
|
214
|
+
of DOM probing. Detection is DSH-version-adaptive: a shell whose
|
|
215
|
+
`SessionSnapshot` carries `composerPhase` uses
|
|
216
|
+
`composerPhase === 'blank' && (openState === 'open' || blank)`, while DSH
|
|
217
|
+
0.1.2 (no `composerPhase`) uses the settled-blank fallback
|
|
218
|
+
`blank && !running && !promptAttempted && (openState === 'open' || blank)`.
|
|
219
|
+
The row stays **in flow** — `display:flex` with the official hero
|
|
206
220
|
row's 20px indent, no absolute positioning — so the framework's list-slot
|
|
207
221
|
arrangement keeps it clear of every other plugin's dock row. It renders
|
|
208
222
|
only on the session-creation page; an active session keeps its entry in the
|
|
@@ -255,10 +269,12 @@ window.__ModuleLoader__.load({
|
|
|
255
269
|
Lifting this to real multi-root confinement needs an upstream change
|
|
256
270
|
(`SandboxExecutionPolicy` carrying extra write roots and the ACL runner
|
|
257
271
|
accepting several workspace write SIDs).
|
|
258
|
-
- Intercepted secondary-directory
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
observation
|
|
272
|
+
- Intercepted secondary-directory mutations do not participate in the
|
|
273
|
+
`fs/write-intent` / `fs/edit-intent` intent guards (the interception calls
|
|
274
|
+
the backend unconditionally, as a full replacement of the tool body), but
|
|
275
|
+
they DO emit `fs/observed` with a presence observation after success, exactly
|
|
276
|
+
like the shipped tools — so the observation layer stays coherent with the
|
|
277
|
+
file content a re-rooted write/edit produced.
|
|
262
278
|
- `presentationMeta` is not computed on the short-circuit path; tool cards fall back to
|
|
263
279
|
their default presentation.
|
|
264
280
|
- `sandbox_permissions` escalation on `pwsh`/`bash` calls in secondary directories is
|
package/lib/client.js
CHANGED
|
@@ -214,12 +214,13 @@ window.__ModuleLoader__.load({
|
|
|
214
214
|
|
|
215
215
|
// ------------------------------------------------------------- plugin
|
|
216
216
|
var name = 'dsh-multi-folder';
|
|
217
|
-
var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale'];
|
|
217
|
+
var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'uiWorkspace', 'connection', 'sessions', 'locale'];
|
|
218
218
|
|
|
219
219
|
function apply(ctx) {
|
|
220
220
|
var slots = ctx.slots;
|
|
221
221
|
var remote = ctx.remote;
|
|
222
222
|
var workspaces = ctx.workspaces;
|
|
223
|
+
var uiWorkspace = ctx.uiWorkspace;
|
|
223
224
|
var connection = ctx.connection;
|
|
224
225
|
var sessions = ctx.sessions;
|
|
225
226
|
var locale = ctx.locale;
|
|
@@ -398,7 +399,18 @@ window.__ModuleLoader__.load({
|
|
|
398
399
|
}
|
|
399
400
|
|
|
400
401
|
function addDirectory() {
|
|
401
|
-
|
|
402
|
+
// DSH 0.1.2 moved the directory picker onto `uiWorkspace`; earlier
|
|
403
|
+
// shells kept it on `workspaces`. Pick whichever service actually
|
|
404
|
+
// exposes it (and call it with the right receiver), surfacing a clear
|
|
405
|
+
// error when neither does instead of throwing synchronously.
|
|
406
|
+
var picker = (uiWorkspace && typeof uiWorkspace.pickDirectory === 'function')
|
|
407
|
+
? uiWorkspace
|
|
408
|
+
: (workspaces && typeof workspaces.pickDirectory === 'function') ? workspaces : null;
|
|
409
|
+
if (picker === null) {
|
|
410
|
+
patch({ error: 'multi-folder: the directory picker service is unavailable' });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
picker.pickDirectory().then(function (path) {
|
|
402
414
|
if (path === null || path === undefined) return;
|
|
403
415
|
var snapshot = getSnapshot();
|
|
404
416
|
if (snapshot.sessionId) {
|
|
@@ -763,10 +775,22 @@ window.__ModuleLoader__.load({
|
|
|
763
775
|
/** The shell's own hero predicate, read from the dock's owner share: a
|
|
764
776
|
* blank conversation with an open session. A still-loading blank
|
|
765
777
|
* session already lists as blank, so it may enter the hero phase
|
|
766
|
-
* before the composer snapshot settles to `open`.
|
|
778
|
+
* before the composer snapshot settles to `open`.
|
|
779
|
+
*
|
|
780
|
+
* DSH 0.1.2 exposes no `composerPhase` on SessionSnapshot; the hero
|
|
781
|
+
* phase there is a settled blank session (mirroring ConversationRoot's
|
|
782
|
+
* `hero = sessionId === undefined || (shellPhase === 'blank' && ...)`),
|
|
783
|
+
* so the blank/lifecycle fields are used as the fallback. Newer DSH
|
|
784
|
+
* shells that do carry `composerPhase` keep their original check. */
|
|
767
785
|
function isHeroPhase(session, blank) {
|
|
768
786
|
if (!session) return false;
|
|
769
|
-
|
|
787
|
+
if (session.composerPhase !== undefined) {
|
|
788
|
+
return session.composerPhase === 'blank' && (session.openState === 'open' || blank === true);
|
|
789
|
+
}
|
|
790
|
+
return session.blank === true
|
|
791
|
+
&& !session.running
|
|
792
|
+
&& !session.promptAttempted
|
|
793
|
+
&& (blank === true || session.openState === 'open');
|
|
770
794
|
}
|
|
771
795
|
|
|
772
796
|
/** The workspace path a session belongs to, from the workspaces list. */
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
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
|
+
* Interception hydrates the configuration AWAITED (never a fire-and-forget
|
|
20
|
+
* read) and looks the dirs up by BOTH the policy root and the header cwd
|
|
21
|
+
* spelling, so a cold first call and a symlinked workspace cannot fall
|
|
22
|
+
* through to the default pipeline and surface a spurious workspace-write
|
|
23
|
+
* denial for a secondary-directory mutation. Successful write/edit
|
|
24
|
+
* short-circuits emit `fs/observed` like the shipped tools.
|
|
19
25
|
* Background shell runs (`run_in_background: true`) register with the
|
|
20
26
|
* generic jobs runtime (`ctx.jobs`) under the same re-rooted policy,
|
|
21
27
|
* mirroring the shipped pwsh/bash tools so `job_output` / `job_kill` and
|
|
@@ -539,8 +545,19 @@ export function apply(ctx) {
|
|
|
539
545
|
}
|
|
540
546
|
|
|
541
547
|
ctx.on('tools/execute', async (exec, next) => {
|
|
542
|
-
|
|
543
|
-
|
|
548
|
+
// Hydration must be AWAITED on the interception path, not fire-and-forget:
|
|
549
|
+
// a first call that arrives before the config read resolves would see an
|
|
550
|
+
// empty dirs cache, fall through to the default pipeline, and be fenced
|
|
551
|
+
// against the PRIMARY workspace root — surfacing as a spurious
|
|
552
|
+
// `[sandbox: file access denied under workspace-write mode]` for a
|
|
553
|
+
// secondary-directory write/edit. `loadDirs` caches, so only the first
|
|
554
|
+
// call pays the read.
|
|
555
|
+
const headerCwd =
|
|
556
|
+
exec.agent && exec.agent.session && exec.agent.session.header
|
|
557
|
+
? exec.agent.session.header.cwd
|
|
558
|
+
: undefined
|
|
559
|
+
if (typeof headerCwd === 'string' && headerCwd.length > 0) {
|
|
560
|
+
await loadDirs(headerCwd)
|
|
544
561
|
}
|
|
545
562
|
if (!INTERCEPT_TOOLS.has(exec.name)) return next()
|
|
546
563
|
try {
|
|
@@ -549,6 +566,13 @@ export function apply(ctx) {
|
|
|
549
566
|
const primary = standing.workspaceRoot
|
|
550
567
|
// An explicit escalation request belongs to the default pipeline.
|
|
551
568
|
if (args && args.sandbox_permissions !== undefined) return next()
|
|
569
|
+
// The policy root is realpath-canonicalized by sandbox-policy while
|
|
570
|
+
// hydration is keyed by the session cwd as spelled in the header; on a
|
|
571
|
+
// workspace reached through a symlinked/junctioned ancestor the two
|
|
572
|
+
// spellings differ, so consult both keys before falling through.
|
|
573
|
+
const dirs =
|
|
574
|
+
dirsForSync(primary) ??
|
|
575
|
+
(typeof headerCwd === 'string' && headerCwd.length > 0 ? dirsForSync(headerCwd) : null)
|
|
552
576
|
|
|
553
577
|
if (exec.name === 'write' || exec.name === 'edit') {
|
|
554
578
|
const filePath = args && typeof args.file_path === 'string' ? args.file_path : null
|
|
@@ -559,15 +583,18 @@ export function apply(ctx) {
|
|
|
559
583
|
const abs = fs.processPath(target)
|
|
560
584
|
// Security boundary: configuration is user-managed. Reject direct
|
|
561
585
|
// write/edit attempts against the host-owned config file, even before
|
|
562
|
-
// any directory matching.
|
|
563
|
-
|
|
586
|
+
// any directory matching. Both spellings of the workspace key are
|
|
587
|
+
// checked (see the dirs lookup above).
|
|
588
|
+
const guardHits = new Set(
|
|
589
|
+
[primary, headerCwd].filter((p) => typeof p === 'string' && p.length > 0).map((p) => wsKey(configPathFor(p))),
|
|
590
|
+
)
|
|
591
|
+
if (guardHits.has(wsKey(abs))) {
|
|
564
592
|
return {
|
|
565
593
|
isError: true,
|
|
566
594
|
error: { message: 'multi-folder configuration is user-managed' },
|
|
567
595
|
content: [{ type: 'text', text: CONFIG_GUARD_TEXT }],
|
|
568
596
|
}
|
|
569
597
|
}
|
|
570
|
-
const dirs = dirsForSync(primary)
|
|
571
598
|
if (dirs === null) return next()
|
|
572
599
|
const hit = longestRootFirst(dirs).find((d) => pathInside(abs, d))
|
|
573
600
|
if (hit === undefined) return next()
|
|
@@ -575,6 +602,11 @@ export function apply(ctx) {
|
|
|
575
602
|
|
|
576
603
|
if (exec.name === 'write') {
|
|
577
604
|
const outcome = await fs.writeText(target, String(args.content), undefined, exec.signal, policy)
|
|
605
|
+
// Keep the observation layer coherent with the shipped write tool's
|
|
606
|
+
// contract: a successful create/update is a presence observation.
|
|
607
|
+
if (typeof ctx.emit === 'function') {
|
|
608
|
+
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
|
609
|
+
}
|
|
578
610
|
const displayPath = displayPathOf(target, filePath)
|
|
579
611
|
const value = {
|
|
580
612
|
path: displayPath,
|
|
@@ -603,6 +635,9 @@ export function apply(ctx) {
|
|
|
603
635
|
exec.signal,
|
|
604
636
|
policy,
|
|
605
637
|
)
|
|
638
|
+
if (typeof ctx.emit === 'function') {
|
|
639
|
+
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
|
640
|
+
}
|
|
606
641
|
const displayPath = displayPathOf(target, filePath)
|
|
607
642
|
const value = { path: displayPath, before: outcome.before, after: outcome.after }
|
|
608
643
|
const text = replaceAll
|
|
@@ -614,7 +649,6 @@ export function apply(ctx) {
|
|
|
614
649
|
if (exec.name === 'pwsh' || exec.name === 'bash') {
|
|
615
650
|
const shell = ctx.get('shell')
|
|
616
651
|
if (shell === undefined) return next()
|
|
617
|
-
const dirs = dirsForSync(primary)
|
|
618
652
|
if (dirs === null) return next()
|
|
619
653
|
const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
|
|
620
654
|
const joined = rawWorkdir === null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"compatibility": {
|
|
43
43
|
"node": ">=20",
|
|
44
44
|
"dshReleases": {
|
|
45
|
+
"0.1.2-alpha.5": "compatible",
|
|
46
|
+
"0.1.2-alpha.4": "compatible",
|
|
47
|
+
"0.1.2-alpha.3": "compatible",
|
|
48
|
+
"0.1.2-alpha.2": "compatible",
|
|
45
49
|
"0.1.1-rc.2": "incompatible",
|
|
46
50
|
"0.1.2-alpha.1": "compatible"
|
|
47
51
|
}
|