@ddtcorex/dsh-maestro-supervisor 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Durable self-restart intent sidecar written by `dsh_web_restart`
3
+ * (`~/.dsh/.supervisor/intents/<sessionId>.json`, mode 600). Consumed by
4
+ * auto-resume so a session that requested the restart is resumed with a
5
+ * contextual message instead of the generic "outcome unknown" recovery text.
6
+ */
7
+ export interface RestartIntent {
8
+ ts: number;
9
+ sessionId?: string;
10
+ reason?: string;
11
+ }
12
+ export declare function intentsDir(): string;
13
+ export declare function intentPath(sessionId: string): string;
14
+ export declare function readIntent(sessionId: string): RestartIntent | undefined;
15
+ export declare function consumeIntent(sessionId: string): void;
package/lib/intents.js ADDED
@@ -0,0 +1,27 @@
1
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ export function intentsDir() {
5
+ return join(homedir(), '.dsh', '.supervisor', 'intents');
6
+ }
7
+ export function intentPath(sessionId) {
8
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
9
+ return join(intentsDir(), `${safe}.json`);
10
+ }
11
+ export function readIntent(sessionId) {
12
+ try {
13
+ const p = intentPath(sessionId);
14
+ if (!existsSync(p))
15
+ return undefined;
16
+ return JSON.parse(readFileSync(p, 'utf8'));
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ export function consumeIntent(sessionId) {
23
+ try {
24
+ unlinkSync(intentPath(sessionId));
25
+ }
26
+ catch { }
27
+ }
package/lib/plugin.d.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * and web restart; this plugin handles the in-process resume.
7
7
  */
8
8
  import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
9
- export declare const inject: readonly ["sessions", "agents", "connection", "skills"];
9
+ import type { RestartIntent } from './intents.js';
10
+ export declare const inject: readonly ["sessions", "agents", "connection", "tools", "skills"];
10
11
  export interface SupervisorPluginConfig {
11
12
  autoResumeWithin?: number | string;
12
13
  autoResumeEnabled?: boolean;
@@ -17,7 +18,10 @@ export declare function runAutoResume(ctx: any, opts?: {
17
18
  resumeInterrupted?: typeof resumeInterrupted;
18
19
  config?: SupervisorPluginConfig;
19
20
  }): Promise<void>;
20
- export declare function resumeInterrupted(ctx: any, ids: string[]): Promise<string[]>;
21
+ export declare function resumeInterrupted(ctx: any, ids: string[], deps?: {
22
+ readIntent?: (id: string) => RestartIntent | undefined;
23
+ consumeIntent?: (id: string) => void;
24
+ }): Promise<string[]>;
21
25
  export declare function createResumeRpcHandler(ctx: any, opts?: {
22
26
  resumeInterrupted?: typeof resumeInterrupted;
23
27
  config?: SupervisorPluginConfig;
package/lib/plugin.js CHANGED
@@ -10,11 +10,12 @@ import * as path from 'node:path';
10
10
  import * as os from 'node:os';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
13
+ import { readIntent, consumeIntent } from './intents.js';
13
14
  import { makeSkillProvider } from './skill-provider.js';
14
15
  import { registerRestartTool } from './restart-tool.js';
15
16
  import { makePreExecuteGuard } from './self-kill-guard.js';
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
- export const inject = ['sessions', 'agents', 'connection', 'skills'];
18
+ export const inject = ['sessions', 'agents', 'connection', 'tools', 'skills'];
18
19
  function parseDuration(s) {
19
20
  if (!s)
20
21
  return undefined;
@@ -171,7 +172,9 @@ export async function runAutoResume(ctx, opts = {}) {
171
172
  catch { }
172
173
  }
173
174
  }
174
- export async function resumeInterrupted(ctx, ids) {
175
+ export async function resumeInterrupted(ctx, ids, deps = {}) {
176
+ const doReadIntent = deps.readIntent ?? readIntent;
177
+ const doConsumeIntent = deps.consumeIntent ?? consumeIntent;
175
178
  const resumed = [];
176
179
  for (const id of ids) {
177
180
  try {
@@ -246,15 +249,31 @@ export async function resumeInterrupted(ctx, ids) {
246
249
  // model to verify external state before retrying. A bare "continue" made
247
250
  // the model reply with text instead of re-issuing bash, leaving the
248
251
  // session stuck after every crash (36646045..., 31ae53a2...).
249
- const resumeMessage = 'The previous turn was interrupted by a crash and the harness has synthesized a tool result with TOOL_OUTCOME_UNKNOWN / TOOL_NOT_STARTED. ' +
252
+ const idleMessage = 'The previous turn was interrupted by a crash and the harness has synthesized a tool result with TOOL_OUTCOME_UNKNOWN / TOOL_NOT_STARTED. ' +
250
253
  'Outcome of the last tool call is unknown — it may or may not have had side effects. ' +
251
254
  'Verify external state with bash (e.g., ls, cat, git status) before retrying. ' +
252
255
  'Retry only if the operation is read-only or idempotent; if it may have side effects, verify first or ask the user. ' +
253
256
  'Then continue the original task from where it was interrupted — re-issue the next bash/tool call that the plan requires.';
257
+ // A session that requested the dsh web restart has a durable intent
258
+ // sidecar (written by dsh_web_restart): resume it with a contextual
259
+ // message instead of the generic "outcome unknown" recovery prompt, then
260
+ // consume the sidecar so it cannot re-trigger on a later resume.
261
+ let resumeMessage = idleMessage;
262
+ try {
263
+ const intent = doReadIntent(sessionId);
264
+ if (intent)
265
+ resumeMessage = `You requested a dsh web restart${intent.reason ? ` (reason: ${intent.reason})` : ''} and it completed. Do NOT call dsh_web_restart again. Verify current state if needed, then continue the original task.`;
266
+ }
267
+ catch { }
254
268
  agent.followup(createUserMessage({
255
269
  content: [{ type: 'text', text: resumeMessage }],
256
270
  source: { kind: 'user' },
257
271
  }));
272
+ try {
273
+ if (resumeMessage !== idleMessage)
274
+ doConsumeIntent(sessionId);
275
+ }
276
+ catch { }
258
277
  resumed.push(id);
259
278
  ctx.logger?.info?.(`[supervisor] auto-resume: sent recovery continue for ${id}`);
260
279
  }
@@ -30,16 +30,29 @@ export declare function dryBootVerify(harnessRoot: string, opts?: {
30
30
  ok: boolean;
31
31
  detail: string;
32
32
  }>;
33
+ /**
34
+ * Classify a failed dry-boot's log tail into a precise one-line detail. The
35
+ * most common operator-actionable failure is an EADDRINUSE — the candidate
36
+ * collided with the live dsh web tree on :3000/:3080 or with another process
37
+ * on the ephemeral 9000-9999 port — so name the colliding port instead of
38
+ * reporting a generic boot failure. Plugin-tree load errors keep their stable
39
+ * codes (the caller's refused message reads `dry-boot failed — restart
40
+ * refused. <detail>`).
41
+ */
42
+ export declare function dryBootFailureDetail(tail: string, exitCode: number): string;
33
43
  /** Minimal file metadata the drift check reads; injectable for deterministic tests. */
34
44
  export interface FileStat {
35
45
  mtimeMs: number;
36
46
  }
37
47
  /**
38
- * Whether the live plugin tree differs from the latest LKG snapshot. Two
48
+ * Whether the live plugin tree differs from the latest LKG snapshot. Three
39
49
  * signals are combined:
40
50
  *
41
51
  * 1. manifest drift — the live profile's web `package.json` text vs baseline;
42
- * 2. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
52
+ * 2. cordis patch drift — the live profile's web `cordis.patch.yml` text vs
53
+ * baseline (a patch-only config edit changes the boot-time row wiring
54
+ * without touching the manifest — the manifest check alone misses it);
55
+ * 3. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
43
56
  * snapshot moment. Link-installed plugins resolve to the same workspace
44
57
  * files in both live and LKG, so the stored copies cannot be compared
45
58
  * byte-wise; the snapshot itself is the meaningful baseline and a rebuilt
@@ -66,7 +66,9 @@ export async function dryBootVerify(harnessRoot, opts = {}) {
66
66
  }
67
67
  const tail = logs.join('').slice(-3000);
68
68
  const loadErr = /ERR_MODULE_NOT_FOUND|assertChannel|must declare output|failed to apply loader entry/.exec(tail);
69
- return { ok: code === 0 && !loadErr, detail: loadErr ? loadErr[0] : (code === 0 ? 'dry-boot ok' : `dry-boot failed (exit ${code})`) };
69
+ if (code === 0 && !loadErr)
70
+ return { ok: true, detail: 'dry-boot ok' };
71
+ return { ok: false, detail: dryBootFailureDetail(tail, code) };
70
72
  }
71
73
  catch (e) {
72
74
  return { ok: false, detail: `dry-boot error: ${e?.message ?? String(e)}` };
@@ -87,11 +89,34 @@ export async function dryBootVerify(harnessRoot, opts = {}) {
87
89
  }
88
90
  }
89
91
  /**
90
- * Whether the live plugin tree differs from the latest LKG snapshot. Two
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
91
113
  * signals are combined:
92
114
  *
93
115
  * 1. manifest drift — the live profile's web `package.json` text vs baseline;
94
- * 2. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
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
95
120
  * snapshot moment. Link-installed plugins resolve to the same workspace
96
121
  * files in both live and LKG, so the stored copies cannot be compared
97
122
  * byte-wise; the snapshot itself is the meaningful baseline and a rebuilt
@@ -122,6 +147,17 @@ export function isPluginTreeChanged(harnessRoot, lkgDir = join(homedir(), '.dsh/
122
147
  return true;
123
148
  if (readFileSync(liveManifest, 'utf8') !== readFileSync(lkgManifest, 'utf8'))
124
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
+ }
125
161
  const snapshotManifest = join(lkgDir, latest, 'manifest.json');
126
162
  const baseline = existsSync(snapshotManifest)
127
163
  ? statFile(snapshotManifest).mtimeMs
@@ -5,21 +5,76 @@
5
5
  * by killing the host.
6
6
  */
7
7
  /**
8
- * Whether a shell command is a self-kill. `livePids` are the pids currently
9
- * holding listening sockets; a `kill <pid>` whose pid is one of ours is a
10
- * self-kill regardless of anything else in the command. The kill parser accepts
11
- * flag forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is
12
- * essentially JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups
13
- * are common), as are kill attempts whose output reports "not found"/"done" —
14
- * but any compound that chains a restart/kill after it (or before the end)
15
- * stays denied.
8
+ * Remove data spans (quoted strings and heredoc bodies) from a command before
9
+ * self-kill matching. Text inside quotes or a heredoc is content an echo,
10
+ * printf, node -e script or cat <<'EOF' body can legitimately discuss kill
11
+ * commands without executing one.
12
+ */
13
+ export declare function stripDataSpans(cmd: string): string;
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 declare const DSH_WEB_PORTS: number[];
20
+ export type TreeBoundaryKind = 'none' | 'launcher' | 'service-manager';
21
+ /**
22
+ * Boundary classification for the ancestor walk — mirrors
23
+ * `skills/dsh-safe-restart/scripts/restart-dsh-web.sh`'s `resolve_tree()`:
24
+ * the walk stops at `pnpm` (the launcher — everything above it is the
25
+ * launching shell, not dsh web) and never walks into a `systemd --user`
26
+ * manager (it owns every user unit on the box). `launcher` pids stay in the
27
+ * forest; `service-manager` pids are never included.
28
+ */
29
+ export declare function treeBoundaryKind(commandLine: string): TreeBoundaryKind;
30
+ /** Convenience boolean form of {@link treeBoundaryKind}. */
31
+ export declare function isTreeBoundary(commandLine: string): boolean;
32
+ export interface ProcessRow {
33
+ pid: number;
34
+ ppid: number;
35
+ }
36
+ /**
37
+ * Resolve the pids that belong to the dsh web process forest. `listeners` are
38
+ * the pids owning the dsh-web ports (already narrowed by the caller). A pid is
39
+ * protected iff its upward ancestor chain reaches the forest before pid 1:
40
+ *
41
+ * - the forest roots are the listeners plus every ancestor up to the
42
+ * `launcher` boundary (pnpm stays inside the forest; systemd --user and
43
+ * the launching shell stay out);
44
+ * - the descendant closure then protects the whole owned subtree — the dsh
45
+ * web node processes AND their bash-tool/browser children — while never
46
+ * climbing into unrelated ancestors.
47
+ *
48
+ * `boundary(pid)` classifies the command line of a walked pid; it is only
49
+ * invoked for the handful of listener + ancestor pids, never for the full
50
+ * table.
51
+ */
52
+ export declare function resolveDshWebTreePids(listeners: number[], rows: ProcessRow[], boundary?: (pid: number) => TreeBoundaryKind): number[];
53
+ /**
54
+ * Whether a shell command is a self-kill. `livePids` are the pids of the dsh
55
+ * web process forest; a `kill <pid>` whose pid is one of ours is a self-kill
56
+ * regardless of anything else in the command. The kill parser accepts flag
57
+ * forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is essentially
58
+ * JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups are common),
59
+ * as are kill attempts whose output reports "not found"/"done" — but any
60
+ * compound that chains a restart/kill after it (or before the end) stays
61
+ * denied.
16
62
  */
17
63
  export declare function isSelfKillCommand(cmd: string, livePids: number[]): boolean;
64
+ /**
65
+ * Live pids of the dsh web OWN forest: pids owning the dsh-web ports
66
+ * (3000/3080) plus their ancestor chain up to the pnpm/systemd boundary and
67
+ * the owned subtree. A kill of an unrelated listener (mysql/sshd/nginx) is
68
+ * therefore allowed — before this scoping the guard denied `kill <pid>` of ANY
69
+ * pid holding a listening socket as a "restart dsh web".
70
+ */
71
+ export declare function dshWebTreeLivePids(): number[];
18
72
  /**
19
73
  * Build a `tools/pre-execute` waterfall listener: deny matching
20
74
  * bash/shell/exec commands, otherwise delegate to `next()`. Live pids default
21
- * to the pids holding listening sockets (`ss -tlnp`) so `kill <pid>` of a
22
- * live host process is caught even when the command names no tool.
75
+ * to the dsh web process forest (see `dshWebTreeLivePids`) so `kill <pid>` of
76
+ * a live host process is caught even when the command names no tool, while a
77
+ * kill of an unrelated service is not.
23
78
  */
24
79
  export declare function makePreExecuteGuard(opts?: {
25
80
  livePids?: () => number[];
@@ -8,49 +8,194 @@ import { createRequire } from 'node:module';
8
8
  const require = createRequire(import.meta.url);
9
9
  // Patterns that restart/stop/start or kill dsh web (systemctl --user units,
10
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
- * Whether a shell command is a self-kill. `livePids` are the pids currently
16
- * holding listening sockets; a `kill <pid>` whose pid is one of ours is a
17
- * self-kill regardless of anything else in the command. The kill parser accepts
18
- * flag forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is
19
- * essentially JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups
20
- * are common), as are kill attempts whose output reports "not found"/"done" —
21
- * but any compound that chains a restart/kill after it (or before the end)
22
- * stays denied.
11
+ // dsh-safe-web-update helper itself). Matched like dsh-maestro-guard
12
+ // against the executed command surface with quoted/heredoc spans stripped, so
13
+ // text that merely MENTIONS these words (echo/printf/script bodies) is data,
14
+ // not argv.
15
+ 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)/i;
16
+ // A segment whose command position is one of these verbs is a real kill-family
17
+ // invocation: blanket-denied even when its target lives inside quotes
18
+ // (`pkill -f "dsh web"` must still be caught). `sudo`/`env VAR=` prefixes are
19
+ // stripped before the verb is read.
20
+ const DANGEROUS_KILL_VERB = /^(?:sudo\s+|env\s+\S+\s+)*(pkill|killall|kill|systemctl)\b/i;
21
+ /**
22
+ * Remove data spans (quoted strings and heredoc bodies) from a command before
23
+ * self-kill matching. Text inside quotes or a heredoc is content — an echo,
24
+ * printf, node -e script or cat <<'EOF' body can legitimately discuss kill
25
+ * commands without executing one.
26
+ */
27
+ export function stripDataSpans(cmd) {
28
+ let out = cmd;
29
+ let prev = '';
30
+ while (out !== prev) {
31
+ prev = out;
32
+ // heredoc FIRST: the `<<'EOF'` delimiter quotes would otherwise be eaten by
33
+ // the generic quote-strip below and the body would survive as unquoted text
34
+ out = out.replace(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?[^\r\n]*\r?\n[\s\S]*?^\1\s*$/gm, ' ');
35
+ // quote spans respect backslash escapes (`\"` inside a double-quoted
36
+ // node -e body is content, not a delimiter) so nested literals stay stripped
37
+ out = out.replace(/"(?:[^"\\]|\\.)*"/g, ' ').replace(/'(?:[^'\\]|\\.)*'/g, ' ');
38
+ }
39
+ return out;
40
+ }
41
+ /**
42
+ * The dsh web MainThread owns both ports (3000 = gitlab-webhook, 3080 = web).
43
+ * Only listeners on these ports can be dsh web; every other listening process
44
+ * on the host (mysql, sshd, nginx, redis, ...) is explicitly NOT protected.
45
+ */
46
+ export const DSH_WEB_PORTS = [3000, 3080];
47
+ /**
48
+ * Boundary classification for the ancestor walk — mirrors
49
+ * `skills/dsh-safe-restart/scripts/restart-dsh-web.sh`'s `resolve_tree()`:
50
+ * the walk stops at `pnpm` (the launcher — everything above it is the
51
+ * launching shell, not dsh web) and never walks into a `systemd --user`
52
+ * manager (it owns every user unit on the box). `launcher` pids stay in the
53
+ * forest; `service-manager` pids are never included.
54
+ */
55
+ export function treeBoundaryKind(commandLine) {
56
+ if (commandLine.includes('pnpm'))
57
+ return 'launcher';
58
+ if (commandLine.includes('systemd --user'))
59
+ return 'service-manager';
60
+ return 'none';
61
+ }
62
+ /** Convenience boolean form of {@link treeBoundaryKind}. */
63
+ export function isTreeBoundary(commandLine) {
64
+ return treeBoundaryKind(commandLine) !== 'none';
65
+ }
66
+ /**
67
+ * Resolve the pids that belong to the dsh web process forest. `listeners` are
68
+ * the pids owning the dsh-web ports (already narrowed by the caller). A pid is
69
+ * protected iff its upward ancestor chain reaches the forest before pid 1:
70
+ *
71
+ * - the forest roots are the listeners plus every ancestor up to the
72
+ * `launcher` boundary (pnpm stays inside the forest; systemd --user and
73
+ * the launching shell stay out);
74
+ * - the descendant closure then protects the whole owned subtree — the dsh
75
+ * web node processes AND their bash-tool/browser children — while never
76
+ * climbing into unrelated ancestors.
77
+ *
78
+ * `boundary(pid)` classifies the command line of a walked pid; it is only
79
+ * invoked for the handful of listener + ancestor pids, never for the full
80
+ * table.
81
+ */
82
+ export function resolveDshWebTreePids(listeners, rows, boundary = () => 'launcher') {
83
+ const byPid = new Map(rows.map(r => [r.pid, r]));
84
+ const roots = new Set();
85
+ for (const pid of listeners) {
86
+ let cur = pid;
87
+ for (let depth = 0; cur && cur !== 1 && depth < 100 && !roots.has(cur); depth++) {
88
+ const row = byPid.get(cur);
89
+ if (!row)
90
+ break;
91
+ const kind = boundary(cur);
92
+ if (kind === 'service-manager')
93
+ break; // never climb into systemd --user
94
+ roots.add(cur);
95
+ if (kind === 'launcher')
96
+ break; // pnpm is the ceiling of the forest
97
+ if (row.ppid === cur || row.ppid <= 0)
98
+ break;
99
+ cur = row.ppid;
100
+ }
101
+ }
102
+ const protectedSet = new Set(roots);
103
+ for (const row of rows) {
104
+ let cur = row.pid;
105
+ for (let depth = 0; cur && cur !== 1 && depth < 100; depth++) {
106
+ if (roots.has(cur)) {
107
+ protectedSet.add(row.pid);
108
+ break;
109
+ }
110
+ const next = byPid.get(cur);
111
+ if (!next || next.ppid === cur || next.ppid <= 0)
112
+ break;
113
+ cur = next.ppid;
114
+ }
115
+ }
116
+ return [...protectedSet].sort((a, b) => a - b);
117
+ }
118
+ /**
119
+ * Whether a shell command is a self-kill. `livePids` are the pids of the dsh
120
+ * web process forest; a `kill <pid>` whose pid is one of ours is a self-kill
121
+ * regardless of anything else in the command. The kill parser accepts flag
122
+ * forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is essentially
123
+ * JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups are common),
124
+ * as are kill attempts whose output reports "not found"/"done" — but any
125
+ * compound that chains a restart/kill after it (or before the end) stays
126
+ * denied.
23
127
  */
24
128
  export function isSelfKillCommand(cmd, livePids) {
129
+ // pid-targeted kill: an exact live pid is a self-kill regardless of context
25
130
  if (/kill\s+(?:-\S+\s+)?(\d+)/i.test(cmd)) {
26
131
  const pid = Number(cmd.match(/kill\s+(?:-\S+\s+)?(\d+)/i)?.[1]);
27
132
  if (livePids.includes(pid))
28
133
  return true;
29
134
  }
30
- return SELF_KILL_RE.test(cmd)
31
- // Exclude only a command that is JUST `kill [flags] <unrelated pid>` —
135
+ // Targeted patterns may span segments (`ss ... | grep 3080 | xargs kill`),
136
+ // so test the stripped full text once, then verbs per segment.
137
+ const stripped = stripDataSpans(cmd);
138
+ if (SELF_KILL_RE.test(stripped))
139
+ return true;
140
+ for (const seg of stripped.split(/\s*(?:&&|\|\||;|\||\r?\n)+\s*/)) {
141
+ // Exclude ONLY a command that is JUST `kill [flags] <unrelated pid>` —
32
142
  // anchored end-to-end so `kill 1234 && systemctl restart dsh-web` cannot
33
143
  // whitelist the compound through its prefix.
34
- && !/^kill\s+(?:-\S+\s+)?\d+\s*$/i.test(cmd.trim())
35
- && !/kill\s+(?:-\S+\s+)?(\d+)\s+.*(not found|done)/i.test(cmd);
144
+ if (/^kill\s+(?:-\S+\s+)?\d+\s*$/i.test(seg.trim()))
145
+ continue;
146
+ if (/kill\s+(?:-\S+\s+)?(\d+)\s+.*(not found|done)/i.test(seg))
147
+ continue;
148
+ if (DANGEROUS_KILL_VERB.test(seg))
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+ /**
154
+ * Live pids of the dsh web OWN forest: pids owning the dsh-web ports
155
+ * (3000/3080) plus their ancestor chain up to the pnpm/systemd boundary and
156
+ * the owned subtree. A kill of an unrelated listener (mysql/sshd/nginx) is
157
+ * therefore allowed — before this scoping the guard denied `kill <pid>` of ANY
158
+ * pid holding a listening socket as a "restart dsh web".
159
+ */
160
+ export function dshWebTreeLivePids() {
161
+ try {
162
+ const { execSync } = require('node:child_process');
163
+ const filter = DSH_WEB_PORTS.map(p => `sport = :${p}`).join(' or ');
164
+ const out = execSync(`ss -tlnp '( ${filter} )' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | sort -u`, { encoding: 'utf8' });
165
+ const listeners = out.trim().split('\n').filter(Boolean).map(Number);
166
+ if (listeners.length === 0)
167
+ return [];
168
+ const psOut = execSync(`ps -eo pid=,ppid=`, { encoding: 'utf8' });
169
+ const rows = psOut.trim().split('\n')
170
+ .map(line => line.trim().split(/\s+/))
171
+ .filter(p => p.length >= 2 && /^\d+$/.test(p[0]) && /^\d+$/.test(p[1]))
172
+ .map(([pid, ppid]) => ({ pid: Number(pid), ppid: Number(ppid) }));
173
+ // Command lines are only fetched for the walked listener/ancestor pids
174
+ // (a handful of subprocess calls), never for the whole process table.
175
+ const boundary = (pid) => {
176
+ try {
177
+ const args = execSync(`ps -o args= -p ${pid}`, { encoding: 'utf8' });
178
+ return treeBoundaryKind(args);
179
+ }
180
+ catch {
181
+ return 'launcher'; // gone or unreadable → stop walking right here
182
+ }
183
+ };
184
+ return resolveDshWebTreePids(listeners, rows, boundary);
185
+ }
186
+ catch {
187
+ return [];
188
+ }
36
189
  }
37
190
  /**
38
191
  * Build a `tools/pre-execute` waterfall listener: deny matching
39
192
  * bash/shell/exec commands, otherwise delegate to `next()`. Live pids default
40
- * to the pids holding listening sockets (`ss -tlnp`) so `kill <pid>` of a
41
- * live host process is caught even when the command names no tool.
193
+ * to the dsh web process forest (see `dshWebTreeLivePids`) so `kill <pid>` of
194
+ * a live host process is caught even when the command names no tool, while a
195
+ * kill of an unrelated service is not.
42
196
  */
43
197
  export function makePreExecuteGuard(opts = {}) {
44
- const livePids = opts.livePids ?? (() => {
45
- try {
46
- const { execSync } = require('node:child_process');
47
- const out = execSync(`ss -tlnp 2>/dev/null | grep -oP 'pid=\\K[0-9]+' | sort -u`, { encoding: 'utf8' });
48
- return out.trim().split('\n').filter(Boolean).map(Number);
49
- }
50
- catch {
51
- return [];
52
- }
53
- });
198
+ const livePids = opts.livePids ?? dshWebTreeLivePids;
54
199
  return async (exec, next) => {
55
200
  // dsh-tools hands the frozen ToolExecution (name + arguments); the guard
56
201
  // also accepts the `args` shape for tests/embedded hosts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",