@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,178 @@
1
+ /**
2
+ * Profile pnpm import isolation — the host-side maintenance behind
3
+ * `/workspace-folders isolate-profiles` (branch review Phase C2, finding R4).
4
+ *
5
+ * Why it exists: a Windows deny ACE applies to the file OBJECT, not to one
6
+ * hardlink name. The pnpm profile defaults upstream writes (`initProfile` in
7
+ * dsh-app-boot) never set `packageImportMethod`, so `dsh plugin add` imports
8
+ * the store into `<home>/profiles/<name>/node_modules` BY HARDLINK. Denying
9
+ * `$DSH_HOME` then rewrites security descriptors shared with every other name
10
+ * of the same objects — including the global pnpm store and other projects
11
+ * linking it. The C1 preflight therefore refuses such a home before any ACL
12
+ * write; this module is the remediation it points at: it detects the confirmed
13
+ * profiles, writes `packageImportMethod: copy` conservatively, and scans the
14
+ * whole home for the multi-link files that still need a reinstall.
15
+ *
16
+ * Boundaries of the subset it touches:
17
+ * - profile identification is conservative — only a directory under
18
+ * `profiles/` whose `package.json` parses to an object carrying
19
+ * `dsh.profile` as an object counts; everything else is enumerated but
20
+ * never written;
21
+ * - the YAML handling is a strict SUBSET scanner, not a parser substitute —
22
+ * the runtime cannot depend on a YAML package, so anything ambiguous is
23
+ * reported as a conflict and refused instead of guessed;
24
+ * - every write is atomic (temp sibling + rename) and never crosses a reparse
25
+ * point;
26
+ * - the whole-home scan never descends a reparse point: with plain `lstat` a
27
+ * symlink/junction reports as a link, and other reparse flavours still fall
28
+ * under the runner's own Win32-attribute preflight, which remains the
29
+ * authoritative gate;
30
+ * - the command sets configuration — it never deletes `node_modules`, never
31
+ * runs pnpm, and never repairs ACLs or ownership written in the past.
32
+ *
33
+ * @module profile-isolation
34
+ */
35
+ /**
36
+ * Mirrors `PROFILE_PNPM_WORKSPACE` written by `initProfile` in
37
+ * `@deepseek-ai/dsh-app-boot`; that constant is not exported, so the template
38
+ * is duplicated here and must stay in sync with upstream.
39
+ */
40
+ export declare const PROFILE_PNPM_WORKSPACE = "packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n";
41
+ /** One accepted top-level `key: value` line, located in the source text. */
42
+ export interface WorkspaceYamlEntry {
43
+ /** The unquoted scalar when the value is one; `undefined` for block/complex. */
44
+ readonly value: string | undefined;
45
+ /**
46
+ * `scalar` — a same-line plain or quoted scalar; `block` — the value is
47
+ * empty, so a nested block (or an explicit comment) follows; `complex` —
48
+ * the value opens with a YAML node property or collection marker
49
+ * (`&`, `*`, `!`, `|`, `>`, `{`, `[`) or is a malformed scalar the subset
50
+ * refuses to interpret.
51
+ */
52
+ readonly kind: 'scalar' | 'block' | 'complex';
53
+ /** 1-based line number the key sits on. */
54
+ readonly line: number;
55
+ /** UTF-16 code-unit offset where the value token begins. */
56
+ readonly valueStart: number;
57
+ /** UTF-16 code-unit offset just past the value token (before ` #comment` or EOL). */
58
+ readonly valueEnd: number;
59
+ }
60
+ /**
61
+ * Read the top-level `key: value` lines of a `pnpm-workspace.yaml` text.
62
+ *
63
+ * This is a strict subset scanner, deliberately NOT a YAML parser: it accepts
64
+ * only top-level `key:` lines (plain or quoted keys) plus indented nested
65
+ * content it never interprets, and throws — naming the file and the 1-based
66
+ * line — on an indented first entry, tabs in indentation, `%` / `---` / `...` directives, duplicate
67
+ * top-level keys, or any other non-indented content line outside the key
68
+ * form. A `#` preceded by whitespace ends a plain scalar; quoted scalars are
69
+ * unquoted for the reported value. `valueStart`/`valueEnd` are the UTF-16
70
+ * offsets of the value token itself, so a caller can replace exactly the
71
+ * scalar while comments and line endings survive untouched.
72
+ *
73
+ * @param text - the yaml text to scan.
74
+ * @param path - the file name used in rejection messages.
75
+ * @returns the top-level entries keyed by their unquoted names.
76
+ */
77
+ export declare function parseWorkspaceYamlKeys(text: string, path?: string): ReadonlyMap<string, WorkspaceYamlEntry>;
78
+ /** What one confirmed profile's `pnpm-workspace.yaml` says about imports. */
79
+ export interface ProfileYamlReport {
80
+ /**
81
+ * `absent` — `packageImportMethod` is not configured (the file may still
82
+ * exist); `copy` — it is a same-line scalar `copy`; `conflict` — it exists
83
+ * but is not a plain `copy` scalar, or the yaml exists but is a reparse
84
+ * point / non-file; `unreadable` — the file cannot be read or the subset
85
+ * scanner rejected it.
86
+ */
87
+ readonly state: 'absent' | 'copy' | 'conflict' | 'unreadable';
88
+ /** Human-readable reason for `conflict`/`unreadable`. */
89
+ readonly detail?: string;
90
+ }
91
+ /** One confirmed profile and its yaml verdict. */
92
+ export interface ProfileReport {
93
+ /** The profile directory name under `profiles/`. */
94
+ readonly name: string;
95
+ /** The profile's absolute path under the canonical home. */
96
+ readonly path: string;
97
+ /** The `packageImportMethod` verdict for its workspace yaml. */
98
+ readonly yaml: ProfileYamlReport;
99
+ }
100
+ /** The read-only whole-home scan accompanying every report. */
101
+ export interface HomeScan {
102
+ /** Directories successfully listed (including the home itself). */
103
+ readonly directories: number;
104
+ /** Non-directory entries inspected — plain files and reparse leaves alike. */
105
+ readonly files: number;
106
+ /** Plain files carrying more than one name (`st_nlink > 1`). */
107
+ readonly multiLinkTotal: number;
108
+ /** Up to {@link SAMPLE_CAP} absolute paths of multi-link files. */
109
+ readonly multiLinkPaths: readonly string[];
110
+ /** Entries `lstat`/`readdir` failed on (first {@link SAMPLE_CAP}). */
111
+ readonly uninspectable: readonly {
112
+ path: string;
113
+ reason: string;
114
+ }[];
115
+ /** Total uninspectable entries — may exceed the retained paths. */
116
+ readonly uninspectableTotal: number;
117
+ }
118
+ /** The read-only verdict for one canonical home. */
119
+ export interface IsolateReport {
120
+ /** The canonical DSH home the report covers. */
121
+ readonly home: string;
122
+ /** Confirmed profiles only; non-profile entries are listed separately. */
123
+ readonly profiles: readonly ProfileReport[];
124
+ /** `profiles/` entries that are not confirmed profiles — never written. */
125
+ readonly nonProfileEntries: readonly string[];
126
+ /** The whole-home link/uninspectable counts. */
127
+ readonly scan: HomeScan;
128
+ /** True whenever anything could not be inspected — never a clean result. */
129
+ readonly incomplete: boolean;
130
+ }
131
+ /** The per-profile outcome of one `--apply` run. */
132
+ export interface ProfileAction {
133
+ readonly name: string;
134
+ /**
135
+ * `written` — the yaml was created or gained the key; `replaced` — a
136
+ * same-line scalar was swapped in place; `noop` — already `copy`;
137
+ * `refused` — a conflict/unreadable file the command will not guess at.
138
+ */
139
+ readonly action: 'written' | 'replaced' | 'noop' | 'refused';
140
+ readonly detail?: string;
141
+ }
142
+ /** The report of one `--apply` run: the read verdict plus the actions. */
143
+ export interface ApplyReport extends IsolateReport {
144
+ readonly actions: readonly ProfileAction[];
145
+ }
146
+ /**
147
+ * Read-only inspection of a DSH home: confirmed profiles under `profiles/`,
148
+ * each one's `packageImportMethod` verdict, and a whole-home `lstat` scan
149
+ * counting directories, files, multi-link files, and uninspectable entries.
150
+ * Nothing is written. The report is `incomplete` — never clean — whenever any
151
+ * entry could not be inspected.
152
+ * @param home - the DSH home to inspect (canonicalized).
153
+ * @returns the isolation report.
154
+ */
155
+ export declare function isolateReport(home: string): IsolateReport;
156
+ /**
157
+ * Write `packageImportMethod: copy` into every fixable confirmed profile.
158
+ * A missing file receives the upstream profile template plus the copy line;
159
+ * a missing key is appended; a same-line non-`copy` scalar is replaced by
160
+ * byte range so comments and CRLF survive; a block/complex/duplicate-key or
161
+ * unreadable file is refused untouched. Every write lands atomically through
162
+ * a sibling temp file + rename, and the command never descends or writes
163
+ * through a reparse point.
164
+ * @param home - the DSH home to migrate (canonicalized).
165
+ * @returns the read verdict plus one action per confirmed profile.
166
+ */
167
+ export declare function applyIsolateConfig(home: string): ApplyReport;
168
+ /**
169
+ * The remediation steps the user must run by hand — this command writes
170
+ * configuration only. For every confirmed profile whose dependencies still
171
+ * need re-installing as copies (config changed under it, or links were seen
172
+ * inside it), the steps say: quit the hosts using it, remove its
173
+ * `node_modules` yourself, reinstall from its lockfile. Refused profiles get
174
+ * a manual-resolution line instead of automated steps.
175
+ * @param report - the {@link ApplyReport} (or a check report) to explain.
176
+ * @returns the guidance lines, in English.
177
+ */
178
+ export declare function remediationSteps(report: IsolateReport): string[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `MultiRootProviders`: the fail-loud guard that proves the services mounted as
3
+ * `ctx.fs` and `ctx.sandbox` are THIS bundle's providers — the only ones whose
4
+ * widening the registry, the instruction row, LSP routing and workspace-files
5
+ * are allowed to attach to.
6
+ *
7
+ * Why a brand and not `instanceof`: the plugin may be installed twice (a
8
+ * duplicated install forks the class identity), and cordis private fields are
9
+ * not a public contract. A well-known symbol survives both. The brand is
10
+ * non-enumerable so it never leaks into spreads, JSON, or surface payloads.
11
+ *
12
+ * Deterministic outcome under a provider conflict: cordis refuses a second
13
+ * `ctx.provide` of an already-registered key, so whoever mounts the colliding
14
+ * provider second loses loudly — but a conflicting plugin can also DISABLE this
15
+ * bundle's rows (the same mechanism this bundle uses on upstream), leaving a
16
+ * foreign `ctx.fs`/`ctx.sandbox` in place. In that case this service's
17
+ * `[Service.init]` logs which service is foreign and throws: the row never
18
+ * activates, so every row injecting `multiRootProviders` — the registry above
19
+ * all — never starts, the scope stays empty, and nothing is ever granted,
20
+ * regardless of patch order. Cordis re-evaluates an injecting fiber when an
21
+ * injected service is unprovided and re-provided, so a LATE replacement re-runs
22
+ * the same check.
23
+ *
24
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/providers
25
+ */
26
+ import { Service } from '@deepseek-ai/cordis';
27
+ /**
28
+ * The well-known brand key both providers carry. `Symbol.for` makes it
29
+ * identical across duplicated installs of this package.
30
+ */
31
+ export declare const MULTI_ROOT_PROVIDER_BRAND: unique symbol;
32
+ /** The two service keys this bundle takes over. */
33
+ export type ProviderKind = 'fs' | 'sandbox';
34
+ /**
35
+ * Stamp the multi-root provider brand on a service value. Non-enumerable:
36
+ * the mark must not travel into structural copies.
37
+ * @param value - the service instance being mounted.
38
+ * @param kind - which provider key it claims.
39
+ * @returns the same value, branded.
40
+ */
41
+ export declare function brandMultiRootProvider<T>(value: T, kind: ProviderKind): T;
42
+ /**
43
+ * Whether `value` is this bundle's provider of `kind`. Structural on purpose:
44
+ * no `instanceof`, so a duplicated install still recognizes the brand.
45
+ * @param value - the service mounted under the key.
46
+ * @param kind - the provider key being checked.
47
+ */
48
+ export declare function isMultiRootProvider(value: unknown, kind: ProviderKind): boolean;
49
+ declare module '@deepseek-ai/cordis' {
50
+ interface Context {
51
+ multiRootProviders: MultiRootProviders;
52
+ }
53
+ }
54
+ /**
55
+ * The conflict gate: `ctx.multiRootProviders` exists only while `ctx.fs` and
56
+ * `ctx.sandbox` are this bundle's branded providers. Every row that widens an
57
+ * authority surface injects it, so a foreign provider composition loses the
58
+ * whole gated set instead of silently widening an unverified one.
59
+ */
60
+ export declare class MultiRootProviders extends Service {
61
+ static provide: string;
62
+ static inject: string[];
63
+ /**
64
+ * Verify the provider identities once per activation. A foreign service is
65
+ * named in the log and the throw leaves this row — and every dependent —
66
+ * unstarted: fail loud, never a widened grant.
67
+ */
68
+ protected [Service.init](): void;
69
+ }
70
+ export default MultiRootProviders;
@@ -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
  /**