@ddtcorex/dsh-maestro-supervisor 0.6.8 → 0.7.1

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * dsh_web_restart tool — the safe restart path for the model running inside
3
+ * dsh web. Scheduling a restart through this tool instead of a raw kill keeps
4
+ * the restart inside the supervisor's ownership loop:
5
+ *
6
+ * 1. dry-boot gate — if the plugin tree changed since the latest LKG, boot a
7
+ * copy of the live profile on an ephemeral DSH_HOME first and only
8
+ * schedule when that boot serves HTTP.
9
+ * 2. intent sidecar — record the caller session + reason under
10
+ * ~/.dsh/.supervisor/intents/ for attribution.
11
+ * 3. hand-off — write the restart-request marker (planned-restart.json with
12
+ * callerSessionId) that the supervisor daemon owns and acts on
13
+ * (out-of-band). This tool NEVER restarts the host in-tree.
14
+ */
15
+ import { join } from 'node:path';
16
+ import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
+ import { tmpdir, homedir } from 'node:os';
18
+ import { spawn } from 'node:child_process';
19
+ import { createRequire } from 'node:module';
20
+ import { writeRestartRequest } from './restart-guards.js';
21
+ /**
22
+ * Boot a copy of the live web profile on an isolated DSH_HOME and verify the
23
+ * plugin tree loads and serves. Returns ok + a one-line detail for the tool
24
+ * message. The spawned tree is killed (best-effort) and the temp home removed.
25
+ * Unit tests mock this (never spawn a real node boot in tests).
26
+ *
27
+ * NOTE (bin.ts finding): the `web` alias already implies `--profile web`, and
28
+ * the web app's own commander program (no allowUnknownOption) rejects a stray
29
+ * `--profile` in its inner args — so the spawn passes only `web --no-open
30
+ * --port <port>`.
31
+ */
32
+ export async function dryBootVerify(harnessRoot, opts = {}) {
33
+ const timeoutMs = opts.timeoutMs ?? 60_000;
34
+ const home = homedir();
35
+ const liveProfile = join(home, '.dsh', 'profiles', 'web');
36
+ if (!existsSync(liveProfile))
37
+ return { ok: true, detail: 'skipped (no live web profile)' };
38
+ const tmpHome = mkdtempSync(join(tmpdir(), 'dsh-dryboot-'));
39
+ const logs = [];
40
+ let child = null;
41
+ try {
42
+ cpSync(liveProfile, join(tmpHome, 'profiles', 'web'), { recursive: true, preserveTimestamps: true });
43
+ const port = String(9000 + Math.floor(Math.random() * 1000));
44
+ const url = `http://127.0.0.1:${port}/`;
45
+ child = spawn('node', ['--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', '--no-open', '--port', port], {
46
+ cwd: harnessRoot, env: { ...process.env, DSH_HOME: tmpHome }, stdio: ['ignore', 'pipe', 'pipe'],
47
+ });
48
+ child.stdout?.on('data', (d) => logs.push(d.toString()));
49
+ child.stderr?.on('data', (d) => logs.push(d.toString()));
50
+ const deadline = Date.now() + timeoutMs;
51
+ let code = -1;
52
+ while (Date.now() < deadline) {
53
+ if (child.exitCode !== null) {
54
+ code = child.exitCode;
55
+ break;
56
+ }
57
+ try {
58
+ const r = await fetch(url);
59
+ if (r.status === 200 || r.status === 401) {
60
+ code = 0;
61
+ break;
62
+ }
63
+ }
64
+ catch { }
65
+ await new Promise(r => setTimeout(r, 500));
66
+ }
67
+ const tail = logs.join('').slice(-3000);
68
+ const loadErr = /ERR_MODULE_NOT_FOUND|assertChannel|must declare output|failed to apply loader entry/.exec(tail);
69
+ if (code === 0 && !loadErr)
70
+ return { ok: true, detail: 'dry-boot ok' };
71
+ return { ok: false, detail: dryBootFailureDetail(tail, code) };
72
+ }
73
+ catch (e) {
74
+ return { ok: false, detail: `dry-boot error: ${e?.message ?? String(e)}` };
75
+ }
76
+ finally {
77
+ // Kill on every exit path — a successful boot included. The dry boot only
78
+ // exists to verify the tree; leaving the child up would orphan a dsh web
79
+ // on the ephemeral port whose temp DSH_HOME is removed below, and a stale
80
+ // orphan could later answer a port collision with a false-positive 200.
81
+ try {
82
+ child?.kill('SIGKILL');
83
+ }
84
+ catch { }
85
+ try {
86
+ rmSync(tmpHome, { recursive: true, force: true });
87
+ }
88
+ catch { }
89
+ }
90
+ }
91
+ /**
92
+ * Classify a failed dry-boot's log tail into a precise one-line detail. The
93
+ * most common operator-actionable failure is an EADDRINUSE — the candidate
94
+ * collided with the live dsh web tree on :3000/:3080 or with another process
95
+ * on the ephemeral 9000-9999 port — so name the colliding port instead of
96
+ * reporting a generic boot failure. Plugin-tree load errors keep their stable
97
+ * codes (the caller's refused message reads `dry-boot failed — restart
98
+ * refused. <detail>`).
99
+ */
100
+ export function dryBootFailureDetail(tail, exitCode) {
101
+ const addrInUse = /EADDRINUSE[^]*?:(\d+)/.exec(tail);
102
+ if (addrInUse) {
103
+ const port = addrInUse[1];
104
+ return `dry-boot failed: port ${port} already in use (EADDRINUSE) — the live dsh web tree or another process holds it`;
105
+ }
106
+ const loadErr = /ERR_MODULE_NOT_FOUND|assertChannel|must declare output|failed to apply loader entry/.exec(tail);
107
+ if (loadErr)
108
+ return `dry-boot failed: ${loadErr[0]}`;
109
+ return `dry-boot failed (exit ${exitCode})`;
110
+ }
111
+ /**
112
+ * Whether the live plugin tree differs from the latest LKG snapshot. Three
113
+ * signals are combined:
114
+ *
115
+ * 1. manifest drift — the live profile's web `package.json` text vs baseline;
116
+ * 2. cordis patch drift — the live profile's web `cordis.patch.yml` text vs
117
+ * baseline (a patch-only config edit changes the boot-time row wiring
118
+ * without touching the manifest — the manifest check alone misses it);
119
+ * 3. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
120
+ * snapshot moment. Link-installed plugins resolve to the same workspace
121
+ * files in both live and LKG, so the stored copies cannot be compared
122
+ * byte-wise; the snapshot itself is the meaningful baseline and a rebuilt
123
+ * `lib/` bumps a file past it even when the manifest text is unchanged.
124
+ * writeLKG writes `manifest.json` LAST, so its FILE mtime is the
125
+ * authoritative snapshot moment; the snapshot dir mtime is only a
126
+ * fallback for legacy snapshots without a manifest.
127
+ *
128
+ * `statFile` (default `statSync`) reads the metadata so tests can inject a
129
+ * controlled reader instead of relying on filesystem utimes (which CI runners
130
+ * do not reliably reflect). No LKG baseline, a missing file on either side, or
131
+ * any stat/read error means "changed" — the caller falls back to the dry-boot
132
+ * gate.
133
+ */
134
+ export function isPluginTreeChanged(harnessRoot, lkgDir = join(homedir(), '.dsh/.supervisor/lkg'), opts = {}) {
135
+ void harnessRoot;
136
+ const statFile = opts.statFile ?? ((p) => statSync(p));
137
+ try {
138
+ const entries = existsSync(lkgDir) ? readdirSync(lkgDir).sort() : [];
139
+ const latest = entries[entries.length - 1];
140
+ if (!latest)
141
+ return true; // no baseline → assume changed
142
+ const lkgHome = join(lkgDir, latest, 'profiles', 'web');
143
+ const live = join(homedir(), '.dsh', 'profiles', 'web');
144
+ const lkgManifest = join(lkgHome, 'package.json');
145
+ const liveManifest = join(live, 'package.json');
146
+ if (!existsSync(lkgManifest) || !existsSync(liveManifest))
147
+ return true;
148
+ if (readFileSync(liveManifest, 'utf8') !== readFileSync(lkgManifest, 'utf8'))
149
+ return true;
150
+ // cordis.patch.yml — compare only when at least one side has it (profiles
151
+ // without a patch are the baseline; a patch appearing on either side alone
152
+ // is drift). The text compare keeps the check cheap and hermetic.
153
+ const lkgPatch = join(lkgHome, 'cordis.patch.yml');
154
+ const livePatch = join(live, 'cordis.patch.yml');
155
+ if (existsSync(lkgPatch) || existsSync(livePatch)) {
156
+ if (!existsSync(lkgPatch) || !existsSync(livePatch))
157
+ return true;
158
+ if (readFileSync(livePatch, 'utf8') !== readFileSync(lkgPatch, 'utf8'))
159
+ return true;
160
+ }
161
+ const snapshotManifest = join(lkgDir, latest, 'manifest.json');
162
+ const baseline = existsSync(snapshotManifest)
163
+ ? statFile(snapshotManifest).mtimeMs
164
+ : statFile(join(lkgDir, latest)).mtimeMs;
165
+ const livePlugins = join(live, 'node_modules', '@ddtcorex');
166
+ if (existsSync(livePlugins)) {
167
+ for (const name of readdirSync(livePlugins)) {
168
+ const libDir = join(livePlugins, name, 'lib');
169
+ if (!existsSync(libDir))
170
+ continue;
171
+ if (newestFileMtime(libDir, statFile) > baseline)
172
+ return true;
173
+ }
174
+ }
175
+ return false;
176
+ }
177
+ catch {
178
+ return true;
179
+ }
180
+ }
181
+ /** Newest mtime under a directory; recursion threads the injected stat reader. */
182
+ function newestFileMtime(dir, statFile) {
183
+ let newest = 0;
184
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
185
+ const p = join(dir, entry.name);
186
+ if (entry.isDirectory())
187
+ newest = Math.max(newest, newestFileMtime(p, statFile));
188
+ else
189
+ newest = Math.max(newest, statFile(p).mtimeMs);
190
+ }
191
+ return newest;
192
+ }
193
+ function currentSessionId(exec, fallback) {
194
+ // dsh-tools dispatch hands a ToolRunContext — the exec itself has no
195
+ // sessionId/session/caller fields; session identity lives on the agent
196
+ // (exec.agent.id is the branded SessionId, exec.agent.session.id also
197
+ // exists). Probe those first so production dispatches are identifiable.
198
+ return exec?.agent?.id ?? exec?.agent?.session?.id ?? exec?.sessionId ?? exec?.session?.id ?? exec?.caller?.sessionId ?? fallback?.(exec);
199
+ }
200
+ /**
201
+ * Register the dsh_web_restart tool. Registration is fail-safe (warns, never
202
+ * throws) and the returned function disposes the registration. `deps` are
203
+ * injectable for tests.
204
+ */
205
+ export function registerRestartTool(ctx, deps = {}) {
206
+ const doDryBoot = deps.dryBoot ?? dryBootVerify;
207
+ const doWrite = deps.writeRestartRequest ?? writeRestartRequest;
208
+ const doSessionId = deps.sessionIdOf ?? currentSessionId;
209
+ let dispose;
210
+ try {
211
+ dispose = ctx.tools.register({
212
+ name: 'dsh_web_restart',
213
+ description: 'Schedule a safe restart of the dsh web host. Verifies the plugin tree first (dry-boot), records an intent for the calling session, and hands the restart to the supervisor daemon (out-of-band). Never restarts in-tree itself.',
214
+ parameters: {
215
+ type: 'object',
216
+ properties: {
217
+ reason: { type: 'string', description: 'Why the restart is happening (recorded in the intent)' },
218
+ pluginChanged: { type: 'boolean', description: 'Override for the auto-detected plugin-tree change check' },
219
+ },
220
+ additionalProperties: false,
221
+ },
222
+ output: {
223
+ schema: { type: 'object', additionalProperties: true, properties: { ok: { type: 'boolean' }, detail: { type: 'string' } } },
224
+ render: (_args, value) => [{ type: 'text', text: value.detail }],
225
+ },
226
+ execute: async (args, exec) => {
227
+ const harnessRoot = deps.harnessRoot ?? (await import('./paths.js')).resolveDeepseekHarnessDir();
228
+ const lkgDir = join(homedir(), '.dsh/.supervisor/lkg');
229
+ const changed = args.pluginChanged === true || (args.pluginChanged !== false && isPluginTreeChanged(harnessRoot, lkgDir));
230
+ if (changed) {
231
+ const gate = await doDryBoot(harnessRoot);
232
+ if (!gate.ok)
233
+ return { ok: false, detail: `dry-boot failed — restart refused. ${gate.detail}` };
234
+ }
235
+ const callerSessionId = doSessionId(exec);
236
+ if (!callerSessionId) {
237
+ // The daemon's grace branch keys on callerSessionId — without it the
238
+ // marker would be written but no restart would ever be supervised.
239
+ return { ok: false, detail: 'cannot identify the calling session — restart not scheduled' };
240
+ }
241
+ doWrite({ callerSessionId, reason: typeof args.reason === 'string' ? args.reason : undefined }, 180_000);
242
+ writeIntentSidecar(callerSessionId, args.reason);
243
+ return { ok: true, detail: `restart scheduled (≈30s) — caller ${callerSessionId}` };
244
+ },
245
+ });
246
+ }
247
+ catch (e) {
248
+ try {
249
+ ctx.logger?.warn?.(`[supervisor] dsh_web_restart tool failed: ${e?.message ?? String(e)}`);
250
+ }
251
+ catch { }
252
+ }
253
+ return () => { try {
254
+ if (typeof dispose === 'function')
255
+ dispose();
256
+ }
257
+ catch { } };
258
+ }
259
+ function writeIntentSidecar(sessionId, reason) {
260
+ try {
261
+ if (!sessionId)
262
+ return;
263
+ const dir = join(homedir(), '.dsh/.supervisor/intents');
264
+ const require = createRequire(import.meta.url);
265
+ const { mkdirSync, writeFileSync, chmodSync } = require('node:fs');
266
+ mkdirSync(dir, { recursive: true });
267
+ // Flatten slash-namespaced ids ('proj/abc' → 'proj_abc') so the sidecar is
268
+ // a single file under intents/ and never needs a nested intents/proj/ dir.
269
+ const safeId = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
270
+ writeFileSync(join(dir, `${safeId}.json`), JSON.stringify({ ts: Date.now(), sessionId, reason: reason ?? '' }), 'utf8');
271
+ try {
272
+ chmodSync(join(dir, `${safeId}.json`), 0o600);
273
+ }
274
+ catch { }
275
+ }
276
+ catch { }
277
+ }
package/lib/scan.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Post-restart session scan: walk recent session logs under
3
+ * <dshHome>/sessions/<project>/<session>/ and flag torn tails (a zstd frame
4
+ * that fails to decode, or a plain-text log that is unreadable). Runs after
5
+ * an intentional dsh-web restart so the supervisor can report whether any
6
+ * in-flight session log was left truncated by the restart.
7
+ */
8
+ interface ScanOptions {
9
+ withinMs?: number;
10
+ }
11
+ /**
12
+ * Scan session logs whose mtime falls within the window. A file whose decode
13
+ * fails (torn zstd frame or unreadable plain text) is reported as torn. A
14
+ * missing sessions root yields an empty scan, never an error.
15
+ */
16
+ export declare function scanSessions(dshHome: string, opts?: ScanOptions): Promise<{
17
+ scanned: number;
18
+ torn: string[];
19
+ }>;
20
+ export {};
package/lib/scan.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Post-restart session scan: walk recent session logs under
3
+ * <dshHome>/sessions/<project>/<session>/ and flag torn tails (a zstd frame
4
+ * that fails to decode, or a plain-text log that is unreadable). Runs after
5
+ * an intentional dsh-web restart so the supervisor can report whether any
6
+ * in-flight session log was left truncated by the restart.
7
+ */
8
+ import { readdirSync, statSync } from 'node:fs';
9
+ import { join, extname, basename } from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
11
+ async function decodeOk(file) {
12
+ try {
13
+ if (extname(file) === '.zstd') {
14
+ execFileSync('zstd', ['-d', '-c', file], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 30_000 });
15
+ }
16
+ else {
17
+ const { readFileSync } = await import('node:fs');
18
+ readFileSync(file, 'utf8');
19
+ }
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /**
27
+ * Scan session logs whose mtime falls within the window. A file whose decode
28
+ * fails (torn zstd frame or unreadable plain text) is reported as torn. A
29
+ * missing sessions root yields an empty scan, never an error.
30
+ */
31
+ export async function scanSessions(dshHome, opts = {}) {
32
+ const sessionsRoot = join(dshHome, 'sessions');
33
+ const now = Date.now();
34
+ const withinMs = opts.withinMs ?? 10 * 60 * 1000;
35
+ const files = [];
36
+ try {
37
+ for (const proj of readdirSync(sessionsRoot)) {
38
+ const projDir = join(sessionsRoot, proj);
39
+ if (!statSync(projDir).isDirectory())
40
+ continue;
41
+ for (const sess of readdirSync(projDir)) {
42
+ const sessDir = join(projDir, sess);
43
+ if (!statSync(sessDir).isDirectory())
44
+ continue;
45
+ for (const f of readdirSync(sessDir)) {
46
+ const name = basename(f);
47
+ if (!name.endsWith('.zstd') && !name.endsWith('.jsonl'))
48
+ continue;
49
+ const fp = join(sessDir, f);
50
+ let mtime = 0;
51
+ try {
52
+ mtime = statSync(fp).mtimeMs;
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ if (now - mtime > withinMs)
58
+ continue;
59
+ files.push(fp);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ catch { /* sessions root absent */ }
65
+ const torn = [];
66
+ for (const f of files) {
67
+ if (!(await decodeOk(f)))
68
+ torn.push(f);
69
+ }
70
+ return { scanned: files.length, torn };
71
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Self-kill guard for `tools/pre-execute`: deny bash/shell commands that would
3
+ * kill or restart the very dsh web process the agent is running inside. The
4
+ * model must route restarts through the supervisor's dsh_web_restart tool, not
5
+ * by killing the host.
6
+ */
7
+ /**
8
+ * The dsh web MainThread owns both ports (3000 = gitlab-webhook, 3080 = web).
9
+ * Only listeners on these ports can be dsh web; every other listening process
10
+ * on the host (mysql, sshd, nginx, redis, ...) is explicitly NOT protected.
11
+ */
12
+ export declare const DSH_WEB_PORTS: number[];
13
+ export type TreeBoundaryKind = 'none' | 'launcher' | 'service-manager';
14
+ /**
15
+ * Boundary classification for the ancestor walk — mirrors
16
+ * `skills/dsh-safe-restart/scripts/restart-dsh-web.sh`'s `resolve_tree()`:
17
+ * the walk stops at `pnpm` (the launcher — everything above it is the
18
+ * launching shell, not dsh web) and never walks into a `systemd --user`
19
+ * manager (it owns every user unit on the box). `launcher` pids stay in the
20
+ * forest; `service-manager` pids are never included.
21
+ */
22
+ export declare function treeBoundaryKind(commandLine: string): TreeBoundaryKind;
23
+ /** Convenience boolean form of {@link treeBoundaryKind}. */
24
+ export declare function isTreeBoundary(commandLine: string): boolean;
25
+ export interface ProcessRow {
26
+ pid: number;
27
+ ppid: number;
28
+ }
29
+ /**
30
+ * Resolve the pids that belong to the dsh web process forest. `listeners` are
31
+ * the pids owning the dsh-web ports (already narrowed by the caller). A pid is
32
+ * protected iff its upward ancestor chain reaches the forest before pid 1:
33
+ *
34
+ * - the forest roots are the listeners plus every ancestor up to the
35
+ * `launcher` boundary (pnpm stays inside the forest; systemd --user and
36
+ * the launching shell stay out);
37
+ * - the descendant closure then protects the whole owned subtree — the dsh
38
+ * web node processes AND their bash-tool/browser children — while never
39
+ * climbing into unrelated ancestors.
40
+ *
41
+ * `boundary(pid)` classifies the command line of a walked pid; it is only
42
+ * invoked for the handful of listener + ancestor pids, never for the full
43
+ * table.
44
+ */
45
+ export declare function resolveDshWebTreePids(listeners: number[], rows: ProcessRow[], boundary?: (pid: number) => TreeBoundaryKind): number[];
46
+ /**
47
+ * Whether a shell command is a self-kill. `livePids` are the pids of the dsh
48
+ * web process forest; a `kill <pid>` whose pid is one of ours is a self-kill
49
+ * regardless of anything else in the command. The kill parser accepts flag
50
+ * forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is essentially
51
+ * JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups are common),
52
+ * as are kill attempts whose output reports "not found"/"done" — but any
53
+ * compound that chains a restart/kill after it (or before the end) stays
54
+ * denied.
55
+ */
56
+ export declare function isSelfKillCommand(cmd: string, livePids: number[]): boolean;
57
+ /**
58
+ * Live pids of the dsh web OWN forest: pids owning the dsh-web ports
59
+ * (3000/3080) plus their ancestor chain up to the pnpm/systemd boundary and
60
+ * the owned subtree. A kill of an unrelated listener (mysql/sshd/nginx) is
61
+ * therefore allowed — before this scoping the guard denied `kill <pid>` of ANY
62
+ * pid holding a listening socket as a "restart dsh web".
63
+ */
64
+ export declare function dshWebTreeLivePids(): number[];
65
+ /**
66
+ * Build a `tools/pre-execute` waterfall listener: deny matching
67
+ * bash/shell/exec commands, otherwise delegate to `next()`. Live pids default
68
+ * to the dsh web process forest (see `dshWebTreeLivePids`) so `kill <pid>` of
69
+ * a live host process is caught even when the command names no tool, while a
70
+ * kill of an unrelated service is not.
71
+ */
72
+ export declare function makePreExecuteGuard(opts?: {
73
+ livePids?: () => number[];
74
+ }): (exec: any, next: () => Promise<any>) => Promise<any>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Self-kill guard for `tools/pre-execute`: deny bash/shell commands that would
3
+ * kill or restart the very dsh web process the agent is running inside. The
4
+ * model must route restarts through the supervisor's dsh_web_restart tool, not
5
+ * by killing the host.
6
+ */
7
+ import { createRequire } from 'node:module';
8
+ const require = createRequire(import.meta.url);
9
+ // Patterns that restart/stop/start or kill dsh web (systemctl --user units,
10
+ // pkill/killall over the dsh tree, killing holders of :3080, and the
11
+ // dsh-safe-web-update helper itself). The bare `kill\s+` alternative is
12
+ // narrowed below so a kill of an unrelated pid is not denied as a self-kill.
13
+ const SELF_KILL_RE = /(systemctl\s+--?user\s+.*(restart|stop|start).*dsh-web|pkill\s+.*dsh|killall\s+.*dsh|ss\s+.*3080.*kill|restart-dsh-web|kill\s+)/i;
14
+ /**
15
+ * The dsh web MainThread owns both ports (3000 = gitlab-webhook, 3080 = web).
16
+ * Only listeners on these ports can be dsh web; every other listening process
17
+ * on the host (mysql, sshd, nginx, redis, ...) is explicitly NOT protected.
18
+ */
19
+ export const DSH_WEB_PORTS = [3000, 3080];
20
+ /**
21
+ * Boundary classification for the ancestor walk — mirrors
22
+ * `skills/dsh-safe-restart/scripts/restart-dsh-web.sh`'s `resolve_tree()`:
23
+ * the walk stops at `pnpm` (the launcher — everything above it is the
24
+ * launching shell, not dsh web) and never walks into a `systemd --user`
25
+ * manager (it owns every user unit on the box). `launcher` pids stay in the
26
+ * forest; `service-manager` pids are never included.
27
+ */
28
+ export function treeBoundaryKind(commandLine) {
29
+ if (commandLine.includes('pnpm'))
30
+ return 'launcher';
31
+ if (commandLine.includes('systemd --user'))
32
+ return 'service-manager';
33
+ return 'none';
34
+ }
35
+ /** Convenience boolean form of {@link treeBoundaryKind}. */
36
+ export function isTreeBoundary(commandLine) {
37
+ return treeBoundaryKind(commandLine) !== 'none';
38
+ }
39
+ /**
40
+ * Resolve the pids that belong to the dsh web process forest. `listeners` are
41
+ * the pids owning the dsh-web ports (already narrowed by the caller). A pid is
42
+ * protected iff its upward ancestor chain reaches the forest before pid 1:
43
+ *
44
+ * - the forest roots are the listeners plus every ancestor up to the
45
+ * `launcher` boundary (pnpm stays inside the forest; systemd --user and
46
+ * the launching shell stay out);
47
+ * - the descendant closure then protects the whole owned subtree — the dsh
48
+ * web node processes AND their bash-tool/browser children — while never
49
+ * climbing into unrelated ancestors.
50
+ *
51
+ * `boundary(pid)` classifies the command line of a walked pid; it is only
52
+ * invoked for the handful of listener + ancestor pids, never for the full
53
+ * table.
54
+ */
55
+ export function resolveDshWebTreePids(listeners, rows, boundary = () => 'launcher') {
56
+ const byPid = new Map(rows.map(r => [r.pid, r]));
57
+ const roots = new Set();
58
+ for (const pid of listeners) {
59
+ let cur = pid;
60
+ for (let depth = 0; cur && cur !== 1 && depth < 100 && !roots.has(cur); depth++) {
61
+ const row = byPid.get(cur);
62
+ if (!row)
63
+ break;
64
+ const kind = boundary(cur);
65
+ if (kind === 'service-manager')
66
+ break; // never climb into systemd --user
67
+ roots.add(cur);
68
+ if (kind === 'launcher')
69
+ break; // pnpm is the ceiling of the forest
70
+ if (row.ppid === cur || row.ppid <= 0)
71
+ break;
72
+ cur = row.ppid;
73
+ }
74
+ }
75
+ const protectedSet = new Set(roots);
76
+ for (const row of rows) {
77
+ let cur = row.pid;
78
+ for (let depth = 0; cur && cur !== 1 && depth < 100; depth++) {
79
+ if (roots.has(cur)) {
80
+ protectedSet.add(row.pid);
81
+ break;
82
+ }
83
+ const next = byPid.get(cur);
84
+ if (!next || next.ppid === cur || next.ppid <= 0)
85
+ break;
86
+ cur = next.ppid;
87
+ }
88
+ }
89
+ return [...protectedSet].sort((a, b) => a - b);
90
+ }
91
+ /**
92
+ * Whether a shell command is a self-kill. `livePids` are the pids of the dsh
93
+ * web process forest; a `kill <pid>` whose pid is one of ours is a self-kill
94
+ * regardless of anything else in the command. The kill parser accepts flag
95
+ * forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is essentially
96
+ * JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups are common),
97
+ * as are kill attempts whose output reports "not found"/"done" — but any
98
+ * compound that chains a restart/kill after it (or before the end) stays
99
+ * denied.
100
+ */
101
+ export function isSelfKillCommand(cmd, livePids) {
102
+ if (/kill\s+(?:-\S+\s+)?(\d+)/i.test(cmd)) {
103
+ const pid = Number(cmd.match(/kill\s+(?:-\S+\s+)?(\d+)/i)?.[1]);
104
+ if (livePids.includes(pid))
105
+ return true;
106
+ }
107
+ return SELF_KILL_RE.test(cmd)
108
+ // Exclude only a command that is JUST `kill [flags] <unrelated pid>` —
109
+ // anchored end-to-end so `kill 1234 && systemctl restart dsh-web` cannot
110
+ // whitelist the compound through its prefix.
111
+ && !/^kill\s+(?:-\S+\s+)?\d+\s*$/i.test(cmd.trim())
112
+ && !/kill\s+(?:-\S+\s+)?(\d+)\s+.*(not found|done)/i.test(cmd);
113
+ }
114
+ /**
115
+ * Live pids of the dsh web OWN forest: pids owning the dsh-web ports
116
+ * (3000/3080) plus their ancestor chain up to the pnpm/systemd boundary and
117
+ * the owned subtree. A kill of an unrelated listener (mysql/sshd/nginx) is
118
+ * therefore allowed — before this scoping the guard denied `kill <pid>` of ANY
119
+ * pid holding a listening socket as a "restart dsh web".
120
+ */
121
+ export function dshWebTreeLivePids() {
122
+ try {
123
+ const { execSync } = require('node:child_process');
124
+ const filter = DSH_WEB_PORTS.map(p => `sport = :${p}`).join(' or ');
125
+ const out = execSync(`ss -tlnp '( ${filter} )' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | sort -u`, { encoding: 'utf8' });
126
+ const listeners = out.trim().split('\n').filter(Boolean).map(Number);
127
+ if (listeners.length === 0)
128
+ return [];
129
+ const psOut = execSync(`ps -eo pid=,ppid=`, { encoding: 'utf8' });
130
+ const rows = psOut.trim().split('\n')
131
+ .map(line => line.trim().split(/\s+/))
132
+ .filter(p => p.length >= 2 && /^\d+$/.test(p[0]) && /^\d+$/.test(p[1]))
133
+ .map(([pid, ppid]) => ({ pid: Number(pid), ppid: Number(ppid) }));
134
+ // Command lines are only fetched for the walked listener/ancestor pids
135
+ // (a handful of subprocess calls), never for the whole process table.
136
+ const boundary = (pid) => {
137
+ try {
138
+ const args = execSync(`ps -o args= -p ${pid}`, { encoding: 'utf8' });
139
+ return treeBoundaryKind(args);
140
+ }
141
+ catch {
142
+ return 'launcher'; // gone or unreadable → stop walking right here
143
+ }
144
+ };
145
+ return resolveDshWebTreePids(listeners, rows, boundary);
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ }
151
+ /**
152
+ * Build a `tools/pre-execute` waterfall listener: deny matching
153
+ * bash/shell/exec commands, otherwise delegate to `next()`. Live pids default
154
+ * to the dsh web process forest (see `dshWebTreeLivePids`) so `kill <pid>` of
155
+ * a live host process is caught even when the command names no tool, while a
156
+ * kill of an unrelated service is not.
157
+ */
158
+ export function makePreExecuteGuard(opts = {}) {
159
+ const livePids = opts.livePids ?? dshWebTreeLivePids;
160
+ return async (exec, next) => {
161
+ // dsh-tools hands the frozen ToolExecution (name + arguments); the guard
162
+ // also accepts the `args` shape for tests/embedded hosts.
163
+ const cmd = String(exec?.args?.command ?? exec?.args?.input ?? exec?.arguments?.command ?? exec?.arguments?.input ?? '');
164
+ if ((exec?.name === 'bash' || exec?.name === 'shell' || exec?.name === 'exec') && isSelfKillCommand(cmd, livePids())) {
165
+ return { kind: 'deny', reason: 'DENIED — this command restarts the dsh web process you are running inside. Use the dsh_web_restart tool (supervisor) for a safe restart.' };
166
+ }
167
+ return next();
168
+ };
169
+ }
@@ -0,0 +1,6 @@
1
+ import type { SkillCandidate, SkillDefinition, SkillLookupOptions } from '@deepseek-ai/dsh-skill';
2
+ export declare function makeSkillProvider(skillsDir: string): {
3
+ name: string;
4
+ list(_options: SkillLookupOptions): Promise<SkillCandidate[]>;
5
+ get(candidate: SkillCandidate, _options: SkillLookupOptions): Promise<SkillDefinition | undefined>;
6
+ };