@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +89 -0
- package/CHANGELOG.md +89 -0
- package/README.en.md +96 -20
- package/README.md +97 -19
- package/cordis.patch.yml +22 -1
- package/lib/client.js +234 -71
- package/lib/client.js.map +1 -1
- package/lib/{command-BSkrYOAf.js → command-DyOWujPo.js} +24 -61
- package/lib/command.js +2 -2
- package/lib/compat-CRdv0vPw.js +272 -0
- package/lib/compat.js +2 -0
- package/lib/{fs-D7UoCb7T.js → fs-D_Sw3o5z.js} +7 -3
- package/lib/fs.js +1 -1
- package/lib/index.js +8 -6
- package/lib/instructions-Cn1Jj5Xi.js +703 -0
- package/lib/instructions.js +2 -0
- package/lib/registry-DySMbjgW.js +793 -0
- package/lib/registry-lease-win32-dy8TQMD9.js +93 -0
- package/lib/registry.js +2 -466
- package/lib/{sandbox-BhuMTDgH.js → sandbox-D8Fw95FM.js} +51 -4
- package/lib/sandbox.js +1 -1
- package/lib/scope.js +1 -1
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/panel-client.d.ts +0 -2
- package/lib/types/command.d.ts +17 -0
- package/lib/types/compat/agent-instructions.d.ts +138 -0
- package/lib/types/compat/client-icons.d.ts +40 -0
- package/lib/types/compat/client-session.d.ts +32 -0
- package/lib/types/compat/dsh-version.d.ts +119 -0
- package/lib/types/compat/llm-message.d.ts +72 -0
- package/lib/types/compat/sandbox-confine.d.ts +58 -0
- package/lib/types/compat/tool-result.d.ts +29 -0
- package/lib/types/compat.d.ts +85 -0
- package/lib/types/contract.d.ts +6 -8
- package/lib/types/index.d.ts +19 -5
- package/lib/types/instructions.d.ts +125 -0
- package/lib/types/registry-lease-win32.d.ts +29 -0
- package/lib/types/registry-lease.d.ts +62 -0
- package/lib/types/registry.d.ts +130 -19
- package/lib/types/roots.d.ts +1 -1
- package/lib/types/sandbox.d.ts +22 -2
- package/package.json +60 -13
- package/lib/{roots-DyT2jgEP.js → roots-DasWEBJk.js} +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
//#region src/registry-lease-win32.ts
|
|
5
|
+
/**
|
|
6
|
+
* Windows named-kernel-semaphore adapter for the registry authority lease.
|
|
7
|
+
*
|
|
8
|
+
* This follows the same kernel-object contract as upstream
|
|
9
|
+
* `SessionWriteLease` (published `@deepseek-ai/dsh-session-persistence-jsonl`
|
|
10
|
+
* 0.1.5-rc.2): a count-1 named semaphore, a zero-timeout wait, and release by
|
|
11
|
+
* restoring the count then closing the handle — the object dies with its last
|
|
12
|
+
* handle, including on process death. It is a local reimplementation; it does
|
|
13
|
+
* not deep-import `session-persistence-jsonl/src/win32.ts` (ADR-0002).
|
|
14
|
+
*
|
|
15
|
+
* Koffi is loaded lazily so POSIX processes never load it.
|
|
16
|
+
*
|
|
17
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/registry-lease-win32
|
|
18
|
+
*/
|
|
19
|
+
const WAIT_OBJECT_0 = 0;
|
|
20
|
+
const WAIT_TIMEOUT = 258;
|
|
21
|
+
const ERROR_SHARING_VIOLATION = 32;
|
|
22
|
+
let bindings;
|
|
23
|
+
/** Load kernel32 through Koffi the first time a Windows process needs it. */
|
|
24
|
+
async function kernel32() {
|
|
25
|
+
if (bindings !== void 0) return bindings;
|
|
26
|
+
const library = (await import("koffi")).default.load("kernel32.dll");
|
|
27
|
+
bindings = {
|
|
28
|
+
createSemaphoreW: library.func("__stdcall", "CreateSemaphoreW", "intptr", [
|
|
29
|
+
"void*",
|
|
30
|
+
"int",
|
|
31
|
+
"int",
|
|
32
|
+
"str16"
|
|
33
|
+
]),
|
|
34
|
+
waitForSingleObject: library.func("__stdcall", "WaitForSingleObject", "uint", ["intptr", "uint"]),
|
|
35
|
+
releaseSemaphore: library.func("__stdcall", "ReleaseSemaphore", "int", [
|
|
36
|
+
"intptr",
|
|
37
|
+
"int",
|
|
38
|
+
"void*"
|
|
39
|
+
]),
|
|
40
|
+
closeHandle: library.func("__stdcall", "CloseHandle", "int", ["intptr"]),
|
|
41
|
+
getLastError: library.func("__stdcall", "GetLastError", "uint", [])
|
|
42
|
+
};
|
|
43
|
+
return bindings;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A syscall-shaped error so POSIX contention (`EAGAIN`) and Windows contention
|
|
47
|
+
* (`EBUSY`) can share one `isLockContention` check.
|
|
48
|
+
*/
|
|
49
|
+
function win32Error(syscall, win32Code, path, dest) {
|
|
50
|
+
const code = win32Code === ERROR_SHARING_VIOLATION ? "EBUSY" : "EIO";
|
|
51
|
+
const error = /* @__PURE__ */ new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`);
|
|
52
|
+
error.code = code;
|
|
53
|
+
error.errno = win32Code;
|
|
54
|
+
error.syscall = syscall;
|
|
55
|
+
error.path = path;
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Derive the semaphore name from the physical parent directory, resolving
|
|
60
|
+
* junctions, symlinks and short path aliases before case folding and hashing.
|
|
61
|
+
* @param path - the lock path; its parent exists, but the file need not.
|
|
62
|
+
* @returns the store's kernel-object name.
|
|
63
|
+
*/
|
|
64
|
+
function leaseNameWin32(path) {
|
|
65
|
+
const absolute = resolve(path);
|
|
66
|
+
const physical = join(realpathSync.native(dirname(absolute)), basename(absolute)).toLowerCase();
|
|
67
|
+
return `Local\\dsh-multi-root-registry-${createHash("sha256").update(physical).digest("hex")}`;
|
|
68
|
+
}
|
|
69
|
+
/** Acquire the semaphore for the physical store directory. */
|
|
70
|
+
async function acquireLockHandleWin32(path) {
|
|
71
|
+
const api = await kernel32();
|
|
72
|
+
const name = leaseNameWin32(path);
|
|
73
|
+
const handle = api.createSemaphoreW(null, 1, 1, name);
|
|
74
|
+
if (handle === 0) throw win32Error("CreateSemaphoreW", api.getLastError(), path, name);
|
|
75
|
+
const wait = api.waitForSingleObject(handle, 0);
|
|
76
|
+
if (wait === WAIT_OBJECT_0) return handle;
|
|
77
|
+
api.closeHandle(handle);
|
|
78
|
+
if (wait === WAIT_TIMEOUT) throw win32Error("WaitForSingleObject", ERROR_SHARING_VIOLATION, path, name);
|
|
79
|
+
throw win32Error("WaitForSingleObject", api.getLastError(), path, name);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Restore the semaphore count and close the handle (the object dies with its
|
|
83
|
+
* last handle, including on process death).
|
|
84
|
+
* @param handle - the open semaphore handle.
|
|
85
|
+
*/
|
|
86
|
+
async function releaseLockHandleWin32(handle) {
|
|
87
|
+
const api = await kernel32();
|
|
88
|
+
const released = api.releaseSemaphore(handle, 1, null);
|
|
89
|
+
const closed = api.closeHandle(handle);
|
|
90
|
+
if (released === 0 || closed === 0) throw win32Error("ReleaseSemaphore", api.getLastError(), `handle:${String(handle)}`, `handle:${String(handle)}`);
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export { acquireLockHandleWin32, releaseLockHandleWin32 };
|
package/lib/registry.js
CHANGED
|
@@ -1,466 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
//#region src/registry.ts
|
|
7
|
-
/**
|
|
8
|
-
* `MultiRootRegistry`: the durable answer to "which directories are extra
|
|
9
|
-
* writable roots of this workspace".
|
|
10
|
-
*
|
|
11
|
-
* It is the M3 data source the M2 providers were already written against: the
|
|
12
|
-
* registry owns the records, validates every mutation, re-validates everything
|
|
13
|
-
* it reads back, and feeds the ONE place enforcement resolves from
|
|
14
|
-
* (`ctx.multiRootScope`). Neither provider learns that a registry exists —
|
|
15
|
-
* `setAdditionalRoots()` stays the scope's only write port.
|
|
16
|
-
*
|
|
17
|
-
* Four deliberate properties:
|
|
18
|
-
*
|
|
19
|
-
* - **Only usable roots are granted.** A registered directory that is absent
|
|
20
|
-
* right now stays registered and is reported as `missing`, but is withheld
|
|
21
|
-
* from the scope: handing a nonexistent root to bwrap or Landlock would fail
|
|
22
|
-
* the confined command instead of merely not granting it. A registration
|
|
23
|
-
* whose path now resolves to a DIFFERENT directory is `redirected` and is
|
|
24
|
-
* withheld for the same reason it was never granted: re-resolving a path is
|
|
25
|
-
* not re-authorizing it (see `src/scope.ts`).
|
|
26
|
-
* - **Nothing is deleted on the operator's behalf.** A record that violates a
|
|
27
|
-
* rule is reported as `invalid` and excluded from the scope; it disappears
|
|
28
|
-
* only when the operator removes it (or when the store is repaired). Storage
|
|
29
|
-
* that cannot be parsed at all is backed up and skipped by the domain layer,
|
|
30
|
-
* with a warning.
|
|
31
|
-
* - **Mutations are serialized per primary root.** Every mutation runs its
|
|
32
|
-
* whole read → validate → persist sequence inside one queue per key. The
|
|
33
|
-
* storage domain serializes individual `put()`/`delete()` calls, which is not
|
|
34
|
-
* enough: two concurrent `add()` calls would both read the same snapshot and
|
|
35
|
-
* the later write would drop the earlier one, and a removal racing an add
|
|
36
|
-
* could write a deleted record back.
|
|
37
|
-
* - **Reads re-check, but never write.** {@link MultiRootRegistry.refresh} is
|
|
38
|
-
* the one revalidation entry point the command and the panel both call: it
|
|
39
|
-
* re-stats and re-canonicalizes every record, republishes the scope, and
|
|
40
|
-
* leaves storage untouched — a read must not rewrite the store, and a
|
|
41
|
-
* read-only refresh must not resurrect anything.
|
|
42
|
-
*
|
|
43
|
-
* @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
|
|
44
|
-
*/
|
|
45
|
-
/** The domain name; also the store file stem (`$DSH_HOME/storages/<name>.json`). */
|
|
46
|
-
const DOMAIN_NAME = "multi_root_workspace";
|
|
47
|
-
/** The single table name inside the domain. */
|
|
48
|
-
const TABLE_NAME = "roots";
|
|
49
|
-
/** Longest accepted display alias. */
|
|
50
|
-
const MAX_ALIAS_LENGTH = 120;
|
|
51
|
-
/** One persisted root record. `zod` validates it at the durable boundary. */
|
|
52
|
-
const persistedRoot = z.object({
|
|
53
|
-
id: z.string().min(1),
|
|
54
|
-
path: z.string().min(1),
|
|
55
|
-
recordedPath: z.string().min(1).optional(),
|
|
56
|
-
alias: z.string().optional(),
|
|
57
|
-
addedAt: z.string().min(1)
|
|
58
|
-
});
|
|
59
|
-
/**
|
|
60
|
-
* One primary root's whole registration list. The domain's `single` layout
|
|
61
|
-
* stores the entire unit as one document, so keys stay opaque strings — which
|
|
62
|
-
* is exactly what lets the canonical primary root itself be the key.
|
|
63
|
-
*/
|
|
64
|
-
const persistedPrimaryRoot = z.object({ roots: z.array(persistedRoot) });
|
|
65
|
-
/**
|
|
66
|
-
* The domain declaration: version 1, `single` layout, one `roots` table keyed
|
|
67
|
-
* by the canonical primary root. Records that fail validation are backed up and
|
|
68
|
-
* skipped rather than blocking the whole harness from starting (see
|
|
69
|
-
* docs/decisions/ADR-0004).
|
|
70
|
-
*/
|
|
71
|
-
const multiRootDomainSpec = defineDomain({
|
|
72
|
-
name: DOMAIN_NAME,
|
|
73
|
-
version: 1,
|
|
74
|
-
invalidRecords: "backup-and-skip",
|
|
75
|
-
tables: { roots: domainTable(persistedPrimaryRoot) }
|
|
76
|
-
});
|
|
77
|
-
/** The registry service: `ctx.multiRootRegistry`. */
|
|
78
|
-
var MultiRootRegistry = class extends Service {
|
|
79
|
-
static inject = ["storageDomain", "multiRootScope"];
|
|
80
|
-
/** The open table; present once the service has started. */
|
|
81
|
-
table;
|
|
82
|
-
/** Why the store is unusable, when it is; every mutation then fails loudly. */
|
|
83
|
-
failure;
|
|
84
|
-
/** Last classified status list per canonical primary root. */
|
|
85
|
-
cache = /* @__PURE__ */ new Map();
|
|
86
|
-
/** Signature of the last published grant per key, so a no-op refresh stays a no-op. */
|
|
87
|
-
published = /* @__PURE__ */ new Map();
|
|
88
|
-
/** One promise chain per canonical primary root: mutations never interleave. */
|
|
89
|
-
chains = /* @__PURE__ */ new Map();
|
|
90
|
-
/** Change listeners, keyed by their own disposer identity. */
|
|
91
|
-
listeners = /* @__PURE__ */ new Set();
|
|
92
|
-
/**
|
|
93
|
-
* @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
|
|
94
|
-
*/
|
|
95
|
-
constructor(ctx) {
|
|
96
|
-
super(ctx, "multiRootRegistry");
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Open the domain and publish every stored root into the scope.
|
|
100
|
-
*
|
|
101
|
-
* A store this build cannot read (corrupt records, a version written by a
|
|
102
|
-
* different plugin version) is reported loudly and degrades to the empty
|
|
103
|
-
* root set instead of failing activation: granting nothing is the safe
|
|
104
|
-
* direction, and a broken side store must not keep the whole harness from
|
|
105
|
-
* starting. Every mutation then reports `storage-unavailable` with that
|
|
106
|
-
* reason until the file is repaired or removed.
|
|
107
|
-
*/
|
|
108
|
-
async [Service.init]() {
|
|
109
|
-
let domain;
|
|
110
|
-
try {
|
|
111
|
-
domain = await this.ctx.storageDomain.open(multiRootDomainSpec);
|
|
112
|
-
} catch (error) {
|
|
113
|
-
this.failure = `${DOMAIN_NAME}: ${error instanceof Error ? error.message : String(error)}`;
|
|
114
|
-
this.ctx.logger.error(`multi-root-workspace: cannot open the root registry store (${DOMAIN_NAME}); no additional root is granted until it is repaired or removed: ${this.failure}`);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
this.ctx.effect(() => () => domain.close(), "multi-root-registry: domain close");
|
|
118
|
-
this.table = domain.table(TABLE_NAME);
|
|
119
|
-
for (const [primaryRoot, record] of this.table.entries()) this.accept(primaryRoot, record);
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Why the store is unusable, when it is. Surfaces show this instead of a
|
|
123
|
-
* silently empty list, so an operator can tell "no roots configured" from
|
|
124
|
-
* "the configuration could not be read".
|
|
125
|
-
* @returns the recorded failure text, or `undefined` while the store is healthy.
|
|
126
|
-
*/
|
|
127
|
-
get unavailable() {
|
|
128
|
-
return this.failure;
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Every registration of one primary root, in registry order, as last
|
|
132
|
-
* classified. A READ of the in-memory state: it never touches the filesystem
|
|
133
|
-
* and never writes. Surfaces that must re-check the directories first call
|
|
134
|
-
* {@link MultiRootRegistry.refresh}.
|
|
135
|
-
* @param primaryRoot - the session workspace root (any spelling).
|
|
136
|
-
* @returns the statuses; empty when nothing is registered.
|
|
137
|
-
*/
|
|
138
|
-
list(primaryRoot) {
|
|
139
|
-
return this.statusesOf(canonicalRoot(primaryRoot));
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Re-examine every registration of one primary root WITHOUT writing storage:
|
|
143
|
-
* re-`stat` every directory, re-resolve every path against its recorded
|
|
144
|
-
* directory, re-judge every rule, then republish the scope and notify
|
|
145
|
-
* listeners.
|
|
146
|
-
*
|
|
147
|
-
* This is the one revalidation entry point the command (`/workspace-folders
|
|
148
|
-
* list`) and the panel (`list` endpoint) both call, so "what the list shows"
|
|
149
|
-
* and "what is granted" cannot drift: a directory deleted after registration
|
|
150
|
-
* becomes `missing` and loses its grant, and a directory that came back is
|
|
151
|
-
* granted again — without either surface asking for a restart.
|
|
152
|
-
*
|
|
153
|
-
* The serialized queue is shared with the mutations, so a refresh never
|
|
154
|
-
* interleaves with a write that is mid-flight.
|
|
155
|
-
* @param primaryRoot - the session workspace root to re-check.
|
|
156
|
-
* @returns the refreshed status list.
|
|
157
|
-
*/
|
|
158
|
-
async refresh(primaryRoot) {
|
|
159
|
-
const key = canonicalRoot(primaryRoot);
|
|
160
|
-
return await this.serialize(key, () => this.reclassify(key));
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Registers one additional root.
|
|
164
|
-
* @param primaryRoot - the session workspace root the root belongs to.
|
|
165
|
-
* @param input - the directory and optional alias.
|
|
166
|
-
* @returns the updated status list.
|
|
167
|
-
* @throws {RootValidationError} when the candidate violates a root rule.
|
|
168
|
-
*/
|
|
169
|
-
async add(primaryRoot, input) {
|
|
170
|
-
const key = canonicalRoot(primaryRoot);
|
|
171
|
-
return await this.serialize(key, async () => {
|
|
172
|
-
const statuses = this.statusesOf(key);
|
|
173
|
-
const canonical = validateRootCandidate(input.path, {
|
|
174
|
-
primaryRoot: key,
|
|
175
|
-
existing: availableRoots(statuses)
|
|
176
|
-
});
|
|
177
|
-
const alias = normalizeAlias(input.alias);
|
|
178
|
-
const revived = statuses.findIndex((status) => status.path === canonical || canonicalRoot(status.path) === canonical);
|
|
179
|
-
const next = revived === -1 ? [...statuses, {
|
|
180
|
-
id: additionalRootId(randomUUID()),
|
|
181
|
-
path: canonical,
|
|
182
|
-
recordedPath: canonical,
|
|
183
|
-
state: "available",
|
|
184
|
-
...alias === void 0 ? {} : { alias },
|
|
185
|
-
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
186
|
-
}] : statuses.map((status, index) => index === revived ? {
|
|
187
|
-
...status,
|
|
188
|
-
path: canonical,
|
|
189
|
-
recordedPath: canonical,
|
|
190
|
-
addedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
191
|
-
...alias === void 0 ? {} : { alias }
|
|
192
|
-
} : status);
|
|
193
|
-
return await this.persist(key, next);
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Remove one registration.
|
|
198
|
-
* @param primaryRoot - the session workspace root the root belongs to.
|
|
199
|
-
* @param ref - which registered root to remove.
|
|
200
|
-
* @returns the updated status list.
|
|
201
|
-
* @throws {RootValidationError} `not-found` when the reference matches nothing.
|
|
202
|
-
*/
|
|
203
|
-
async remove(primaryRoot, ref) {
|
|
204
|
-
return await this.removeAt(primaryRoot, ref);
|
|
205
|
-
}
|
|
206
|
-
/**
|
|
207
|
-
* Remove exactly one registration — the one the reference names.
|
|
208
|
-
*
|
|
209
|
-
* Unlike a filter by id, this removes a single record, so a store holding two
|
|
210
|
-
* records that share an id can be cleaned up one entry at a time instead of
|
|
211
|
-
* losing both to one click.
|
|
212
|
-
* @param primaryRoot - the session workspace root the root belongs to.
|
|
213
|
-
* @param ref - which registered root to remove.
|
|
214
|
-
* @returns the updated status list.
|
|
215
|
-
* @throws {RootValidationError} `not-found` when the reference matches nothing.
|
|
216
|
-
*/
|
|
217
|
-
async removeAt(primaryRoot, ref) {
|
|
218
|
-
const key = canonicalRoot(primaryRoot);
|
|
219
|
-
return await this.serialize(key, async () => await this.persist(key, removeStatusAt(this.statusesOf(key), ref)));
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Set or clear one registration's display alias.
|
|
223
|
-
* @param primaryRoot - the session workspace root the root belongs to.
|
|
224
|
-
* @param ref - which registered root to rename.
|
|
225
|
-
* @param alias - the new alias; empty or absent clears it.
|
|
226
|
-
* @returns the updated status list.
|
|
227
|
-
* @throws {RootValidationError} `not-found`/`invalid-alias`.
|
|
228
|
-
*/
|
|
229
|
-
async setAlias(primaryRoot, ref, alias) {
|
|
230
|
-
const key = canonicalRoot(primaryRoot);
|
|
231
|
-
return await this.serialize(key, async () => {
|
|
232
|
-
const statuses = this.statusesOf(key);
|
|
233
|
-
const targetIndex = resolveRootIndex(statuses, ref);
|
|
234
|
-
const normalized = normalizeAlias(alias);
|
|
235
|
-
const next = statuses.map((status, index) => {
|
|
236
|
-
if (index !== targetIndex) return status;
|
|
237
|
-
const { alias: _dropped, ...rest } = status;
|
|
238
|
-
return normalized === void 0 ? rest : {
|
|
239
|
-
...rest,
|
|
240
|
-
alias: normalized
|
|
241
|
-
};
|
|
242
|
-
});
|
|
243
|
-
return await this.persist(key, next);
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Move one registration within the display order.
|
|
248
|
-
* @param primaryRoot - the session workspace root the root belongs to.
|
|
249
|
-
* @param ref - which registered root to move.
|
|
250
|
-
* @param beforeRef - the anchor to move in front of; absent moves it to the end.
|
|
251
|
-
* @returns the updated status list.
|
|
252
|
-
* @throws {RootValidationError} `not-found` when either reference matches nothing.
|
|
253
|
-
*/
|
|
254
|
-
async move(primaryRoot, ref, beforeRef) {
|
|
255
|
-
const key = canonicalRoot(primaryRoot);
|
|
256
|
-
return await this.serialize(key, async () => {
|
|
257
|
-
const statuses = this.statusesOf(key);
|
|
258
|
-
const targetIndex = resolveRootIndex(statuses, ref);
|
|
259
|
-
const anchorIndex = beforeRef === void 0 ? void 0 : resolveRootIndex(statuses, beforeRef);
|
|
260
|
-
if (anchorIndex === targetIndex) return statuses;
|
|
261
|
-
const target = statuses[targetIndex];
|
|
262
|
-
const without = statuses.filter((_status, index) => index !== targetIndex);
|
|
263
|
-
const adjustedAnchor = anchorIndex === void 0 ? without.length : anchorIndex - (targetIndex < anchorIndex ? 1 : 0);
|
|
264
|
-
const next = [
|
|
265
|
-
...without.slice(0, adjustedAnchor),
|
|
266
|
-
target,
|
|
267
|
-
...without.slice(adjustedAnchor)
|
|
268
|
-
];
|
|
269
|
-
return await this.persist(key, next);
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
/**
|
|
273
|
-
* Re-examine every registration of one primary root and persist what the
|
|
274
|
-
* re-examination produced — {@link MultiRootRegistry.refresh} plus one durable
|
|
275
|
-
* write. The recovery path after a directory was restored by hand, and the
|
|
276
|
-
* only read-shaped operation that is allowed to touch the store.
|
|
277
|
-
* @param primaryRoot - the session workspace root to re-check.
|
|
278
|
-
* @returns the refreshed status list.
|
|
279
|
-
*/
|
|
280
|
-
async recheck(primaryRoot) {
|
|
281
|
-
const key = canonicalRoot(primaryRoot);
|
|
282
|
-
return await this.serialize(key, async () => await this.persist(key, this.reclassify(key)));
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Observe registry changes.
|
|
286
|
-
* @param listener - called with the canonical primary root after each durable change.
|
|
287
|
-
* @returns the disposer that stops observing.
|
|
288
|
-
*/
|
|
289
|
-
onChange(listener) {
|
|
290
|
-
this.listeners.add(listener);
|
|
291
|
-
return () => {
|
|
292
|
-
this.listeners.delete(listener);
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* The scope the registry publishes: only usable roots, in registry order.
|
|
297
|
-
* @param primaryRoot - the session workspace root (any spelling).
|
|
298
|
-
* @returns the canonical paths of every `available` root.
|
|
299
|
-
*/
|
|
300
|
-
granted(primaryRoot) {
|
|
301
|
-
return availableRoots(this.statusesOf(canonicalRoot(primaryRoot)));
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* The scope the registry publishes: only usable roots, in registry order.
|
|
305
|
-
* @param policy - the per-call policy; its workspace root is the primary root.
|
|
306
|
-
* @returns the granted canonical roots.
|
|
307
|
-
*/
|
|
308
|
-
grantedFor(policy) {
|
|
309
|
-
return this.granted(policy.workspaceRoot);
|
|
310
|
-
}
|
|
311
|
-
/** Classify one stored record set and cache it. */
|
|
312
|
-
accept(primaryRoot, record) {
|
|
313
|
-
const key = canonicalRoot(primaryRoot);
|
|
314
|
-
const statuses = classifyStoredRoots(key, record.roots.map(toRegisteredRoot));
|
|
315
|
-
this.cache.set(key, statuses);
|
|
316
|
-
this.publish(key, statuses);
|
|
317
|
-
return statuses;
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* The statuses of one key, from memory where possible: the cache, else what
|
|
321
|
-
* storage holds, else empty. A cached answer is never re-checked here — that
|
|
322
|
-
* is {@link MultiRootRegistry.reclassify}, which the read surfaces and
|
|
323
|
-
* {@link MultiRootRegistry.refresh} call deliberately.
|
|
324
|
-
*/
|
|
325
|
-
statusesOf(key) {
|
|
326
|
-
const cached = this.cache.get(key);
|
|
327
|
-
if (cached !== void 0) return cached;
|
|
328
|
-
const record = this.lookupRecords(key);
|
|
329
|
-
if (record === void 0) return [];
|
|
330
|
-
return this.accept(key, record);
|
|
331
|
-
}
|
|
332
|
-
/**
|
|
333
|
-
* The stored record set of one key, or `undefined` when there is none (or
|
|
334
|
-
* when the store itself could not be read: an unusable store reads as
|
|
335
|
-
* "nothing registered" so reads stay answerable and the surfaces can explain
|
|
336
|
-
* the situation, while every write fails loudly in `requireTable()`).
|
|
337
|
-
*/
|
|
338
|
-
lookupRecords(key) {
|
|
339
|
-
if (this.failure !== void 0) return void 0;
|
|
340
|
-
return this.requireTable().get(key);
|
|
341
|
-
}
|
|
342
|
-
/**
|
|
343
|
-
* Re-classify one key from its stored records: every directory is `stat`ed
|
|
344
|
-
* again and every path is resolved again against its recorded canonical
|
|
345
|
-
* directory. Updates the cache, republishes the scope, and returns the new
|
|
346
|
-
* list — the shared body of the read-path re-checks.
|
|
347
|
-
*/
|
|
348
|
-
reclassify(key) {
|
|
349
|
-
const record = this.lookupRecords(key);
|
|
350
|
-
if (record === void 0) {
|
|
351
|
-
this.cache.set(key, []);
|
|
352
|
-
this.publish(key, []);
|
|
353
|
-
return [];
|
|
354
|
-
}
|
|
355
|
-
return this.accept(key, record);
|
|
356
|
-
}
|
|
357
|
-
/**
|
|
358
|
-
* Persist one status list, then republish it into the scope and to listeners.
|
|
359
|
-
* The durable write happens first: a failed write must not leave the running
|
|
360
|
-
* scope granting something storage does not hold.
|
|
361
|
-
*/
|
|
362
|
-
async persist(key, statuses) {
|
|
363
|
-
const table = this.requireTable();
|
|
364
|
-
if (statuses.length === 0) await table.delete(key);
|
|
365
|
-
else {
|
|
366
|
-
const durable = persistedPrimaryRoot.parse({ roots: statuses.map(toPersistedRoot) });
|
|
367
|
-
await table.put(key, durable);
|
|
368
|
-
}
|
|
369
|
-
const classified = statuses.length === 0 ? [] : classifyStoredRoots(key, statuses.map(toRegisteredRoot));
|
|
370
|
-
this.cache.set(key, classified);
|
|
371
|
-
this.publish(key, classified);
|
|
372
|
-
return classified;
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Run one job for one primary root after every job already queued for that
|
|
376
|
-
* root. The whole read → validate → write sequence belongs inside the job:
|
|
377
|
-
* the storage domain serializes individual writes, so without this the loser
|
|
378
|
-
* of a race reads a snapshot that is already stale and writes it back.
|
|
379
|
-
*/
|
|
380
|
-
async serialize(key, job) {
|
|
381
|
-
const run = (this.chains.get(key) ?? Promise.resolve()).then(job);
|
|
382
|
-
const tail = run.then(() => void 0, () => void 0);
|
|
383
|
-
this.chains.set(key, tail);
|
|
384
|
-
try {
|
|
385
|
-
return await run;
|
|
386
|
-
} finally {
|
|
387
|
-
if (this.chains.get(key) === tail) this.chains.delete(key);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Push one status list into the scope and notify listeners.
|
|
392
|
-
*
|
|
393
|
-
* What is published is the effective GRANT — only `available` roots, so a
|
|
394
|
-
* withheld registration can never reach a provider. When the grant and the
|
|
395
|
-
* withheld set are unchanged (the common case for a read-path refresh on a
|
|
396
|
-
* healthy workspace) the scope is left alone and no listener fires: a refresh
|
|
397
|
-
* exists to keep the two in sync, not to churn on every listing — and, since
|
|
398
|
-
* the scope rebuilds its root table on each call, skipping the no-op write is
|
|
399
|
-
* also what keeps the read path cheap.
|
|
400
|
-
*/
|
|
401
|
-
publish(key, statuses) {
|
|
402
|
-
const withheld = statuses.filter((status) => status.state !== "available");
|
|
403
|
-
const signature = [...statuses.filter((status) => status.state === "available").map((status) => `available:${status.recordedPath}`), ...withheld.map((status) => `${status.state}:${status.path}`)].join("\n");
|
|
404
|
-
if (this.published.get(key) === signature) return;
|
|
405
|
-
this.published.set(key, signature);
|
|
406
|
-
const effective = statuses.flatMap((status) => {
|
|
407
|
-
if (status.state !== "available" || status.recordedPath === void 0) return [];
|
|
408
|
-
return [{
|
|
409
|
-
id: status.id,
|
|
410
|
-
path: status.path,
|
|
411
|
-
recordedPath: status.recordedPath,
|
|
412
|
-
...status.alias === void 0 ? {} : { alias: status.alias }
|
|
413
|
-
}];
|
|
414
|
-
});
|
|
415
|
-
this.ctx.multiRootScope.setAdditionalRoots(key, effective);
|
|
416
|
-
if (withheld.length > 0) this.ctx.logger.warn(`multi-root-workspace: ${withheld.length} registered root(s) of ${key} are not writable right now (${withheld.map((status) => `${status.path}: ${status.state}`).join(", ")}); they stay registered and are reported by /workspace-folders`);
|
|
417
|
-
for (const listener of this.listeners) listener(key);
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* The open table.
|
|
421
|
-
* @returns the live table.
|
|
422
|
-
* @throws {RootValidationError} `storage-unavailable` when the store could
|
|
423
|
-
* not be opened at activation, or the service has not started.
|
|
424
|
-
*/
|
|
425
|
-
requireTable() {
|
|
426
|
-
if (this.failure !== void 0) throw new RootValidationError("storage-unavailable", `the root registry store is unavailable (${this.failure}); remove or repair \$DSH_HOME/storages/multi_root_workspace.json and restart dsh`);
|
|
427
|
-
if (this.table === void 0) throw new Error("multi-root registry is not started yet");
|
|
428
|
-
return this.table;
|
|
429
|
-
}
|
|
430
|
-
};
|
|
431
|
-
/** Project one status back to the durable record shape. */
|
|
432
|
-
function toPersistedRoot(status) {
|
|
433
|
-
return {
|
|
434
|
-
id: status.id,
|
|
435
|
-
path: status.path,
|
|
436
|
-
...status.recordedPath === void 0 || status.recordedPath === "" ? {} : { recordedPath: status.recordedPath },
|
|
437
|
-
...status.alias === void 0 ? {} : { alias: status.alias },
|
|
438
|
-
addedAt: status.addedAt
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
/** Recover the registered-root view of a stored record entry. */
|
|
442
|
-
function toRegisteredRoot(entry) {
|
|
443
|
-
return {
|
|
444
|
-
id: additionalRootId(entry.id),
|
|
445
|
-
path: entry.path,
|
|
446
|
-
...entry.recordedPath === void 0 ? {} : { recordedPath: entry.recordedPath },
|
|
447
|
-
...entry.alias === void 0 ? {} : { alias: entry.alias },
|
|
448
|
-
addedAt: entry.addedAt
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Validate and normalize an optional display alias.
|
|
453
|
-
* @param alias - the operator input; empty means "no alias".
|
|
454
|
-
* @returns the trimmed alias, or `undefined`.
|
|
455
|
-
* @throws {RootValidationError} `invalid-alias` for control characters or an over-long value.
|
|
456
|
-
*/
|
|
457
|
-
function normalizeAlias(alias) {
|
|
458
|
-
if (alias === void 0) return void 0;
|
|
459
|
-
const trimmed = alias.trim();
|
|
460
|
-
if (trimmed === "") return void 0;
|
|
461
|
-
if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new RootValidationError("invalid-alias", "an alias must not contain control characters");
|
|
462
|
-
if (trimmed.length > 120) throw new RootValidationError("invalid-alias", `an alias must be at most 120 characters`);
|
|
463
|
-
return trimmed;
|
|
464
|
-
}
|
|
465
|
-
//#endregion
|
|
466
|
-
export { DOMAIN_NAME, MAX_ALIAS_LENGTH, MultiRootRegistry, MultiRootRegistry as default, TABLE_NAME, multiRootDomainSpec };
|
|
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,5 +1,27 @@
|
|
|
1
1
|
import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
|
|
2
2
|
import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
|
|
3
|
+
//#region src/compat/sandbox-confine.ts
|
|
4
|
+
/** Whether a value is a thenable, i.e. whether the installed `confine` is async. */
|
|
5
|
+
function isThenable(value) {
|
|
6
|
+
return typeof value?.then === "function";
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Call the upstream `confine` and widen its result, preserving sync/async shape.
|
|
10
|
+
*
|
|
11
|
+
* @param base - the upstream `confine`, already bound to its provider.
|
|
12
|
+
* @param widen - the pure transformation to apply to the upstream result. It
|
|
13
|
+
* runs exactly once per call, on whichever release; it may throw, and the
|
|
14
|
+
* throw surfaces synchronously or as a rejection to match the base shape.
|
|
15
|
+
* @param argv - the caller's argv, forwarded verbatim.
|
|
16
|
+
* @param policy - the per-call policy, forwarded verbatim.
|
|
17
|
+
* @param signal - forwarded when the installed release accepts it.
|
|
18
|
+
* @returns the widened result, in the shape the installed release uses.
|
|
19
|
+
*/
|
|
20
|
+
function widenConfined(base, widen, argv, policy, signal) {
|
|
21
|
+
const confined = base(argv, policy, signal);
|
|
22
|
+
return isThenable(confined) ? confined.then(widen) : widen(confined);
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
3
25
|
//#region src/dialects.ts
|
|
4
26
|
/**
|
|
5
27
|
* Raised when a confined argv cannot be recognized or widened. The provider
|
|
@@ -185,7 +207,11 @@ function widenLandlock(profileArgs, workspaceRoot, additionalRoots) {
|
|
|
185
207
|
* grant mechanics stay the upstream implementation's.
|
|
186
208
|
*/
|
|
187
209
|
var MultiRootSandboxProvider = class extends LocalSandboxProvider {
|
|
188
|
-
static inject = [
|
|
210
|
+
static inject = [
|
|
211
|
+
"multiRootCompat",
|
|
212
|
+
"sandboxPolicy",
|
|
213
|
+
"multiRootScope"
|
|
214
|
+
];
|
|
189
215
|
/** Whether the Windows ACL limitation has already been reported (once per provider). */
|
|
190
216
|
warnedAboutWindowsAcl = false;
|
|
191
217
|
/**
|
|
@@ -197,12 +223,33 @@ var MultiRootSandboxProvider = class extends LocalSandboxProvider {
|
|
|
197
223
|
* bash executor and the PTY backend both hand their argv straight to the local
|
|
198
224
|
* runtime in that mode. Widening therefore only ever ADDS the scope's
|
|
199
225
|
* additional roots to an upstream `workspace-write` profile.
|
|
226
|
+
*
|
|
227
|
+
* The return shape is the INSTALLED upstream provider's: synchronous on a
|
|
228
|
+
* release with a synchronous `confine`, a promise on a release with an
|
|
229
|
+
* asynchronous one. `widenConfined` preserves whichever it is — see
|
|
230
|
+
* `./compat/sandbox-confine.ts`.
|
|
200
231
|
* @param argv - the exact argv the caller is about to spawn.
|
|
201
232
|
* @param policy - the file-effect policy this execution runs under.
|
|
233
|
+
* @param signal - cancellation, forwarded to releases that accept it.
|
|
202
234
|
* @returns the argv to spawn instead, plus the selected backend's facts.
|
|
203
235
|
*/
|
|
204
|
-
confine(argv, policy) {
|
|
205
|
-
const
|
|
236
|
+
confine(argv, policy, signal) {
|
|
237
|
+
const base = super.confine;
|
|
238
|
+
return widenConfined(base.bind(this), (confined) => this.widenForScope(confined, argv, policy), argv, policy, signal);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Graft the scope's additional roots onto one upstream `confine` result.
|
|
242
|
+
*
|
|
243
|
+
* Pure with respect to the upstream call: it only ever reads the scope and
|
|
244
|
+
* rewrites `argv`. `enforcement`, `denialSignatures` and `runnerFailureRules`
|
|
245
|
+
* are passed through untouched, because the bash executor derives its denial
|
|
246
|
+
* and enforcement reporting from them.
|
|
247
|
+
* @param confined - the upstream result for this call.
|
|
248
|
+
* @param argv - the caller's argv, needed to re-split the wrapped form.
|
|
249
|
+
* @param policy - the per-call policy.
|
|
250
|
+
* @returns the upstream result element for element, or one with a widened argv.
|
|
251
|
+
*/
|
|
252
|
+
widenForScope(confined, argv, policy) {
|
|
206
253
|
if (policy.mode !== "workspace-write") return confined;
|
|
207
254
|
const scope = this.ctx.multiRootScope.resolve(policy);
|
|
208
255
|
if (scope.additionalRoots.length === 0) return confined;
|
|
@@ -240,4 +287,4 @@ var MultiRootSandboxProvider = class extends LocalSandboxProvider {
|
|
|
240
287
|
}
|
|
241
288
|
};
|
|
242
289
|
//#endregion
|
|
243
|
-
export { MultiRootSandboxProvider as t };
|
|
290
|
+
export { widenConfined as n, MultiRootSandboxProvider as t };
|
package/lib/sandbox.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as MultiRootSandboxProvider } from "./sandbox-
|
|
1
|
+
import { t as MultiRootSandboxProvider } from "./sandbox-D8Fw95FM.js";
|
|
2
2
|
export { MultiRootSandboxProvider, MultiRootSandboxProvider as default };
|
package/lib/scope.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
1
2
|
import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
|
|
2
3
|
import { statSync } from "node:fs";
|
|
3
|
-
import { Service } from "@deepseek-ai/cordis";
|
|
4
4
|
//#region src/scope.ts
|
|
5
5
|
/**
|
|
6
6
|
* The multi-root workspace scope: the plugin's single home for the question
|