@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +156 -0
- package/README.md +156 -0
- package/cordis.patch.yml +44 -0
- package/lib/client.js +6512 -0
- package/lib/client.js.map +1 -0
- package/lib/command-BSkrYOAf.js +727 -0
- package/lib/command.js +2 -0
- package/lib/fs-D7UoCb7T.js +152 -0
- package/lib/fs.js +2 -0
- package/lib/index.js +17 -0
- package/lib/registry.js +466 -0
- package/lib/roots-DyT2jgEP.js +345 -0
- package/lib/sandbox-BhuMTDgH.js +243 -0
- package/lib/sandbox.js +2 -0
- package/lib/scope.js +143 -0
- package/lib/types/client/WorkspaceFoldersAction.d.ts +43 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/locales.d.ts +118 -0
- package/lib/types/client/panel-client.d.ts +59 -0
- package/lib/types/client/styles.d.ts +14 -0
- package/lib/types/command.d.ts +66 -0
- package/lib/types/containment.d.ts +36 -0
- package/lib/types/contract.d.ts +164 -0
- package/lib/types/dialects.d.ts +92 -0
- package/lib/types/fs.d.ts +90 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/registry.d.ts +293 -0
- package/lib/types/roots.d.ts +238 -0
- package/lib/types/sandbox.d.ts +65 -0
- package/lib/types/scope.d.ts +120 -0
- package/package.json +145 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MultiRootRegistry`: the durable answer to "which directories are extra
|
|
3
|
+
* writable roots of this workspace".
|
|
4
|
+
*
|
|
5
|
+
* It is the M3 data source the M2 providers were already written against: the
|
|
6
|
+
* registry owns the records, validates every mutation, re-validates everything
|
|
7
|
+
* it reads back, and feeds the ONE place enforcement resolves from
|
|
8
|
+
* (`ctx.multiRootScope`). Neither provider learns that a registry exists —
|
|
9
|
+
* `setAdditionalRoots()` stays the scope's only write port.
|
|
10
|
+
*
|
|
11
|
+
* Four deliberate properties:
|
|
12
|
+
*
|
|
13
|
+
* - **Only usable roots are granted.** A registered directory that is absent
|
|
14
|
+
* right now stays registered and is reported as `missing`, but is withheld
|
|
15
|
+
* from the scope: handing a nonexistent root to bwrap or Landlock would fail
|
|
16
|
+
* the confined command instead of merely not granting it. A registration
|
|
17
|
+
* whose path now resolves to a DIFFERENT directory is `redirected` and is
|
|
18
|
+
* withheld for the same reason it was never granted: re-resolving a path is
|
|
19
|
+
* not re-authorizing it (see `src/scope.ts`).
|
|
20
|
+
* - **Nothing is deleted on the operator's behalf.** A record that violates a
|
|
21
|
+
* rule is reported as `invalid` and excluded from the scope; it disappears
|
|
22
|
+
* only when the operator removes it (or when the store is repaired). Storage
|
|
23
|
+
* that cannot be parsed at all is backed up and skipped by the domain layer,
|
|
24
|
+
* with a warning.
|
|
25
|
+
* - **Mutations are serialized per primary root.** Every mutation runs its
|
|
26
|
+
* whole read → validate → persist sequence inside one queue per key. The
|
|
27
|
+
* storage domain serializes individual `put()`/`delete()` calls, which is not
|
|
28
|
+
* enough: two concurrent `add()` calls would both read the same snapshot and
|
|
29
|
+
* the later write would drop the earlier one, and a removal racing an add
|
|
30
|
+
* could write a deleted record back.
|
|
31
|
+
* - **Reads re-check, but never write.** {@link MultiRootRegistry.refresh} is
|
|
32
|
+
* the one revalidation entry point the command and the panel both call: it
|
|
33
|
+
* re-stats and re-canonicalizes every record, republishes the scope, and
|
|
34
|
+
* leaves storage untouched — a read must not rewrite the store, and a
|
|
35
|
+
* read-only refresh must not resurrect anything.
|
|
36
|
+
*
|
|
37
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
|
|
38
|
+
*/
|
|
39
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
40
|
+
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox';
|
|
41
|
+
import { z } from 'zod';
|
|
42
|
+
import { type AdditionalRootId, type RegisteredRoot, type RootRef, type RootStatus } from './roots.ts';
|
|
43
|
+
/** The domain name; also the store file stem (`$DSH_HOME/storages/<name>.json`). */
|
|
44
|
+
export declare const DOMAIN_NAME = "multi_root_workspace";
|
|
45
|
+
/** The single table name inside the domain. */
|
|
46
|
+
export declare const TABLE_NAME = "roots";
|
|
47
|
+
/** Longest accepted display alias. */
|
|
48
|
+
export declare const MAX_ALIAS_LENGTH = 120;
|
|
49
|
+
declare module '@deepseek-ai/cordis' {
|
|
50
|
+
interface Context {
|
|
51
|
+
multiRootRegistry: MultiRootRegistry;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* One primary root's whole registration list. The domain's `single` layout
|
|
56
|
+
* stores the entire unit as one document, so keys stay opaque strings — which
|
|
57
|
+
* is exactly what lets the canonical primary root itself be the key.
|
|
58
|
+
*/
|
|
59
|
+
declare const persistedPrimaryRoot: z.ZodObject<{
|
|
60
|
+
roots: z.ZodArray<z.ZodObject<{
|
|
61
|
+
id: z.ZodString;
|
|
62
|
+
path: z.ZodString;
|
|
63
|
+
recordedPath: z.ZodOptional<z.ZodString>;
|
|
64
|
+
alias: z.ZodOptional<z.ZodString>;
|
|
65
|
+
addedAt: z.ZodString;
|
|
66
|
+
}, z.core.$strip>>;
|
|
67
|
+
}, z.core.$strip>;
|
|
68
|
+
/** Stored shape of one primary root's records. */
|
|
69
|
+
export type PersistedPrimaryRoot = z.infer<typeof persistedPrimaryRoot>;
|
|
70
|
+
/**
|
|
71
|
+
* The domain declaration: version 1, `single` layout, one `roots` table keyed
|
|
72
|
+
* by the canonical primary root. Records that fail validation are backed up and
|
|
73
|
+
* skipped rather than blocking the whole harness from starting (see
|
|
74
|
+
* docs/decisions/ADR-0004).
|
|
75
|
+
*/
|
|
76
|
+
export declare const multiRootDomainSpec: {
|
|
77
|
+
name: string;
|
|
78
|
+
version: number;
|
|
79
|
+
invalidRecords: "backup-and-skip";
|
|
80
|
+
tables: {
|
|
81
|
+
roots: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, {
|
|
82
|
+
roots: {
|
|
83
|
+
id: string;
|
|
84
|
+
path: string;
|
|
85
|
+
addedAt: string;
|
|
86
|
+
recordedPath?: string | undefined;
|
|
87
|
+
alias?: string | undefined;
|
|
88
|
+
}[];
|
|
89
|
+
}>;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/** Registration request for one new additional root. */
|
|
93
|
+
export interface AddRootInput {
|
|
94
|
+
/** The directory, as typed by the operator (a leading `~` is expanded). */
|
|
95
|
+
readonly path: string;
|
|
96
|
+
/** Optional display alias. */
|
|
97
|
+
readonly alias?: string;
|
|
98
|
+
}
|
|
99
|
+
/** The registry service: `ctx.multiRootRegistry`. */
|
|
100
|
+
export declare class MultiRootRegistry extends Service {
|
|
101
|
+
static inject: string[];
|
|
102
|
+
/** The open table; present once the service has started. */
|
|
103
|
+
private table?;
|
|
104
|
+
/** Why the store is unusable, when it is; every mutation then fails loudly. */
|
|
105
|
+
private failure?;
|
|
106
|
+
/** Last classified status list per canonical primary root. */
|
|
107
|
+
private readonly cache;
|
|
108
|
+
/** Signature of the last published grant per key, so a no-op refresh stays a no-op. */
|
|
109
|
+
private readonly published;
|
|
110
|
+
/** One promise chain per canonical primary root: mutations never interleave. */
|
|
111
|
+
private readonly chains;
|
|
112
|
+
/** Change listeners, keyed by their own disposer identity. */
|
|
113
|
+
private readonly listeners;
|
|
114
|
+
/**
|
|
115
|
+
* @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
|
|
116
|
+
*/
|
|
117
|
+
constructor(ctx: Context);
|
|
118
|
+
/**
|
|
119
|
+
* Open the domain and publish every stored root into the scope.
|
|
120
|
+
*
|
|
121
|
+
* A store this build cannot read (corrupt records, a version written by a
|
|
122
|
+
* different plugin version) is reported loudly and degrades to the empty
|
|
123
|
+
* root set instead of failing activation: granting nothing is the safe
|
|
124
|
+
* direction, and a broken side store must not keep the whole harness from
|
|
125
|
+
* starting. Every mutation then reports `storage-unavailable` with that
|
|
126
|
+
* reason until the file is repaired or removed.
|
|
127
|
+
*/
|
|
128
|
+
protected [Service.init](): Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* Why the store is unusable, when it is. Surfaces show this instead of a
|
|
131
|
+
* silently empty list, so an operator can tell "no roots configured" from
|
|
132
|
+
* "the configuration could not be read".
|
|
133
|
+
* @returns the recorded failure text, or `undefined` while the store is healthy.
|
|
134
|
+
*/
|
|
135
|
+
get unavailable(): string | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* Every registration of one primary root, in registry order, as last
|
|
138
|
+
* classified. A READ of the in-memory state: it never touches the filesystem
|
|
139
|
+
* and never writes. Surfaces that must re-check the directories first call
|
|
140
|
+
* {@link MultiRootRegistry.refresh}.
|
|
141
|
+
* @param primaryRoot - the session workspace root (any spelling).
|
|
142
|
+
* @returns the statuses; empty when nothing is registered.
|
|
143
|
+
*/
|
|
144
|
+
list(primaryRoot: string): readonly RootStatus[];
|
|
145
|
+
/**
|
|
146
|
+
* Re-examine every registration of one primary root WITHOUT writing storage:
|
|
147
|
+
* re-`stat` every directory, re-resolve every path against its recorded
|
|
148
|
+
* directory, re-judge every rule, then republish the scope and notify
|
|
149
|
+
* listeners.
|
|
150
|
+
*
|
|
151
|
+
* This is the one revalidation entry point the command (`/workspace-folders
|
|
152
|
+
* list`) and the panel (`list` endpoint) both call, so "what the list shows"
|
|
153
|
+
* and "what is granted" cannot drift: a directory deleted after registration
|
|
154
|
+
* becomes `missing` and loses its grant, and a directory that came back is
|
|
155
|
+
* granted again — without either surface asking for a restart.
|
|
156
|
+
*
|
|
157
|
+
* The serialized queue is shared with the mutations, so a refresh never
|
|
158
|
+
* interleaves with a write that is mid-flight.
|
|
159
|
+
* @param primaryRoot - the session workspace root to re-check.
|
|
160
|
+
* @returns the refreshed status list.
|
|
161
|
+
*/
|
|
162
|
+
refresh(primaryRoot: string): Promise<readonly RootStatus[]>;
|
|
163
|
+
/**
|
|
164
|
+
* Registers one additional root.
|
|
165
|
+
* @param primaryRoot - the session workspace root the root belongs to.
|
|
166
|
+
* @param input - the directory and optional alias.
|
|
167
|
+
* @returns the updated status list.
|
|
168
|
+
* @throws {RootValidationError} when the candidate violates a root rule.
|
|
169
|
+
*/
|
|
170
|
+
add(primaryRoot: string, input: AddRootInput): Promise<readonly RootStatus[]>;
|
|
171
|
+
/**
|
|
172
|
+
* Remove one registration.
|
|
173
|
+
* @param primaryRoot - the session workspace root the root belongs to.
|
|
174
|
+
* @param ref - which registered root to remove.
|
|
175
|
+
* @returns the updated status list.
|
|
176
|
+
* @throws {RootValidationError} `not-found` when the reference matches nothing.
|
|
177
|
+
*/
|
|
178
|
+
remove(primaryRoot: string, ref: RootRef): Promise<readonly RootStatus[]>;
|
|
179
|
+
/**
|
|
180
|
+
* Remove exactly one registration — the one the reference names.
|
|
181
|
+
*
|
|
182
|
+
* Unlike a filter by id, this removes a single record, so a store holding two
|
|
183
|
+
* records that share an id can be cleaned up one entry at a time instead of
|
|
184
|
+
* losing both to one click.
|
|
185
|
+
* @param primaryRoot - the session workspace root the root belongs to.
|
|
186
|
+
* @param ref - which registered root to remove.
|
|
187
|
+
* @returns the updated status list.
|
|
188
|
+
* @throws {RootValidationError} `not-found` when the reference matches nothing.
|
|
189
|
+
*/
|
|
190
|
+
removeAt(primaryRoot: string, ref: RootRef): Promise<readonly RootStatus[]>;
|
|
191
|
+
/**
|
|
192
|
+
* Set or clear one registration's display alias.
|
|
193
|
+
* @param primaryRoot - the session workspace root the root belongs to.
|
|
194
|
+
* @param ref - which registered root to rename.
|
|
195
|
+
* @param alias - the new alias; empty or absent clears it.
|
|
196
|
+
* @returns the updated status list.
|
|
197
|
+
* @throws {RootValidationError} `not-found`/`invalid-alias`.
|
|
198
|
+
*/
|
|
199
|
+
setAlias(primaryRoot: string, ref: RootRef, alias: string | undefined): Promise<readonly RootStatus[]>;
|
|
200
|
+
/**
|
|
201
|
+
* Move one registration within the display order.
|
|
202
|
+
* @param primaryRoot - the session workspace root the root belongs to.
|
|
203
|
+
* @param ref - which registered root to move.
|
|
204
|
+
* @param beforeRef - the anchor to move in front of; absent moves it to the end.
|
|
205
|
+
* @returns the updated status list.
|
|
206
|
+
* @throws {RootValidationError} `not-found` when either reference matches nothing.
|
|
207
|
+
*/
|
|
208
|
+
move(primaryRoot: string, ref: RootRef, beforeRef?: RootRef): Promise<readonly RootStatus[]>;
|
|
209
|
+
/**
|
|
210
|
+
* Re-examine every registration of one primary root and persist what the
|
|
211
|
+
* re-examination produced — {@link MultiRootRegistry.refresh} plus one durable
|
|
212
|
+
* write. The recovery path after a directory was restored by hand, and the
|
|
213
|
+
* only read-shaped operation that is allowed to touch the store.
|
|
214
|
+
* @param primaryRoot - the session workspace root to re-check.
|
|
215
|
+
* @returns the refreshed status list.
|
|
216
|
+
*/
|
|
217
|
+
recheck(primaryRoot: string): Promise<readonly RootStatus[]>;
|
|
218
|
+
/**
|
|
219
|
+
* Observe registry changes.
|
|
220
|
+
* @param listener - called with the canonical primary root after each durable change.
|
|
221
|
+
* @returns the disposer that stops observing.
|
|
222
|
+
*/
|
|
223
|
+
onChange(listener: (primaryRoot: string) => void): () => void;
|
|
224
|
+
/**
|
|
225
|
+
* The scope the registry publishes: only usable roots, in registry order.
|
|
226
|
+
* @param primaryRoot - the session workspace root (any spelling).
|
|
227
|
+
* @returns the canonical paths of every `available` root.
|
|
228
|
+
*/
|
|
229
|
+
granted(primaryRoot: string): readonly string[];
|
|
230
|
+
/**
|
|
231
|
+
* The scope the registry publishes: only usable roots, in registry order.
|
|
232
|
+
* @param policy - the per-call policy; its workspace root is the primary root.
|
|
233
|
+
* @returns the granted canonical roots.
|
|
234
|
+
*/
|
|
235
|
+
grantedFor(policy: SandboxExecutionPolicy): readonly string[];
|
|
236
|
+
/** Classify one stored record set and cache it. */
|
|
237
|
+
private accept;
|
|
238
|
+
/**
|
|
239
|
+
* The statuses of one key, from memory where possible: the cache, else what
|
|
240
|
+
* storage holds, else empty. A cached answer is never re-checked here — that
|
|
241
|
+
* is {@link MultiRootRegistry.reclassify}, which the read surfaces and
|
|
242
|
+
* {@link MultiRootRegistry.refresh} call deliberately.
|
|
243
|
+
*/
|
|
244
|
+
private statusesOf;
|
|
245
|
+
/**
|
|
246
|
+
* The stored record set of one key, or `undefined` when there is none (or
|
|
247
|
+
* when the store itself could not be read: an unusable store reads as
|
|
248
|
+
* "nothing registered" so reads stay answerable and the surfaces can explain
|
|
249
|
+
* the situation, while every write fails loudly in `requireTable()`).
|
|
250
|
+
*/
|
|
251
|
+
private lookupRecords;
|
|
252
|
+
/**
|
|
253
|
+
* Re-classify one key from its stored records: every directory is `stat`ed
|
|
254
|
+
* again and every path is resolved again against its recorded canonical
|
|
255
|
+
* directory. Updates the cache, republishes the scope, and returns the new
|
|
256
|
+
* list — the shared body of the read-path re-checks.
|
|
257
|
+
*/
|
|
258
|
+
private reclassify;
|
|
259
|
+
/**
|
|
260
|
+
* Persist one status list, then republish it into the scope and to listeners.
|
|
261
|
+
* The durable write happens first: a failed write must not leave the running
|
|
262
|
+
* scope granting something storage does not hold.
|
|
263
|
+
*/
|
|
264
|
+
private persist;
|
|
265
|
+
/**
|
|
266
|
+
* Run one job for one primary root after every job already queued for that
|
|
267
|
+
* root. The whole read → validate → write sequence belongs inside the job:
|
|
268
|
+
* the storage domain serializes individual writes, so without this the loser
|
|
269
|
+
* of a race reads a snapshot that is already stale and writes it back.
|
|
270
|
+
*/
|
|
271
|
+
private serialize;
|
|
272
|
+
/**
|
|
273
|
+
* Push one status list into the scope and notify listeners.
|
|
274
|
+
*
|
|
275
|
+
* What is published is the effective GRANT — only `available` roots, so a
|
|
276
|
+
* withheld registration can never reach a provider. When the grant and the
|
|
277
|
+
* withheld set are unchanged (the common case for a read-path refresh on a
|
|
278
|
+
* healthy workspace) the scope is left alone and no listener fires: a refresh
|
|
279
|
+
* exists to keep the two in sync, not to churn on every listing — and, since
|
|
280
|
+
* the scope rebuilds its root table on each call, skipping the no-op write is
|
|
281
|
+
* also what keeps the read path cheap.
|
|
282
|
+
*/
|
|
283
|
+
private publish;
|
|
284
|
+
/**
|
|
285
|
+
* The open table.
|
|
286
|
+
* @returns the live table.
|
|
287
|
+
* @throws {RootValidationError} `storage-unavailable` when the store could
|
|
288
|
+
* not be opened at activation, or the service has not started.
|
|
289
|
+
*/
|
|
290
|
+
private requireTable;
|
|
291
|
+
}
|
|
292
|
+
export type { AdditionalRootId, RegisteredRoot, RootRef, RootStatus };
|
|
293
|
+
export default MultiRootRegistry;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root vocabulary: identities, the durable record shape, the status a
|
|
3
|
+
* stored record is reported with, and the validation rules every write and
|
|
4
|
+
* every startup re-read passes through.
|
|
5
|
+
*
|
|
6
|
+
* This module is the single home of "what makes a directory a legal additional
|
|
7
|
+
* root" (requirement §15). Both the registry (which persists) and the command
|
|
8
|
+
* and panel surfaces (which report) speak these types, so a rule can never be
|
|
9
|
+
* enforced on one path and forgotten on another.
|
|
10
|
+
*
|
|
11
|
+
* Rules, applied in this order — the first failure names the reason:
|
|
12
|
+
*
|
|
13
|
+
* 1. `not-absolute` — a relative input (after `~` expansion) is a
|
|
14
|
+
* misconfiguration, not a path to guess at.
|
|
15
|
+
* 2. `missing` — the directory does not exist.
|
|
16
|
+
* 3. `not-a-directory` — it exists but is not a directory.
|
|
17
|
+
* 4. `equals-primary` — the canonical candidate IS the session's workspace
|
|
18
|
+
* root, which is already granted by the upstream policy.
|
|
19
|
+
* 5. `primary-overlap` — the candidate lies under the workspace root, or
|
|
20
|
+
* contains it. The union grants nothing new, and the displayed list would
|
|
21
|
+
* stop matching the enforced root set.
|
|
22
|
+
* 6. `duplicate` — another registered root canonicalizes to the same path.
|
|
23
|
+
* 7. `nested` — the candidate lies under a registered root, or contains one.
|
|
24
|
+
* Same reasoning as rule 5, applied to the additional roots.
|
|
25
|
+
*
|
|
26
|
+
* The stored side of the same rules lives in {@link classifyStoredRoots}, which
|
|
27
|
+
* must never throw: a registration that stopped being usable is REPORTED, not
|
|
28
|
+
* deleted, so the operator can see it and remove it deliberately.
|
|
29
|
+
*
|
|
30
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/roots
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Stable identity of one registered additional root. A generated uuid, never
|
|
34
|
+
* the path: a path is re-canonicalized on every read and a display alias may
|
|
35
|
+
* change, while a reference anchor must stay put. The brand is local to this
|
|
36
|
+
* package — it is a compile-time distinction only and has no runtime form.
|
|
37
|
+
*/
|
|
38
|
+
export type AdditionalRootId = string & {
|
|
39
|
+
readonly __multiRootAdditionalRootId: 'AdditionalRootId';
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Brand one generated uuid as an {@link AdditionalRootId}.
|
|
43
|
+
* @param value - the raw uuid.
|
|
44
|
+
* @returns the branded id.
|
|
45
|
+
*/
|
|
46
|
+
export declare function additionalRootId(value: string): AdditionalRootId;
|
|
47
|
+
/** One registered additional root exactly as it is persisted. */
|
|
48
|
+
export interface RegisteredRoot {
|
|
49
|
+
/** Stable registry identity. */
|
|
50
|
+
readonly id: AdditionalRootId;
|
|
51
|
+
/** Canonical absolute directory (the `realpath` captured at registration). */
|
|
52
|
+
readonly path: string;
|
|
53
|
+
/**
|
|
54
|
+
* The canonical directory this registration was GRANTED for: the `realpath`
|
|
55
|
+
* observed when the operator registered it (or the last time the operator
|
|
56
|
+
* restored it). Authorization is `canonicalPath(path) === recordedPath` — a
|
|
57
|
+
* root whose current resolution moved somewhere else is reported as
|
|
58
|
+
* `redirected` and withheld, never silently granted elsewhere.
|
|
59
|
+
*/
|
|
60
|
+
readonly recordedPath?: string;
|
|
61
|
+
/** Optional display alias; absent when the operator cleared it. */
|
|
62
|
+
readonly alias?: string;
|
|
63
|
+
/** ISO-8601 instant of registration. */
|
|
64
|
+
readonly addedAt: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* How one stored record currently stands.
|
|
68
|
+
*
|
|
69
|
+
* - `available` — the directory is there, resolves to the recorded canonical
|
|
70
|
+
* directory, and is granted.
|
|
71
|
+
* - `missing` — the directory is not there right now; kept, withheld.
|
|
72
|
+
* - `redirected` — the path no longer resolves to the directory it was
|
|
73
|
+
* registered for (it was replaced by a symlink, or a symlink in the chain
|
|
74
|
+
* changed); kept, withheld, and `recheck` restores it once the original
|
|
75
|
+
* directory is back.
|
|
76
|
+
* - `invalid` — the record violates a rule and can only be removed.
|
|
77
|
+
*/
|
|
78
|
+
export type RootState = 'available' | 'missing' | 'redirected' | 'invalid';
|
|
79
|
+
/** One registered root as the surfaces report it. */
|
|
80
|
+
export interface RootStatus extends RegisteredRoot {
|
|
81
|
+
/** Whether the root is granted right now, withheld, or unusable. */
|
|
82
|
+
readonly state: RootState;
|
|
83
|
+
/** Why the root is not `available`; absent otherwise. */
|
|
84
|
+
readonly detail?: string;
|
|
85
|
+
}
|
|
86
|
+
/** One status together with the 1-based ordinal the surfaces accept for it. */
|
|
87
|
+
export interface IndexedRootStatus {
|
|
88
|
+
/** The 1-based position in the registration list. */
|
|
89
|
+
readonly ordinal: number;
|
|
90
|
+
/** The record at that position. */
|
|
91
|
+
readonly status: RootStatus;
|
|
92
|
+
}
|
|
93
|
+
/** How a caller names one registered root, including a verified surface snapshot. */
|
|
94
|
+
export type RootRef = {
|
|
95
|
+
readonly kind: 'id';
|
|
96
|
+
readonly id: string;
|
|
97
|
+
} | {
|
|
98
|
+
readonly kind: 'path';
|
|
99
|
+
readonly path: string;
|
|
100
|
+
} | {
|
|
101
|
+
readonly kind: 'ordinal';
|
|
102
|
+
readonly ordinal: number;
|
|
103
|
+
} | {
|
|
104
|
+
readonly kind: 'entry';
|
|
105
|
+
readonly ordinal: number;
|
|
106
|
+
readonly id: string;
|
|
107
|
+
readonly path: string;
|
|
108
|
+
readonly addedAt: string;
|
|
109
|
+
};
|
|
110
|
+
/** Every way root handling can fail, as a stable code the surfaces localize. */
|
|
111
|
+
export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'reveal-unavailable';
|
|
112
|
+
/** A rejected root operation; `code` is the stable contract, `message` the fallback prose. */
|
|
113
|
+
export declare class RootValidationError extends Error {
|
|
114
|
+
readonly code: RootValidationCode;
|
|
115
|
+
readonly detail?: {
|
|
116
|
+
readonly conflict?: string;
|
|
117
|
+
readonly reference?: string;
|
|
118
|
+
} | undefined;
|
|
119
|
+
/**
|
|
120
|
+
* @param code - stable failure code.
|
|
121
|
+
* @param message - human-readable fallback text (English; the panel localizes by code).
|
|
122
|
+
* @param detail - structured context, e.g. the conflicting root.
|
|
123
|
+
*/
|
|
124
|
+
constructor(code: RootValidationCode, message: string, detail?: {
|
|
125
|
+
readonly conflict?: string;
|
|
126
|
+
readonly reference?: string;
|
|
127
|
+
} | undefined);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Expand the one shell shorthand an operator may reasonably type, and nothing
|
|
131
|
+
* else: a leading `~` (alone or as `~/…`). Every other relative spelling is
|
|
132
|
+
* left intact so validation can reject it as `not-absolute` instead of
|
|
133
|
+
* silently resolving it against some unrelated process cwd.
|
|
134
|
+
* @param raw - the raw operator input.
|
|
135
|
+
* @param home - the home directory to expand against; defaults to `os.homedir()`.
|
|
136
|
+
* @returns the expanded path (still unvalidated).
|
|
137
|
+
*/
|
|
138
|
+
export declare function expandRootInput(raw: string, home?: string): string;
|
|
139
|
+
/**
|
|
140
|
+
* Whether `candidate` is `root` itself or lies beneath it. Both sides must
|
|
141
|
+
* already be canonical: symlinks are resolved by then, so the lexical test is
|
|
142
|
+
* exact for the alias cases (8.3 names, casing) the fence handles separately.
|
|
143
|
+
* @param candidate - canonical path to test.
|
|
144
|
+
* @param root - canonical root to test against.
|
|
145
|
+
* @returns whether the candidate is contained by the root.
|
|
146
|
+
*/
|
|
147
|
+
export declare function isCanonicallyUnder(candidate: string, root: string): boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Canonicalize a directory path the way the enforcement layers do (`realpath`),
|
|
150
|
+
* leaving an unreachable path untouched so the caller can report it as missing.
|
|
151
|
+
* @param path - the path to canonicalize.
|
|
152
|
+
* @returns the canonical path.
|
|
153
|
+
*/
|
|
154
|
+
export declare function canonicalRoot(path: string): string;
|
|
155
|
+
/** Caller-supplied facts one candidate is judged against. */
|
|
156
|
+
export interface RootCandidateContext {
|
|
157
|
+
/** The session's workspace root; the candidate may not equal or overlap it. */
|
|
158
|
+
readonly primaryRoot: string;
|
|
159
|
+
/**
|
|
160
|
+
* The home directory a leading `~` expands against. Defaults to `os.homedir()`;
|
|
161
|
+
* a caller that knows which home it means (a test, a probe) may say so instead
|
|
162
|
+
* of depending on where the host's home directory happens to sit.
|
|
163
|
+
*/
|
|
164
|
+
readonly home?: string;
|
|
165
|
+
/**
|
|
166
|
+
* Canonical paths already granted. Missing and invalid registrations are
|
|
167
|
+
* deliberately excluded: a withheld root grants nothing, so a candidate may
|
|
168
|
+
* legitimately sit under it, and an unusable record must not block a store
|
|
169
|
+
* the operator is trying to make work again.
|
|
170
|
+
*/
|
|
171
|
+
readonly existing: readonly string[];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Judge one candidate root and return its canonical spelling.
|
|
175
|
+
* @param raw - the raw operator input (a path, possibly `~`-prefixed).
|
|
176
|
+
* @param context - the primary root and the currently granted roots.
|
|
177
|
+
* @returns the canonical path the caller must store (and record as `recordedPath`).
|
|
178
|
+
* @throws {RootValidationError} with the first rule that failed.
|
|
179
|
+
*/
|
|
180
|
+
export declare function validateRootCandidate(raw: string, context: RootCandidateContext): string;
|
|
181
|
+
/**
|
|
182
|
+
* Re-judge every stored record of one primary root — the startup and refresh
|
|
183
|
+
* path, which must never throw: a registration that stopped being usable is
|
|
184
|
+
* REPORTED, not deleted, so the operator can see it and remove it deliberately.
|
|
185
|
+
*
|
|
186
|
+
* Existence and reachability are the only rules a record may recover from on
|
|
187
|
+
* its own, hence `missing` and `redirected` (kept, withheld) rather than
|
|
188
|
+
* `invalid` (kept, unusable).
|
|
189
|
+
* @param primaryRoot - the session workspace root the records belong to.
|
|
190
|
+
* @param records - the stored records, in registry order.
|
|
191
|
+
* @returns one status per record, in the same order.
|
|
192
|
+
*/
|
|
193
|
+
export declare function classifyStoredRoots(primaryRoot: string, records: readonly RegisteredRoot[]): RootStatus[];
|
|
194
|
+
/**
|
|
195
|
+
* The canonical roots a status list grants, in registry order.
|
|
196
|
+
* @param statuses - the statuses to project.
|
|
197
|
+
* @returns the canonical paths of every `available` root.
|
|
198
|
+
*/
|
|
199
|
+
export declare function availableRoots(statuses: readonly RootStatus[]): string[];
|
|
200
|
+
/**
|
|
201
|
+
* Pair every status with the 1-based ordinal the surfaces accept for it. The
|
|
202
|
+
* ordinal belongs to the POSITION, so it stays correct even for records whose
|
|
203
|
+
* id is duplicated or missing — which is exactly the case where a positional
|
|
204
|
+
* removal is the only unambiguous way to delete one of them.
|
|
205
|
+
* @param statuses - the statuses to index.
|
|
206
|
+
* @returns one entry per status, in the same order.
|
|
207
|
+
*/
|
|
208
|
+
export declare function indexedStatuses(statuses: readonly RootStatus[]): IndexedRootStatus[];
|
|
209
|
+
/**
|
|
210
|
+
* Resolve one operator-supplied reference against a status list.
|
|
211
|
+
* @param statuses - the statuses to search, in registry order.
|
|
212
|
+
* @param ref - the reference to resolve.
|
|
213
|
+
* @returns the matched status.
|
|
214
|
+
* @throws {RootValidationError} `not-found` when nothing matches.
|
|
215
|
+
*/
|
|
216
|
+
export declare function resolveRootRef(statuses: readonly RootStatus[], ref: RootRef): RootStatus;
|
|
217
|
+
/**
|
|
218
|
+
* Resolve one reference to its exact position.
|
|
219
|
+
*
|
|
220
|
+
* Id/path references are accepted only when unique. An `entry` reference also
|
|
221
|
+
* verifies the row identity captured by the surface, so a concurrent reorder
|
|
222
|
+
* cannot turn a click into an operation on a different record.
|
|
223
|
+
*/
|
|
224
|
+
export declare function resolveRootIndex(statuses: readonly RootStatus[], ref: RootRef): number;
|
|
225
|
+
/** Capture a list row as a mutation-safe reference. */
|
|
226
|
+
export declare function entryRootRef(status: RootStatus, ordinal: number): RootRef;
|
|
227
|
+
/**
|
|
228
|
+
* Remove exactly ONE record — the one the reference names — from a status list.
|
|
229
|
+
*
|
|
230
|
+
* Removing by id is rejected when it is ambiguous. Exact entry or ordinal
|
|
231
|
+
* references address one record, so a duplicate-id pair can be cleaned up one
|
|
232
|
+
* entry at a time without turning one operation into several.
|
|
233
|
+
* @param statuses - the statuses to remove from.
|
|
234
|
+
* @param ref - which record to remove.
|
|
235
|
+
* @returns a new list without that record, order preserved.
|
|
236
|
+
* @throws {RootValidationError} `not-found` when the reference matches nothing.
|
|
237
|
+
*/
|
|
238
|
+
export declare function removeStatusAt(statuses: readonly RootStatus[], ref: RootRef): RootStatus[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MultiRootSandboxProvider`: the kernel-sandbox dialect provider mounted as
|
|
3
|
+
* `ctx.sandbox`.
|
|
4
|
+
*
|
|
5
|
+
* Every confined execution in this composition funnels through `confine()` —
|
|
6
|
+
* the bash executor wraps `['bash','-c',command]`, and the terminal backend
|
|
7
|
+
* wraps its PTY spawn argv — so widening the grant here is what gives bash, PTY,
|
|
8
|
+
* and the fs fence one and the same root set (requirement §13). The bash
|
|
9
|
+
* executor itself is therefore left upstream: it never computes roots.
|
|
10
|
+
*
|
|
11
|
+
* The widening itself lives in `./dialects.ts`: the upstream profile builders
|
|
12
|
+
* are unreachable in the published package, so the additional roots are grafted
|
|
13
|
+
* onto the profile `super.confine` actually produced (recognize the dialect
|
|
14
|
+
* structurally, clone its grant spelling, fail loudly when the shape is not
|
|
15
|
+
* known). This class owns only the policy that decides WHEN to graft:
|
|
16
|
+
*
|
|
17
|
+
* 1. with no additional roots, or under any mode other than `workspace-write`,
|
|
18
|
+
* the upstream result is returned ELEMENT FOR ELEMENT — same argv, same
|
|
19
|
+
* `enforcement`, same `denialSignatures`, same `runnerFailureRules`;
|
|
20
|
+
* 2. `enforcement`, `denialSignatures`, and `runnerFailureRules` are NEVER
|
|
21
|
+
* recomputed here, even when the argv is widened — the bash executor derives
|
|
22
|
+
* its denial and enforcement reporting from them;
|
|
23
|
+
* 3. only `argv` may differ, and a dialect whose shape cannot be extended fails
|
|
24
|
+
* loudly (`SandboxUnavailableError`) instead of silently confining to the
|
|
25
|
+
* primary root alone.
|
|
26
|
+
*
|
|
27
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/sandbox
|
|
28
|
+
*/
|
|
29
|
+
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
|
|
30
|
+
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local';
|
|
31
|
+
import type { Config } from '@deepseek-ai/dsh-sandbox-local';
|
|
32
|
+
export type { Config };
|
|
33
|
+
/**
|
|
34
|
+
* Local process-sandbox provider over the multi-root scope. Registers as
|
|
35
|
+
* `ctx.sandbox`; the runner chain, probing, denial dialects, and Windows ACL
|
|
36
|
+
* grant mechanics stay the upstream implementation's.
|
|
37
|
+
*/
|
|
38
|
+
export declare class MultiRootSandboxProvider extends LocalSandboxProvider {
|
|
39
|
+
static inject: string[];
|
|
40
|
+
/** Whether the Windows ACL limitation has already been reported (once per provider). */
|
|
41
|
+
private warnedAboutWindowsAcl;
|
|
42
|
+
/**
|
|
43
|
+
* Wrap `argv` so it executes confined under `policy` on this host, granting
|
|
44
|
+
* the scope's additional roots inside the SAME dialect profile.
|
|
45
|
+
*
|
|
46
|
+
* Under `read-only` nothing is added (the mode denies every write, additional
|
|
47
|
+
* roots included), and `danger-full-access` never reaches here at all: the
|
|
48
|
+
* bash executor and the PTY backend both hand their argv straight to the local
|
|
49
|
+
* runtime in that mode. Widening therefore only ever ADDS the scope's
|
|
50
|
+
* additional roots to an upstream `workspace-write` profile.
|
|
51
|
+
* @param argv - the exact argv the caller is about to spawn.
|
|
52
|
+
* @param policy - the file-effect policy this execution runs under.
|
|
53
|
+
* @returns the argv to spawn instead, plus the selected backend's facts.
|
|
54
|
+
*/
|
|
55
|
+
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv;
|
|
56
|
+
/**
|
|
57
|
+
* Report the documented first-release limitation exactly once: the Windows ACL
|
|
58
|
+
* rung grants through per-workspace write SIDs rather than argv paths, so
|
|
59
|
+
* additional roots stay unenforced for confined commands there while the
|
|
60
|
+
* in-process filesystem fence still allows them.
|
|
61
|
+
* @param rootCount - how many additional roots the scope carries.
|
|
62
|
+
*/
|
|
63
|
+
private warnAboutWindowsAcl;
|
|
64
|
+
}
|
|
65
|
+
export default MultiRootSandboxProvider;
|