@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.
- package/lib/intents.d.ts +15 -0
- package/lib/intents.js +27 -0
- package/lib/plugin.d.ts +6 -2
- package/lib/plugin.js +22 -3
- package/lib/restart-tool.d.ts +15 -2
- package/lib/restart-tool.js +39 -3
- package/lib/self-kill-guard.d.ts +65 -10
- package/lib/self-kill-guard.js +173 -28
- package/package.json +1 -1
package/lib/intents.d.ts
ADDED
|
@@ -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
|
-
|
|
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[]
|
|
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
|
|
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
|
}
|
package/lib/restart-tool.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
package/lib/restart-tool.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
package/lib/self-kill-guard.d.ts
CHANGED
|
@@ -5,21 +5,76 @@
|
|
|
5
5
|
* by killing the host.
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
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
|
|
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[];
|
package/lib/self-kill-guard.js
CHANGED
|
@@ -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).
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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