@khorsheed/dsh-local-agent-dsh 0.1.0-rc.6

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 (45) hide show
  1. package/README.en.md +150 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +151 -0
  4. package/cordis.patch.yml +24 -0
  5. package/lib/client.js +1115 -0
  6. package/lib/dsh-cli-provider-UhWvivte.js +1701 -0
  7. package/lib/dsh-cli-provider.js +3 -0
  8. package/lib/index.js +624 -0
  9. package/lib/invariant.js +33 -0
  10. package/lib/provision.js +602 -0
  11. package/lib/records.js +109 -0
  12. package/lib/session-log-DOsSz56-.js +88 -0
  13. package/lib/types/client/SettingsCard.d.ts +78 -0
  14. package/lib/types/client/SettingsCard.js +200 -0
  15. package/lib/types/client/icons.d.ts +18 -0
  16. package/lib/types/client/icons.js +2 -0
  17. package/lib/types/client/index.d.ts +45 -0
  18. package/lib/types/client/index.js +88 -0
  19. package/lib/types/client/locales.d.ts +76 -0
  20. package/lib/types/client/locales.js +67 -0
  21. package/lib/types/dsh-cli-provider.d.ts +213 -0
  22. package/lib/types/dsh-cli-provider.js +733 -0
  23. package/lib/types/index.d.ts +124 -0
  24. package/lib/types/index.js +347 -0
  25. package/lib/types/invariant.d.ts +16 -0
  26. package/lib/types/invariant.js +37 -0
  27. package/lib/types/live-driver.d.ts +180 -0
  28. package/lib/types/live-driver.js +887 -0
  29. package/lib/types/live-switch.d.ts +76 -0
  30. package/lib/types/live-switch.js +100 -0
  31. package/lib/types/model-broker.d.ts +86 -0
  32. package/lib/types/model-broker.js +149 -0
  33. package/lib/types/model-catalog.d.ts +27 -0
  34. package/lib/types/model-catalog.js +71 -0
  35. package/lib/types/provision.d.ts +382 -0
  36. package/lib/types/provision.js +656 -0
  37. package/lib/types/records.d.ts +41 -0
  38. package/lib/types/records.js +125 -0
  39. package/lib/types/session-log.d.ts +58 -0
  40. package/lib/types/session-log.js +83 -0
  41. package/lib/types/session-mirror.d.ts +155 -0
  42. package/lib/types/session-mirror.js +451 -0
  43. package/locale/en.json +6 -0
  44. package/locale/zh.json +6 -0
  45. package/package.json +121 -0
@@ -0,0 +1,602 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import yaml from "js-yaml";
6
+ //#region lib/types/provision.js
7
+ /**
8
+ * Provision the sub-dsh's headless profile inside the harness scoped home.
9
+ * The sub-dsh runs `dsh --profile <name>` with `$DSH_HOME` pointed at the
10
+ * scoped home, so its profile must live under
11
+ * `<scoped home>/profiles/<name>` — a manifest listing the official
12
+ * `@deepseek-ai/dsh-base` layer, the family headless composition as the
13
+ * profile's own patch layer (copied verbatim from the headless bundle's
14
+ * `cordis.patch.yml`), and a single symlink resolving
15
+ * `@khorsheed/dsh-local-agent-dsh-headless` from the sub-profile's
16
+ * node_modules (the loader resolves the patch's insert rows through it).
17
+ * Everything else the boot needs (dsh-base and its whole dependency graph,
18
+ * cordis, the healed `profiles/node_modules` fallback) is resolved from the
19
+ * dsh installation anchor automatically, so provisioning costs no pnpm
20
+ * install.
21
+ *
22
+ * A sub-profile may additionally carry a PRESET ROSTER: one appended patch
23
+ * layer inserting `@deepseek-ai/dsh-agent-presets` with a `default` preset
24
+ * id. Without it the sub-dsh's model-facing rows come from the host plane and
25
+ * the agent reads them off the global layer — which is why "the same harness
26
+ * under two presets" was not a thing a condition could ask for. With it, the
27
+ * scope directory IS the capability face: two scopes whose rosters name two
28
+ * presets are two subjects, and the difference is a file a person can read.
29
+ *
30
+ * Where the preset DIRECTORY lives is the scope's business too. The roster
31
+ * derives a user root of `<$DSH_HOME>/.agent-presets` and the sub-dsh runs
32
+ * with `DSH_HOME` pointed at the scoped home, so a scope that keeps its own
33
+ * copy there is resolved identically on the host and inside an evaluation
34
+ * unit (which bind-mounts the scope, and nothing else). `provisionDshScope`
35
+ * below is the entry that puts the copy there, records what the scope
36
+ * composes in a file of its own, and rosters it with no `roots` at all.
37
+ *
38
+ * It may also carry a PERMISSION BOUNDARY: one appended patch layer pinning
39
+ * `sandbox-policy`'s mode and `user-approval`'s policy to one of dsh's own
40
+ * three permission presets. Without it the sub-dsh runs whatever `dsh-base`
41
+ * composes — `workspace-write` plus `ask` — which is the right default for a
42
+ * sub-dsh on a developer's machine and the wrong one inside an evaluation
43
+ * unit, where the container IS the boundary and nobody is there to answer an
44
+ * approval prompt. The layer is a FILE in the scope directory for the same
45
+ * reason the roster is: a scoped home bind-mounted into a unit carries its
46
+ * own boundary with it, and the value never has to ride the spawn env (where
47
+ * every name enters the unit's composite fingerprint) or be baked into an
48
+ * image.
49
+ *
50
+ * The headless bundle declares no `dsh.bundle` on purpose (the T6/G3
51
+ * incident): a declaration would let the host's `dsh plugin` reconcile mount
52
+ * its sub-dsh-only composition into any profile where the package is a
53
+ * direct dependency — duplicate `ptc-runtime` at best. The patch therefore
54
+ * reaches the sub-profile through this copy, not through the manifest
55
+ * declaration; being the profile's own layer, it lands after every bundle
56
+ * layer, the same position the headless bundle layer used to occupy.
57
+ * @module @khorsheed/dsh-local-agent-dsh/provision
58
+ */
59
+ /** Default name of the sub-dsh profile under the scoped home. */
60
+ const DEFAULT_SUB_PROFILE_NAME = "headless-local-agent-dsh";
61
+ /** The user patch layer filename inside a profile directory. */
62
+ const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
63
+ /** Filename of the family headless bundle's patch, inside the bundle directory. */
64
+ const HEADLESS_PATCH_FILENAME = "cordis.patch.yml";
65
+ /**
66
+ * The approval policy each permission preset pairs with, copied from
67
+ * `dsh-base`'s own `permission-presets` table. Pinning the two rows
68
+ * separately (they are two plugins) must not let them drift apart: the sub-
69
+ * dsh that sandboxes at `danger-full-access` while still asking for approval
70
+ * would be a boundary nobody asked for.
71
+ */
72
+ const APPROVAL_FOR = {
73
+ "read-only": "ask",
74
+ "workspace-write": "ask",
75
+ "danger-full-access": "never"
76
+ };
77
+ /** The roster row's id inside the sub-profile patch (and the marker this module rewrites). */
78
+ const PRESET_ROSTER_ROW_ID = "agent-presets";
79
+ /**
80
+ * The roster package the roster row names.
81
+ *
82
+ * Deliberately NOT symlinked into the sub-profile the way the headless bundle
83
+ * is. The headless bundle is a `@khorsheed` package the dsh installation does
84
+ * not carry, so it must be linked in from the plugin installation; the roster
85
+ * is an OFFICIAL package, already in the installation anchor's closure beside
86
+ * `@deepseek-ai/dsh-base`. Linking a second copy would give it a second
87
+ * `@deepseek-ai/cordis` — and cordis does service lookup and type checks by
88
+ * instance identity, so the symptom would be silently missing services rather
89
+ * than an error (the same failure the dual-filesystem contract in the README
90
+ * describes for the bundle). A deployment whose anchor genuinely lacks the
91
+ * roster gets the loader's own "cannot resolve" message, which names the
92
+ * module better than provisioning could.
93
+ */
94
+ const PRESET_ROSTER_MODULE = "@deepseek-ai/dsh-agent-presets";
95
+ /** A preset id doubles as a directory name — the roster's own rule. */
96
+ const SUB_PROFILE_PRESET_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
97
+ /** Header line that opens the generated roster layer (the parse anchor). */
98
+ const PRESET_BLOCK_HEADER = "# --- preset roster (written by local-agent-dsh provisioning) ---";
99
+ /** Header line that opens the generated permission layer (the parse anchor). */
100
+ const PERMISSION_BLOCK_HEADER = "# --- permission boundary (written by local-agent-dsh provisioning) ---";
101
+ /** The sub-profile's bundle layer list: the official base layer. The headless
102
+ * composition rides the profile's own patch layer (see the module doc). */
103
+ const SUB_PROFILE_BUNDLES = ["@deepseek-ai/dsh-base"];
104
+ /** The sub-profile manifest the boot reads. */
105
+ function subProfileManifest(profileName) {
106
+ return JSON.stringify({
107
+ name: `dsh-profile-${profileName}`,
108
+ private: true,
109
+ dependencies: {},
110
+ dsh: { profile: { bundles: [...SUB_PROFILE_BUNDLES] } }
111
+ }, void 0, 2) + "\n";
112
+ }
113
+ /**
114
+ * Resolve the family headless bundle's directory. The bundle is a declared
115
+ * dependency of this package, so it resolves from the same installation; the
116
+ * explicit config override exists for deployments that keep it elsewhere.
117
+ * @param config - provisioning config carrying the optional override.
118
+ * @returns the absolute bundle directory (holding `cordis.patch.yml`).
119
+ */
120
+ function resolveHeadlessBundleDir(config = {}) {
121
+ if (config.headlessBundleDir !== void 0) return config.headlessBundleDir;
122
+ const manifestPath = createRequire(import.meta.url).resolve("@khorsheed/dsh-local-agent-dsh-headless/package.json");
123
+ return dirname(manifestPath);
124
+ }
125
+ /** YAML-quote a scalar the roster config carries (paths and ids are operator input). */
126
+ function yamlString(value) {
127
+ return JSON.stringify(value);
128
+ }
129
+ /**
130
+ * The roster layer appended to the sub-profile's patch, or `''` when the
131
+ * sub-profile composes no preset.
132
+ *
133
+ * It is a SEPARATE patch operation appended after the headless bundle's own
134
+ * list, which is what keeps the two independent: an upgraded headless bundle
135
+ * rewrites its half and the roster survives, and dropping the preset drops
136
+ * exactly these lines.
137
+ * @param preset - the roster to compose, or undefined for none.
138
+ * @returns the YAML text to append (already newline-terminated), or `''`.
139
+ */
140
+ function presetRosterLayer(preset) {
141
+ if (preset === void 0) return "";
142
+ if (!SUB_PROFILE_PRESET_ID_RE.test(preset.id)) throw new Error(`provision-dsh: preset id ${JSON.stringify(preset.id)} must match ${String(SUB_PROFILE_PRESET_ID_RE)} (it is a directory name)`);
143
+ const lines = [
144
+ "",
145
+ PRESET_BLOCK_HEADER,
146
+ "# The sub-dsh composes every session from this preset; the headless agent",
147
+ "# loader joins the roster in its agent setup. Remove this layer and the",
148
+ "# agent falls back to the global layer.",
149
+ "- insert:",
150
+ ` - id: ${PRESET_ROSTER_ROW_ID}`,
151
+ ` name: '${PRESET_ROSTER_MODULE}'`,
152
+ " config:",
153
+ ` default: ${yamlString(preset.id)}`
154
+ ];
155
+ if (preset.includeShippedRoot !== void 0) lines.push(` includeShippedRoot: ${String(preset.includeShippedRoot)}`);
156
+ if (preset.includeUserRoot !== void 0) lines.push(` includeUserRoot: ${String(preset.includeUserRoot)}`);
157
+ if (preset.roots !== void 0 && preset.roots.length > 0) {
158
+ lines.push(" roots:");
159
+ for (const root of preset.roots) {
160
+ lines.push(` - path: ${yamlString(root.path)}`);
161
+ if (root.trust !== void 0) lines.push(` trust: ${root.trust}`);
162
+ }
163
+ }
164
+ return `${lines.join("\n")}\n`;
165
+ }
166
+ /**
167
+ * The preset id a provisioned sub-profile's patch composes, read back from
168
+ * the file. The roster layer is generated, so it is also parseable: a lock
169
+ * check or a status surface can answer "which preset does this scope run?"
170
+ * without booting the sub-dsh.
171
+ * @param homeDir - the harness scoped home.
172
+ * @param profileName - the sub-profile name (default {@link DEFAULT_SUB_PROFILE_NAME}).
173
+ * @returns the preset id, or undefined when the sub-profile composes no roster.
174
+ */
175
+ function readSubProfilePreset(homeDir, profileName = DEFAULT_SUB_PROFILE_NAME) {
176
+ const patchPath = join(homeDir, "profiles", profileName, PROFILE_PATCH_FILENAME);
177
+ let content;
178
+ try {
179
+ content = readFileSync(patchPath, "utf8");
180
+ } catch {
181
+ return;
182
+ }
183
+ const header = content.indexOf(PRESET_BLOCK_HEADER);
184
+ if (header === -1) return void 0;
185
+ return /^\s*default:\s*"([^"]*)"\s*$/m.exec(content.slice(header))?.[1];
186
+ }
187
+ /**
188
+ * The permission layer appended to the sub-profile's patch, or `''` when the
189
+ * sub-profile pins no boundary.
190
+ *
191
+ * Two PATCH rows, not inserts: `sandbox-policy` and `approval` already exist
192
+ * in the `@deepseek-ai/dsh-base` layer this profile stacks on, and the layer
193
+ * lands after it, so these override what the base composed. A base that
194
+ * mounts neither row (a future line, a different bundle) makes the loader
195
+ * warn "entry not found" and skip — a sub-dsh that boots with the composition
196
+ * it has, never one that fails to boot over a knob.
197
+ *
198
+ * Both rows RE-STATE every key they own. A patch `config` is a whole-value
199
+ * REPLACE, not a deep merge (`applyEntryPatches`: `target[key] = value`), so
200
+ * a row that wrote `mode` alone would silently drop `workspaceRoot` — and
201
+ * both rows carry `name` so a foreign plugin squatting the id makes the
202
+ * loader report a name mismatch and skip, rather than take this config.
203
+ *
204
+ * Pinning the literal also takes the boundary OFF `DSH_PERMISSION_MODE`,
205
+ * which is what `dsh-base` otherwise reads it from: the value belongs to the
206
+ * scope directory, where `home.sha` already hashes it, rather than to
207
+ * whatever environment happened to spawn the process.
208
+ * @param permissions - the boundary to pin, or undefined for none.
209
+ * @returns the YAML text to append (already newline-terminated), or `''`.
210
+ */
211
+ function permissionBoundaryLayer(permissions) {
212
+ if (permissions === void 0) return "";
213
+ if (!Object.hasOwn(APPROVAL_FOR, permissions)) throw new Error(`provision-dsh: permissions ${JSON.stringify(permissions)} must be one of ${Object.keys(APPROVAL_FOR).join(", ")}`);
214
+ const approval = APPROVAL_FOR[permissions];
215
+ return `${[
216
+ "",
217
+ PERMISSION_BLOCK_HEADER,
218
+ "# The sub-dsh runs every confined call under this mode and answers every",
219
+ "# escalation with this policy. Remove this layer and the sub-dsh falls",
220
+ "# back to what dsh-base composes (workspace-write + ask).",
221
+ "- id: sandbox-policy",
222
+ " name: '@deepseek-ai/dsh-sandbox-policy'",
223
+ " config:",
224
+ ` mode: ${permissions}`,
225
+ " workspaceRoot: !!js process.cwd()",
226
+ "- id: approval",
227
+ " name: '@deepseek-ai/dsh-user-approval'",
228
+ " config:",
229
+ ` policy: ${approval}`
230
+ ].join("\n")}\n`;
231
+ }
232
+ /**
233
+ * The permission boundary a provisioned sub-profile's patch pins, read back
234
+ * from the file — the counterpart of {@link readSubProfilePreset}, and
235
+ * readable for the same reason: the layer is generated, so a lock check or a
236
+ * status surface can answer "what boundary does this scope run under?"
237
+ * without booting the sub-dsh.
238
+ * @param homeDir - the harness scoped home.
239
+ * @param profileName - the sub-profile name (default {@link DEFAULT_SUB_PROFILE_NAME}).
240
+ * @returns the pinned mode, or undefined when the sub-profile pins none.
241
+ */
242
+ function readSubProfilePermissions(homeDir, profileName = DEFAULT_SUB_PROFILE_NAME) {
243
+ const patchPath = join(homeDir, "profiles", profileName, PROFILE_PATCH_FILENAME);
244
+ let content;
245
+ try {
246
+ content = readFileSync(patchPath, "utf8");
247
+ } catch {
248
+ return;
249
+ }
250
+ const header = content.indexOf(PERMISSION_BLOCK_HEADER);
251
+ if (header === -1) return void 0;
252
+ const mode = /^\s*mode:\s*(\S+)\s*$/m.exec(content.slice(header))?.[1];
253
+ return mode !== void 0 && Object.hasOwn(APPROVAL_FOR, mode) ? mode : void 0;
254
+ }
255
+ /** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */
256
+ function ensureSymlink(link, target) {
257
+ let stat;
258
+ try {
259
+ stat = lstatSync(link);
260
+ } catch (error) {
261
+ if (error.code !== "ENOENT") throw error;
262
+ symlinkSync(target, link);
263
+ return;
264
+ }
265
+ if (stat.isDirectory()) throw new Error(`provision-dsh: ${link} is a real directory, not the expected symlink; remove it manually`);
266
+ if (readlinkSync(link) === target) return;
267
+ rmSync(link, { force: true });
268
+ symlinkSync(target, link);
269
+ }
270
+ /**
271
+ * Provision (idempotently) the sub-dsh headless profile in the harness
272
+ * scoped home: manifest, the headless patch as the profile's own patch
273
+ * layer, and the bundle symlink. A file whose content already matches is
274
+ * left untouched, so re-running after a restart is a no-op; a drifted file
275
+ * (an upgraded headless bundle, or a profile provisioned by the pre-T6
276
+ * scheme whose manifest listed the headless bundle as a layer) is rewritten,
277
+ * which heals it before the sub-dsh boots.
278
+ * @param homeDir - the `dsh` harness's scoped home (its `$DSH_HOME` root).
279
+ * @param config - profile naming and bundle-dir overrides.
280
+ * @returns the absolute sub-profile directory.
281
+ */
282
+ function provisionDshSubProfile(homeDir, config = {}) {
283
+ const profileName = config.profileName ?? "headless-local-agent-dsh";
284
+ const bundleDir = resolveHeadlessBundleDir(config);
285
+ const profileDir = join(homeDir, "profiles", profileName);
286
+ mkdirSync(profileDir, { recursive: true });
287
+ const manifestPath = join(profileDir, "package.json");
288
+ const manifestContent = subProfileManifest(profileName);
289
+ if (!existsSync(manifestPath) || readFileSync(manifestPath, "utf8") !== manifestContent) writeFileSync(manifestPath, manifestContent);
290
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
291
+ const patchContent = readFileSync(join(bundleDir, HEADLESS_PATCH_FILENAME), "utf8") + presetRosterLayer(config.preset) + permissionBoundaryLayer(config.permissions);
292
+ if (!existsSync(patchPath) || readFileSync(patchPath, "utf8") !== patchContent) writeFileSync(patchPath, patchContent);
293
+ const bundleLink = join(profileDir, "node_modules", "@khorsheed", "dsh-local-agent-dsh-headless");
294
+ mkdirSync(dirname(bundleLink), { recursive: true });
295
+ ensureSymlink(bundleLink, bundleDir);
296
+ return profileDir;
297
+ }
298
+ /**
299
+ * The preset root a harness home derives — the roster's own rule, spelled
300
+ * here because this module has to write into it.
301
+ */
302
+ const USER_PRESET_DIR = ".agent-presets";
303
+ /** The file that makes a preset directory a preset (the roster's rule). */
304
+ const PRESET_COMPOSITION_FILE = "agent.cordis.yml";
305
+ /**
306
+ * The scope's own declaration of what its sub-profile composes.
307
+ *
308
+ * A FILE in the scope directory, for the third time and the same reason as
309
+ * the roster layer and the permission boundary: a scoped home bind-mounted
310
+ * into an evaluation unit carries its own composition with it, and nothing
311
+ * has to ride the spawn env or be baked into an image.
312
+ *
313
+ * It exists because the plugin's own `config` is instance-global — one
314
+ * deployment, one answer — while a preset is a property of the SUBJECT. Two
315
+ * scopes rostering two presets was therefore unexpressible, and the only way
316
+ * to build it was to hand-append the roster layer to each scope's patch. That
317
+ * hand edit does not survive: this module regenerates the patch WHOLE, and
318
+ * the registry re-provisions a scope the first time anything names it in a
319
+ * fresh host process — so the roster silently disappears on the next restart,
320
+ * and the next read-back reports a scope that rosters nothing.
321
+ */
322
+ const SCOPE_SUB_PROFILE_FILENAME = "sub-profile.json";
323
+ /**
324
+ * Read one scope's own sub-profile declaration.
325
+ *
326
+ * Degrades to `undefined` for every unreadable shape — no file, unparsable
327
+ * JSON, a `preset` that is not a usable id — because the caller's next step
328
+ * is the plugin-level config, and a scope whose declaration is junk must fall
329
+ * back to the deployment's answer rather than fail to provision at all.
330
+ * @param homeDir - the harness scoped home.
331
+ * @returns the declaration, or undefined when the scope declares nothing usable.
332
+ */
333
+ function readScopeSubProfile(homeDir) {
334
+ let text;
335
+ try {
336
+ text = readFileSync(join(homeDir, SCOPE_SUB_PROFILE_FILENAME), "utf8");
337
+ } catch {
338
+ return;
339
+ }
340
+ let document;
341
+ try {
342
+ document = JSON.parse(text);
343
+ } catch {
344
+ return;
345
+ }
346
+ if (typeof document !== "object" || document === null || Array.isArray(document)) return void 0;
347
+ const preset = document["preset"];
348
+ if (typeof preset !== "string" || !SUB_PROFILE_PRESET_ID_RE.test(preset)) return void 0;
349
+ return { preset };
350
+ }
351
+ /**
352
+ * Record one scope's sub-profile declaration, so the next provisioning of
353
+ * that scope reproduces it without being told. Idempotent — a file that
354
+ * already says this is left untouched.
355
+ * @param homeDir - the harness scoped home.
356
+ * @param declaration - what the scope composes.
357
+ */
358
+ function writeScopeSubProfile(homeDir, declaration) {
359
+ const path = join(homeDir, SCOPE_SUB_PROFILE_FILENAME);
360
+ const content = JSON.stringify(declaration, void 0, 2) + "\n";
361
+ if (existsSync(path) && readFileSync(path, "utf8") === content) return;
362
+ mkdirSync(homeDir, { recursive: true });
363
+ writeFileSync(path, content);
364
+ }
365
+ /**
366
+ * A YAML schema that keeps `!!js` expressions as their SOURCE TEXT.
367
+ *
368
+ * The loader evaluates those expressions; this module only reads them, and
369
+ * evaluating one here would run preset text as code inside the host process.
370
+ * Keeping the source is what lets {@link compositionAbsolutePaths} see an
371
+ * absolute path hidden inside one.
372
+ */
373
+ const EXPRESSION_SCHEMA = yaml.DEFAULT_SCHEMA.extend([
374
+ "scalar",
375
+ "sequence",
376
+ "mapping"
377
+ ].map((kind) => new yaml.Type("tag:yaml.org,2002:js", {
378
+ kind,
379
+ construct: (data) => ({ __jsExpr: typeof data === "string" ? data : "" })
380
+ })));
381
+ /** A quoted absolute path inside an expression's source text. */
382
+ const QUOTED_ABSOLUTE_RE = /(['"])(\/[^'"\n]*)\1/g;
383
+ /**
384
+ * Every absolute filesystem path a preset composition names.
385
+ *
386
+ * An absolute path is the one thing in a composition that cannot be true in
387
+ * two places at once, and a preset used as an evaluation factor is copied:
388
+ * it is read on the host from the deployment's preset root, again from the
389
+ * scope's own copy, and a third time inside a unit where the scope is bound
390
+ * at a different mount point. `skill-filesystem` resolves each
391
+ * `customSkillDirs` entry with `resolve()`, so an absolute entry is taken as
392
+ * written and a bare relative one resolves against the process's cwd —
393
+ * neither travels.
394
+ *
395
+ * What does travel is the loader's own expression form, which the shipped
396
+ * `cordis` preset already uses:
397
+ *
398
+ * ```yaml
399
+ * customSkillDirs:
400
+ * - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"
401
+ * ```
402
+ *
403
+ * `baseUrl` is the composition's own directory (the include rewrites it), so
404
+ * the root resolves wherever the preset is installed.
405
+ *
406
+ * Unparsable YAML reports NO findings: the loader is the authority on whether
407
+ * a composition loads, and refusing a preset because this reader could not
408
+ * parse it would be this module overruling it.
409
+ * @param text - the `agent.cordis.yml` content.
410
+ * @returns the offending literals, in document order, deduplicated.
411
+ */
412
+ function compositionAbsolutePaths(text) {
413
+ let document;
414
+ try {
415
+ document = yaml.load(text, { schema: EXPRESSION_SCHEMA });
416
+ } catch {
417
+ return [];
418
+ }
419
+ const found = [];
420
+ const add = (value) => {
421
+ if (!found.includes(value)) found.push(value);
422
+ };
423
+ const walk = (value) => {
424
+ if (typeof value === "string") {
425
+ if (isAbsolute(value) || value.startsWith("file:///")) add(value);
426
+ return;
427
+ }
428
+ if (Array.isArray(value)) {
429
+ for (const item of value) walk(item);
430
+ return;
431
+ }
432
+ if (typeof value !== "object" || value === null) return;
433
+ const expression = value["__jsExpr"];
434
+ if (typeof expression === "string") {
435
+ for (const match of expression.matchAll(QUOTED_ABSOLUTE_RE)) {
436
+ const literal = match[2];
437
+ if (literal !== void 0) add(literal);
438
+ }
439
+ return;
440
+ }
441
+ for (const item of Object.values(value)) walk(item);
442
+ };
443
+ walk(document);
444
+ return found;
445
+ }
446
+ /** Every regular file under `dir`, as posix-ish relative paths, sorted. */
447
+ function presetTreeFiles(dir) {
448
+ const files = [];
449
+ const walk = (absDir, rel) => {
450
+ let entries;
451
+ try {
452
+ entries = readdirSync(absDir, { withFileTypes: true });
453
+ } catch {
454
+ return false;
455
+ }
456
+ for (const entry of entries) {
457
+ const relChild = rel === "" ? entry.name : `${rel}/${entry.name}`;
458
+ if (entry.isSymbolicLink()) return false;
459
+ if (entry.isDirectory()) {
460
+ if (!walk(join(absDir, entry.name), relChild)) return false;
461
+ continue;
462
+ }
463
+ if (!entry.isFile()) return false;
464
+ files.push(relChild);
465
+ }
466
+ return true;
467
+ };
468
+ if (!walk(dir, "")) return void 0;
469
+ return files.sort();
470
+ }
471
+ /**
472
+ * Whether two preset directories are the same directory, byte for byte.
473
+ *
474
+ * This is the whole claim the evaluation rests on: a capability face carries
475
+ * no filesystem path, so the fingerprint taken against the deployment's copy
476
+ * describes the scope's copy exactly when the two hold the same bytes.
477
+ * @param a - one preset directory.
478
+ * @param b - the other.
479
+ * @returns true when both hold the same files with the same contents.
480
+ */
481
+ function presetTreesEqual(a, b) {
482
+ const left = presetTreeFiles(a);
483
+ const right = presetTreeFiles(b);
484
+ if (left === void 0 || right === void 0) return false;
485
+ if (left.length !== right.length || left.some((name, index) => name !== right[index])) return false;
486
+ return left.every((name) => {
487
+ try {
488
+ return readFileSync(join(a, name)).equals(readFileSync(join(b, name)));
489
+ } catch {
490
+ return false;
491
+ }
492
+ });
493
+ }
494
+ /** Copy one preset directory onto another, replacing whatever was there. */
495
+ function mirrorPresetTree(source, target) {
496
+ const files = presetTreeFiles(source);
497
+ if (files === void 0) throw new Error(`provision-dsh: preset directory ${source} is not a plain tree of files (a symlink or a device node cannot be copied into a scope — an evaluation unit resolves neither)`);
498
+ rmSync(target, {
499
+ recursive: true,
500
+ force: true
501
+ });
502
+ for (const name of files) {
503
+ const destination = join(target, name);
504
+ mkdirSync(dirname(destination), { recursive: true });
505
+ copyFileSync(join(source, name), destination);
506
+ }
507
+ }
508
+ /**
509
+ * Give one scope its own copy of a preset, from the deployment's preset root.
510
+ *
511
+ * The copy is what makes a preset reachable on BOTH paths. The roster derives
512
+ * a user root of `<$DSH_HOME>/.agent-presets`, and the sub-dsh runs with
513
+ * `DSH_HOME` pointed at the scoped home — which is `<scope>/.agent-presets`
514
+ * on the host and `/creds/dsh/.agent-presets` inside an evaluation unit,
515
+ * one directory, because the unit binds the scope. A roster pointed at the
516
+ * deployment's root instead resolves on the host and names a path the unit
517
+ * does not have.
518
+ *
519
+ * `refresh` is the difference between provisioning a condition and healing a
520
+ * scope. A deliberate provision re-syncs from the deployment's copy, because
521
+ * that is the moment a new lock is minted and an edited preset should be
522
+ * picked up. Every other provisioning leaves an existing copy alone: nothing
523
+ * may move the subject under a run.
524
+ * @param homeDir - the harness scoped home.
525
+ * @param presetRoot - the deployment's preset root.
526
+ * @param id - the preset id (a directory name in both roots).
527
+ * @param options - `refresh` re-syncs an existing copy.
528
+ * @returns where the copy is and whether it matches the source.
529
+ * @throws when the source is missing, is not a preset, or names an absolute path.
530
+ */
531
+ function snapshotScopePreset(homeDir, presetRoot, id, options = {}) {
532
+ if (!SUB_PROFILE_PRESET_ID_RE.test(id)) throw new Error(`provision-dsh: preset id ${JSON.stringify(id)} must match ${String(SUB_PROFILE_PRESET_ID_RE)} (it is a directory name)`);
533
+ const source = join(presetRoot, id);
534
+ const composition = join(source, PRESET_COMPOSITION_FILE);
535
+ if (!existsSync(composition)) throw new Error(`provision-dsh: no preset ${JSON.stringify(id)} in ${presetRoot} (expected ${join(id, PRESET_COMPOSITION_FILE)}) — install it into this deployment's preset root before a scope can compose it`);
536
+ const offenders = compositionAbsolutePaths(readFileSync(composition, "utf8"));
537
+ if (offenders.length > 0) throw new Error(`provision-dsh: preset ${JSON.stringify(id)} names absolute path(s) ${offenders.map((path) => JSON.stringify(path)).join(", ")} — a preset that is copied into a scope and bind-mounted into a unit is read from three different directories, so an absolute path is wrong in at least two of them. Use the loader expression the shipped \`cordis\` preset uses: !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"`);
538
+ const dir = join(homeDir, USER_PRESET_DIR, id);
539
+ if (existsSync(dir) && options.refresh !== true) return {
540
+ dir,
541
+ matchesSource: presetTreesEqual(source, dir),
542
+ copied: false
543
+ };
544
+ if (existsSync(dir) && presetTreesEqual(source, dir)) return {
545
+ dir,
546
+ matchesSource: true,
547
+ copied: false
548
+ };
549
+ mirrorPresetTree(source, dir);
550
+ return {
551
+ dir,
552
+ matchesSource: true,
553
+ copied: true
554
+ };
555
+ }
556
+ /**
557
+ * The deployment's own preset root, when the caller names no other.
558
+ *
559
+ * The roster's own derivation, repeated here because the provisioning paths
560
+ * that heal a scope (a host round, a live runtime) have no settings service
561
+ * in reach: the harness home from the environment, else the installation
562
+ * default.
563
+ */
564
+ function defaultPresetRoot() {
565
+ return join(process.env["DSH_HOME"] ?? join(homedir(), ".dsh-official"), USER_PRESET_DIR);
566
+ }
567
+ /**
568
+ * Provision one scope: its own preset copy, its declaration, and the
569
+ * sub-profile that rosters it.
570
+ *
571
+ * The preset resolves in one order, and the order is the decision: the
572
+ * CALLER's explicit request, then the SCOPE's own declaration, then the
573
+ * deployment's plugin config. The first is how a condition says which subject
574
+ * this scope is; the second is how that survives a restart; the third is the
575
+ * deployment-wide answer every scope had before either existed.
576
+ *
577
+ * A scope-owned preset rosters with NO `roots`. The roster's derived user
578
+ * root is exactly the scope's copy on both paths, so leaving the roots alone
579
+ * is what gives the host and the unit one resolution rule instead of two.
580
+ * @param homeDir - the harness scoped home.
581
+ * @param config - the deployment's sub-profile config.
582
+ * @param options - per-scope inputs.
583
+ * @returns what the scope now composes.
584
+ */
585
+ function provisionDshScope(homeDir, config = {}, options = {}) {
586
+ const requested = options.preset;
587
+ const declared = requested ?? readScopeSubProfile(homeDir)?.preset;
588
+ if (requested !== void 0) writeScopeSubProfile(homeDir, { preset: requested });
589
+ let snapshot;
590
+ if (declared !== void 0) snapshot = snapshotScopePreset(homeDir, options.presetRoot ?? defaultPresetRoot(), declared, { refresh: requested !== void 0 });
591
+ const preset = declared === void 0 ? config.preset : { id: declared };
592
+ return {
593
+ profileDir: provisionDshSubProfile(homeDir, {
594
+ ...config,
595
+ ...preset === void 0 ? {} : { preset }
596
+ }),
597
+ ...preset === void 0 ? {} : { preset: preset.id },
598
+ ...snapshot === void 0 ? {} : { presetSnapshot: { matchesSource: snapshot.matchesSource } }
599
+ };
600
+ }
601
+ //#endregion
602
+ export { DEFAULT_SUB_PROFILE_NAME, PRESET_ROSTER_MODULE, SCOPE_SUB_PROFILE_FILENAME, SUB_PROFILE_PRESET_ID_RE, USER_PRESET_DIR, compositionAbsolutePaths, defaultPresetRoot, permissionBoundaryLayer, presetRosterLayer, presetTreesEqual, provisionDshScope, provisionDshSubProfile, readScopeSubProfile, readSubProfilePermissions, readSubProfilePreset, resolveHeadlessBundleDir, snapshotScopePreset, writeScopeSubProfile };