external-subagent 0.5.2 → 0.5.4

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 (44) hide show
  1. package/README.md +8 -5
  2. package/bin/external-subagent-mcp.mjs +3 -2
  3. package/cli/commands/agents.mjs +4 -9
  4. package/cli/commands/config.mjs +1 -1
  5. package/cli/commands/daemon.mjs +8 -3
  6. package/cli/commands/maintenance.mjs +158 -1
  7. package/cli/commands/plugin.mjs +28 -3
  8. package/cli/commands/tasks.mjs +7 -1
  9. package/cli/commands/update.mjs +18 -7
  10. package/cli/config/write.mjs +195 -14
  11. package/cli/constants.mjs +2 -2
  12. package/cli/install/codex.mjs +1 -1
  13. package/cli/install/dsh.mjs +613 -0
  14. package/cli/install/init.mjs +23 -33
  15. package/cli/install/layout.mjs +10 -2
  16. package/cli/install/payload.mjs +59 -12
  17. package/cli/install/service-activation.mjs +10 -2
  18. package/cli/install/service-linux.mjs +1 -1
  19. package/cli/install/service-macos.mjs +40 -14
  20. package/cli/install/service-windows.mjs +463 -0
  21. package/cli/install/update.mjs +6 -5
  22. package/cli/main.mjs +10 -14
  23. package/cli/maintenance.mjs +7 -1
  24. package/cli/paths.mjs +152 -8
  25. package/npm/native/README.md +2 -2
  26. package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
  27. package/npm/native/darwin-arm64/external-subagentd +0 -0
  28. package/npm/native/darwin-arm64/payload.json +5 -5
  29. package/npm/native/linux-x64/external-subagent-mcp +0 -0
  30. package/npm/native/linux-x64/external-subagentd +0 -0
  31. package/npm/native/linux-x64/payload.json +5 -5
  32. package/package.json +3 -2
  33. package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +5 -3
  34. package/schema/external-subagent-public-api.json +3 -3
  35. package/scripts/release/build-native-payload.mjs +22 -5
  36. package/scripts/release/check-native-tarball.mjs +34 -14
  37. package/scripts/release/stage-debug-plugin.mjs +9 -3
  38. package/scripts/release/test-installed-tarball.mjs +1 -1
  39. package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +0 -55
  40. package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +0 -19
  41. package/plugins/codex/external-subagent/hooks/guard-bash.mjs +0 -15
  42. package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +0 -206
  43. package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +0 -149
  44. package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +0 -58
@@ -0,0 +1,613 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { CliError } from '../errors.mjs';
6
+ import { atomicWrite, readOptional, restoreOptional } from '../fs-atomic.mjs';
7
+ import { MCP_BIN_NAME } from '../constants.mjs';
8
+ import { nativeBinary, pluginSourceRoot, PLUGIN_NAME } from './layout.mjs';
9
+ import { guardedRestores } from './plugin-stage.mjs';
10
+
11
+ // Managed DSH host binding. DSH boots profiles as an ordered stack of
12
+ // plugin-bundle patch layers under the user's own overrides; the user layer
13
+ // lives in $DSH_HOME/profiles/<name>/cordis.patch.yml as a top-level YAML
14
+ // array of loader patch entries. This module appends exactly one managed
15
+ // `dsh-mcp-client` stdio entry — the MCP facade plus the daemon socket —
16
+ // to that file (marked by a sentinel comment) and stages the product skill
17
+ // into the user skill root (<dshHome>/skills), which dsh-skill-filesystem
18
+ // scans by default (rank 400; OBSERVED against dsh 0.2.0-rc.2). It never
19
+ // touches the profile's package.json, bundles, node_modules, or any dsh
20
+ // market/plugin-install state. There is no registry file: the binding is
21
+ // fully derivable from the patch sentinel plus the staged skill, so
22
+ // reconcile and uninstall detect rather than remember it (the zcode model).
23
+
24
+ // The MCP client plugin is resolved by the dsh loader from the CLI's own
25
+ // install tree when the bare package name is used (OBSERVED: a fresh
26
+ // DSH_HOME with no profile node_modules composes an insert row with this
27
+ // name cleanly). The physical presence check below is a fail-closed guard
28
+ // against a future dsh build that stops shipping it.
29
+ const MCP_CLIENT_PACKAGE = '@deepseek-ai/dsh-mcp-client';
30
+ // The npm dsh CLI rejects dump requests for the desktop profile (reserved
31
+ // for the Electron-owned installation), so desktop cannot be verified by
32
+ // naming it directly. The refusal is name-only: the same composition is
33
+ // dumpable under a mirror profile name, and the Electron app ships its own
34
+ // CLI whose plugin subcommand manages desktop. Desktop is therefore
35
+ // installed like any other profile and verified through a mirror dump
36
+ // driven by the Electron-bundled CLI (verifyWithMirrorDump below).
37
+ export const DSH_FORBIDDEN_PROFILES = [];
38
+ // Stable loader entry id for the managed mcp-client row. dsh's patch
39
+ // grammar makes `id` optional on insert rows, but a row without one is
40
+ // unmanageable: the loader assigns a random 8-hex id at each boot and never
41
+ // persists it, so dsh's own plugin manager marks the row read-only
42
+ // (`unaddressable`) and no later patch row can target it. Every managed
43
+ // block therefore pins this id, and uninstall also strips disable overrides
44
+ // carrying it.
45
+ export const MCP_ENTRY_ID = 'mcp-external-subagent';
46
+ // The Electron-bundled dsh CLI, usable for desktop verification where the
47
+ // npm CLI refuses the profile name. Windows ships dsh.cmd at the parallel
48
+ // resources path.
49
+ export const ELECTRON_DSH_CLI = {
50
+ darwin: '/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh',
51
+ win32: `${process.env.LOCALAPPDATA || '~/AppData/Local/Programs'}\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd`,
52
+ };
53
+ // `external_subagent_wait` defaults to 290s with a 299s ceiling; dsh's
54
+ // mcp-client caps tool calls at 60s unless overridden, which would cut
55
+ // every long wait. 300000ms covers the ceiling with margin (the same
56
+ // posture as the zcode binding's timeoutMs).
57
+ const MCP_TOOL_CALL_TIMEOUT_MS = 300000;
58
+ // The sentinel comment marking the managed block. ASCII-only on purpose:
59
+ // the patch file is user-facing YAML and must survive any editor.
60
+ const SENTINEL = '# @managed-by: external-subagent (install-plugin dsh) — this block is rewritten by the product; keep manual edits outside it';
61
+
62
+ export function dshHomeFor(options = {}, paths) {
63
+ if (typeof options.dshHome === 'string' && options.dshHome.length > 0) return options.dshHome;
64
+ if (typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0) return process.env.DSH_HOME;
65
+ const home = (paths && paths.home) || os.homedir();
66
+ return path.join(home, '.dsh');
67
+ }
68
+
69
+ export function dshProfileDir(options = {}, paths) {
70
+ const name = options.profile;
71
+ if (typeof name !== 'string' || name.length === 0) {
72
+ throw new CliError('INVALID_ARGUMENT', 'the dsh host requires --profile <name> (the profile directory under $DSH_HOME/profiles)', 2);
73
+ }
74
+ if (DSH_FORBIDDEN_PROFILES.includes(name)) {
75
+ throw new CliError('INVALID_ARGUMENT', `the dsh CLI refuses the "${name}" profile for external management; bind web, headless, acp, or a custom profile instead`, 2);
76
+ }
77
+ if (name === 'desktop' && process.platform !== 'darwin' && process.platform !== 'win32') {
78
+ throw new CliError('INVALID_ARGUMENT', 'the desktop profile is Electron-owned and only manageable on macOS and Windows installations', 2);
79
+ }
80
+ if (/[\\/]/u.test(name)) throw new CliError('INVALID_ARGUMENT', 'dsh profile name must not contain path separators', 2);
81
+ return path.join(dshHomeFor(options, paths), 'profiles', name);
82
+ }
83
+
84
+ // Resolve the dsh executable for local probes: an explicit absolute
85
+ // DSH_RUNTIME_PATH file first (the same variable the daemon's own discovery
86
+ // consumes), otherwise `dsh` from PATH. Returns { exe, explicit } where exe
87
+ // may be a PATH token the caller resolves through the shell.
88
+ function dshExecutable() {
89
+ const configured = process.env.DSH_RUNTIME_PATH;
90
+ if (typeof configured === 'string' && configured !== '' && path.isAbsolute(configured) && fs.existsSync(configured)) {
91
+ return { exe: configured, explicit: true };
92
+ }
93
+ return { exe: 'dsh', explicit: false };
94
+ }
95
+
96
+ // The native facade command: same choice as the codex binding (dsh has no
97
+ // ZCode-style restricted spawn context that kills signed native binaries;
98
+ // OBSERVED: dsh spawns stdio MCP servers as ordinary child processes).
99
+ export function dshMcpBinding(paths) {
100
+ const command = nativeBinary(MCP_BIN_NAME);
101
+ if (!command) throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', 'no native MCP facade payload is available for this platform');
102
+ return { command, args: [], toolCallTimeoutMs: MCP_TOOL_CALL_TIMEOUT_MS };
103
+ }
104
+
105
+ function mcpClientPackagePresent() {
106
+ // The dsh CLI carries its plugin packages under
107
+ // <dsh-lib-root>/node_modules/@deepseek-ai. Resolve the lib root from
108
+ // the dsh executable; absence is advisory, not fatal, because a
109
+ // differently laid-out dsh build may still resolve the package.
110
+ const { exe, explicit } = dshExecutable();
111
+ const which = spawnSync(exe, ['--version'], { encoding: 'utf8', timeout: 15000 });
112
+ if (which.error || which.status !== 0) return { present: false, version: null };
113
+ const version = (which.stdout || '').split(/\s+/u).find((word) => /^\d/u.test(word)) || null;
114
+ const resolved = explicit ? path.resolve(exe) : (() => {
115
+ const bin = spawnSync('sh', ['-c', 'command -v dsh'], { encoding: 'utf8', timeout: 15000 });
116
+ return (bin.stdout || '').trim() || null;
117
+ })();
118
+ if (!resolved) return { present: false, version };
119
+ // <prefix>/bin/dsh (npm global, Homebrew) -> <prefix>/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai
120
+ try {
121
+ const prefix = path.dirname(path.dirname(resolved));
122
+ const probe = path.join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', MCP_CLIENT_PACKAGE);
123
+ return { present: fs.existsSync(probe), version };
124
+ } catch {
125
+ return { present: false, version };
126
+ }
127
+ }
128
+
129
+ // Serialize the managed block as YAML text. Dynamic values are single-
130
+ // quoted with YAML's doubled-quote escaping: the package name contains a
131
+ // reserved `@`, and paths may contain characters that are ambiguous as
132
+ // plain scalars, so quoting is unconditional rather than value-dependent
133
+ // (a plain-scalar allowlist would silently break on the next odd path).
134
+ function yamlScalar(value) {
135
+ return `'${String(value).replaceAll("'", "''")}'`;
136
+ }
137
+
138
+ function managedBlock(binding, socket) {
139
+ return [
140
+ SENTINEL,
141
+ '- insert:',
142
+ ` - id: ${MCP_ENTRY_ID}`,
143
+ ` name: ${yamlScalar(MCP_CLIENT_PACKAGE)}`,
144
+ ' config:',
145
+ ` serverName: ${yamlScalar('external_subagent')}`,
146
+ ` transport: ${yamlScalar('stdio')}`,
147
+ ` command: ${yamlScalar(binding.command)}`,
148
+ ` args: ${JSON.stringify(binding.args)}`,
149
+ ' env:',
150
+ ` EXTERNAL_SUBAGENT_SOCKET: ${yamlScalar(socket)}`,
151
+ ` toolCallTimeoutMs: ${binding.toolCallTimeoutMs}`,
152
+ ].join('\n');
153
+ }
154
+
155
+ // Append (or replace) the managed block in the profile patch text. This is
156
+ // a text-level transaction, deliberately NOT a YAML parse/rewrite: user
157
+ // comments and formatting survive byte-for-byte outside the managed block.
158
+ //
159
+ // Fresh shipped templates end with a bare `[]` root; appending list items
160
+ // after it would be invalid YAML, so that exact trailing empty-array line
161
+ // is removed first (the shipped template shape, OBSERVED 0.2.0-rc.2).
162
+ export function composePatchText(priorText, binding, socket) {
163
+ const block = managedBlock(binding, socket);
164
+ const prior = priorText === null ? '' : priorText;
165
+ const range = managedRange(prior);
166
+ if (range !== null) {
167
+ // Replace in place, preserving the exact blank-line separation that
168
+ // surrounded the prior block (head keeps its trailing blank line, tail
169
+ // its leading one), so an unchanged binding recomposes byte-for-byte.
170
+ const head = prior.slice(0, range.start).replace(/\n{3,}$/u, '\n\n');
171
+ const tail = prior.slice(range.end).replace(/^\n{3,}/u, '\n\n');
172
+ const suffix = tail.length === 0 ? '\n' : '';
173
+ return `${head}${block}${suffix}${tail}`;
174
+ }
175
+ let body = prior;
176
+ const lines = body.split('\n');
177
+ const lastNonEmpty = lines.reduce((acc, line, index) => (line.trim() === '' ? acc : index), -1);
178
+ if (lastNonEmpty !== -1 && lines[lastNonEmpty].trim() === '[]') {
179
+ lines.splice(lastNonEmpty, 1);
180
+ body = lines.join('\n');
181
+ }
182
+ const trimmed = body.replace(/\s+$/u, '');
183
+ return trimmed.length === 0 ? `${block}\n` : `${trimmed}\n\n${block}\n`;
184
+ }
185
+
186
+ // Locate the managed block boundaries in current text: [start, end).
187
+ // start is the sentinel line; the block then covers the managed entry —
188
+ // the first `- ` line at or after the sentinel — and every following line
189
+ // until the next non-empty, non-comment line at column 0 (a following
190
+ // top-level item) or EOF. Returns null when the sentinel is absent, and
191
+ // a sentinel-only range when the entry itself was deleted by hand.
192
+ function managedRange(text) {
193
+ const sentinelIndex = text.indexOf(SENTINEL);
194
+ if (sentinelIndex === -1) return null;
195
+ const afterSentinelLine = text.indexOf('\n', sentinelIndex);
196
+ const afterSentinel = afterSentinelLine === -1 ? text.length : afterSentinelLine + 1;
197
+ // Find the managed entry's `- ` start, allowing blank/comment lines
198
+ // between the sentinel and the entry.
199
+ const head = text.slice(afterSentinel);
200
+ const entryOffset = head.search(/^- /mu);
201
+ if (entryOffset === -1) {
202
+ // Sentinel present but the entry is gone: the block is the sentinel line.
203
+ return { start: sentinelIndex, end: afterSentinel };
204
+ }
205
+ const entryStart = afterSentinel + entryOffset;
206
+ // The entry consumes its own line plus every indented (or blank/comment)
207
+ // line that follows, stopping before the next column-0 content line.
208
+ const lines = text.slice(entryStart).split('\n');
209
+ let end = entryStart + (lines[0]?.length ?? 0);
210
+ for (let index = 1; index < lines.length; index += 1) {
211
+ const line = lines[index];
212
+ if (line.trim() !== '' && !line.startsWith(' ') && !line.startsWith('#')) break;
213
+ end += 1 + line.length;
214
+ }
215
+ return { start: sentinelIndex, end: Math.min(end, text.length) };
216
+ }
217
+
218
+ // Remove top-level disable overrides for the managed entry id. A matching
219
+ // row is `- id: <MCP_ENTRY_ID>` (single- or double-quoted or bare, matching
220
+ // dsh's own writer which emits `{id, disabled}`) followed only by `disabled`
221
+ // and comment lines; a row carrying any other key (`name`, `config`,
222
+ // `insert`) belongs to the user and survives even if it reuses the id.
223
+ function stripDisableOverrides(text) {
224
+ const lines = text.split('\n');
225
+ const kept = [];
226
+ let index = 0;
227
+ while (index < lines.length) {
228
+ const line = lines[index];
229
+ const match = /^- id:\s*('?|"?)mcp-external-subagent\1\s*$/u.exec(line);
230
+ if (match) {
231
+ let end = index + 1;
232
+ while (end < lines.length) {
233
+ const next = lines[end];
234
+ if (/^ disabled:\s*\S+/u.test(next) || next.trim() === '' || next.startsWith('#')) end += 1;
235
+ else break;
236
+ }
237
+ const body = lines.slice(index + 1, end).filter((l) => l.trim() !== '' && !l.startsWith('#'));
238
+ const onlyDisabled = body.length > 0 && body.every((l) => /^ disabled:\s*\S+/u.test(l));
239
+ if (onlyDisabled) {
240
+ index = end;
241
+ continue;
242
+ }
243
+ }
244
+ kept.push(line);
245
+ index += 1;
246
+ }
247
+ return kept.join('\n');
248
+ }
249
+
250
+ function skillPaths(dshHome) {
251
+ const dir = path.join(dshHome, 'skills', 'external-subagent');
252
+ return { dir, file: path.join(dir, 'SKILL.md') };
253
+ }
254
+
255
+ function stageSkill(dshHome, source) {
256
+ const from = path.join(source, 'skills', 'external-subagent', 'SKILL.md');
257
+ const bytes = readOptional(from);
258
+ if (bytes === null) throw new CliError('INVALID_PLUGIN_SOURCE', `plugin skill source is missing (${from})`);
259
+ const { dir, file } = skillPaths(dshHome);
260
+ const prior = readOptional(file);
261
+ // Foreign content at the managed skill path is never overwritten; a
262
+ // byte-equal prior is an idempotent no-op.
263
+ if (prior !== null && !prior.equals(bytes)) {
264
+ throw new CliError('DSH_SKILL_CONFLICT', `a non-product SKILL.md already exists at ${file}; remove it or install manually`);
265
+ }
266
+ if (prior === null) {
267
+ atomicWrite(file, bytes);
268
+ return { dir, written: true };
269
+ }
270
+ return { dir, written: false };
271
+ }
272
+
273
+ // Remove the staged skill only when it is unmistakably ours: byte-equal
274
+ // content, or an absent file. Any other content stays and is reported.
275
+ function unstageSkill(dshHome, source) {
276
+ const from = path.join(source, 'skills', 'external-subagent', 'SKILL.md');
277
+ const ours = readOptional(from);
278
+ const { dir, file } = skillPaths(dshHome);
279
+ const staged = readOptional(file);
280
+ if (staged === null) return { removed: false, note: 'no staged skill present' };
281
+ if (ours === null || !ours.equals(staged)) {
282
+ return { removed: false, note: `staged skill at ${file} differs from the product source; left in place` };
283
+ }
284
+ fs.rmSync(dir, { recursive: true, force: true });
285
+ return { removed: true, dir };
286
+ }
287
+
288
+ // Compose the profile through the real dsh CLI and prove the managed entry
289
+ // survives: a clean dump (stderr without a `patch:` failure marker) whose
290
+ // tail carries our serverName under the patch banner. The probe is bounded
291
+ // and read-only; `desktop` and other refusals surface as verification
292
+ // failures. Returns the observed dsh version token (advisory evidence).
293
+ // `options.verify` is the test seam: an injected verifier replaces the
294
+ // spawn without touching any other install logic.
295
+ // Compose a throwaway mirror of the desktop profile under a non-reserved
296
+ // name inside the SAME dsh home and dump it with the Electron-bundled CLI.
297
+ // The npm CLI (and even the Electron CLI) refuse `--dump-config` for the
298
+ // literal name "desktop" — the refusal is name-only (OBSERVED 0.2.1-rc.3),
299
+ // so an identical composition under another name verifies the same patch
300
+ // bytes. The mirror copies the profile's composition inputs (cordis.yml,
301
+ // cordis.patch.yml, package.json, pnpm-*.yaml) but never node_modules: a
302
+ // symlinked node_modules would let dsh's link-projection cleanup write into
303
+ // the real profile through the mirror, and bundle resolution falling back
304
+ // to the CLI's own installation is exactly what we want to observe anyway.
305
+ // Returns { status, stderr, stdout } or throws CliError on spawn failure.
306
+ function dumpMirrorProfile(dshHome, sourceProfileDir, exe) {
307
+ const profilesRoot = path.join(dshHome, 'profiles');
308
+ const mirrorName = `esa-verify-${process.pid}`;
309
+ const mirrorDir = path.join(profilesRoot, mirrorName);
310
+ const mirrorHome = fs.mkdtempSync(path.join(os.tmpdir(), 'external-subagent-mirror-'));
311
+ const mirrorProfiles = path.join(mirrorHome, 'profiles', mirrorName);
312
+ fs.mkdirSync(mirrorProfiles, { recursive: true });
313
+ let dump;
314
+ try {
315
+ for (const file of ['cordis.yml', 'cordis.patch.yml', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) {
316
+ const from = path.join(sourceProfileDir, file);
317
+ if (fs.existsSync(from)) fs.copyFileSync(from, path.join(mirrorProfiles, file));
318
+ }
319
+ // DSH_HOME points at the scratch home: bundle resolution walks the
320
+ // CLI's own installation first, so shared bundles resolve there, and a
321
+ // profile-local bundle that cannot resolve is reported by the dump
322
+ // itself (an unrelated-user-layer diagnostic, not a crash).
323
+ dump = spawnSync(exe, ['--profile', mirrorName, '--dump-config'], {
324
+ encoding: 'utf8',
325
+ timeout: 30000,
326
+ env: { ...process.env, DSH_HOME: mirrorHome, DSH_PERMISSION_MODE: 'read-only' },
327
+ });
328
+ } finally {
329
+ fs.rmSync(mirrorHome, { recursive: true, force: true });
330
+ }
331
+ if (dump.error) {
332
+ throw new CliError('DSH_BINDING_UNVERIFIABLE', `desktop mirror dump could not run: ${dump.error.message}`);
333
+ }
334
+ return dump;
335
+ }
336
+
337
+ // Resolve the Electron-bundled dsh CLI for desktop verification. Returns
338
+ // null when the expected install is absent (non-Electron machines); the
339
+ // caller degrades to an unverified install with an explicit warning.
340
+ function electronDshCli() {
341
+ const candidate = process.platform === 'win32'
342
+ ? ELECTRON_DSH_CLI.win32
343
+ : ELECTRON_DSH_CLI.darwin;
344
+ try {
345
+ if (candidate && fs.existsSync(candidate)) return candidate;
346
+ } catch { /* probe is advisory */ }
347
+ return null;
348
+ }
349
+
350
+ function verifyWithDump(dshHome, profileName, options = {}) {
351
+ if (typeof options.verify === 'function') return options.verify({ dshHome, profile: profileName });
352
+ let dump;
353
+ let unrelatedDiagnostics = [];
354
+ if (profileName === 'desktop') {
355
+ const exe = electronDshCli();
356
+ if (exe === null) return { dsh_version: null, config_verified: false, warning: 'the Electron-bundled dsh CLI was not found; the desktop patch was written but its composition was not dump-verified (confirm in the DeepSeek Harness plugin page after restart)' };
357
+ const profileDir = path.join(dshHome, 'profiles', profileName);
358
+ dump = dumpMirrorProfile(dshHome, profileDir, exe);
359
+ } else {
360
+ const { exe } = dshExecutable();
361
+ dump = spawnSync(exe, ['--profile', profileName, '--dump-config'], {
362
+ encoding: 'utf8',
363
+ timeout: 30000,
364
+ env: { ...process.env, DSH_HOME: dshHome, DSH_PERMISSION_MODE: 'read-only' },
365
+ });
366
+ }
367
+ if (dump.error) {
368
+ throw new CliError('DSH_BINDING_UNVERIFIABLE', `dsh --dump-config could not run: ${dump.error.message}`);
369
+ }
370
+ const stderr = dump.stderr || '';
371
+ if (dump.status !== 0) {
372
+ throw new CliError('DSH_BINDING_UNVERIFIABLE', `dsh --dump-config exited ${dump.status}: ${stderr.trim().split('\n').slice(-4).join(' | ')}`);
373
+ }
374
+ // dsh 0.2.0-rc.2 exits 0 even when a patch row fails, printing the reason
375
+ // on stderr (OBSERVED classes: `patch: entry "<id>" not found` for a
376
+ // missing target, `patch: id is required for non-insert patches` for a
377
+ // malformed row). Fail-open by design upstream — so stderr is a
378
+ // first-class signal here, not a footnote: a `patch:` diagnostic naming
379
+ // OUR entry id rejects the install, because it means the managed row was
380
+ // dropped or malformed. Diagnostics about other rows (an unrelated user
381
+ // override targeting a missing entry, pre-existing profile drift) are
382
+ // reported as a warning instead: the mirror composition of a
383
+ // long-lived desktop profile routinely carries such pre-existing
384
+ // diagnostics that this install neither caused nor can fix.
385
+ const patchDiagnostics = stderr.split('\n').filter((line) => line.includes('patch:'));
386
+ const ours = patchDiagnostics.filter((line) => line.includes(MCP_ENTRY_ID) || line.includes('serverName: external_subagent'));
387
+ unrelatedDiagnostics = patchDiagnostics.filter((line) => !ours.includes(line));
388
+ if (ours.length > 0) {
389
+ throw new CliError('DSH_BINDING_UNVERIFIABLE', `dsh reported failed patch rows: ${ours.join(' | ')}`);
390
+ }
391
+ const out = dump.stdout || '';
392
+ // Presence must be established by a STRUCTURAL match, not a loose
393
+ // substring over the dump tail: a stale or foreign entry elsewhere in the
394
+ // composition (an older manual row, another profile's leftovers in a
395
+ // copied fixture) could otherwise satisfy a substring check and mask a
396
+ // silently-dropped managed row. The composed dump re-serializes the
397
+ // entry with its id first (dsh emits `id`, `name`, `config` in key
398
+ // order), so anchor on OUR entry id and require both the plugin name and
399
+ // the serverName in the same mapping block. Anchoring on the id is what
400
+ // makes this structural: a sibling `- id:`/`- name:` regression (two list
401
+ // items instead of one) drops the id from the composed entry, and this
402
+ // check then fails even though a bare `name:` line still exists for some
403
+ // other mcp-client row in the dump. The dump may render the package name
404
+ // quoted or bare depending on the dsh version, so accept both spellings.
405
+ const anchor = out.indexOf(`- id: ${MCP_ENTRY_ID}\n`);
406
+ if (anchor === -1) {
407
+ throw new CliError('DSH_BINDING_UNVERIFIABLE', `the managed entry id ${MCP_ENTRY_ID} is absent from the composed profile dump`);
408
+ }
409
+ const window = out.slice(anchor, anchor + 1200);
410
+ if (!window.includes(`name: ${MCP_CLIENT_PACKAGE}`) && !window.includes(`name: '${MCP_CLIENT_PACKAGE}'`)) {
411
+ throw new CliError('DSH_BINDING_UNVERIFIABLE', 'the managed entry is present but does not carry the mcp-client plugin name');
412
+ }
413
+ if (!window.includes('serverName: external_subagent')) {
414
+ throw new CliError('DSH_BINDING_UNVERIFIABLE', 'the managed mcp-client entry is present but its serverName is missing from the composed dump');
415
+ }
416
+ const version = (dump.stdout.match(/dsh[^\n]*?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/u) || [])[1]
417
+ || stderr.match(/version[^\n]*?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/ui)?.[1]
418
+ || null;
419
+ if (unrelatedDiagnostics.length > 0) {
420
+ return { dsh_version: version, warning: `the profile composition carries pre-existing patch diagnostics unrelated to this binding: ${unrelatedDiagnostics.join(' | ')}` };
421
+ }
422
+ return version;
423
+ }
424
+
425
+ export function installDshPlugin(paths, options = {}) {
426
+ const source = options.source || pluginSourceRoot();
427
+ const dshHome = dshHomeFor(options, paths);
428
+ const profileName = options.profile;
429
+ const profileDir = dshProfileDir(options, paths);
430
+ if (!fs.existsSync(profileDir) || !fs.statSync(profileDir).isDirectory()) {
431
+ throw new CliError('INVALID_ARGUMENT', `dsh profile directory not found: ${profileDir} (boot the profile once or create it with: dsh --profile ${profileName} --from-default-profile web)`, 2);
432
+ }
433
+ const patchFile = path.join(profileDir, 'cordis.patch.yml');
434
+ if (options.dryRun) {
435
+ return {
436
+ dry_run: true, operation: options.uninstall ? 'uninstall' : 'install', host: 'dsh',
437
+ dsh_home: dshHome, profile: profileName, profile_dir: profileDir, patch_file: patchFile,
438
+ skill_dir: skillPaths(dshHome).dir, cli: 'dsh-patch', source,
439
+ };
440
+ }
441
+ if (options.uninstall) return uninstallDshPlugin(paths, options);
442
+ const packageProbe = mcpClientPackagePresent();
443
+ const binding = dshMcpBinding(paths);
444
+ const priorPatch = readOptional(patchFile);
445
+ const nextText = composePatchText(priorPatch === null ? null : priorPatch.toString('utf8'), binding, paths.socket);
446
+ if (priorPatch !== null && priorPatch.toString('utf8') === nextText) {
447
+ // Byte-equal idempotent reinstall: still ensure the skill is present.
448
+ const skill = stageSkill(dshHome, source);
449
+ return {
450
+ installed: true, host: 'dsh', dsh_home: dshHome, profile: profileName, patch_file: patchFile,
451
+ idempotent: true, config_verified: true,
452
+ binding: { command: binding.command, socket: paths.socket },
453
+ skill: { dir: skill.dir, written: skill.written },
454
+ dsh_version: packageProbe.version,
455
+ };
456
+ }
457
+ try {
458
+ atomicWrite(patchFile, Buffer.from(nextText, 'utf8'));
459
+ const skill = stageSkill(dshHome, source);
460
+ const verification = verifyWithDump(dshHome, profileName, options);
461
+ // verifyWithDump returns a plain version token on a clean dump, or an
462
+ // object {dsh_version, warning} when the composition verified but
463
+ // carries a caveat the user should see (no Electron CLI for a desktop
464
+ // dump, or pre-existing unrelated patch diagnostics).
465
+ const isObject = typeof verification === 'object' && verification !== null;
466
+ const version = isObject ? verification.dsh_version : verification;
467
+ const verifyWarning = isObject ? verification.warning : null;
468
+ const result = {
469
+ installed: true, host: 'dsh', dsh_home: dshHome, profile: profileName, patch_file: patchFile,
470
+ config_verified: !isObject || verification.config_verified !== false,
471
+ binding: { command: binding.command, socket: paths.socket },
472
+ skill: { dir: skill.dir, written: skill.written },
473
+ dsh_version: version ?? packageProbe.version,
474
+ };
475
+ if (verifyWarning) result.warning = verifyWarning;
476
+ if (!packageProbe.present) {
477
+ const probeWarning = `the ${MCP_CLIENT_PACKAGE} plugin was not found beside the dsh CLI; if the profile fails to load the entry, run: dsh plugin --profile ${profileName} add ${MCP_CLIENT_PACKAGE}`;
478
+ result.warning = result.warning ? `${result.warning}; ${probeWarning}` : probeWarning;
479
+ }
480
+ return result;
481
+ } catch (error) {
482
+ throw guardedRestores(error, [
483
+ { resource: patchFile, restore: () => restoreOptional(patchFile, priorPatch) },
484
+ { resource: skillPaths(dshHome).dir, restore: () => unstageSkill(dshHome, source) },
485
+ ]);
486
+ }
487
+ }
488
+
489
+ export function uninstallDshPlugin(paths, options = {}) {
490
+ const source = options.source || pluginSourceRoot();
491
+ const dshHome = dshHomeFor(options, paths);
492
+ const profileName = options.profile;
493
+ const profileDir = dshProfileDir(options, paths);
494
+ const patchFile = path.join(profileDir, 'cordis.patch.yml');
495
+ if (options.dryRun) {
496
+ return { dry_run: true, operation: 'uninstall', host: 'dsh', dsh_home: dshHome, profile: profileName, patch_file: patchFile, skill_dir: skillPaths(dshHome).dir };
497
+ }
498
+ const priorPatch = readOptional(patchFile);
499
+ let patchRemoved = false;
500
+ let overrideRemoved = false;
501
+ let fallback = null;
502
+ if (priorPatch !== null) {
503
+ const text = priorPatch.toString('utf8');
504
+ const range = managedRange(text);
505
+ if (range !== null) {
506
+ let next = text.slice(0, range.start) + text.slice(range.end);
507
+ // A user (or dsh's own plugin manager) may have toggled the managed
508
+ // entry, leaving a top-level disable override carrying our id outside
509
+ // the sentinel block. With the insert row gone the override targets
510
+ // nothing: dsh prints `patch: entry "<id>" not found` on every boot,
511
+ // and this product's own dump verification treats any patch:
512
+ // diagnostic naming our id as a failure — so a leftover override
513
+ // would break the NEXT install. Strip matching top-level override
514
+ // rows: `- id: <ours>` plus an optional `disabled`/comment-only body,
515
+ // never a row that also carries `name` or `config` (that would be a
516
+ // user-authored entry that merely reuses the id).
517
+ next = stripDisableOverrides(next);
518
+ overrideRemoved = next !== text.slice(0, range.start) + text.slice(range.end);
519
+ const trimmed = next.replace(/\s+$/u, '');
520
+ const remainderHasEntries = trimmed.split('\n').some((line) => /^- /u.test(line));
521
+ if (!remainderHasEntries) {
522
+ // Nothing user-owned remains (comments at most): restore the
523
+ // shipped empty-root shape so the file stays valid YAML.
524
+ atomicWrite(patchFile, Buffer.from('# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array of loader patch entries (id-targeted config\n# overrides, disables, and insert lists; `!!js` expressions allowed).\n[]\n', 'utf8'));
525
+ } else {
526
+ atomicWrite(patchFile, Buffer.from(`${trimmed}\n`, 'utf8'));
527
+ }
528
+ patchRemoved = true;
529
+ } else if (text.includes('serverName: external_subagent')) {
530
+ fallback = 'managed sentinel not found; the patch file was left untouched (a serverName: external_subagent entry remains — remove it manually)';
531
+ }
532
+ }
533
+ // The shared skill is released only after the patch removal succeeded or
534
+ // proved absent — a failed patch removal must not strand the skill.
535
+ const skill = patchRemoved || priorPatch === null ? unstageSkill(dshHome, source) : { removed: false };
536
+ const existed = patchRemoved || skill.removed;
537
+ return {
538
+ uninstalled: existed || patchRemoved,
539
+ host: 'dsh', dsh_home: dshHome, profile: profileName, patch_file: patchFile,
540
+ patch_removed: patchRemoved,
541
+ skill_removed: skill.removed,
542
+ ...(skill.note ? { skill_note: skill.note } : {}),
543
+ ...(fallback ? { warning: fallback } : {}),
544
+ ...(overrideRemoved ? { override_removed: true } : {}),
545
+ ...(!existed && !patchRemoved ? { note: 'no dsh binding present' } : {}),
546
+ };
547
+ }
548
+
549
+ // Stateless reconcile for update flows (the zcode model): every profile
550
+ // directory whose patch file carries the sentinel is rebound; the skill is
551
+ // refreshed alongside. Unknown/corrupt states are reported, never fatal to
552
+ // the caller's broader reconcile.
553
+ export function reconcileDshBindings(paths, options = {}) {
554
+ const dshHome = dshHomeFor(options, paths);
555
+ const profilesRoot = path.join(dshHome, 'profiles');
556
+ let entries;
557
+ try {
558
+ entries = fs.readdirSync(profilesRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory());
559
+ } catch (error) {
560
+ if (error?.code === 'ENOENT') return { bound: false, profiles: [], note: 'no dsh profiles directory' };
561
+ return { bound: false, profiles: [], error: { code: 'DSH_PROFILES_UNREADABLE', message: error.message } };
562
+ }
563
+ const results = [];
564
+ for (const entry of entries) {
565
+ if (DSH_FORBIDDEN_PROFILES.includes(entry.name)) continue;
566
+ const patchFile = path.join(profilesRoot, entry.name, 'cordis.patch.yml');
567
+ const bytes = readOptional(patchFile);
568
+ if (bytes === null || !bytes.toString('utf8').includes(SENTINEL)) continue;
569
+ try {
570
+ const install = installDshPlugin(paths, { ...options, profile: entry.name });
571
+ results.push({ profile: entry.name, status: 'updated', idempotent: Boolean(install.idempotent) });
572
+ } catch (error) {
573
+ results.push({ profile: entry.name, status: 'failed', error: { code: error.code || 'DSH_BINDING_FAILED', message: error.message } });
574
+ }
575
+ }
576
+ return { bound: results.length > 0, profiles: results };
577
+ }
578
+
579
+ // Product uninstall: release every detected binding at once. Detection is
580
+ // the same sentinel scan as reconcile; each profile is uninstalled
581
+ // independently (a failure on one never skips the rest, matching the
582
+ // per-home codex release posture), and the shared staged skill is removed
583
+ // when no bound profile remains.
584
+ export function uninstallAllDshBindings(paths, options = {}) {
585
+ const dshHome = dshHomeFor(options, paths);
586
+ const profilesRoot = path.join(dshHome, 'profiles');
587
+ let entries;
588
+ try {
589
+ entries = fs.readdirSync(profilesRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory());
590
+ } catch {
591
+ return { released: 0, profiles: [] };
592
+ }
593
+ const results = [];
594
+ for (const entry of entries) {
595
+ if (DSH_FORBIDDEN_PROFILES.includes(entry.name)) continue;
596
+ const patchFile = path.join(profilesRoot, entry.name, 'cordis.patch.yml');
597
+ const bytes = readOptional(patchFile);
598
+ if (bytes === null || !bytes.toString('utf8').includes(SENTINEL)) continue;
599
+ try {
600
+ const removed = uninstallDshPlugin(paths, { ...options, profile: entry.name });
601
+ results.push({ profile: entry.name, status: removed.uninstalled ? 'released' : 'absent' });
602
+ } catch (error) {
603
+ results.push({ profile: entry.name, status: 'failed', error: { code: error.code || 'DSH_BINDING_RELEASE_FAILED', message: error.message } });
604
+ }
605
+ }
606
+ // The skill is shared across profiles; remove it once no binding remains.
607
+ if (results.length > 0 && results.every((result) => result.status === 'released')) {
608
+ try {
609
+ unstageSkill(dshHome, options.source || pluginSourceRoot());
610
+ } catch { /* reported per-profile above; a stuck skill never fails the release */ }
611
+ }
612
+ return { released: results.filter((result) => result.status === 'released').length, profiles: results };
613
+ }