@dsh-electron/dsh-plugin-multi-root-workspace 0.1.6 → 0.1.7

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.
Files changed (93) hide show
  1. package/CHANGELOG.en.md +44 -1
  2. package/CHANGELOG.md +44 -1
  3. package/README.en.md +155 -92
  4. package/README.md +156 -91
  5. package/cordis.patch.yml +57 -0
  6. package/lib/client.js +2678 -276
  7. package/lib/client.js.map +1 -1
  8. package/lib/command-CESuwlVt.js +1306 -0
  9. package/lib/command.js +2 -2
  10. package/lib/{compat-B6ZqbQBy.js → compat-DF_yL1bB.js} +160 -2
  11. package/lib/compat.js +1 -1
  12. package/lib/contract-DZ4zczDT.js +238 -0
  13. package/lib/{fs-D_Sw3o5z.js → fs-9JIzGaf3.js} +55 -19
  14. package/lib/fs.js +2 -1
  15. package/lib/index.js +10 -8
  16. package/lib/{instructions-DfzN-wVf.js → instructions-D19jRxN0.js} +3 -2
  17. package/lib/instructions.js +1 -1
  18. package/lib/lsp.js +7 -2
  19. package/lib/method-wrapper-CFY3p3j2.js +26 -0
  20. package/lib/process-fence.js +280 -0
  21. package/lib/providers.js +87 -0
  22. package/lib/{registry-DySMbjgW.js → registry-DCxC5Lay.js} +256 -35
  23. package/lib/registry.js +2 -2
  24. package/lib/{roots-DasWEBJk.js → roots-B-0l8mNG.js} +117 -20
  25. package/lib/sandbox-Kup4_ZIb.js +747 -0
  26. package/lib/sandbox.js +2 -1
  27. package/lib/scope.js +229 -20
  28. package/lib/types/client/RootSwitcher.d.ts +63 -0
  29. package/lib/types/client/WorkspaceFoldersAction.d.ts +39 -1
  30. package/lib/types/client/files-tree/FilesBody.module.css.d.ts +24 -0
  31. package/lib/types/client/files-tree/FilesTree.d.ts +47 -0
  32. package/lib/types/client/files-tree/adapter.d.ts +94 -0
  33. package/lib/types/client/files-tree/directory-node.d.ts +62 -0
  34. package/lib/types/client/files-tree/face.d.ts +107 -0
  35. package/lib/types/client/files-tree/locales.d.ts +10 -0
  36. package/lib/types/client/files-tree/primitives.d.ts +33 -0
  37. package/lib/types/client/files-tree/store.d.ts +81 -0
  38. package/lib/types/client/locales.d.ts +56 -10
  39. package/lib/types/client/open-in-app/OpenInAppAction.d.ts +27 -0
  40. package/lib/types/client/open-in-app/OpenTargetButton.d.ts +52 -0
  41. package/lib/types/client/open-in-app/OpenTargetButton.module.css.d.ts +29 -0
  42. package/lib/types/client/open-in-app/adapter.d.ts +46 -0
  43. package/lib/types/client/open-in-app/applications.d.ts +4 -0
  44. package/lib/types/client/open-in-app/controller.d.ts +53 -0
  45. package/lib/types/client/open-in-app/locales.d.ts +10 -0
  46. package/lib/types/client/open-in-app/open-failure-toast.d.ts +11 -0
  47. package/lib/types/client/open-in-app/open-path.d.ts +13 -0
  48. package/lib/types/client/panel-client.d.ts +2 -1
  49. package/lib/types/client/styles.d.ts +1 -1
  50. package/lib/types/command.d.ts +16 -3
  51. package/lib/types/compat/client-icons.d.ts +18 -0
  52. package/lib/types/compat/dsh-version.d.ts +1 -1
  53. package/lib/types/compat/open-in-app-routes.d.ts +38 -0
  54. package/lib/types/compat/process-entrypoints.d.ts +31 -0
  55. package/lib/types/compat/process-result.d.ts +14 -0
  56. package/lib/types/compat/pty-marker.d.ts +83 -0
  57. package/lib/types/compat/sandbox-confine.d.ts +2 -0
  58. package/lib/types/compat/storage-zod.d.ts +65 -0
  59. package/lib/types/compat/windows-deny-preflight.d.ts +21 -0
  60. package/lib/types/compat/windows-native.d.ts +68 -0
  61. package/lib/types/compat/windows-path-guard.d.ts +56 -0
  62. package/lib/types/compat/workspace-files.d.ts +2 -0
  63. package/lib/types/contract.d.ts +31 -19
  64. package/lib/types/dialects.d.ts +99 -3
  65. package/lib/types/fs.d.ts +32 -10
  66. package/lib/types/index.d.ts +7 -2
  67. package/lib/types/process-fence.d.ts +54 -0
  68. package/lib/types/profile-isolation.d.ts +178 -0
  69. package/lib/types/providers.d.ts +70 -0
  70. package/lib/types/registry.d.ts +132 -31
  71. package/lib/types/roots.d.ts +100 -13
  72. package/lib/types/sandbox.d.ts +59 -3
  73. package/lib/types/scope.d.ts +200 -14
  74. package/lib/types/windows-broker.d.ts +17 -0
  75. package/lib/types/windows-deny.d.ts +1 -0
  76. package/lib/types/windows-profile.d.ts +10 -13
  77. package/lib/types/windows-runner-contract.d.ts +97 -0
  78. package/lib/windows-runner-contract-CEfmZzu0.js +141 -0
  79. package/lib/windows-runner.js +1256 -13
  80. package/lib/workspace-files.js +138 -3
  81. package/lib/workspace-routing-YPxaQ4V3.js +26 -0
  82. package/package.json +84 -62
  83. package/scripts/windows-broker/install.ps1 +89 -0
  84. package/scripts/windows-broker/rights.cs +58 -0
  85. package/scripts/windows-broker/service.cs +323 -0
  86. package/scripts/windows-broker/verify-install.ps1 +80 -0
  87. package/lib/command-CTdNvwt1.js +0 -791
  88. package/lib/method-wrapper-Zc-JlDP3.js +0 -51
  89. package/lib/sandbox-BRXl_FpF.js +0 -132
  90. package/lib/types/client/RootFileTree.d.ts +0 -12
  91. package/lib/types/panel-files.d.ts +0 -3
  92. package/lib/windows-profile-CiFsFYIV.js +0 -216
  93. package/lib/{registry-lease-win32-dy8TQMD9.js → registry-lease-win32-DTO6QpT9.js} +2 -2
@@ -37,14 +37,15 @@
37
37
  * memory-authoritative after open, so two DSH processes sharing a storage
38
38
  * root cannot each keep a live domain snapshot. A store-wide kernel lease
39
39
  * (ADR-0007) elects one authority; a contended process publishes an empty
40
- * scope, rejects mutations, and retries the lease from {@link MultiRootRegistry.refresh}.
40
+ * scope marked `authority: 'unavailable'` — under which Full-access is
41
+ * limited to primary-root-only writes — rejects mutations, and retries the lease
42
+ * from {@link MultiRootRegistry.refresh}.
41
43
  *
42
44
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
43
45
  */
44
46
  import { Context, Service } from '@deepseek-ai/cordis';
45
47
  import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox';
46
- import { z } from 'zod';
47
- import { type AdditionalRootId, type RegisteredRoot, type RootRef, type RootStatus } from './roots.ts';
48
+ import { type AdditionalRootId, type RegisteredRoot, type RootAccess, type RootRef, type RootStatus } from './roots.ts';
48
49
  /** The domain name; also the store file stem (`$DSH_HOME/storages/<name>.json`). */
49
50
  export declare const DOMAIN_NAME = "multi_root_workspace";
50
51
  /** The single table name inside the domain. */
@@ -56,10 +57,20 @@ export declare const MAX_ALIAS_LENGTH = 120;
56
57
  * locale; the panel also shows this string verbatim via `RootsView.unavailable`.
57
58
  */
58
59
  export declare const REGISTRY_CONTENDED_MESSAGE = "the root registry is owned by another DSH process; additional roots are not granted here. Close the other process, then retry.";
60
+ /**
61
+ * Default cap on stored additional-root records per primary root. The bench
62
+ * evidence for this default lives in `docs/reference/bench-root-scale-*.json`
63
+ * (`pnpm bench:roots`): classification and argv widening stay flat far beyond
64
+ * it, so the cap exists to bound the operator-facing list and the widened
65
+ * profile size, not to protect the classifier.
66
+ */
67
+ export declare const DEFAULT_MAX_ADDITIONAL_ROOTS = 16;
68
+ /** Hard ceiling for `maxAdditionalRoots`: values above it are config-invalid. */
69
+ export declare const MAX_ADDITIONAL_ROOTS_CEILING = 64;
59
70
  /**
60
71
  * Whether this process is the Registry Authority for the store-wide domain
61
72
  * medium. Contended and storage-failed are both fail-closed: the scope stays
62
- * empty and mutations reject.
73
+ * empty, is marked `authority: 'unavailable'`, and mutations reject.
63
74
  */
64
75
  export type RegistryAuthorityState = {
65
76
  readonly kind: 'active';
@@ -68,6 +79,9 @@ export type RegistryAuthorityState = {
68
79
  } | {
69
80
  readonly kind: 'storage-failed';
70
81
  readonly reason: string;
82
+ } | {
83
+ readonly kind: 'config-invalid';
84
+ readonly reason: string;
71
85
  };
72
86
  /**
73
87
  * Plugin configuration for the registry row. `leasePath` must sit beside the
@@ -78,6 +92,16 @@ export type RegistryAuthorityState = {
78
92
  export interface Config {
79
93
  /** Absolute path of the store-wide kernel lock file. */
80
94
  readonly leasePath?: string;
95
+ /**
96
+ * Cap on STORED additional-root records per primary root (every state
97
+ * counts, including withheld and unusable ones, so the count is stable and
98
+ * independent of the filesystem). Must be an integer in
99
+ * 1..{@link MAX_ADDITIONAL_ROOTS_CEILING}; an invalid value puts the
100
+ * registry in the fail-closed `config-invalid` state instead of throwing.
101
+ * A store that already exceeds the limit keeps every record and grants
102
+ * exactly what classification grants — only new adds are refused.
103
+ */
104
+ readonly maxAdditionalRoots?: number;
81
105
  }
82
106
  declare module '@deepseek-ai/cordis' {
83
107
  interface Context {
@@ -85,21 +109,54 @@ declare module '@deepseek-ai/cordis' {
85
109
  }
86
110
  }
87
111
  /**
88
- * One primary root's whole registration list. The domain's `single` layout
89
- * stores the entire unit as one document, so keys stay opaque strings — which
90
- * is exactly what lets the canonical primary root itself be the key.
112
+ * Stored shape of one root record.
113
+ *
114
+ * The access level is expressed by WHICH grant key is present, never by an
115
+ * `access` field on the same `recordedPath` (ADR-0017):
116
+ *
117
+ * - `recordedPath` means `inherit` — the historical writable grant, which
118
+ * under B9 keeps following the session mode. Upgrading a legacy record to
119
+ * `inherit` changes nothing about its effective authority.
120
+ * - `readOnlyRecordedPath` means `read-only`.
121
+ * - `readWriteRecordedPath` means fixed `read-write` (B9).
122
+ *
123
+ * That split is what makes this single layout downgrade-safe at domain
124
+ * version 1 (the JSON backend checks the version with strict equality, so a
125
+ * bump would break both upgrade and downgrade):
126
+ *
127
+ * - an OLDER plugin version parses this record with a schema that strips
128
+ * unknown keys, so a read-only or fixed read-write record loses its grant
129
+ * field and arrives with NO `recordedPath`; the old classification reports
130
+ * it `invalid` and grants nothing — a downgrade can never escalate either
131
+ * restricted level into a write grant. A fixed read-write record must
132
+ * therefore NEVER also write `recordedPath`: an older plugin would see the
133
+ * legacy field and grant it as plain read-write;
134
+ * - a record with `recordedPath` only is the writable grant it always was,
135
+ * so upgrading changes nothing for legacy records.
91
136
  */
92
- declare const persistedPrimaryRoot: z.ZodObject<{
93
- roots: z.ZodArray<z.ZodObject<{
94
- id: z.ZodString;
95
- path: z.ZodString;
96
- recordedPath: z.ZodOptional<z.ZodString>;
97
- alias: z.ZodOptional<z.ZodString>;
98
- addedAt: z.ZodString;
99
- }, z.core.$strip>>;
100
- }, z.core.$strip>;
101
- /** Stored shape of one primary root's records. */
102
- export type PersistedPrimaryRoot = z.infer<typeof persistedPrimaryRoot>;
137
+ type PersistedRoot = {
138
+ id: string;
139
+ path: string;
140
+ /**
141
+ * Absent only in records written before the field existed (or by hand),
142
+ * and in non-inherit records. Such a record is reported `invalid` rather
143
+ * than granted on a guess — see classifyStoredRoots.
144
+ */
145
+ recordedPath?: string | undefined;
146
+ readOnlyRecordedPath?: string | undefined;
147
+ readWriteRecordedPath?: string | undefined;
148
+ alias?: string | undefined;
149
+ addedAt: string;
150
+ };
151
+ /**
152
+ * Stored shape of one primary root's records: the whole registration list.
153
+ * The domain's `single` layout stores the entire unit as one document, so
154
+ * keys stay opaque strings — which is exactly what lets the canonical
155
+ * primary root itself be the key.
156
+ */
157
+ export type PersistedPrimaryRoot = {
158
+ roots: PersistedRoot[];
159
+ };
103
160
  /**
104
161
  * The domain declaration: version 1, `single` layout, one `roots` table keyed
105
162
  * by the canonical primary root. Records that fail validation are backed up and
@@ -111,15 +168,7 @@ export declare const multiRootDomainSpec: {
111
168
  version: number;
112
169
  invalidRecords: "backup-and-skip";
113
170
  tables: {
114
- roots: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, {
115
- roots: {
116
- id: string;
117
- path: string;
118
- addedAt: string;
119
- recordedPath?: string | undefined;
120
- alias?: string | undefined;
121
- }[];
122
- }>;
171
+ roots: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, PersistedPrimaryRoot>;
123
172
  };
124
173
  };
125
174
  /** Registration request for one new additional root. */
@@ -128,6 +177,12 @@ export interface AddRootInput {
128
177
  readonly path: string;
129
178
  /** Optional display alias. */
130
179
  readonly alias?: string;
180
+ /**
181
+ * The write authority to register; defaults to `inherit` (the historical
182
+ * session-following grant). On a REVIVE the existing record keeps its
183
+ * access unless this field is given explicitly.
184
+ */
185
+ readonly access?: RootAccess;
131
186
  }
132
187
  /** The registry service: `ctx.multiRootRegistry`. */
133
188
  export declare class MultiRootRegistry extends Service {
@@ -156,9 +211,13 @@ export declare class MultiRootRegistry extends Service {
156
211
  private loggedContended;
157
212
  /** True once the fiber disposer has run: no further acquisition may happen. */
158
213
  private disposed;
214
+ /** The validated per-primary-root cap (the default when the row omits it). */
215
+ private readonly maxRoots;
216
+ /** Keys already warned about a historical over-limit store (one warning each). */
217
+ private readonly warnedOverLimit;
159
218
  /**
160
219
  * @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
161
- * @param config - the patch row's `leasePath`.
220
+ * @param config - the patch row's `leasePath` and `maxAdditionalRoots`.
162
221
  */
163
222
  constructor(ctx: Context, config?: Config);
164
223
  /**
@@ -185,6 +244,17 @@ export declare class MultiRootRegistry extends Service {
185
244
  * Tests and diagnostics read this; surfaces use {@link unavailable}.
186
245
  */
187
246
  get authority(): RegistryAuthorityState;
247
+ /**
248
+ * The configured cap on stored additional-root records per primary root.
249
+ * Surfaces show it next to the count so "N of LIMIT" is always explainable.
250
+ */
251
+ get limit(): number;
252
+ /**
253
+ * Whether one primary root's stored record count exceeds {@link limit} — a
254
+ * historical over-limit store, which stays fully classified and granted.
255
+ * @param primaryRoot - the session workspace root (any spelling).
256
+ */
257
+ overLimit(primaryRoot: string): boolean;
188
258
  /**
189
259
  * Every registration of one primary root, in registry order, as last
190
260
  * classified. A READ of the in-memory state: it never touches the filesystem
@@ -225,6 +295,29 @@ export declare class MultiRootRegistry extends Service {
225
295
  * @throws {RootValidationError} when the candidate violates a root rule.
226
296
  */
227
297
  add(primaryRoot: string, input: AddRootInput): Promise<readonly RootStatus[]>;
298
+ /**
299
+ * Change one registration's write authority WITHOUT re-recording its path.
300
+ *
301
+ * Re-resolving is not re-authorizing: the grant keeps the canonical
302
+ * directory it was recorded for; only which persisted field carries it
303
+ * changes. The three levels are not a total order — `inherit` is weaker
304
+ * than `read-write` under a read-only session but stronger under
305
+ * Full-access — so "upgrade" cannot be a single direction. The gate is
306
+ * therefore: pinning to `read-only` is always allowed (a withheld root may
307
+ * be restricted while it is withheld), while ANY other target requires the
308
+ * root to be `available` under the FRESH classification — every
309
+ * non-read-only level can widen authority under some session mode, and a
310
+ * root that is not currently granted must not be granted anything by a
311
+ * metadata change.
312
+ * @param primaryRoot - the session workspace root the root belongs to.
313
+ * @param ref - which registered root to change.
314
+ * @param access - the new write authority.
315
+ * @returns the updated status list.
316
+ * @throws {RootValidationError} `not-found` when the reference matches
317
+ * nothing, `root-unavailable` when the target is not `available` and the
318
+ * new level is not `read-only`.
319
+ */
320
+ setAccess(primaryRoot: string, ref: RootRef, access: RootAccess): Promise<readonly RootStatus[]>;
228
321
  /**
229
322
  * Remove one registration.
230
323
  * @param primaryRoot - the session workspace root the root belongs to.
@@ -329,13 +422,21 @@ export declare class MultiRootRegistry extends Service {
329
422
  /**
330
423
  * Push one status list into the scope and notify listeners.
331
424
  *
332
- * What is published is the effective GRANT — only `available` roots, so a
333
- * withheld registration can never reach a provider. When the grant and the
334
- * withheld set are unchanged (the common case for a read-path refresh on a
425
+ * What is published is the effective GRANT — membership requires
426
+ * `available`, so a withheld registration can never reach a provider as a
427
+ * grant. Withheld entries still publish for their DENY boundary alone;
428
+ * that includes `membership-conflict` records, whose recorded grant is
429
+ * intact and trustworthy even though their membership cannot stand. When
430
+ * the grant and the withheld set are unchanged (the common case for a
431
+ * read-path refresh on a
335
432
  * healthy workspace) the scope is left alone and no listener fires: a refresh
336
433
  * exists to keep the two in sync, not to churn on every listing — and, since
337
434
  * the scope rebuilds its root table on each call, skipping the no-op write is
338
435
  * also what keeps the read path cheap.
436
+ *
437
+ * Available roots keep registry order: instruction discovery consumes them
438
+ * under a shared byte budget, so a move must update the live scope immediately.
439
+ * Aliases and no-op moves still leave the signature unchanged.
339
440
  */
340
441
  private publish;
341
442
  /**
@@ -22,13 +22,23 @@
22
22
  * 6. `duplicate` — another registered root canonicalizes to the same path.
23
23
  * 7. `nested` — the candidate lies under a registered root, or contains one.
24
24
  * Same reasoning as rule 5, applied to the additional roots.
25
+ * 8. `unsupported-path` — on Windows, a network or device path (`\\server`,
26
+ * `\\?\…`, `\\.\…`): the ACL confinement cannot be shown to cover it, so it
27
+ * is rejected lexically before any filesystem touch.
25
28
  *
26
29
  * The stored side of the same rules lives in {@link classifyStoredRoots}, which
27
30
  * must never throw: a registration that stopped being usable is REPORTED, not
28
- * deleted, so the operator can see it and remove it deliberately.
31
+ * deleted, so the operator can see it and remove it deliberately. It runs in
32
+ * two passes — every record's own structural trust and availability first,
33
+ * then overlap between the RECORDED boundaries of all structurally sound
34
+ * records — so the answer never depends on registry order, and a pair of
35
+ * records whose grants collide (`membership-conflict`) keeps its trustworthy
36
+ * negative boundary even while its membership is withheld.
29
37
  *
30
38
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/roots
31
39
  */
40
+ import { isWindowsNetworkOrDevicePath } from './windows-runner-contract.ts';
41
+ export { isWindowsNetworkOrDevicePath };
32
42
  /**
33
43
  * Stable identity of one registered additional root. A generated uuid, never
34
44
  * the path: a path is re-canonicalized on every read and a display alias may
@@ -44,6 +54,19 @@ export type AdditionalRootId = string & {
44
54
  * @returns the branded id.
45
55
  */
46
56
  export declare function additionalRootId(value: string): AdditionalRootId;
57
+ /**
58
+ * The write authority one registration carries (B9, ADR-0017).
59
+ *
60
+ * - `inherit` — the root follows the session mode: writable under
61
+ * `workspace-write` and unrestricted under `danger-full-access`, read-only
62
+ * under `read-only`. This is what legacy `recordedPath` records mean.
63
+ * - `read-only` — the root stays a workspace member (instructions, LSP,
64
+ * workspace-file routing, and reads all still see it) but a write is refused
65
+ * under EVERY mode, including `danger-full-access`.
66
+ * - `read-write` — the root is writable under EVERY mode, including a global
67
+ * `read-only` session; it does not widen anything beyond its own directory.
68
+ */
69
+ export type RootAccess = 'inherit' | 'read-write' | 'read-only';
47
70
  /** One registered additional root exactly as it is persisted. */
48
71
  export interface RegisteredRoot {
49
72
  /** Stable registry identity. */
@@ -51,13 +74,35 @@ export interface RegisteredRoot {
51
74
  /** Canonical absolute directory (the `realpath` captured at registration). */
52
75
  readonly path: string;
53
76
  /**
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.
77
+ * The write authority this registration carries. In memory every record has
78
+ * exactly one value; on disk it is expressed by WHICH grant field is
79
+ * present — `recordedPath` means `inherit`, `readOnlyRecordedPath` means
80
+ * `read-only`, `readWriteRecordedPath` means `read-write` — never by a
81
+ * separate `access` key (see `registry.ts` for the downgrade safety that
82
+ * split buys).
83
+ */
84
+ readonly access: RootAccess;
85
+ /**
86
+ * The canonical directory this INHERIT registration was GRANTED for: the
87
+ * `realpath` observed when the operator registered it (or the last time the
88
+ * operator restored it). Authorization is `canonicalPath(path) ===
89
+ * recordedPath` — a root whose current resolution moved somewhere else is
90
+ * reported as `redirected` and withheld, never silently granted elsewhere.
59
91
  */
60
92
  readonly recordedPath?: string;
93
+ /**
94
+ * The canonical directory this READ-ONLY registration was granted for. It
95
+ * plays exactly the role `recordedPath` plays for an inherit record, kept
96
+ * under a separate key so the single persisted layout stays downgrade-safe.
97
+ */
98
+ readonly readOnlyRecordedPath?: string;
99
+ /**
100
+ * The canonical directory this fixed READ-WRITE registration was granted
101
+ * for. Same role as the other two grant fields, under its own key: an older
102
+ * plugin strips the unknown field and classifies the record `invalid`
103
+ * rather than granting it as a legacy `recordedPath` write.
104
+ */
105
+ readonly readWriteRecordedPath?: string;
61
106
  /** Optional display alias; absent when the operator cleared it. */
62
107
  readonly alias?: string;
63
108
  /** ISO-8601 instant of registration. */
@@ -73,7 +118,9 @@ export interface RegisteredRoot {
73
118
  * registered for (it was replaced by a symlink, or a symlink in the chain
74
119
  * changed); kept, withheld, and `recheck` restores it once the original
75
120
  * directory is back.
76
- * - `invalid` — the record violates a rule and can only be removed.
121
+ * - `invalid` — the record violates a rule and can only be removed. Whether the
122
+ * violation also voids the record's negative boundary is told apart by
123
+ * {@link RootStatus.invalidReason}.
77
124
  */
78
125
  export type RootState = 'available' | 'missing' | 'redirected' | 'invalid';
79
126
  /** One registered root as the surfaces report it. */
@@ -82,6 +129,20 @@ export interface RootStatus extends RegisteredRoot {
82
129
  readonly state: RootState;
83
130
  /** Why the root is not `available`; absent otherwise. */
84
131
  readonly detail?: string;
132
+ /**
133
+ * Distinguishes WHY an `invalid` record is unusable. Internal only: it is
134
+ * neither persisted nor sent over the panel wire — `state` and `detail`
135
+ * carry everything a surface needs.
136
+ *
137
+ * `'membership-conflict'` means the record passed every structural rule —
138
+ * including an intact, absolute recorded grant — but its recorded boundary
139
+ * overlaps another record's. Neither membership may be granted while the
140
+ * conflict stands, yet the boundary remains a trustworthy negative
141
+ * restriction, so {@link reservedRootBoundary} keeps it reserved and the
142
+ * registry publishes the root as a withheld, deny-only entry. Every other
143
+ * `invalid` verdict left the grant unverifiable, so it contributes nothing.
144
+ */
145
+ readonly invalidReason?: 'membership-conflict';
85
146
  }
86
147
  /** One status together with the 1-based ordinal the surfaces accept for it. */
87
148
  export interface IndexedRootStatus {
@@ -108,7 +169,7 @@ export type RootRef = {
108
169
  readonly addedAt: string;
109
170
  };
110
171
  /** 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' | 'registry-contended' | 'reveal-unavailable' | 'session-not-found';
172
+ export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'registry-contended' | 'root-limit-reached' | 'config-invalid' | 'reveal-unavailable' | 'session-not-found' | 'unsupported-path' | 'root-unavailable';
112
173
  /** A rejected root operation; `code` is the stable contract, `message` the fallback prose. */
113
174
  export declare class RootValidationError extends Error {
114
175
  readonly code: RootValidationCode;
@@ -163,10 +224,19 @@ export interface RootCandidateContext {
163
224
  */
164
225
  readonly home?: string;
165
226
  /**
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.
227
+ * The platform the rules run for; defaults to `process.platform`. A caller
228
+ * (a test, a probe) may exercise another platform's rules — notably the
229
+ * Windows network/device-path refusal — on any host.
230
+ */
231
+ readonly platform?: NodeJS.Platform;
232
+ /**
233
+ * The recorded boundaries already reserved: {@link reservedRootBoundary}
234
+ * applied to every other status — available, missing, redirected, and
235
+ * membership-conflict records alike. A withheld root grants nothing, but
236
+ * its recorded boundary is the negative restriction a new writable root
237
+ * must not swallow while the operator has not removed it. Only a
238
+ * structurally invalid record contributes nothing, and the row being
239
+ * revived exempts only itself.
170
240
  */
171
241
  readonly existing: readonly string[];
172
242
  }
@@ -188,15 +258,32 @@ export declare function validateRootCandidate(raw: string, context: RootCandidat
188
258
  * `invalid` (kept, unusable).
189
259
  * @param primaryRoot - the session workspace root the records belong to.
190
260
  * @param records - the stored records, in registry order.
261
+ * @param platform - the platform the rules run for; defaults to `process.platform`.
191
262
  * @returns one status per record, in the same order.
192
263
  */
193
- export declare function classifyStoredRoots(primaryRoot: string, records: readonly RegisteredRoot[]): RootStatus[];
264
+ export declare function classifyStoredRoots(primaryRoot: string, records: readonly RegisteredRoot[], platform?: NodeJS.Platform): RootStatus[];
194
265
  /**
195
266
  * The canonical roots a status list grants, in registry order.
196
267
  * @param statuses - the statuses to project.
197
268
  * @returns the canonical paths of every `available` root.
198
269
  */
199
270
  export declare function availableRoots(statuses: readonly RootStatus[]): string[];
271
+ /**
272
+ * The recorded boundary one status still RESERVES: the grant it was
273
+ * registered for, which a new or revived registration may not overlap merely
274
+ * because the root is currently unusable.
275
+ *
276
+ * `available`, `missing`, `redirected`, and membership-conflict statuses all
277
+ * carry an intact, trustworthy recorded grant; every other `invalid` verdict
278
+ * means the grant could not be parsed at all, so it reserves — and denies —
279
+ * nothing. This is not an independent authorization table: it derives the
280
+ * boundary from the status list {@link classifyStoredRoots} produced, with
281
+ * the same grant-extraction semantics, so its answer can never disagree with
282
+ * classification.
283
+ * @param status - one classified registration.
284
+ * @returns the absolute recorded boundary, or `undefined` when there is none.
285
+ */
286
+ export declare function reservedRootBoundary(status: RootStatus): string | undefined;
200
287
  /**
201
288
  * Pair every status with the 1-based ordinal the surfaces accept for it. The
202
289
  * ordinal belongs to the POSITION, so it stays correct even for records whose
@@ -27,7 +27,8 @@
27
27
  *
28
28
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/sandbox
29
29
  */
30
- import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
30
+ import type { Context } from '@deepseek-ai/cordis';
31
+ import type { SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
31
32
  import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local';
32
33
  import type { Config } from '@deepseek-ai/dsh-sandbox-local';
33
34
  import type { UpstreamConfined } from './compat/sandbox-confine.ts';
@@ -39,6 +40,7 @@ export type { Config };
39
40
  */
40
41
  export declare class MultiRootSandboxProvider extends LocalSandboxProvider {
41
42
  static inject: string[];
43
+ constructor(ctx: Context, config: Config);
42
44
  /**
43
45
  * Wrap `argv` so it executes confined under `policy` on this host, granting
44
46
  * the scope's additional roots inside the SAME dialect profile.
@@ -60,17 +62,71 @@ export declare class MultiRootSandboxProvider extends LocalSandboxProvider {
60
62
  */
61
63
  confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): UpstreamConfined;
62
64
  /**
63
- * Graft the scope's additional roots onto one upstream `confine` result.
65
+ * Graft the scope's effective grants and denies onto one upstream `confine`
66
+ * result.
64
67
  *
65
68
  * Pure with respect to the upstream call: it only ever reads the scope and
66
69
  * rewrites `argv`. `enforcement`, `denialSignatures` and `runnerFailureRules`
67
70
  * are passed through untouched, because the bash executor derives its denial
68
71
  * and enforcement reporting from them.
72
+ *
73
+ * Mode behavior (B9, R2): `workspace-write` widens the grant with the
74
+ * effective writable roots AND denies any restricted root the upstream
75
+ * grants could reach (a `read-only` root registered under the platform
76
+ * temp). `read-only` grafts the fixed `read-write` roots back onto the
77
+ * deny-all baseline — the three-state matrix's standout cell — and then
78
+ * applies the SAME deny pass: a grafted writable parent must not silently
79
+ * swallow a denied child (Seatbelt appends the deny after the allow, bwrap
80
+ * adds a mandatory `--ro-bind`, Landlock / windows-acl refuse the
81
+ * unrepresentable subtraction). `danger-full-access` never reaches
82
+ * here (consumers bypass `confine`); its deny semantics live in
83
+ * {@link restrictedArgvPrefix}.
69
84
  * @param confined - the upstream result for this call.
70
85
  * @param argv - the caller's argv, needed to re-split the wrapped form.
71
86
  * @param policy - the per-call policy.
72
- * @returns the upstream result element for element, or one with a widened argv.
87
+ * @param scope - the authorization snapshot taken at dispatch.
88
+ * @returns the upstream result element for element, or one with a rewritten argv.
73
89
  */
74
90
  private widenForScope;
91
+ /**
92
+ * Build the runner-prefix that confines an ambient-writable execution to
93
+ * everything EXCEPT `deniedRoots` — the `danger-full-access` + explicit
94
+ * `read-only` cell, which the shell, PTY and PTC consumers never route
95
+ * through `confine()` at all.
96
+ *
97
+ * The restricted profile does not depend on the callee's argv, so the prefix
98
+ * is computed once per call here and the synchronous `ctx.subprocess.spawn`
99
+ * seam only has to prepend it. The upstream profile builders stay private:
100
+ * this probes `confine` under `read-only` for the runner's argument skeleton
101
+ * and rewrites the file-effect part per dialect. Windows selects the
102
+ * standalone ambient deny-token runner. Landlock cannot express
103
+ * "everything except", so this combination selects bubblewrap; unusable
104
+ * runners fail without spawning an unconfined command.
105
+ *
106
+ * The return preserves the installed `confine` shape: a synchronous result
107
+ * on a synchronous release, a promise on an asynchronous one.
108
+ * @param policy - the caller's policy (mode must be `danger-full-access`).
109
+ * @param deniedRoots - canonical roots this execution must not write.
110
+ * @param signal - cancellation, forwarded to releases that accept it.
111
+ * @param protectedHome - diagnostics label: which deny root is the
112
+ * protected `$DSH_HOME`. Forwarded to the Windows runner only when it is
113
+ * a member of `deniedRoots`; it never changes authorization.
114
+ * @returns the argv prefix, ending at the `--` separator.
115
+ */
116
+ restrictedArgvPrefix(policy: SandboxExecutionPolicy, deniedRoots: readonly string[], signal?: AbortSignal, protectedHome?: string): readonly string[] | Promise<readonly string[]>;
117
+ /** Full-access restriction and the actual selected backend's diagnostics. */
118
+ restrictedExecution(policy: SandboxExecutionPolicy, deniedRoots: readonly string[], signal?: AbortSignal, protectedHome?: string): UpstreamConfined;
119
+ /**
120
+ * Limit an execution with unknown registry authority to the primary root.
121
+ * Shared temp grants are unsafe: another process may have registered a
122
+ * read-only root beneath them. Harvest the upstream read-only skeleton and
123
+ * graft only the primary root, retaining diagnostics and the sync/async shape.
124
+ * Explicit denies still win; an unrepresentable overlap refuses launch.
125
+ * @param policy - the caller's execution policy.
126
+ * @param deniedRoots - known negative boundaries, including protected home.
127
+ * @param signal - cancellation forwarded to upstream.
128
+ * @returns the confined prefix ending at `--`, without the caller's command.
129
+ */
130
+ clampedExecution(policy: SandboxExecutionPolicy, deniedRoots: readonly string[], signal?: AbortSignal): UpstreamConfined;
75
131
  }
76
132
  export default MultiRootSandboxProvider;