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.
- package/README.md +8 -5
- package/bin/external-subagent-mcp.mjs +3 -2
- package/cli/commands/agents.mjs +4 -9
- package/cli/commands/config.mjs +1 -1
- package/cli/commands/daemon.mjs +8 -3
- package/cli/commands/maintenance.mjs +158 -1
- package/cli/commands/plugin.mjs +28 -3
- package/cli/commands/tasks.mjs +7 -1
- package/cli/commands/update.mjs +18 -7
- package/cli/config/write.mjs +195 -14
- package/cli/constants.mjs +2 -2
- package/cli/install/codex.mjs +1 -1
- package/cli/install/dsh.mjs +613 -0
- package/cli/install/init.mjs +23 -33
- package/cli/install/layout.mjs +10 -2
- package/cli/install/payload.mjs +59 -12
- package/cli/install/service-activation.mjs +10 -2
- package/cli/install/service-linux.mjs +1 -1
- package/cli/install/service-macos.mjs +40 -14
- package/cli/install/service-windows.mjs +463 -0
- package/cli/install/update.mjs +6 -5
- package/cli/main.mjs +10 -14
- package/cli/maintenance.mjs +7 -1
- package/cli/paths.mjs +152 -8
- package/npm/native/README.md +2 -2
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +5 -5
- package/npm/native/linux-x64/external-subagent-mcp +0 -0
- package/npm/native/linux-x64/external-subagentd +0 -0
- package/npm/native/linux-x64/payload.json +5 -5
- package/package.json +3 -2
- package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +5 -3
- package/schema/external-subagent-public-api.json +3 -3
- package/scripts/release/build-native-payload.mjs +22 -5
- package/scripts/release/check-native-tarball.mjs +34 -14
- package/scripts/release/stage-debug-plugin.mjs +9 -3
- package/scripts/release/test-installed-tarball.mjs +1 -1
- package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +0 -55
- package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +0 -19
- package/plugins/codex/external-subagent/hooks/guard-bash.mjs +0 -15
- package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +0 -206
- package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +0 -149
- 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
|
+
}
|