@dsh-electron/dsh-plugin-multi-root-workspace 0.1.5 → 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 (101) hide show
  1. package/CHANGELOG.en.md +56 -1
  2. package/CHANGELOG.md +56 -1
  3. package/README.en.md +157 -92
  4. package/README.md +158 -93
  5. package/cordis.patch.yml +61 -0
  6. package/lib/client.js +2819 -115
  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-82Y27Iom.js → compat-DF_yL1bB.js} +180 -4
  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-Cn1Jj5Xi.js → instructions-D19jRxN0.js} +109 -28
  17. package/lib/instructions.js +1 -1
  18. package/lib/lsp.js +43 -0
  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 +240 -25
  28. package/lib/session-directory-BLOstut1.js +9 -0
  29. package/lib/tool-context-CpgIFcCB.js +6 -0
  30. package/lib/types/client/RootSwitcher.d.ts +63 -0
  31. package/lib/types/client/WorkspaceFoldersAction.d.ts +41 -0
  32. package/lib/types/client/files-tree/FilesBody.module.css.d.ts +24 -0
  33. package/lib/types/client/files-tree/FilesTree.d.ts +47 -0
  34. package/lib/types/client/files-tree/adapter.d.ts +94 -0
  35. package/lib/types/client/files-tree/directory-node.d.ts +62 -0
  36. package/lib/types/client/files-tree/face.d.ts +107 -0
  37. package/lib/types/client/files-tree/locales.d.ts +10 -0
  38. package/lib/types/client/files-tree/primitives.d.ts +33 -0
  39. package/lib/types/client/files-tree/store.d.ts +81 -0
  40. package/lib/types/client/index.d.ts +13 -2
  41. package/lib/types/client/locales.d.ts +60 -0
  42. package/lib/types/client/open-in-app/OpenInAppAction.d.ts +27 -0
  43. package/lib/types/client/open-in-app/OpenTargetButton.d.ts +52 -0
  44. package/lib/types/client/open-in-app/OpenTargetButton.module.css.d.ts +29 -0
  45. package/lib/types/client/open-in-app/adapter.d.ts +46 -0
  46. package/lib/types/client/open-in-app/applications.d.ts +4 -0
  47. package/lib/types/client/open-in-app/controller.d.ts +53 -0
  48. package/lib/types/client/open-in-app/locales.d.ts +10 -0
  49. package/lib/types/client/open-in-app/open-failure-toast.d.ts +11 -0
  50. package/lib/types/client/open-in-app/open-path.d.ts +13 -0
  51. package/lib/types/client/panel-client.d.ts +2 -1
  52. package/lib/types/client/styles.d.ts +1 -1
  53. package/lib/types/command.d.ts +16 -3
  54. package/lib/types/compat/client-icons.d.ts +18 -0
  55. package/lib/types/compat/client-session.d.ts +2 -0
  56. package/lib/types/compat/dsh-version.d.ts +5 -3
  57. package/lib/types/compat/instruction-overlap.d.ts +7 -0
  58. package/lib/types/compat/open-in-app-routes.d.ts +38 -0
  59. package/lib/types/compat/process-entrypoints.d.ts +31 -0
  60. package/lib/types/compat/process-result.d.ts +14 -0
  61. package/lib/types/compat/pty-marker.d.ts +83 -0
  62. package/lib/types/compat/sandbox-confine.d.ts +2 -0
  63. package/lib/types/compat/session-directory.d.ts +4 -0
  64. package/lib/types/compat/storage-zod.d.ts +65 -0
  65. package/lib/types/compat/tool-context.d.ts +9 -0
  66. package/lib/types/compat/windows-deny-preflight.d.ts +21 -0
  67. package/lib/types/compat/windows-native.d.ts +68 -0
  68. package/lib/types/compat/windows-path-guard.d.ts +56 -0
  69. package/lib/types/compat/workspace-files.d.ts +7 -0
  70. package/lib/types/contract.d.ts +35 -4
  71. package/lib/types/dialects.d.ts +99 -3
  72. package/lib/types/fs.d.ts +32 -10
  73. package/lib/types/index.d.ts +5 -0
  74. package/lib/types/lsp.d.ts +4 -0
  75. package/lib/types/method-wrapper.d.ts +2 -0
  76. package/lib/types/process-fence.d.ts +54 -0
  77. package/lib/types/profile-isolation.d.ts +178 -0
  78. package/lib/types/providers.d.ts +70 -0
  79. package/lib/types/registry.d.ts +132 -31
  80. package/lib/types/roots.d.ts +100 -13
  81. package/lib/types/sandbox.d.ts +59 -12
  82. package/lib/types/scope.d.ts +203 -14
  83. package/lib/types/windows-broker.d.ts +17 -0
  84. package/lib/types/windows-deny.d.ts +1 -0
  85. package/lib/types/windows-profile.d.ts +10 -0
  86. package/lib/types/windows-runner-contract.d.ts +97 -0
  87. package/lib/types/windows-runner.d.ts +1 -0
  88. package/lib/types/workspace-files.d.ts +4 -0
  89. package/lib/types/workspace-routing.d.ts +11 -0
  90. package/lib/windows-runner-contract-CEfmZzu0.js +141 -0
  91. package/lib/windows-runner.js +1311 -0
  92. package/lib/workspace-files.js +203 -0
  93. package/lib/workspace-routing-YPxaQ4V3.js +26 -0
  94. package/package.json +116 -51
  95. package/scripts/windows-broker/install.ps1 +89 -0
  96. package/scripts/windows-broker/rights.cs +58 -0
  97. package/scripts/windows-broker/service.cs +323 -0
  98. package/scripts/windows-broker/verify-install.ps1 +80 -0
  99. package/lib/command-DyOWujPo.js +0 -690
  100. package/lib/sandbox-D8Fw95FM.js +0 -290
  101. package/lib/{registry-lease-win32-dy8TQMD9.js → registry-lease-win32-DTO6QpT9.js} +2 -2
@@ -0,0 +1,87 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ //#region src/providers.ts
3
+ /**
4
+ * `MultiRootProviders`: the fail-loud guard that proves the services mounted as
5
+ * `ctx.fs` and `ctx.sandbox` are THIS bundle's providers — the only ones whose
6
+ * widening the registry, the instruction row, LSP routing and workspace-files
7
+ * are allowed to attach to.
8
+ *
9
+ * Why a brand and not `instanceof`: the plugin may be installed twice (a
10
+ * duplicated install forks the class identity), and cordis private fields are
11
+ * not a public contract. A well-known symbol survives both. The brand is
12
+ * non-enumerable so it never leaks into spreads, JSON, or surface payloads.
13
+ *
14
+ * Deterministic outcome under a provider conflict: cordis refuses a second
15
+ * `ctx.provide` of an already-registered key, so whoever mounts the colliding
16
+ * provider second loses loudly — but a conflicting plugin can also DISABLE this
17
+ * bundle's rows (the same mechanism this bundle uses on upstream), leaving a
18
+ * foreign `ctx.fs`/`ctx.sandbox` in place. In that case this service's
19
+ * `[Service.init]` logs which service is foreign and throws: the row never
20
+ * activates, so every row injecting `multiRootProviders` — the registry above
21
+ * all — never starts, the scope stays empty, and nothing is ever granted,
22
+ * regardless of patch order. Cordis re-evaluates an injecting fiber when an
23
+ * injected service is unprovided and re-provided, so a LATE replacement re-runs
24
+ * the same check.
25
+ *
26
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/providers
27
+ */
28
+ /**
29
+ * The well-known brand key both providers carry. `Symbol.for` makes it
30
+ * identical across duplicated installs of this package.
31
+ */
32
+ const MULTI_ROOT_PROVIDER_BRAND = Symbol.for("@dsh-electron/dsh-plugin-multi-root-workspace/provider");
33
+ /**
34
+ * Stamp the multi-root provider brand on a service value. Non-enumerable:
35
+ * the mark must not travel into structural copies.
36
+ * @param value - the service instance being mounted.
37
+ * @param kind - which provider key it claims.
38
+ * @returns the same value, branded.
39
+ */
40
+ function brandMultiRootProvider(value, kind) {
41
+ Object.defineProperty(value, MULTI_ROOT_PROVIDER_BRAND, {
42
+ value: kind,
43
+ enumerable: false,
44
+ writable: false,
45
+ configurable: false
46
+ });
47
+ return value;
48
+ }
49
+ /**
50
+ * Whether `value` is this bundle's provider of `kind`. Structural on purpose:
51
+ * no `instanceof`, so a duplicated install still recognizes the brand.
52
+ * @param value - the service mounted under the key.
53
+ * @param kind - the provider key being checked.
54
+ */
55
+ function isMultiRootProvider(value, kind) {
56
+ return value !== null && (typeof value === "object" || typeof value === "function") && value[MULTI_ROOT_PROVIDER_BRAND] === kind;
57
+ }
58
+ /**
59
+ * The conflict gate: `ctx.multiRootProviders` exists only while `ctx.fs` and
60
+ * `ctx.sandbox` are this bundle's branded providers. Every row that widens an
61
+ * authority surface injects it, so a foreign provider composition loses the
62
+ * whole gated set instead of silently widening an unverified one.
63
+ */
64
+ var MultiRootProviders = class extends Service {
65
+ static provide = "multiRootProviders";
66
+ static inject = [
67
+ "multiRootCompat",
68
+ "fs",
69
+ "sandbox"
70
+ ];
71
+ /**
72
+ * Verify the provider identities once per activation. A foreign service is
73
+ * named in the log and the throw leaves this row — and every dependent —
74
+ * unstarted: fail loud, never a widened grant.
75
+ */
76
+ [Service.init]() {
77
+ const foreign = [];
78
+ if (!isMultiRootProvider(this.ctx.fs, "fs")) foreign.push("fs");
79
+ if (!isMultiRootProvider(this.ctx.sandbox, "sandbox")) foreign.push("sandbox");
80
+ if (foreign.length === 0) return;
81
+ const names = foreign.map((name) => `ctx.${name}`).join(" and ");
82
+ this.ctx.logger.error(`multi-root-workspace: ${names} is provided by another plugin, not this bundle's multi-root provider; refusing to start the multi-root rows so no widened grant can attach to an unverified provider. Resolve the conflict by disabling the other plugin's provider rows.`);
83
+ throw new Error(`multi-root-workspace: ${names} is a foreign provider; multi-root rows are disabled`);
84
+ }
85
+ };
86
+ //#endregion
87
+ export { MULTI_ROOT_PROVIDER_BRAND, MultiRootProviders, MultiRootProviders as default, brandMultiRootProvider, isMultiRootProvider };
@@ -1,11 +1,58 @@
1
- import { a as classifyStoredRoots, d as resolveRootIndex, i as canonicalRoot, n as additionalRootId, p as validateRootCandidate, r as availableRoots, t as RootValidationError, u as removeStatusAt } from "./roots-DasWEBJk.js";
1
+ import { i as isWindowsNetworkOrDevicePath } from "./windows-runner-contract-CEfmZzu0.js";
2
+ import { a as classifyStoredRoots, d as reservedRootBoundary, f as resolveRootIndex, i as canonicalRoot, m as validateRootCandidate, n as additionalRootId, r as availableRoots, s as expandRootInput, t as RootValidationError, u as removeStatusAt } from "./roots-B-0l8mNG.js";
3
+ import { createRequire } from "node:module";
2
4
  import { Service } from "@deepseek-ai/cordis";
3
- import { randomUUID } from "node:crypto";
4
- import { dirname, isAbsolute } from "node:path";
5
5
  import { closeSync, fstatSync, mkdirSync, openSync, statSync } from "node:fs";
6
+ import { dirname, isAbsolute } from "node:path";
7
+ import { randomUUID } from "node:crypto";
6
8
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
7
- import { z } from "zod";
8
9
  import { tryLockExclusive } from "@deepseek-ai/node-addon-system/flock";
10
+ //#region src/compat/storage-zod.ts
11
+ /**
12
+ * Build the domain record schemas with the zod module the installed
13
+ * `@deepseek-ai/dsh-storage-domain` resolves.
14
+ *
15
+ * ```text
16
+ * through 0.2.1-alpha.2 storage-domain declares zod ^4.4.3
17
+ * from 0.2.1-alpha.3 storage-domain declares zod 4.4.3 exactly
18
+ * ```
19
+ *
20
+ * `domainTable` types its `schema` parameter as `ZodType`, resolved from
21
+ * whichever zod copy storage-domain itself depends on — never from the copy
22
+ * this package installs for its own validation work (`src/contract.ts`
23
+ * parses the panel wire shapes with it). While upstream declared `^4.4.3`,
24
+ * both sides resolved to one install; the exact `4.4.3` pin since
25
+ * `0.2.1-alpha.3` splits the tree into two resolutions of the same v4 line,
26
+ * and zod v4 brands every type with the minor it was compiled by, so a
27
+ * schema produced by `4.6.x` is not assignable to a `ZodType` declared by
28
+ * `4.4.3`. Pinning this package to `4.4.3` would only move the split to the
29
+ * other side: on the supported releases before the pin changed, upstream's
30
+ * `^4.4.3` resolves to `4.6.x` and the pinned copy is the foreign one.
31
+ *
32
+ * The fix resolves the BUILDER the same way the boundary resolves the TYPE:
33
+ * `require('zod')` from inside the storage-domain package. A schema built by
34
+ * that namespace is by construction an instance of the `ZodType` the
35
+ * installed `domainTable` accepts, on every supported release — whichever
36
+ * zod version a release pins, both sides keep coming from one module.
37
+ *
38
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/compat/storage-zod
39
+ */
40
+ /**
41
+ * The `z` namespace as the installed `@deepseek-ai/dsh-storage-domain`
42
+ * resolves it.
43
+ *
44
+ * A resolution failure propagates rather than falling back to this package's
45
+ * zod: a storage-domain that no longer depends on zod changes what
46
+ * `domainTable` accepts, and that is an adapter decision, not a silent
47
+ * degradation. The failure surfaces where a missing peer already would — at
48
+ * module load, before any row runs.
49
+ * @returns the zod module, typed as the used surface only.
50
+ */
51
+ function domainZod() {
52
+ const require = createRequire(import.meta.url);
53
+ return createRequire(require.resolve("@deepseek-ai/dsh-storage-domain/package.json"))("zod");
54
+ }
55
+ //#endregion
9
56
  //#region src/registry-lease.ts
10
57
  /**
11
58
  * Store-wide kernel lease for the multi-root registry.
@@ -76,7 +123,7 @@ var RegistryAuthorityLease = class RegistryAuthorityLease {
76
123
  mode: 448
77
124
  });
78
125
  if (process.platform === "win32") {
79
- const { acquireLockHandleWin32 } = await import("./registry-lease-win32-dy8TQMD9.js");
126
+ const { acquireLockHandleWin32 } = await import("./registry-lease-win32-DTO6QpT9.js");
80
127
  let handle;
81
128
  try {
82
129
  handle = await acquireLockHandleWin32(leasePath);
@@ -128,7 +175,7 @@ var RegistryAuthorityLease = class RegistryAuthorityLease {
128
175
  if (this.released) return;
129
176
  this.released = true;
130
177
  if (this.held.kind === "win32") {
131
- const { releaseLockHandleWin32 } = await import("./registry-lease-win32-dy8TQMD9.js");
178
+ const { releaseLockHandleWin32 } = await import("./registry-lease-win32-DTO6QpT9.js");
132
179
  await releaseLockHandleWin32(this.held.handle);
133
180
  return;
134
181
  }
@@ -176,7 +223,9 @@ var RegistryAuthorityLease = class RegistryAuthorityLease {
176
223
  * memory-authoritative after open, so two DSH processes sharing a storage
177
224
  * root cannot each keep a live domain snapshot. A store-wide kernel lease
178
225
  * (ADR-0007) elects one authority; a contended process publishes an empty
179
- * scope, rejects mutations, and retries the lease from {@link MultiRootRegistry.refresh}.
226
+ * scope marked `authority: 'unavailable'` — under which Full-access is
227
+ * limited to primary-root-only writes — rejects mutations, and retries the lease
228
+ * from {@link MultiRootRegistry.refresh}.
180
229
  *
181
230
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
182
231
  */
@@ -191,19 +240,36 @@ const MAX_ALIAS_LENGTH = 120;
191
240
  * locale; the panel also shows this string verbatim via `RootsView.unavailable`.
192
241
  */
193
242
  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.";
194
- /** One persisted root record. `zod` validates it at the durable boundary. */
243
+ /**
244
+ * Default cap on stored additional-root records per primary root. The bench
245
+ * evidence for this default lives in `docs/reference/bench-root-scale-*.json`
246
+ * (`pnpm bench:roots`): classification and argv widening stay flat far beyond
247
+ * it, so the cap exists to bound the operator-facing list and the widened
248
+ * profile size, not to protect the classifier.
249
+ */
250
+ const DEFAULT_MAX_ADDITIONAL_ROOTS = 16;
251
+ /** Hard ceiling for `maxAdditionalRoots`: values above it are config-invalid. */
252
+ const MAX_ADDITIONAL_ROOTS_CEILING = 64;
253
+ /**
254
+ * The durable-boundary validators. The builder is the zod module the
255
+ * INSTALLED storage-domain resolves, not this package's own `zod` copy:
256
+ * `domainTable` types `schema` as the `ZodType` of ITS resolved zod, and
257
+ * since `0.2.1-alpha.3` upstream pins that copy at `4.4.3` exactly while
258
+ * this package floats on `^4.6.2` for its wire validation. The two copies
259
+ * brand incompatible types on either side of that upstream change, so the
260
+ * schema is built where the boundary type comes from — see
261
+ * `src/compat/storage-zod.ts`.
262
+ */
263
+ const z = domainZod();
195
264
  const persistedRoot = z.object({
196
265
  id: z.string().min(1),
197
266
  path: z.string().min(1),
198
267
  recordedPath: z.string().min(1).optional(),
268
+ readOnlyRecordedPath: z.string().min(1).optional(),
269
+ readWriteRecordedPath: z.string().min(1).optional(),
199
270
  alias: z.string().optional(),
200
271
  addedAt: z.string().min(1)
201
272
  });
202
- /**
203
- * One primary root's whole registration list. The domain's `single` layout
204
- * stores the entire unit as one document, so keys stay opaque strings — which
205
- * is exactly what lets the canonical primary root itself be the key.
206
- */
207
273
  const persistedPrimaryRoot = z.object({ roots: z.array(persistedRoot) });
208
274
  /**
209
275
  * The domain declaration: version 1, `single` layout, one `roots` table keyed
@@ -221,6 +287,7 @@ const multiRootDomainSpec = defineDomain({
221
287
  var MultiRootRegistry = class extends Service {
222
288
  static inject = [
223
289
  "multiRootCompat",
290
+ "multiRootProviders",
224
291
  "storageDomain",
225
292
  "multiRootScope"
226
293
  ];
@@ -248,13 +315,27 @@ var MultiRootRegistry = class extends Service {
248
315
  loggedContended = false;
249
316
  /** True once the fiber disposer has run: no further acquisition may happen. */
250
317
  disposed = false;
318
+ /** The validated per-primary-root cap (the default when the row omits it). */
319
+ maxRoots;
320
+ /** Keys already warned about a historical over-limit store (one warning each). */
321
+ warnedOverLimit = /* @__PURE__ */ new Set();
251
322
  /**
252
323
  * @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
253
- * @param config - the patch row's `leasePath`.
324
+ * @param config - the patch row's `leasePath` and `maxAdditionalRoots`.
254
325
  */
255
326
  constructor(ctx, config = {}) {
256
327
  super(ctx, "multiRootRegistry");
257
328
  this.leasePath = config.leasePath;
329
+ this.ctx.multiRootScope.setRegistryAuthority(false);
330
+ const configured = config.maxAdditionalRoots === void 0 ? 16 : config.maxAdditionalRoots;
331
+ if (Number.isInteger(configured) && configured >= 1 && configured <= 64) this.maxRoots = configured;
332
+ else {
333
+ this.maxRoots = 16;
334
+ this.authorityState = {
335
+ kind: "config-invalid",
336
+ reason: `${DOMAIN_NAME}: maxAdditionalRoots must be an integer between 1 and 64, got ${JSON.stringify(configured)}`
337
+ };
338
+ }
258
339
  }
259
340
  /**
260
341
  * Become the Registry Authority if possible: acquire the store-wide lease,
@@ -268,6 +349,10 @@ var MultiRootRegistry = class extends Service {
268
349
  */
269
350
  async [Service.init]() {
270
351
  this.ctx.effect(() => () => this.releaseAuthority(), "multi-root-registry: authority release");
352
+ if (this.authorityState.kind === "config-invalid") {
353
+ this.ctx.logger.error(`multi-root-workspace: ${this.authorityState.reason}; no additional root is granted`);
354
+ return;
355
+ }
271
356
  await this.ensureAuthority();
272
357
  }
273
358
  /**
@@ -281,7 +366,8 @@ var MultiRootRegistry = class extends Service {
281
366
  switch (this.authorityState.kind) {
282
367
  case "active": return;
283
368
  case "contended": return REGISTRY_CONTENDED_MESSAGE;
284
- case "storage-failed": return this.authorityState.reason;
369
+ case "storage-failed":
370
+ case "config-invalid": return this.authorityState.reason;
285
371
  }
286
372
  }
287
373
  /**
@@ -292,6 +378,21 @@ var MultiRootRegistry = class extends Service {
292
378
  return this.authorityState;
293
379
  }
294
380
  /**
381
+ * The configured cap on stored additional-root records per primary root.
382
+ * Surfaces show it next to the count so "N of LIMIT" is always explainable.
383
+ */
384
+ get limit() {
385
+ return this.maxRoots;
386
+ }
387
+ /**
388
+ * Whether one primary root's stored record count exceeds {@link limit} — a
389
+ * historical over-limit store, which stays fully classified and granted.
390
+ * @param primaryRoot - the session workspace root (any spelling).
391
+ */
392
+ overLimit(primaryRoot) {
393
+ return this.statusesOf(canonicalRoot(primaryRoot)).length > this.maxRoots;
394
+ }
395
+ /**
295
396
  * Every registration of one primary root, in registry order, as last
296
397
  * classified. A READ of the in-memory state: it never touches the filesystem
297
398
  * and never writes. Surfaces that must re-check the directories first call
@@ -342,24 +443,41 @@ var MultiRootRegistry = class extends Service {
342
443
  this.requireActive();
343
444
  const key = canonicalRoot(primaryRoot);
344
445
  return await this.serialize(key, async () => {
345
- const statuses = this.statusesOf(key);
446
+ const earlier = this.cache.get(key);
447
+ const statuses = this.reclassify(key);
448
+ const alias = normalizeAlias(input.alias);
449
+ const expanded = expandRootInput(input.path);
450
+ if (process.platform === "win32" && isWindowsNetworkOrDevicePath(expanded)) throw new RootValidationError("unsupported-path", `Windows ACL confinement cannot cover network or device paths: ${expanded}`);
451
+ const preliminary = isAbsolute(expanded) ? canonicalRoot(expanded) : void 0;
452
+ const revived = preliminary === void 0 ? -1 : statuses.findIndex((status) => {
453
+ if (process.platform === "win32" && isWindowsNetworkOrDevicePath(status.path)) return false;
454
+ if (status.path !== preliminary && canonicalRoot(status.path) !== preliminary) return false;
455
+ const before = earlier?.find((candidate) => candidate.id === status.id);
456
+ return status.state !== "available" || before !== void 0 && before.state !== "available";
457
+ });
458
+ if (revived === -1 && statuses.length >= this.maxRoots) throw new RootValidationError("root-limit-reached", `this workspace already has ${statuses.length} additional roots; the configured limit is ${this.maxRoots}. Remove one first, or raise maxAdditionalRoots.`, { reference: String(this.maxRoots) });
346
459
  const canonical = validateRootCandidate(input.path, {
347
460
  primaryRoot: key,
348
- existing: availableRoots(statuses)
461
+ existing: statuses.flatMap((status, index) => {
462
+ if (index === revived) return [];
463
+ const boundary = reservedRootBoundary(status);
464
+ return boundary === void 0 ? [] : [boundary];
465
+ })
349
466
  });
350
- const alias = normalizeAlias(input.alias);
351
- const revived = statuses.findIndex((status) => status.path === canonical || canonicalRoot(status.path) === canonical);
467
+ const access = input.access ?? "inherit";
352
468
  const next = revived === -1 ? [...statuses, {
353
469
  id: additionalRootId(randomUUID()),
354
470
  path: canonical,
355
- recordedPath: canonical,
471
+ access,
472
+ ...grantFields(access, canonical),
356
473
  state: "available",
357
474
  ...alias === void 0 ? {} : { alias },
358
475
  addedAt: (/* @__PURE__ */ new Date()).toISOString()
359
476
  }] : statuses.map((status, index) => index === revived ? {
360
- ...status,
477
+ ...dropGrantFields(status),
361
478
  path: canonical,
362
- recordedPath: canonical,
479
+ access: input.access ?? status.access,
480
+ ...grantFields(input.access ?? status.access, canonical),
363
481
  addedAt: (/* @__PURE__ */ new Date()).toISOString(),
364
482
  ...alias === void 0 ? {} : { alias }
365
483
  } : status);
@@ -367,6 +485,47 @@ var MultiRootRegistry = class extends Service {
367
485
  });
368
486
  }
369
487
  /**
488
+ * Change one registration's write authority WITHOUT re-recording its path.
489
+ *
490
+ * Re-resolving is not re-authorizing: the grant keeps the canonical
491
+ * directory it was recorded for; only which persisted field carries it
492
+ * changes. The three levels are not a total order — `inherit` is weaker
493
+ * than `read-write` under a read-only session but stronger under
494
+ * Full-access — so "upgrade" cannot be a single direction. The gate is
495
+ * therefore: pinning to `read-only` is always allowed (a withheld root may
496
+ * be restricted while it is withheld), while ANY other target requires the
497
+ * root to be `available` under the FRESH classification — every
498
+ * non-read-only level can widen authority under some session mode, and a
499
+ * root that is not currently granted must not be granted anything by a
500
+ * metadata change.
501
+ * @param primaryRoot - the session workspace root the root belongs to.
502
+ * @param ref - which registered root to change.
503
+ * @param access - the new write authority.
504
+ * @returns the updated status list.
505
+ * @throws {RootValidationError} `not-found` when the reference matches
506
+ * nothing, `root-unavailable` when the target is not `available` and the
507
+ * new level is not `read-only`.
508
+ */
509
+ async setAccess(primaryRoot, ref, access) {
510
+ await this.ensureAuthority();
511
+ this.requireActive();
512
+ const key = canonicalRoot(primaryRoot);
513
+ return await this.serialize(key, async () => {
514
+ const statuses = this.reclassify(key);
515
+ const targetIndex = resolveRootIndex(statuses, ref);
516
+ const target = statuses[targetIndex];
517
+ if (target.access === access) return statuses;
518
+ if (access !== "read-only" && target.state !== "available") throw new RootValidationError("root-unavailable", `"${target.path}" is ${target.state} and cannot change to "${access}"; restore the directory first — only "read-only" may be set while a root is not granted`, { reference: target.id });
519
+ const recorded = target.recordedPath ?? target.readOnlyRecordedPath ?? target.readWriteRecordedPath;
520
+ const next = statuses.map((status, index) => index === targetIndex ? {
521
+ ...dropGrantFields(status),
522
+ access,
523
+ ...recorded === void 0 ? {} : grantFields(access, recorded)
524
+ } : status);
525
+ return await this.persist(key, next);
526
+ });
527
+ }
528
+ /**
370
529
  * Remove one registration.
371
530
  * @param primaryRoot - the session workspace root the root belongs to.
372
531
  * @param ref - which registered root to remove.
@@ -391,7 +550,7 @@ var MultiRootRegistry = class extends Service {
391
550
  await this.ensureAuthority();
392
551
  this.requireActive();
393
552
  const key = canonicalRoot(primaryRoot);
394
- return await this.serialize(key, async () => await this.persist(key, removeStatusAt(this.statusesOf(key), ref)));
553
+ return await this.serialize(key, async () => await this.persist(key, removeStatusAt(this.reclassify(key), ref)));
395
554
  }
396
555
  /**
397
556
  * Set or clear one registration's display alias.
@@ -406,7 +565,7 @@ var MultiRootRegistry = class extends Service {
406
565
  this.requireActive();
407
566
  const key = canonicalRoot(primaryRoot);
408
567
  return await this.serialize(key, async () => {
409
- const statuses = this.statusesOf(key);
568
+ const statuses = this.reclassify(key);
410
569
  const targetIndex = resolveRootIndex(statuses, ref);
411
570
  const normalized = normalizeAlias(alias);
412
571
  const next = statuses.map((status, index) => {
@@ -433,7 +592,7 @@ var MultiRootRegistry = class extends Service {
433
592
  this.requireActive();
434
593
  const key = canonicalRoot(primaryRoot);
435
594
  return await this.serialize(key, async () => {
436
- const statuses = this.statusesOf(key);
595
+ const statuses = this.reclassify(key);
437
596
  const targetIndex = resolveRootIndex(statuses, ref);
438
597
  const anchorIndex = beforeRef === void 0 ? void 0 : resolveRootIndex(statuses, beforeRef);
439
598
  if (anchorIndex === targetIndex) return statuses;
@@ -494,6 +653,10 @@ var MultiRootRegistry = class extends Service {
494
653
  const key = canonicalRoot(primaryRoot);
495
654
  const statuses = classifyStoredRoots(key, record.roots.map(toRegisteredRoot));
496
655
  this.cache.set(key, statuses);
656
+ if (statuses.length > this.maxRoots && !this.warnedOverLimit.has(key)) {
657
+ this.warnedOverLimit.add(key);
658
+ this.ctx.logger.warn(`multi-root-workspace: ${statuses.length} roots are stored for ${key}, above the configured limit of ${this.maxRoots}; all of them stay granted, only new adds are refused`);
659
+ }
497
660
  this.publish(key, statuses);
498
661
  return statuses;
499
662
  }
@@ -571,25 +734,36 @@ var MultiRootRegistry = class extends Service {
571
734
  /**
572
735
  * Push one status list into the scope and notify listeners.
573
736
  *
574
- * What is published is the effective GRANT — only `available` roots, so a
575
- * withheld registration can never reach a provider. When the grant and the
576
- * withheld set are unchanged (the common case for a read-path refresh on a
737
+ * What is published is the effective GRANT — membership requires
738
+ * `available`, so a withheld registration can never reach a provider as a
739
+ * grant. Withheld entries still publish for their DENY boundary alone;
740
+ * that includes `membership-conflict` records, whose recorded grant is
741
+ * intact and trustworthy even though their membership cannot stand. When
742
+ * the grant and the withheld set are unchanged (the common case for a
743
+ * read-path refresh on a
577
744
  * healthy workspace) the scope is left alone and no listener fires: a refresh
578
745
  * exists to keep the two in sync, not to churn on every listing — and, since
579
746
  * the scope rebuilds its root table on each call, skipping the no-op write is
580
747
  * also what keeps the read path cheap.
748
+ *
749
+ * Available roots keep registry order: instruction discovery consumes them
750
+ * under a shared byte budget, so a move must update the live scope immediately.
751
+ * Aliases and no-op moves still leave the signature unchanged.
581
752
  */
582
753
  publish(key, statuses) {
583
754
  const withheld = statuses.filter((status) => status.state !== "available");
584
- const signature = [...statuses.filter((status) => status.state === "available").map((status) => `available:${status.recordedPath}`), ...withheld.map((status) => `${status.state}:${status.path}`)].join("\n");
585
- if (this.published.get(key) === signature) return;
755
+ const signature = [...statuses.filter((status) => status.state === "available").map((status) => `available:${status.access}:${recordedGrantOf(status)}`), ...withheld.map((status) => `${status.state}:${status.invalidReason ?? ""}:${status.access}:${status.path}:${recordedGrantOf(status) ?? ""}`)].join("\n");
756
+ if ((this.published.get(key) ?? "") === signature) return;
586
757
  this.published.set(key, signature);
587
758
  const effective = statuses.flatMap((status) => {
588
- if (status.state !== "available" || status.recordedPath === void 0) return [];
759
+ const recorded = recordedGrantOf(status);
760
+ if (status.state === "invalid" && status.invalidReason !== "membership-conflict" || recorded === void 0) return [];
589
761
  return [{
590
762
  id: status.id,
591
763
  path: status.path,
592
- recordedPath: status.recordedPath,
764
+ recordedPath: recorded,
765
+ access: status.access,
766
+ ...status.state === "available" ? {} : { withheld: true },
593
767
  ...status.alias === void 0 ? {} : { alias: status.alias }
594
768
  }];
595
769
  });
@@ -606,6 +780,7 @@ var MultiRootRegistry = class extends Service {
606
780
  */
607
781
  requireTable() {
608
782
  if (this.authorityState.kind === "contended") throw new RootValidationError("registry-contended", REGISTRY_CONTENDED_MESSAGE);
783
+ if (this.authorityState.kind === "config-invalid") throw new RootValidationError("config-invalid", `the root registry configuration is invalid (${this.authorityState.reason}); no additional root is granted`);
609
784
  if (this.authorityState.kind === "storage-failed") throw new RootValidationError("storage-unavailable", `the root registry store is unavailable (${this.authorityState.reason}); remove or repair \$DSH_HOME/storages/multi_root_workspace.json and restart dsh`);
610
785
  if (this.table === void 0) throw new Error("multi-root registry is not started yet");
611
786
  return this.table;
@@ -628,6 +803,7 @@ var MultiRootRegistry = class extends Service {
628
803
  await this.serializeAuthority(async () => {
629
804
  if (this.disposed) return;
630
805
  if (this.authorityState.kind === "active") return;
806
+ if (this.authorityState.kind === "config-invalid") return;
631
807
  if (this.lease !== void 0) {
632
808
  await this.openDomain();
633
809
  return;
@@ -676,10 +852,12 @@ var MultiRootRegistry = class extends Service {
676
852
  this.cache.clear();
677
853
  this.published.clear();
678
854
  for (const [primaryRoot, record] of this.table.entries()) this.accept(primaryRoot, record);
855
+ this.ctx.multiRootScope.setRegistryAuthority(true);
679
856
  }
680
857
  /** Fail closed: empty scope, no table, contended state. */
681
858
  enterContended() {
682
859
  this.withdrawPublished();
860
+ this.ctx.multiRootScope.setRegistryAuthority(false);
683
861
  this.table = void 0;
684
862
  this.authorityState = { kind: "contended" };
685
863
  if (!this.loggedContended) {
@@ -690,6 +868,7 @@ var MultiRootRegistry = class extends Service {
690
868
  /** Fail closed: empty scope, no table, storage-failed state. The lease, if held, stays. */
691
869
  enterStorageFailed(reason) {
692
870
  this.withdrawPublished();
871
+ this.ctx.multiRootScope.setRegistryAuthority(false);
693
872
  this.table = void 0;
694
873
  this.authorityState = {
695
874
  kind: "storage-failed",
@@ -720,6 +899,7 @@ var MultiRootRegistry = class extends Service {
720
899
  await this.serializeAuthority(async () => {
721
900
  this.table = void 0;
722
901
  this.authorityState = { kind: "contended" };
902
+ this.ctx.multiRootScope.setRegistryAuthority(false);
723
903
  await this.drainMutations();
724
904
  this.withdrawPublished();
725
905
  const domain = this.domain;
@@ -755,22 +935,63 @@ var MultiRootRegistry = class extends Service {
755
935
  return await run;
756
936
  }
757
937
  };
938
+ /** The recorded canonical directory a record was granted for, whichever grant field holds it. */
939
+ function recordedGrantOf(record) {
940
+ return record.recordedPath ?? record.readOnlyRecordedPath ?? record.readWriteRecordedPath;
941
+ }
942
+ /** The grant fields one access level persists: exactly one key, always. */
943
+ function grantFields(access, recorded) {
944
+ if (access === "read-only") return { readOnlyRecordedPath: recorded };
945
+ if (access === "read-write") return { readWriteRecordedPath: recorded };
946
+ return { recordedPath: recorded };
947
+ }
948
+ /** Strip every grant field so a re-write can set exactly the right one back. */
949
+ function dropGrantFields(status) {
950
+ const { recordedPath: _inherit, readOnlyRecordedPath: _read, readWriteRecordedPath: _write, ...rest } = status;
951
+ return rest;
952
+ }
758
953
  /** Project one status back to the durable record shape. */
759
954
  function toPersistedRoot(status) {
955
+ const grant = [
956
+ status.recordedPath,
957
+ status.readOnlyRecordedPath,
958
+ status.readWriteRecordedPath
959
+ ].filter((grant) => grant !== void 0 && grant !== "").length > 1 ? {
960
+ ...status.recordedPath === void 0 ? {} : { recordedPath: status.recordedPath },
961
+ ...status.readOnlyRecordedPath === void 0 ? {} : { readOnlyRecordedPath: status.readOnlyRecordedPath },
962
+ ...status.readWriteRecordedPath === void 0 ? {} : { readWriteRecordedPath: status.readWriteRecordedPath }
963
+ } : status.access === "read-only" ? status.readOnlyRecordedPath === void 0 || status.readOnlyRecordedPath === "" ? {} : { readOnlyRecordedPath: status.readOnlyRecordedPath } : status.access === "read-write" ? status.readWriteRecordedPath === void 0 || status.readWriteRecordedPath === "" ? {} : { readWriteRecordedPath: status.readWriteRecordedPath } : status.recordedPath === void 0 || status.recordedPath === "" ? {} : { recordedPath: status.recordedPath };
760
964
  return {
761
965
  id: status.id,
762
966
  path: status.path,
763
- ...status.recordedPath === void 0 || status.recordedPath === "" ? {} : { recordedPath: status.recordedPath },
967
+ ...grant,
764
968
  ...status.alias === void 0 ? {} : { alias: status.alias },
765
969
  addedAt: status.addedAt
766
970
  };
767
971
  }
768
- /** Recover the registered-root view of a stored record entry. */
972
+ /**
973
+ * Recover the registered-root view of a stored record entry.
974
+ *
975
+ * Access is derived from WHICH grant field is present, never read from a
976
+ * stored `access` key: the persisted layout carries no such key on purpose.
977
+ * That is the downgrade safety in action — an older plugin version parses the
978
+ * same document with a schema that strips unknown keys, so a read-only record
979
+ * (no `recordedPath`) reaches the old classifier as "no grant recorded" and is
980
+ * reported `invalid`: downgrading can never escalate a read-only registration
981
+ * into a writable grant.
982
+ */
769
983
  function toRegisteredRoot(entry) {
984
+ const hasInheritGrant = entry.recordedPath !== void 0 && entry.recordedPath !== "";
985
+ const hasReadGrant = entry.readOnlyRecordedPath !== void 0 && entry.readOnlyRecordedPath !== "";
986
+ const hasWriteGrant = entry.readWriteRecordedPath !== void 0 && entry.readWriteRecordedPath !== "";
987
+ const access = hasWriteGrant && !hasInheritGrant && !hasReadGrant ? "read-write" : hasReadGrant && !hasInheritGrant && !hasWriteGrant ? "read-only" : "inherit";
770
988
  return {
771
989
  id: additionalRootId(entry.id),
772
990
  path: entry.path,
991
+ access,
773
992
  ...entry.recordedPath === void 0 ? {} : { recordedPath: entry.recordedPath },
993
+ ...entry.readOnlyRecordedPath === void 0 ? {} : { readOnlyRecordedPath: entry.readOnlyRecordedPath },
994
+ ...entry.readWriteRecordedPath === void 0 ? {} : { readWriteRecordedPath: entry.readWriteRecordedPath },
774
995
  ...entry.alias === void 0 ? {} : { alias: entry.alias },
775
996
  addedAt: entry.addedAt
776
997
  };
@@ -790,4 +1011,4 @@ function normalizeAlias(alias) {
790
1011
  return trimmed;
791
1012
  }
792
1013
  //#endregion
793
- export { TABLE_NAME as a, RegistryLeaseContendedError as c, REGISTRY_CONTENDED_MESSAGE as i, MAX_ALIAS_LENGTH as n, multiRootDomainSpec as o, MultiRootRegistry as r, RegistryAuthorityLease as s, DOMAIN_NAME as t };
1014
+ export { MultiRootRegistry as a, multiRootDomainSpec as c, MAX_ALIAS_LENGTH as i, RegistryAuthorityLease as l, DOMAIN_NAME as n, REGISTRY_CONTENDED_MESSAGE as o, MAX_ADDITIONAL_ROOTS_CEILING as r, TABLE_NAME as s, DEFAULT_MAX_ADDITIONAL_ROOTS as t, RegistryLeaseContendedError as u };
package/lib/registry.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as TABLE_NAME, i as REGISTRY_CONTENDED_MESSAGE, n as MAX_ALIAS_LENGTH, o as multiRootDomainSpec, r as MultiRootRegistry, t as DOMAIN_NAME } from "./registry-DySMbjgW.js";
2
- export { DOMAIN_NAME, MAX_ALIAS_LENGTH, MultiRootRegistry, MultiRootRegistry as default, REGISTRY_CONTENDED_MESSAGE, TABLE_NAME, multiRootDomainSpec };
1
+ import { a as MultiRootRegistry, c as multiRootDomainSpec, i as MAX_ALIAS_LENGTH, n as DOMAIN_NAME, o as REGISTRY_CONTENDED_MESSAGE, r as MAX_ADDITIONAL_ROOTS_CEILING, s as TABLE_NAME, t as DEFAULT_MAX_ADDITIONAL_ROOTS } from "./registry-DCxC5Lay.js";
2
+ export { DEFAULT_MAX_ADDITIONAL_ROOTS, DOMAIN_NAME, MAX_ADDITIONAL_ROOTS_CEILING, MAX_ALIAS_LENGTH, MultiRootRegistry, MultiRootRegistry as default, REGISTRY_CONTENDED_MESSAGE, TABLE_NAME, multiRootDomainSpec };