@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.
- package/lib/cli.d.ts +13 -0
- package/lib/cli.js +87 -55
- package/lib/intents.d.ts +15 -0
- package/lib/intents.js +27 -0
- package/lib/plugin.d.ts +6 -2
- package/lib/plugin.js +94 -3
- package/lib/restart-guards.d.ts +11 -0
- package/lib/restart-guards.js +23 -0
- package/lib/restart-tool.d.ts +83 -0
- package/lib/restart-tool.js +277 -0
- package/lib/scan.d.ts +20 -0
- package/lib/scan.js +71 -0
- package/lib/self-kill-guard.d.ts +74 -0
- package/lib/self-kill-guard.js +169 -0
- package/lib/skill-provider.d.ts +6 -0
- package/lib/skill-provider.js +70 -0
- package/lib/supervisor.d.ts +9 -0
- package/lib/supervisor.js +73 -1
- package/package.json +2 -1
- package/skills/dsh-safe-restart/SKILL.md +90 -0
- package/skills/dsh-safe-restart/scripts/restart-dsh-web.sh +279 -0
|
@@ -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
|
+
};
|