@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.
@@ -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;