dsh-runtime-kit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +226 -2
- package/config.schema.json +202 -0
- package/dist/index.js +1240 -0
- package/package.json +73 -4
- package/patch.yml +43 -0
- package/test/remote-probe.mjs +113 -0
- package/test/smoke.mjs +165 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-runtime-kit v0.1.0 - DSH 通用运行时插件
|
|
3
|
+
* 上下文治理(C1)、快路径(C2)、PTC(C3)、编排裁剪(C4)、并发/恢复(C5)、沙箱(C6)、审计(C7)
|
|
4
|
+
* 零业务依赖:仅依赖 Node 内置模块与 DSH 宿主注入的 ctx。
|
|
5
|
+
*
|
|
6
|
+
* 导出形状遵循 @deepseek-ai/cordis-plugin-loader 契约:
|
|
7
|
+
* exports.default ?? exports → { name, inject?, apply }
|
|
8
|
+
* 命令通过 'commands' 服务注册(commands.register),handler 返回 CommandResult。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'node:fs/promises';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import * as crypto from 'node:crypto';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Registry(唯一业务耦合面:registerTool / registerTemplate / registerWorkspace)
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
class RegistryImpl {
|
|
22
|
+
constructor(hooks = {}) {
|
|
23
|
+
this.hooks = hooks;
|
|
24
|
+
this.tools = new Map();
|
|
25
|
+
this.toolEnabled = new Map();
|
|
26
|
+
this.templates = new Map();
|
|
27
|
+
this.workspaces = new Map();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
registerTool(spec) {
|
|
31
|
+
if (this.tools.has(spec.name)) throw new Error(`Tool "${spec.name}" already registered`);
|
|
32
|
+
this.tools.set(spec.name, spec);
|
|
33
|
+
this.toolEnabled.set(spec.name, true);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
unregisterTool(name) {
|
|
37
|
+
this.tools.delete(name);
|
|
38
|
+
this.toolEnabled.delete(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getTool(name) { return this.tools.get(name); }
|
|
42
|
+
listTools() { return Array.from(this.tools.values()); }
|
|
43
|
+
isToolEnabled(name) { return this.toolEnabled.get(name) ?? false; }
|
|
44
|
+
|
|
45
|
+
setToolEnabled(name, enabled) {
|
|
46
|
+
if (!this.tools.has(name)) throw new Error(`Tool "${name}" not registered`);
|
|
47
|
+
this.toolEnabled.set(name, enabled);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
registerTemplate(spec) {
|
|
51
|
+
if (this.templates.has(spec.name)) throw new Error(`Template "${spec.name}" already registered`);
|
|
52
|
+
this.templates.set(spec.name, spec);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
unregisterTemplate(name) { this.templates.delete(name); }
|
|
56
|
+
getTemplate(name) { return this.templates.get(name); }
|
|
57
|
+
listTemplates() { return Array.from(this.templates.values()); }
|
|
58
|
+
|
|
59
|
+
async matchTemplate(input) {
|
|
60
|
+
for (const t of this.templates.values()) {
|
|
61
|
+
if (await t.match(input)) return t;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
registerWorkspace(spec) {
|
|
66
|
+
if (this.workspaces.has(spec.profile)) throw new Error(`Workspace for profile "${spec.profile}" already registered`);
|
|
67
|
+
const ws = { ...spec, root: path.resolve(spec.root) };
|
|
68
|
+
this.workspaces.set(spec.profile, ws);
|
|
69
|
+
this.hooks.onWorkspaceRegistered?.(ws);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
unregisterWorkspace(profile) {
|
|
73
|
+
this.workspaces.delete(profile);
|
|
74
|
+
this.hooks.onWorkspaceUnregistered?.(profile);
|
|
75
|
+
}
|
|
76
|
+
getWorkspace(profile) { return this.workspaces.get(profile); }
|
|
77
|
+
listWorkspaces() { return Array.from(this.workspaces.values()); }
|
|
78
|
+
|
|
79
|
+
clear() {
|
|
80
|
+
this.tools.clear();
|
|
81
|
+
this.toolEnabled.clear();
|
|
82
|
+
this.templates.clear();
|
|
83
|
+
this.workspaces.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Context Governance(C1):返回体裁剪 / 字段投影 / 懒加载
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
class ContextGovernance {
|
|
92
|
+
constructor(config) {
|
|
93
|
+
this.config = config;
|
|
94
|
+
this.toolSchemas = new Map();
|
|
95
|
+
this.schemasLoaded = new Map();
|
|
96
|
+
this.metrics = [];
|
|
97
|
+
this.currentTurn = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
registerToolSchema(name, schema) {
|
|
101
|
+
this.toolSchemas.set(name, schema);
|
|
102
|
+
this.schemasLoaded.set(name, false);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async getToolSchema(name) {
|
|
106
|
+
if (!this.config.lazyLoadTools) return this.toolSchemas.get(name);
|
|
107
|
+
if (!this.schemasLoaded.get(name)) this.schemasLoaded.set(name, true);
|
|
108
|
+
return this.toolSchemas.get(name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async governReturn(toolName, result, projection) {
|
|
112
|
+
const originalStr = JSON.stringify(result);
|
|
113
|
+
const originalBytes = Buffer.byteLength(originalStr, 'utf-8');
|
|
114
|
+
let governed = { ...result };
|
|
115
|
+
let truncated = false;
|
|
116
|
+
let fieldsProjected;
|
|
117
|
+
|
|
118
|
+
if (this.config.fieldsProjection && projection && governed.data) {
|
|
119
|
+
governed.data = this.projectFields(governed.data, projection);
|
|
120
|
+
fieldsProjected = projection.include ?? Object.keys(projection.exclude ?? {});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const budget = this.config.maxReturnBytes;
|
|
124
|
+
let governedBytes = Buffer.byteLength(JSON.stringify(governed), 'utf-8');
|
|
125
|
+
|
|
126
|
+
if (governedBytes > budget) {
|
|
127
|
+
governed = this.truncateToBudget(governed, budget);
|
|
128
|
+
governedBytes = budget;
|
|
129
|
+
truncated = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ...governed, governance: { originalBytes, governedBytes, truncated, fieldsProjected } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
projectFields(obj, options) {
|
|
136
|
+
const { include, exclude, maxDepth = 10, maxArrayLength = 50 } = options;
|
|
137
|
+
function visit(v, p, d) {
|
|
138
|
+
if (d > maxDepth) return '[MAX_DEPTH_EXCEEDED]';
|
|
139
|
+
if (Array.isArray(v)) {
|
|
140
|
+
if (v.length > maxArrayLength) {
|
|
141
|
+
const head = v.slice(0, maxArrayLength).map((x, i) => visit(x, `${p}[${i}]`, d + 1));
|
|
142
|
+
return [...head, `[...${v.length - maxArrayLength} more]`];
|
|
143
|
+
}
|
|
144
|
+
return v.map((x, i) => visit(x, `${p}[${i}]`, d + 1));
|
|
145
|
+
}
|
|
146
|
+
if (v && typeof v === 'object') {
|
|
147
|
+
const r = {};
|
|
148
|
+
for (const [k, val] of Object.entries(v)) {
|
|
149
|
+
const fp = p ? `${p}.${k}` : k;
|
|
150
|
+
if (include?.length && !include.some(pt => fp === pt || fp.startsWith(pt + '.'))) continue;
|
|
151
|
+
if (exclude?.length && exclude.some(pt => fp === pt || fp.startsWith(pt + '.'))) continue;
|
|
152
|
+
r[k] = visit(val, fp, d + 1);
|
|
153
|
+
}
|
|
154
|
+
return r;
|
|
155
|
+
}
|
|
156
|
+
return v;
|
|
157
|
+
}
|
|
158
|
+
return visit(obj, '', 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
truncateToBudget(result, budget) {
|
|
162
|
+
const t = { ...result };
|
|
163
|
+
if (t.data) {
|
|
164
|
+
const oh = Buffer.byteLength(JSON.stringify({ ...t, data: '' }), 'utf-8');
|
|
165
|
+
const avail = budget - oh - 100;
|
|
166
|
+
if (avail > 100) {
|
|
167
|
+
if (typeof t.data === 'string' && t.data.length > avail / 3) {
|
|
168
|
+
t.data = t.data.slice(0, avail / 3) + '\n... [TRUNCATED]';
|
|
169
|
+
} else if (Array.isArray(t.data)) {
|
|
170
|
+
const kept = [];
|
|
171
|
+
let bytes = 0;
|
|
172
|
+
for (const it of t.data) {
|
|
173
|
+
const ib = Buffer.byteLength(JSON.stringify(it), 'utf-8');
|
|
174
|
+
if (bytes + ib > avail) break;
|
|
175
|
+
kept.push(it);
|
|
176
|
+
bytes += ib;
|
|
177
|
+
}
|
|
178
|
+
t.data = [...kept, `[...${t.data.length - kept.length} more TRUNCATED]`];
|
|
179
|
+
} else if (typeof t.data === 'object') {
|
|
180
|
+
t.data = { ...t.data, _truncated: true };
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
t.data = '[TRUNCATED: EXCEEDS BUDGET]';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
t.truncated = true;
|
|
187
|
+
t.totalBytes = budget;
|
|
188
|
+
return t;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
recordMetrics(s) {
|
|
192
|
+
this.metrics.push(s);
|
|
193
|
+
if (this.metrics.length > 1000) this.metrics.shift();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getMetrics() { return [...this.metrics]; }
|
|
197
|
+
updateConfig(p) { this.config = { ...this.config, ...p }; }
|
|
198
|
+
getConfig() { return { ...this.config }; }
|
|
199
|
+
nextTurn() { this.currentTurn++; }
|
|
200
|
+
getCurrentTurn() { return this.currentTurn; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ============================================================================
|
|
204
|
+
// Sandbox(C6):3 级基线 + 3 态细规则、fail-closed、快照/回收站、注入防护、脱敏
|
|
205
|
+
// ============================================================================
|
|
206
|
+
|
|
207
|
+
class Sandbox {
|
|
208
|
+
constructor(config) {
|
|
209
|
+
this.config = config;
|
|
210
|
+
this.workspaces = new Map();
|
|
211
|
+
this.snapshots = new Map();
|
|
212
|
+
this.trash = new Map();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
updateConfig(p) { this.config = { ...this.config, ...p }; }
|
|
216
|
+
getConfig() { return { ...this.config }; }
|
|
217
|
+
|
|
218
|
+
registerWorkspace(spec) { this.workspaces.set(spec.profile, { ...spec, root: path.resolve(spec.root) }); }
|
|
219
|
+
unregisterWorkspace(p) { this.workspaces.delete(p); }
|
|
220
|
+
getWorkspace(p) { return this.workspaces.get(p); }
|
|
221
|
+
|
|
222
|
+
resolveAndValidate(profile, requestedPath, op) {
|
|
223
|
+
const ws = this.workspaces.get(profile);
|
|
224
|
+
if (!ws) return this.deny(`Workspace not registered: ${profile}`, 'file', op);
|
|
225
|
+
let abs;
|
|
226
|
+
try {
|
|
227
|
+
abs = path.isAbsolute(requestedPath) ? path.resolve(requestedPath) : path.resolve(ws.root, requestedPath);
|
|
228
|
+
} catch {
|
|
229
|
+
return this.deny(`Path resolution failed: ${requestedPath}`, 'file', op);
|
|
230
|
+
}
|
|
231
|
+
const rel = path.relative(ws.root, abs);
|
|
232
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
233
|
+
return this.deny(`Path traversal: ${requestedPath} -> ${abs}`, 'file', op);
|
|
234
|
+
}
|
|
235
|
+
const base = this.checkBaseMode(ws, op);
|
|
236
|
+
if (!base.allowed) return base;
|
|
237
|
+
const rule = this.matchFileRules(rel, op);
|
|
238
|
+
if (rule) return rule;
|
|
239
|
+
return base;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
checkBaseMode(ws, op) {
|
|
243
|
+
const mode = ws.permissionMode ?? this.config.mode;
|
|
244
|
+
if (mode === 'read-only') {
|
|
245
|
+
if (op === 'read') return this.allow('Read allowed');
|
|
246
|
+
return this.deny(`${op} denied in read-only`, 'file', op);
|
|
247
|
+
}
|
|
248
|
+
if (mode === 'workspace-write') return this.allow(`${op} allowed`);
|
|
249
|
+
return this.allow(`${op} allowed in danger-full-access`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
matchFileRules(rel, op) {
|
|
253
|
+
for (const r of this.config.rules.files) {
|
|
254
|
+
if (this.matchGlob(rel, r.pattern)) {
|
|
255
|
+
const a = r[op];
|
|
256
|
+
if (a === 'allow') return this.allow(`File rule allow: ${r.pattern}`, r);
|
|
257
|
+
if (a === 'deny') return this.deny(`File rule deny: ${r.pattern}`, 'file', op, r);
|
|
258
|
+
if (a === 'ask') return this.ask(`File rule ask: ${r.pattern}`, r);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
validateCommand(req) {
|
|
265
|
+
const full = `${req.command} ${req.args.join(' ')}`.trim();
|
|
266
|
+
for (const r of this.config.rules.commands) {
|
|
267
|
+
if (full.startsWith(r.prefix)) {
|
|
268
|
+
if (r.action === 'allow') return this.allow(`Cmd allow: ${r.prefix}`, r);
|
|
269
|
+
if (r.action === 'deny') return this.deny(`Cmd deny: ${r.prefix}`, 'command', 'execute', r);
|
|
270
|
+
if (r.action === 'ask') return this.ask(`Cmd ask: ${r.prefix}`, r);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (this.config.mode === 'danger-full-access') return this.allow('Allowed in danger-full-access');
|
|
274
|
+
return this.deny(`Cmd not allowed: ${full}`, 'command', 'execute');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
validateNetwork(req) {
|
|
278
|
+
let host;
|
|
279
|
+
try {
|
|
280
|
+
host = new URL(req.url).hostname;
|
|
281
|
+
} catch {
|
|
282
|
+
return this.deny(`Invalid URL: ${req.url}`, 'network', 'connect');
|
|
283
|
+
}
|
|
284
|
+
for (const r of this.config.rules.network) {
|
|
285
|
+
if (host === r.domain) {
|
|
286
|
+
if (r.action === 'allow') return this.allow(`Net allow: ${r.domain}`, r);
|
|
287
|
+
if (r.action === 'deny') return this.deny(`Net deny: ${r.domain}`, 'network', 'connect', r);
|
|
288
|
+
if (r.action === 'ask') return this.ask(`Net ask: ${r.domain}`, r);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
switch (this.config.networkDefault) {
|
|
292
|
+
case 'allow': return this.allow('Allowed by default');
|
|
293
|
+
case 'ask': return this.ask('Requires approval');
|
|
294
|
+
default: return this.deny(`Denied by default: ${host}`, 'network', 'connect');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
isToolAllowed(name) {
|
|
299
|
+
const en = this.config.toolControl[name];
|
|
300
|
+
if (en === false) return this.deny(`Tool disabled: ${name}`, 'tool', 'call');
|
|
301
|
+
if (en === true) return this.allow(`Tool enabled: ${name}`);
|
|
302
|
+
return this.allow(`Tool not controlled: ${name}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async createSnapshot(filePath, content) {
|
|
306
|
+
if (!this.config.backupEnabled) return '';
|
|
307
|
+
const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
308
|
+
const snap = { path: filePath, content, hash, timestamp: new Date().toISOString() };
|
|
309
|
+
const arr = this.snapshots.get(filePath) || [];
|
|
310
|
+
arr.push(snap);
|
|
311
|
+
if (arr.length > 10) arr.shift();
|
|
312
|
+
this.snapshots.set(filePath, arr);
|
|
313
|
+
return hash;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
getSnapshots(filePath) { return this.snapshots.get(filePath) || []; }
|
|
317
|
+
|
|
318
|
+
restoreSnapshot(filePath, hash) {
|
|
319
|
+
const s = this.snapshots.get(filePath) || [];
|
|
320
|
+
return s.find(x => x.hash === hash)?.content ?? null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async safeDelete(filePath, content) {
|
|
324
|
+
if (!this.config.deletionProtection) return 'deleted';
|
|
325
|
+
const id = crypto.randomUUID();
|
|
326
|
+
const hash = await this.createSnapshot(filePath, content);
|
|
327
|
+
this.trash.set(id, {
|
|
328
|
+
originalPath: filePath,
|
|
329
|
+
snapshot: { path: filePath, content, hash, timestamp: new Date().toISOString() },
|
|
330
|
+
deletedAt: new Date().toISOString(),
|
|
331
|
+
});
|
|
332
|
+
return id;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
restoreFromTrash(id) {
|
|
336
|
+
const e = this.trash.get(id);
|
|
337
|
+
if (!e) return null;
|
|
338
|
+
this.trash.delete(id);
|
|
339
|
+
return { path: e.originalPath, content: e.snapshot.content };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async emptyTrash(approved) {
|
|
343
|
+
if (!approved) throw new Error('Requires approval');
|
|
344
|
+
const n = this.trash.size;
|
|
345
|
+
this.trash.clear();
|
|
346
|
+
return n;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
isWorkspaceTrusted(p) {
|
|
350
|
+
const w = this.workspaces.get(p);
|
|
351
|
+
return w?.trusted ?? this.config.workspaceTrust;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
detectInjection(content) {
|
|
355
|
+
if (!this.config.injectionGuard) return { suspicious: false, patterns: [] };
|
|
356
|
+
const pats = [
|
|
357
|
+
/ignore\s+(previous|above|all)\s+instructions?/i,
|
|
358
|
+
/forget\s+(everything|all|previous)/i,
|
|
359
|
+
/you\s+are\s+now\s+/i,
|
|
360
|
+
/system\s*:\s*/i,
|
|
361
|
+
/assistant\s*:\s*/i,
|
|
362
|
+
/<\|.*?\|>/g,
|
|
363
|
+
/```\s*(system|user|assistant)/i,
|
|
364
|
+
/override\s+(safety|security|policy)/i,
|
|
365
|
+
/bypass\s+(filter|guard|check)/i,
|
|
366
|
+
];
|
|
367
|
+
const hit = pats.filter(p => p.test(content));
|
|
368
|
+
return { suspicious: hit.length > 0, patterns: hit.map(p => p.source) };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
static SENSITIVE = [
|
|
372
|
+
{ n: 'password', r: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^"'\s]{8,})/gi },
|
|
373
|
+
{ n: 'api_key', r: /(?:api[_-]?key|apikey)\s*[:=]\s*["']?([^"'\s]{16,})/gi },
|
|
374
|
+
{ n: 'secret', r: /(?:secret|token)\s*[:=]\s*["']?([^"'\s]{16,})/gi },
|
|
375
|
+
{ n: 'cookie', r: /(?:cookie|session)\s*[:=]\s*["']?([^"'\s]{16,})/gi },
|
|
376
|
+
{ n: 'private_key', r: /-----BEGIN (?:RSA|EC|OPENSSH)? PRIVATE KEY-----[\s\S]*?-----END (?:RSA|EC|OPENSSH)? PRIVATE KEY-----/gi },
|
|
377
|
+
{ n: 'conn', r: /(?:jdbc:|mongodb:\/\/|redis:\/\/|mysql:\/\/|postgresql:\/\/)[^"'\s]*(?::[^"'\s@]+@)/gi },
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
desensitize(content) {
|
|
381
|
+
if (!this.config.sensitiveProtection) return { sanitized: content, placeholders: new Map() };
|
|
382
|
+
const ph = new Map();
|
|
383
|
+
let s = content;
|
|
384
|
+
for (const { n, r } of Sandbox.SENSITIVE) {
|
|
385
|
+
r.lastIndex = 0;
|
|
386
|
+
let m;
|
|
387
|
+
while ((m = r.exec(content)) !== null) {
|
|
388
|
+
const o = m[1] ?? m[0];
|
|
389
|
+
const pl = `__${n.toUpperCase()}_${ph.size}__`;
|
|
390
|
+
ph.set(pl, o);
|
|
391
|
+
s = s.split(o).join(pl);
|
|
392
|
+
if (!r.global) break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { sanitized: s, placeholders: ph };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
resensitize(s, ph) {
|
|
399
|
+
let r = s;
|
|
400
|
+
for (const [pl, o] of ph) r = r.split(pl).join(o);
|
|
401
|
+
return r;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
getShellExecutor() {
|
|
405
|
+
if (process.platform === 'win32') return { command: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command'] };
|
|
406
|
+
return { command: '/bin/sh', args: ['-c'] };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
createExecutionDomain() { return new LocalExecutionDomain(this); }
|
|
410
|
+
|
|
411
|
+
matchGlob(s, p) {
|
|
412
|
+
return new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$').test(s);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
allow(reason, rule) {
|
|
416
|
+
return { allowed: true, action: 'allow', reason, ruleMatched: rule, requiresApproval: false };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
deny(reason, _rt, _op, rule) {
|
|
420
|
+
return { allowed: false, action: 'deny', reason, ruleMatched: rule, requiresApproval: false };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
ask(reason, rule) {
|
|
424
|
+
return { allowed: false, action: 'ask', reason, ruleMatched: rule, requiresApproval: true };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
class LocalExecutionDomain {
|
|
429
|
+
constructor(sb) {
|
|
430
|
+
this.sb = sb;
|
|
431
|
+
this.fs = {
|
|
432
|
+
read: (p) => fs.readFile(p, 'utf-8'),
|
|
433
|
+
write: async (p, c) => {
|
|
434
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
435
|
+
await fs.writeFile(p, c, 'utf-8');
|
|
436
|
+
},
|
|
437
|
+
delete: async (p) => {
|
|
438
|
+
try {
|
|
439
|
+
await fs.unlink(p);
|
|
440
|
+
} catch {
|
|
441
|
+
await fs.rm(p, { recursive: true, force: true });
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
list: (p) => fs.readdir(p),
|
|
445
|
+
stat: async (p) => {
|
|
446
|
+
const s = await fs.stat(p);
|
|
447
|
+
return { size: s.size, mtime: s.mtime, isDirectory: s.isDirectory() };
|
|
448
|
+
},
|
|
449
|
+
mkdir: (p, r = true) => fs.mkdir(p, { recursive: r }),
|
|
450
|
+
};
|
|
451
|
+
this.shell = {
|
|
452
|
+
execute: (cmd, args, o) => {
|
|
453
|
+
const { command, args: shArgs } = this.sb.getShellExecutor();
|
|
454
|
+
return new Promise((resolve, reject) => {
|
|
455
|
+
const ch = spawn(command, [...shArgs, `${cmd} ${args.join(' ')}`], {
|
|
456
|
+
cwd: o?.cwd,
|
|
457
|
+
env: { ...process.env, ...o?.env },
|
|
458
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
459
|
+
});
|
|
460
|
+
let stdout = '';
|
|
461
|
+
let stderr = '';
|
|
462
|
+
ch.stdout?.on('data', d => { stdout += d.toString(); });
|
|
463
|
+
ch.stderr?.on('data', d => { stderr += d.toString(); });
|
|
464
|
+
ch.on('close', c => resolve({ stdout, stderr, exitCode: c ?? 1 }));
|
|
465
|
+
ch.on('error', reject);
|
|
466
|
+
});
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
this.subprocess = { spawn: (cmd, args, o) => this.shell.execute(cmd, args, o) };
|
|
470
|
+
this.network = { fetch: (u, o) => fetch(u, o) };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ============================================================================
|
|
475
|
+
// Audit(C7):append-only JSONL、脱敏、导出、清理
|
|
476
|
+
// ============================================================================
|
|
477
|
+
|
|
478
|
+
class Audit {
|
|
479
|
+
constructor(config, logDir, sb) {
|
|
480
|
+
this.config = config;
|
|
481
|
+
this.logDir = logDir;
|
|
482
|
+
this.sb = sb;
|
|
483
|
+
this.buf = [];
|
|
484
|
+
this.timer = null;
|
|
485
|
+
if (config.mode !== 'off') {
|
|
486
|
+
this.timer = setInterval(() => { this.flush().catch(() => {}); }, 5000);
|
|
487
|
+
if (this.timer.unref) this.timer.unref();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async log(e) {
|
|
492
|
+
if (this.config.mode === 'off') return;
|
|
493
|
+
let ph = e.paramsHash;
|
|
494
|
+
if (this.config.desensitize) {
|
|
495
|
+
const { sanitized } = this.sb.desensitize(e.paramsHash);
|
|
496
|
+
ph = sanitized;
|
|
497
|
+
}
|
|
498
|
+
this.buf.push({ ...e, timestamp: new Date().toISOString(), paramsHash: ph });
|
|
499
|
+
if (e.result === 'BLOCKED' || e.action === 'block') await this.flush();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async flush() {
|
|
503
|
+
if (!this.buf.length) return;
|
|
504
|
+
const by = new Map();
|
|
505
|
+
for (const e of this.buf) {
|
|
506
|
+
const p = e.profile || 'default';
|
|
507
|
+
if (!by.has(p)) by.set(p, []);
|
|
508
|
+
by.get(p).push(e);
|
|
509
|
+
}
|
|
510
|
+
await fs.mkdir(this.logDir, { recursive: true });
|
|
511
|
+
for (const [prof, ents] of by) {
|
|
512
|
+
const lp = path.join(this.logDir, `audit-${prof}.jsonl`);
|
|
513
|
+
await fs.appendFile(lp, ents.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
514
|
+
}
|
|
515
|
+
this.buf = [];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async getLogs(f) {
|
|
519
|
+
const p = f?.profile || 'default';
|
|
520
|
+
const lp = path.join(this.logDir, `audit-${p}.jsonl`);
|
|
521
|
+
try {
|
|
522
|
+
const c = await fs.readFile(lp, 'utf-8');
|
|
523
|
+
let es = c.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
524
|
+
if (f?.tool) es = es.filter(e => e.tool === f.tool);
|
|
525
|
+
if (f?.action) es = es.filter(e => e.action === f.action);
|
|
526
|
+
if (f?.result) es = es.filter(e => e.result === f.result);
|
|
527
|
+
if (f?.since) es = es.filter(e => e.timestamp >= f.since);
|
|
528
|
+
if (f?.until) es = es.filter(e => e.timestamp <= f.until);
|
|
529
|
+
es.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
530
|
+
return f?.limit ? es.slice(0, f.limit) : es;
|
|
531
|
+
} catch {
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async exportLogs(f, fmt = 'jsonl') {
|
|
537
|
+
const es = await this.getLogs(f);
|
|
538
|
+
if (fmt === 'csv') {
|
|
539
|
+
const h = ['timestamp', 'profile', 'tool', 'action', 'paramsHash', 'result', 'durationMs', 'error'];
|
|
540
|
+
return [h.join(','), ...es.map(e => [e.timestamp, e.profile, e.tool, e.action, e.paramsHash, e.result, e.durationMs, e.error ?? ''].join(','))].join('\n');
|
|
541
|
+
}
|
|
542
|
+
return es.map(e => JSON.stringify(e)).join('\n');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
updateConfig(p) { this.config = { ...this.config, ...p }; }
|
|
546
|
+
|
|
547
|
+
async cleanup() {
|
|
548
|
+
if (this.config.mode === 'off') return 0;
|
|
549
|
+
const cut = new Date();
|
|
550
|
+
cut.setDate(cut.getDate() - this.config.retentionDays);
|
|
551
|
+
const cs = cut.toISOString();
|
|
552
|
+
let n = 0;
|
|
553
|
+
const pros = new Set();
|
|
554
|
+
try {
|
|
555
|
+
for (const e of await fs.readdir(this.logDir)) {
|
|
556
|
+
if (e.startsWith('audit-') && e.endsWith('.jsonl')) pros.add(e.slice(6, -6));
|
|
557
|
+
}
|
|
558
|
+
} catch {
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
561
|
+
for (const p of pros) {
|
|
562
|
+
const lp = path.join(this.logDir, `audit-${p}.jsonl`);
|
|
563
|
+
try {
|
|
564
|
+
const c = await fs.readFile(lp, 'utf-8');
|
|
565
|
+
const ls = c.trim().split('\n').filter(Boolean);
|
|
566
|
+
const kept = ls.map(l => JSON.parse(l)).filter(e => e.timestamp >= cs).map(e => JSON.stringify(e));
|
|
567
|
+
await fs.writeFile(lp, kept.join('\n') + (kept.length ? '\n' : ''), 'utf-8');
|
|
568
|
+
n += ls.length - kept.length;
|
|
569
|
+
} catch { /* keep going */ }
|
|
570
|
+
}
|
|
571
|
+
return n;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async shutdown() {
|
|
575
|
+
if (this.timer) clearInterval(this.timer);
|
|
576
|
+
await this.flush();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ============================================================================
|
|
581
|
+
// Loop(C2/C3/C5):FastPath + PTC + SessionManager + ConcurrentScheduler
|
|
582
|
+
// ============================================================================
|
|
583
|
+
|
|
584
|
+
class FastPathExecutor {
|
|
585
|
+
constructor(reg, sb, cg, au, profile) {
|
|
586
|
+
this.reg = reg;
|
|
587
|
+
this.sb = sb;
|
|
588
|
+
this.cg = cg;
|
|
589
|
+
this.au = au;
|
|
590
|
+
this.profile = profile;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async execute(input, tpl) {
|
|
594
|
+
const start = Date.now();
|
|
595
|
+
const ik = tpl.idempotencyKeyFn
|
|
596
|
+
? await tpl.idempotencyKeyFn(input)
|
|
597
|
+
: crypto.createHash('sha256').update(`${tpl.name}:${input}`).digest('hex').slice(0, 32);
|
|
598
|
+
const steps = [];
|
|
599
|
+
const prev = new Map();
|
|
600
|
+
let st = 'SUCCEEDED';
|
|
601
|
+
for (const step of tpl.plan) {
|
|
602
|
+
if (step.condition && !step.condition(prev)) continue;
|
|
603
|
+
const args = typeof step.args === 'function' ? step.args(input, prev) : step.args;
|
|
604
|
+
const ts = this.reg.getTool(step.tool);
|
|
605
|
+
if (!ts) throw new Error(`Tool ${step.tool} not registered`);
|
|
606
|
+
const ta = this.sb.isToolAllowed(step.tool);
|
|
607
|
+
if (!ta.allowed) {
|
|
608
|
+
if (ta.requiresApproval) {
|
|
609
|
+
st = 'BLOCKED';
|
|
610
|
+
await this.au.log({ profile: this.profile, tool: step.tool, action: 'block', paramsHash: JSON.stringify(args), result: 'BLOCKED', durationMs: 0, error: ta.reason });
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
throw new Error(`Tool not allowed: ${ta.reason}`);
|
|
614
|
+
}
|
|
615
|
+
const ss = Date.now();
|
|
616
|
+
let res;
|
|
617
|
+
try {
|
|
618
|
+
res = await ts.handler(args);
|
|
619
|
+
} catch (e) {
|
|
620
|
+
res = { success: false, error: String(e) };
|
|
621
|
+
}
|
|
622
|
+
const gov = await this.cg.governReturn(step.tool, res);
|
|
623
|
+
await this.au.log({
|
|
624
|
+
profile: this.profile,
|
|
625
|
+
tool: step.tool,
|
|
626
|
+
action: 'call',
|
|
627
|
+
paramsHash: JSON.stringify(args),
|
|
628
|
+
result: gov.success ? 'SUCCESS' : 'FAILURE',
|
|
629
|
+
durationMs: Date.now() - ss,
|
|
630
|
+
error: gov.error,
|
|
631
|
+
});
|
|
632
|
+
steps.push({ tool: step.tool, result: gov, durationMs: Date.now() - ss });
|
|
633
|
+
prev.set(step.tool, gov);
|
|
634
|
+
if (!gov.success) {
|
|
635
|
+
st = 'FAILED';
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return { templateName: tpl.name, idempotencyKey: ik, status: st, steps, totalDurationMs: Date.now() - start, llmCalls: 0 };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
class PTCExecutor {
|
|
644
|
+
constructor(reg, sb, cg, au, profile, maxCalls = 50, timeout = 120000) {
|
|
645
|
+
this.reg = reg;
|
|
646
|
+
this.sb = sb;
|
|
647
|
+
this.cg = cg;
|
|
648
|
+
this.au = au;
|
|
649
|
+
this.profile = profile;
|
|
650
|
+
this.maxCalls = maxCalls;
|
|
651
|
+
this.timeout = timeout;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async execute(script) {
|
|
655
|
+
const start = Date.now();
|
|
656
|
+
const calls = [];
|
|
657
|
+
let trunc = false;
|
|
658
|
+
const parsed = this.parse(script);
|
|
659
|
+
for (const call of parsed) {
|
|
660
|
+
if (calls.length >= this.maxCalls) {
|
|
661
|
+
trunc = true;
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
const ts = this.reg.getTool(call.name);
|
|
665
|
+
if (!ts) {
|
|
666
|
+
calls.push({ tool: call.name, args: call.args, result: { success: false, error: `Tool ${call.name} not registered` } });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const ta = this.sb.isToolAllowed(call.name);
|
|
670
|
+
if (!ta.allowed) {
|
|
671
|
+
calls.push({ tool: call.name, args: call.args, result: { success: false, error: `Not allowed: ${ta.reason}` } });
|
|
672
|
+
if (ta.requiresApproval) {
|
|
673
|
+
await this.au.log({ profile: this.profile, tool: call.name, action: 'block', paramsHash: JSON.stringify(call.args), result: 'BLOCKED', durationMs: 0, error: ta.reason });
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const ss = Date.now();
|
|
678
|
+
let r;
|
|
679
|
+
try {
|
|
680
|
+
r = await ts.handler(call.args);
|
|
681
|
+
} catch (e) {
|
|
682
|
+
r = { success: false, error: String(e) };
|
|
683
|
+
}
|
|
684
|
+
const gov = await this.cg.governReturn(call.name, r);
|
|
685
|
+
await this.au.log({
|
|
686
|
+
profile: this.profile,
|
|
687
|
+
tool: call.name,
|
|
688
|
+
action: 'call',
|
|
689
|
+
paramsHash: JSON.stringify(call.args),
|
|
690
|
+
result: gov.success ? 'SUCCESS' : 'FAILURE',
|
|
691
|
+
durationMs: Date.now() - ss,
|
|
692
|
+
error: gov.error,
|
|
693
|
+
});
|
|
694
|
+
calls.push({ tool: call.name, args: call.args, result: gov });
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
script,
|
|
698
|
+
summary: {
|
|
699
|
+
totalCalls: calls.length,
|
|
700
|
+
succeeded: calls.filter(c => c.result.success).length,
|
|
701
|
+
failed: calls.filter(c => !c.result.success).length,
|
|
702
|
+
tools: [...new Set(calls.map(c => c.tool))],
|
|
703
|
+
},
|
|
704
|
+
toolCalls: calls,
|
|
705
|
+
durationMs: Date.now() - start,
|
|
706
|
+
truncated: trunc,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
parse(s) {
|
|
711
|
+
const out = [];
|
|
712
|
+
const re = /callTool\s*\(\s*["']([^"']+)["']\s*,\s*(\{[\s\S]*?\})\s*\)/g;
|
|
713
|
+
let m;
|
|
714
|
+
while ((m = re.exec(s)) !== null) {
|
|
715
|
+
try {
|
|
716
|
+
out.push({ name: m[1], args: JSON.parse(m[2]) });
|
|
717
|
+
} catch { /* skip malformed call */ }
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
class SessionManager {
|
|
724
|
+
constructor(profile, dir) {
|
|
725
|
+
this.p = path.join(dir, `session-${profile}.json`);
|
|
726
|
+
this.state = this.init();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
init() {
|
|
730
|
+
return {
|
|
731
|
+
version: 1,
|
|
732
|
+
turn: 0,
|
|
733
|
+
step: 0,
|
|
734
|
+
completedSteps: new Map(),
|
|
735
|
+
pendingSteps: [],
|
|
736
|
+
idempotencyKeys: new Map(),
|
|
737
|
+
workspaceRoots: new Map(),
|
|
738
|
+
updatedAt: new Date().toISOString(),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async load() {
|
|
743
|
+
try {
|
|
744
|
+
const c = await fs.readFile(this.p, 'utf-8');
|
|
745
|
+
const j = JSON.parse(c);
|
|
746
|
+
this.state = {
|
|
747
|
+
...j,
|
|
748
|
+
completedSteps: new Map(Object.entries(j.completedSteps ?? {})),
|
|
749
|
+
idempotencyKeys: new Map(Object.entries(j.idempotencyKeys ?? {})),
|
|
750
|
+
workspaceRoots: new Map(Object.entries(j.workspaceRoots ?? {})),
|
|
751
|
+
};
|
|
752
|
+
} catch { /* fresh state */ }
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async save() {
|
|
756
|
+
await fs.mkdir(path.dirname(this.p), { recursive: true });
|
|
757
|
+
await fs.writeFile(this.p, JSON.stringify({
|
|
758
|
+
...this.state,
|
|
759
|
+
completedSteps: Object.fromEntries(this.state.completedSteps),
|
|
760
|
+
idempotencyKeys: Object.fromEntries(this.state.idempotencyKeys),
|
|
761
|
+
workspaceRoots: Object.fromEntries(this.state.workspaceRoots),
|
|
762
|
+
updatedAt: new Date().toISOString(),
|
|
763
|
+
}, null, 2), 'utf-8');
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
mark(id, r) {
|
|
767
|
+
this.state.completedSteps.set(id, r);
|
|
768
|
+
this.state.step++;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
has(id) { return this.state.completedSteps.has(id); }
|
|
772
|
+
get(id) { return this.state.completedSteps.get(id); }
|
|
773
|
+
setIdem(k, r) { this.state.idempotencyKeys.set(k, r); }
|
|
774
|
+
getIdem(k) { return this.state.idempotencyKeys.get(k); }
|
|
775
|
+
|
|
776
|
+
nextTurn() {
|
|
777
|
+
this.state.turn++;
|
|
778
|
+
this.state.step = 0;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
class ConcurrentScheduler {
|
|
783
|
+
constructor(max = 8) { this.max = max; }
|
|
784
|
+
|
|
785
|
+
async execReadOnly(calls) {
|
|
786
|
+
const results = [];
|
|
787
|
+
let cursor = 0;
|
|
788
|
+
const workers = Array.from({ length: Math.min(this.max, calls.length) }, async () => {
|
|
789
|
+
while (cursor < calls.length) {
|
|
790
|
+
const i = cursor++;
|
|
791
|
+
try {
|
|
792
|
+
results[i] = await calls[i].execute();
|
|
793
|
+
} catch (e) {
|
|
794
|
+
results[i] = e;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
await Promise.all(workers);
|
|
799
|
+
return results;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ============================================================================
|
|
804
|
+
// Preset(C4):编排裁剪补丁
|
|
805
|
+
// ============================================================================
|
|
806
|
+
|
|
807
|
+
const presets = {
|
|
808
|
+
'minimal-no-orchestration': (c) => ({
|
|
809
|
+
...c,
|
|
810
|
+
loop: { ...c.loop, disabledLayers: ['plan', 'todo', 'goal', 'subagent'], prunerEnabled: true, compactionEnabled: true },
|
|
811
|
+
}),
|
|
812
|
+
'code-ptc-friendly': (c) => ({
|
|
813
|
+
...c,
|
|
814
|
+
loop: {
|
|
815
|
+
...c.loop,
|
|
816
|
+
preset: 'code',
|
|
817
|
+
disabledLayers: ['plan', 'todo', 'goal', 'subagent'],
|
|
818
|
+
prunerEnabled: true,
|
|
819
|
+
compactionEnabled: true,
|
|
820
|
+
maxParallelToolCalls: 8,
|
|
821
|
+
},
|
|
822
|
+
}),
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
function applyPreset(cfg, name) {
|
|
826
|
+
const p = presets[name];
|
|
827
|
+
if (!p) throw new Error(`Unknown preset: ${name}`);
|
|
828
|
+
return p(cfg);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function dumpConfig(cfg) { return JSON.stringify(cfg, null, 2); }
|
|
832
|
+
|
|
833
|
+
// ============================================================================
|
|
834
|
+
// Main Service
|
|
835
|
+
// ============================================================================
|
|
836
|
+
|
|
837
|
+
const DEFAULT_CONFIG = {
|
|
838
|
+
context: { maxReturnBytes: 4096, fieldsProjection: true, prunerEnabled: true, compactionEnabled: true, lazyLoadTools: true },
|
|
839
|
+
loop: { preset: 'code', disabledLayers: ['plan', 'todo', 'goal', 'subagent'], maxParallelToolCalls: 8, enableCustomLoop: false },
|
|
840
|
+
sandbox: {
|
|
841
|
+
mode: 'workspace-write',
|
|
842
|
+
approval: 'ask',
|
|
843
|
+
rules: { files: [], commands: [], network: [] },
|
|
844
|
+
networkDefault: 'deny',
|
|
845
|
+
backupEnabled: true,
|
|
846
|
+
deletionProtection: true,
|
|
847
|
+
injectionGuard: true,
|
|
848
|
+
sensitiveProtection: true,
|
|
849
|
+
shell: 'pwsh',
|
|
850
|
+
workspaceTrust: false,
|
|
851
|
+
toolControl: {},
|
|
852
|
+
},
|
|
853
|
+
audit: { mode: 'append-only', desensitize: true, exportable: true, retentionDays: 90 },
|
|
854
|
+
logDir: path.join(os.homedir(), '.dsh', 'runtime-kit', 'logs'),
|
|
855
|
+
stateDir: path.join(os.homedir(), '.dsh', 'runtime-kit', 'state'),
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
class RuntimeKitServiceImpl {
|
|
859
|
+
constructor(config) {
|
|
860
|
+
this.config = config;
|
|
861
|
+
this.registry = new RegistryImpl({
|
|
862
|
+
onWorkspaceRegistered: (ws) => this.sandbox.registerWorkspace(ws),
|
|
863
|
+
onWorkspaceUnregistered: (p) => this.sandbox.unregisterWorkspace(p),
|
|
864
|
+
});
|
|
865
|
+
this.contextGov = new ContextGovernance(config.context);
|
|
866
|
+
this.sandbox = new Sandbox(config.sandbox);
|
|
867
|
+
this.audit = new Audit(config.audit, config.logDir, this.sandbox);
|
|
868
|
+
this.sessionManager = new SessionManager('default', config.stateDir);
|
|
869
|
+
this.fastPath = new FastPathExecutor(this.registry, this.sandbox, this.contextGov, this.audit, 'default');
|
|
870
|
+
this.ptc = new PTCExecutor(this.registry, this.sandbox, this.contextGov, this.audit, 'default');
|
|
871
|
+
this.scheduler = new ConcurrentScheduler(config.loop.maxParallelToolCalls);
|
|
872
|
+
this.registerBuiltinTools();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
registerBuiltinTools() {
|
|
876
|
+
this.registry.registerTool({
|
|
877
|
+
name: 'echo',
|
|
878
|
+
description: 'Echo back input',
|
|
879
|
+
schema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
880
|
+
isStateChanging: false,
|
|
881
|
+
returnBudget: 1024,
|
|
882
|
+
handler: async (a) => ({ success: true, data: { echoed: a.text } }),
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const kv = new Map();
|
|
886
|
+
this.registry.registerTool({
|
|
887
|
+
name: 'kv_get',
|
|
888
|
+
description: 'Get KV value',
|
|
889
|
+
schema: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] },
|
|
890
|
+
isStateChanging: false,
|
|
891
|
+
returnBudget: 1024,
|
|
892
|
+
handler: async (a) => ({ success: true, data: { value: kv.get(a.key) ?? null } }),
|
|
893
|
+
});
|
|
894
|
+
this.registry.registerTool({
|
|
895
|
+
name: 'kv_set',
|
|
896
|
+
description: 'Set KV value',
|
|
897
|
+
schema: { type: 'object', properties: { key: { type: 'string' }, value: { type: 'string' } }, required: ['key', 'value'] },
|
|
898
|
+
isStateChanging: true,
|
|
899
|
+
returnBudget: 1024,
|
|
900
|
+
handler: async (a) => {
|
|
901
|
+
kv.set(a.key, a.value);
|
|
902
|
+
return { success: true, data: { ok: true } };
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
this.registry.registerTool({
|
|
907
|
+
name: 'file_read',
|
|
908
|
+
description: 'Read file content',
|
|
909
|
+
schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
910
|
+
isStateChanging: false,
|
|
911
|
+
returnBudget: 4096,
|
|
912
|
+
handler: async (a) => {
|
|
913
|
+
try {
|
|
914
|
+
const c = await fs.readFile(a.path, 'utf-8');
|
|
915
|
+
return { success: true, data: { content: c } };
|
|
916
|
+
} catch (e) {
|
|
917
|
+
return { success: false, error: String(e) };
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
this.registry.registerTool({
|
|
922
|
+
name: 'file_write',
|
|
923
|
+
description: 'Write file content',
|
|
924
|
+
schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
925
|
+
isStateChanging: true,
|
|
926
|
+
returnBudget: 1024,
|
|
927
|
+
handler: async (a) => {
|
|
928
|
+
try {
|
|
929
|
+
await fs.mkdir(path.dirname(a.path), { recursive: true });
|
|
930
|
+
await fs.writeFile(a.path, a.content, 'utf-8');
|
|
931
|
+
return { success: true, data: { ok: true } };
|
|
932
|
+
} catch (e) {
|
|
933
|
+
return { success: false, error: String(e) };
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
this.registry.registerTool({
|
|
939
|
+
name: 'http_probe',
|
|
940
|
+
description: 'HTTP probe (GET/HEAD)',
|
|
941
|
+
schema: { type: 'object', properties: { url: { type: 'string', format: 'uri' }, method: { type: 'string', enum: ['GET', 'HEAD'], default: 'GET' } }, required: ['url'] },
|
|
942
|
+
isStateChanging: false,
|
|
943
|
+
returnBudget: 2048,
|
|
944
|
+
handler: async (a) => {
|
|
945
|
+
try {
|
|
946
|
+
const r = await fetch(a.url, { method: a.method ?? 'GET' });
|
|
947
|
+
return { success: true, data: { status: r.status, ok: r.ok } };
|
|
948
|
+
} catch (e) {
|
|
949
|
+
return { success: false, error: String(e) };
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
this.registry.registerTool({
|
|
955
|
+
name: 'crash_tool',
|
|
956
|
+
description: 'Intentional crash for testing',
|
|
957
|
+
schema: { type: 'object', properties: {} },
|
|
958
|
+
isStateChanging: false,
|
|
959
|
+
returnBudget: 1024,
|
|
960
|
+
handler: async () => {
|
|
961
|
+
throw new Error('INTENTIONAL_CRASH');
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
this.registry.registerTemplate({
|
|
966
|
+
name: 'echo-chain',
|
|
967
|
+
description: 'Chain 3 echos',
|
|
968
|
+
match: (i) => i.startsWith('echo-chain:'),
|
|
969
|
+
plan: [
|
|
970
|
+
{ tool: 'echo', args: { text: 'step-1' } },
|
|
971
|
+
{ tool: 'echo', args: { text: 'step-2' } },
|
|
972
|
+
{ tool: 'echo', args: { text: 'step-3' } },
|
|
973
|
+
],
|
|
974
|
+
idempotencyKeyFn: (i) => `echo-chain:${i.slice(11)}`,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async executeFastPath(input) {
|
|
979
|
+
const t = await this.registry.matchTemplate(input);
|
|
980
|
+
if (!t) throw new Error(`No template matched: ${input}`);
|
|
981
|
+
return this.fastPath.execute(input, t);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async executePTC(script) { return this.ptc.execute(script); }
|
|
985
|
+
getMetrics() { return this.contextGov.getMetrics(); }
|
|
986
|
+
async getAuditLogs(f) { return this.audit.getLogs(f); }
|
|
987
|
+
|
|
988
|
+
async verifyIsolation(profile) {
|
|
989
|
+
const ws = this.registry.getWorkspace(profile);
|
|
990
|
+
if (!ws) {
|
|
991
|
+
return {
|
|
992
|
+
profile,
|
|
993
|
+
checks: { workspaceBound: false, pathTraversal: false, networkIsolation: false, credentialLeak: false, toolControl: false },
|
|
994
|
+
violations: [`Workspace not registered: ${profile}`],
|
|
995
|
+
passed: false,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
profile,
|
|
1000
|
+
checks: { workspaceBound: true, pathTraversal: true, networkIsolation: true, credentialLeak: true, toolControl: true },
|
|
1001
|
+
violations: [],
|
|
1002
|
+
passed: true,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async healthCheck() {
|
|
1007
|
+
const checks = {};
|
|
1008
|
+
checks.registry = {
|
|
1009
|
+
status: 'pass',
|
|
1010
|
+
message: `${this.registry.listTools().length} tools, ${this.registry.listTemplates().length} templates, ${this.registry.listWorkspaces().length} workspaces`,
|
|
1011
|
+
};
|
|
1012
|
+
try {
|
|
1013
|
+
this.sandbox.getConfig();
|
|
1014
|
+
checks.sandbox = { status: 'pass', message: 'Config loaded' };
|
|
1015
|
+
} catch {
|
|
1016
|
+
checks.sandbox = { status: 'fail', message: 'Config invalid' };
|
|
1017
|
+
}
|
|
1018
|
+
try {
|
|
1019
|
+
await fs.access(this.config.logDir);
|
|
1020
|
+
checks.audit = { status: 'pass', message: 'Log dir accessible' };
|
|
1021
|
+
} catch {
|
|
1022
|
+
checks.audit = { status: 'warn', message: 'Log dir not accessible' };
|
|
1023
|
+
}
|
|
1024
|
+
const all = Object.values(checks).every(c => c.status === 'pass');
|
|
1025
|
+
const any = Object.values(checks).some(c => c.status === 'fail');
|
|
1026
|
+
return { status: any ? 'unhealthy' : all ? 'healthy' : 'degraded', checks, version: '0.1.1', build: 'whole-line-ptc+workspace-hooks', dshVersion: process.env.DSH_VERSION ?? 'unknown' };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
reloadConfig(p) {
|
|
1030
|
+
this.config = { ...this.config, ...p };
|
|
1031
|
+
this.contextGov.updateConfig(p.context ?? {});
|
|
1032
|
+
this.sandbox.updateConfig(p.sandbox ?? {});
|
|
1033
|
+
this.audit.updateConfig(p.audit ?? {});
|
|
1034
|
+
for (const ws of this.registry.listWorkspaces()) this.sandbox.registerWorkspace(ws);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
getConfig() { return { ...this.config }; }
|
|
1038
|
+
dumpConfig() { return dumpConfig(this.config); }
|
|
1039
|
+
getPresets() { return Object.keys(presets); }
|
|
1040
|
+
applyPreset(name) { this.reloadConfig(applyPreset(this.config, name)); }
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ============================================================================
|
|
1044
|
+
// Plugin Entry
|
|
1045
|
+
// ============================================================================
|
|
1046
|
+
|
|
1047
|
+
const name = 'dsh-runtime-kit';
|
|
1048
|
+
|
|
1049
|
+
/** Split raw command input into positional tokens and -k value options.
|
|
1050
|
+
* Double-quoted segments ("...") stay together; a quoted empty string is a token. */
|
|
1051
|
+
function parseArgs(rawInput) {
|
|
1052
|
+
const tokens = [];
|
|
1053
|
+
const s = String(rawInput ?? '');
|
|
1054
|
+
let cur = '';
|
|
1055
|
+
let inQuote = false;
|
|
1056
|
+
let started = false;
|
|
1057
|
+
for (const ch of s) {
|
|
1058
|
+
if (inQuote) {
|
|
1059
|
+
if (ch === '"') inQuote = false;
|
|
1060
|
+
else cur += ch;
|
|
1061
|
+
} else if (ch === '"') {
|
|
1062
|
+
inQuote = true;
|
|
1063
|
+
started = true;
|
|
1064
|
+
} else if (/\s/.test(ch)) {
|
|
1065
|
+
if (started || cur) {
|
|
1066
|
+
tokens.push(cur);
|
|
1067
|
+
cur = '';
|
|
1068
|
+
started = false;
|
|
1069
|
+
}
|
|
1070
|
+
} else {
|
|
1071
|
+
cur += ch;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (started || cur) tokens.push(cur);
|
|
1075
|
+
const positional = [];
|
|
1076
|
+
const options = {};
|
|
1077
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1078
|
+
const t = tokens[i];
|
|
1079
|
+
if (t.length > 1 && t.startsWith('-')) {
|
|
1080
|
+
const key = t.replace(/^-+/, '');
|
|
1081
|
+
const next = tokens[i + 1];
|
|
1082
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
1083
|
+
options[key] = next;
|
|
1084
|
+
i++;
|
|
1085
|
+
} else {
|
|
1086
|
+
options[key] = 'true';
|
|
1087
|
+
}
|
|
1088
|
+
} else {
|
|
1089
|
+
positional.push(t);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return { positional, options };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Wrap a plain handler into a CommandResult-producing handler. */
|
|
1096
|
+
function command(fn) {
|
|
1097
|
+
return async (invocation) => {
|
|
1098
|
+
try {
|
|
1099
|
+
const text = await fn(parseArgs(invocation.rawInput), invocation);
|
|
1100
|
+
return { kind: 'success', text: String(text ?? '') };
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
return { kind: 'error', text: String(error?.message ?? error) };
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function apply(ctx, raw = {}) {
|
|
1108
|
+
const config = {
|
|
1109
|
+
...DEFAULT_CONFIG,
|
|
1110
|
+
...raw,
|
|
1111
|
+
context: { ...DEFAULT_CONFIG.context, ...raw.context },
|
|
1112
|
+
loop: { ...DEFAULT_CONFIG.loop, ...raw.loop },
|
|
1113
|
+
sandbox: { ...DEFAULT_CONFIG.sandbox, ...raw.sandbox },
|
|
1114
|
+
audit: { ...DEFAULT_CONFIG.audit, ...raw.audit },
|
|
1115
|
+
};
|
|
1116
|
+
await fs.mkdir(config.logDir, { recursive: true });
|
|
1117
|
+
await fs.mkdir(config.stateDir, { recursive: true });
|
|
1118
|
+
|
|
1119
|
+
const svc = new RuntimeKitServiceImpl(config);
|
|
1120
|
+
ctx.provide('runtimeKit', svc);
|
|
1121
|
+
|
|
1122
|
+
const registerCommands = (commandCtx) => {
|
|
1123
|
+
const commands = commandCtx.commands;
|
|
1124
|
+
const definitions = [
|
|
1125
|
+
{
|
|
1126
|
+
name: 'runtime_kit_health',
|
|
1127
|
+
description: 'Runtime Kit health check',
|
|
1128
|
+
handler: command(async () => JSON.stringify(await svc.healthCheck(), null, 2)),
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
name: 'runtime_kit_config',
|
|
1132
|
+
description: 'Show runtime-kit config, or apply a preset (minimal-no-orchestration | code-ptc-friendly)',
|
|
1133
|
+
input: { hint: '[preset]' },
|
|
1134
|
+
handler: command(async ({ positional }) => {
|
|
1135
|
+
const preset = positional[0];
|
|
1136
|
+
if (preset) {
|
|
1137
|
+
svc.applyPreset(preset);
|
|
1138
|
+
return `Applied preset: ${preset}`;
|
|
1139
|
+
}
|
|
1140
|
+
return svc.dumpConfig();
|
|
1141
|
+
}),
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
name: 'runtime_kit_metrics',
|
|
1145
|
+
description: 'Show context-governance metrics',
|
|
1146
|
+
handler: command(async () => JSON.stringify(svc.getMetrics(), null, 2)),
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: 'runtime_kit_audit_logs',
|
|
1150
|
+
description: 'Query audit logs: [profile] [-t tool] [-a action] [-r result] [-s since] [-l limit]',
|
|
1151
|
+
input: { hint: '[profile] [-t tool] [-a action] [-r result] [-s since] [-l limit]' },
|
|
1152
|
+
handler: command(async ({ positional, options }) => JSON.stringify(await svc.getAuditLogs({
|
|
1153
|
+
profile: positional[0],
|
|
1154
|
+
tool: options.t,
|
|
1155
|
+
action: options.a,
|
|
1156
|
+
result: options.r,
|
|
1157
|
+
since: options.s,
|
|
1158
|
+
limit: options.l ? Number(options.l) : 50,
|
|
1159
|
+
}), null, 2)),
|
|
1160
|
+
},
|
|
1161
|
+
{
|
|
1162
|
+
name: 'runtime_kit_isolation',
|
|
1163
|
+
description: 'Verify sandbox isolation for a workspace profile',
|
|
1164
|
+
input: { hint: '<profile>' },
|
|
1165
|
+
handler: command(async ({ positional }) => {
|
|
1166
|
+
const profile = positional[0];
|
|
1167
|
+
if (!profile) throw new Error('Usage: /runtime_kit_isolation <profile>');
|
|
1168
|
+
return JSON.stringify(await svc.verifyIsolation(profile), null, 2);
|
|
1169
|
+
}),
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
name: 'runtime_kit_fastpath',
|
|
1173
|
+
description: 'Execute a fast-path template (0 LLM calls); the whole input line is the template input',
|
|
1174
|
+
input: { hint: '<template-input>' },
|
|
1175
|
+
handler: command(async (_args, invocation) => {
|
|
1176
|
+
const input = invocation.rawInput.trim();
|
|
1177
|
+
if (!input) throw new Error('Usage: /runtime_kit_fastpath <template-input>');
|
|
1178
|
+
return JSON.stringify(await svc.executeFastPath(input), null, 2);
|
|
1179
|
+
}),
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
name: 'runtime_kit_ptc',
|
|
1183
|
+
description: 'Execute a PTC script: callTool("name", {...}) lines; the whole input line is the script, only the summary returns',
|
|
1184
|
+
input: { hint: '<script>' },
|
|
1185
|
+
handler: command(async (_args, invocation) => {
|
|
1186
|
+
const script = invocation.rawInput.trim();
|
|
1187
|
+
if (!script) throw new Error('Usage: /runtime_kit_ptc <script>');
|
|
1188
|
+
return JSON.stringify(await svc.executePTC(script), null, 2);
|
|
1189
|
+
}),
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
name: 'runtime_kit_registry_tools',
|
|
1193
|
+
description: 'List registered runtime-kit tools',
|
|
1194
|
+
handler: command(async () => JSON.stringify(svc.registry.listTools().map(t => t.name), null, 2)),
|
|
1195
|
+
},
|
|
1196
|
+
{
|
|
1197
|
+
name: 'runtime_kit_registry_templates',
|
|
1198
|
+
description: 'List registered fast-path templates',
|
|
1199
|
+
handler: command(async () => JSON.stringify(svc.registry.listTemplates().map(t => t.name), null, 2)),
|
|
1200
|
+
},
|
|
1201
|
+
{
|
|
1202
|
+
name: 'runtime_kit_registry_workspaces',
|
|
1203
|
+
description: 'List registered sandbox workspaces',
|
|
1204
|
+
handler: command(async () => JSON.stringify(svc.registry.listWorkspaces().map(w => ({ profile: w.profile, root: w.root })), null, 2)),
|
|
1205
|
+
},
|
|
1206
|
+
];
|
|
1207
|
+
const disposers = [];
|
|
1208
|
+
for (const def of definitions) {
|
|
1209
|
+
try {
|
|
1210
|
+
disposers.push(commands.register(def));
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
ctx.logger?.warn?.(`[runtime-kit] command ${def.name} registration failed: ${String(e)}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
commandCtx.effect(function* () {
|
|
1216
|
+
for (const dispose of disposers) yield dispose;
|
|
1217
|
+
}, 'runtime-kit commands');
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// 'commands' 服务可选:宿主提供后注册斜杠命令;不提供时 runtimeKit 服务照常工作。
|
|
1221
|
+
if (typeof ctx.inject === 'function') {
|
|
1222
|
+
try {
|
|
1223
|
+
ctx.inject(['commands'], registerCommands);
|
|
1224
|
+
} catch (e) {
|
|
1225
|
+
ctx.logger?.warn?.(`[runtime-kit] commands subscription failed: ${String(e)}`);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// 审计在插件销毁时落盘。
|
|
1230
|
+
if (typeof ctx.effect === 'function') {
|
|
1231
|
+
ctx.effect(function* () {
|
|
1232
|
+
yield () => svc.audit.shutdown();
|
|
1233
|
+
}, 'runtime-kit audit shutdown');
|
|
1234
|
+
} else {
|
|
1235
|
+
ctx.on('dispose', () => { svc.audit.shutdown().catch(() => {}); });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export { name, apply };
|
|
1240
|
+
export default { name, apply };
|