dsh-db-tool 0.1.0
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/README.md +66 -0
- package/client/README.md +53 -0
- package/client/client.js +1128 -0
- package/cordis.patch.yml +6 -0
- package/dist/adapters/dmdb/index.js +326 -0
- package/dist/adapters/gaussdb/index.js +32 -0
- package/dist/adapters/index.js +41 -0
- package/dist/adapters/mongodb/index.js +508 -0
- package/dist/adapters/mysql/index.js +222 -0
- package/dist/adapters/oracle/index.js +369 -0
- package/dist/adapters/postgresql/index.js +15 -0
- package/dist/adapters/redis/index.js +492 -0
- package/dist/adapters/sql-shared/common.js +69 -0
- package/dist/adapters/sql-shared/pg-like.js +170 -0
- package/dist/adapters/sqlite/index.js +106 -0
- package/dist/adapters/types.js +1 -0
- package/dist/guard/index.js +221 -0
- package/dist/http/index.js +396 -0
- package/dist/index.js +130 -0
- package/dist/manager.js +469 -0
- package/dist/script/runner.js +147 -0
- package/dist/script/worker.cjs +104 -0
- package/dist/store/audit.js +41 -0
- package/dist/store/connections.js +167 -0
- package/dist/store/grants.js +82 -0
- package/dist/store/index.js +41 -0
- package/dist/store/io.js +66 -0
- package/dist/store/normalize.js +22 -0
- package/dist/store/secrets.js +42 -0
- package/docs/api-contract.md +76 -0
- package/docs/apple-redesign-spec.md +80 -0
- package/docs/dsh-market-submission/mengqi1436__dsh-db-tool.yml +6 -0
- package/docs/install.md +75 -0
- package/docs/review-findings.md +95 -0
- package/docs/skill.md +48 -0
- package/package.json +70 -0
- package/scripts/build-gaussdb.ps1 +72 -0
- package/scripts/build-gaussdb.sh +53 -0
- package/scripts/diag-profile.mjs +21 -0
- package/scripts/verify-host.mjs +53 -0
- package/skills/db-admin/SKILL.md +138 -0
package/dist/manager.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { getAdapter } from './adapters/index.js';
|
|
2
|
+
import { ChallengeStore, classifyStatement, } from './guard/index.js';
|
|
3
|
+
import { normalizeProjectKey, } from './store/index.js';
|
|
4
|
+
import { runScriptInChild } from './script/runner.js';
|
|
5
|
+
/* ---------------- 错误与结果类型 ---------------- */
|
|
6
|
+
/** 携带机器可读 code 的业务错误(HTTP 层直接映射 {ok:false,error,code}) */
|
|
7
|
+
export class DbToolError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = 'DbToolError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function assertNonEmpty(v, label) {
|
|
16
|
+
const s = typeof v === 'string' ? v.trim() : '';
|
|
17
|
+
if (s === '')
|
|
18
|
+
throw new DbToolError('INVALID_ARGUMENT', `缺少必填参数: ${label}`);
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
21
|
+
/* ---------------- 服务核心 ---------------- */
|
|
22
|
+
export class DbToolService {
|
|
23
|
+
store;
|
|
24
|
+
adapterCache = new Map();
|
|
25
|
+
challenges;
|
|
26
|
+
resolver;
|
|
27
|
+
disposed = false;
|
|
28
|
+
constructor(store, opts) {
|
|
29
|
+
this.store = store;
|
|
30
|
+
this.resolver = opts?.adapterResolver ?? getAdapter;
|
|
31
|
+
this.challenges = opts?.challenges ?? new ChallengeStore();
|
|
32
|
+
}
|
|
33
|
+
/* -- 连接管理(无需授权) -- */
|
|
34
|
+
listConnections() {
|
|
35
|
+
return this.store.connections.list();
|
|
36
|
+
}
|
|
37
|
+
createConnection(input) {
|
|
38
|
+
const meta = this.store.connections.create(input);
|
|
39
|
+
this.audit('', input.id, 'create_connection', input.id, 'none', false, true);
|
|
40
|
+
return meta;
|
|
41
|
+
}
|
|
42
|
+
updateConnection(id, patch) {
|
|
43
|
+
const meta = this.store.connections.update(id, patch);
|
|
44
|
+
if (!meta)
|
|
45
|
+
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
46
|
+
// url/凭证/ssl 变更后旧适配器仍持旧连接串,必须作废重建
|
|
47
|
+
void this.dropAdapters(id);
|
|
48
|
+
this.audit('', id, 'update_connection', id, 'none', false, true);
|
|
49
|
+
return meta;
|
|
50
|
+
}
|
|
51
|
+
removeConnection(id) {
|
|
52
|
+
const ok = this.store.connections.remove(id);
|
|
53
|
+
if (!ok)
|
|
54
|
+
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
55
|
+
void this.dropAdapters(id);
|
|
56
|
+
this.audit('', id, 'remove_connection', id, 'none', false, true);
|
|
57
|
+
return ok;
|
|
58
|
+
}
|
|
59
|
+
async testConnection(id) {
|
|
60
|
+
const rc = this.requireConn(id);
|
|
61
|
+
try {
|
|
62
|
+
const factory = await this.resolver(rc.meta.kind);
|
|
63
|
+
const adapter = await factory(rc);
|
|
64
|
+
try {
|
|
65
|
+
return await adapter.testConnect();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await adapter.close().catch(() => { });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
throw this.toDriverError(e);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 测试未保存的连接草稿(侧边栏「保存前测试」)。不落库、不写审计,
|
|
77
|
+
* 一次性适配器用完即关。url/fields 校验交给适配器层(与保存后测试同口径)。
|
|
78
|
+
*/
|
|
79
|
+
async testDraft(input) {
|
|
80
|
+
const rc = {
|
|
81
|
+
meta: { id: '(draft)', kind: input.kind, name: '(draft)' },
|
|
82
|
+
};
|
|
83
|
+
if (input.url !== undefined)
|
|
84
|
+
rc.url = input.url;
|
|
85
|
+
if (input.fields !== undefined)
|
|
86
|
+
rc.fields = { ...input.fields };
|
|
87
|
+
if (input.ssl !== undefined)
|
|
88
|
+
rc.ssl = input.ssl;
|
|
89
|
+
try {
|
|
90
|
+
const factory = await this.resolver(input.kind);
|
|
91
|
+
const adapter = await factory(rc);
|
|
92
|
+
try {
|
|
93
|
+
return await adapter.testConnect();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await adapter.close().catch(() => { });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
throw this.toDriverError(e);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/* -- 授权管理 -- */
|
|
104
|
+
/** 项目路径归一化(/api/project-context 无会话解析器时的回退路径) */
|
|
105
|
+
projectKey(projectPath) {
|
|
106
|
+
return normalizeProjectKey(projectPath);
|
|
107
|
+
}
|
|
108
|
+
grantsFor(projectPath) {
|
|
109
|
+
return this.store.grants.grantsFor(normalizeProjectKey(projectPath));
|
|
110
|
+
}
|
|
111
|
+
grant(projectPath, connId, mode) {
|
|
112
|
+
const key = normalizeProjectKey(projectPath);
|
|
113
|
+
this.store.grants.grant(key, connId, mode);
|
|
114
|
+
// 授权模式变更(含 rw→ro 降级)必须作废旧会话适配器,否则降级不生效
|
|
115
|
+
void this.dropAdapters(connId);
|
|
116
|
+
this.audit(key, connId, 'grant', `${connId} -> ${mode}`, 'none', false, true);
|
|
117
|
+
}
|
|
118
|
+
revokeGrant(projectPath, connId) {
|
|
119
|
+
const key = normalizeProjectKey(projectPath);
|
|
120
|
+
this.store.grants.revoke(key, connId);
|
|
121
|
+
void this.dropAdapters(connId);
|
|
122
|
+
this.audit(key, connId, 'revoke', connId, 'none', false, true);
|
|
123
|
+
}
|
|
124
|
+
/* -- 浏览(ro 即可) -- */
|
|
125
|
+
async databases(projectPath, connId) {
|
|
126
|
+
const { key, adapter } = await this.authorize(projectPath, connId, false);
|
|
127
|
+
try {
|
|
128
|
+
const result = await adapter.listDatabases();
|
|
129
|
+
this.audit(key, connId, 'databases', 'listDatabases', 'none', false, true);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
this.audit(key, connId, 'databases', 'listDatabases', 'none', false, false, this.errText(e));
|
|
134
|
+
throw this.toDriverError(e);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async tables(projectPath, connId, database) {
|
|
138
|
+
const { key, adapter } = await this.authorize(projectPath, connId, false);
|
|
139
|
+
try {
|
|
140
|
+
const result = await adapter.listTables(database);
|
|
141
|
+
this.audit(key, connId, 'tables', database ?? 'default', 'none', false, true);
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
this.audit(key, connId, 'tables', database ?? 'default', 'none', false, false, this.errText(e));
|
|
146
|
+
throw this.toDriverError(e);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async schema(projectPath, connId, table, database) {
|
|
150
|
+
const t = assertNonEmpty(table, 'table');
|
|
151
|
+
const { key, adapter } = await this.authorize(projectPath, connId, false);
|
|
152
|
+
try {
|
|
153
|
+
const result = await adapter.describeTable(t, database);
|
|
154
|
+
this.audit(key, connId, 'schema', t, 'none', false, true);
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
this.audit(key, connId, 'schema', t, 'none', false, false, this.errText(e));
|
|
159
|
+
throw this.toDriverError(e);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async preview(projectPath, connId, table, limit, database, offset) {
|
|
163
|
+
const t = assertNonEmpty(table, 'table');
|
|
164
|
+
const capped = Math.max(1, Math.min(50, Math.floor(Number(limit) || 10)));
|
|
165
|
+
const off = Math.max(0, Math.floor(Number(offset) || 0));
|
|
166
|
+
const { key, adapter } = await this.authorize(projectPath, connId, false);
|
|
167
|
+
try {
|
|
168
|
+
const result = await adapter.previewRows(t, capped, database, off);
|
|
169
|
+
this.audit(key, connId, 'preview', `PREVIEW ${t} LIMIT ${capped} OFFSET ${off}`, 'none', false, true, undefined, result.rowCount);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
this.audit(key, connId, 'preview', `PREVIEW ${t} LIMIT ${capped} OFFSET ${off}`, 'none', false, false, this.errText(e));
|
|
174
|
+
throw this.toDriverError(e);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/* -- SQL 控制台(guard + challenge) -- */
|
|
178
|
+
async query(projectPath, connId, sql, params, challengeId) {
|
|
179
|
+
const statement = assertNonEmpty(sql, 'sql');
|
|
180
|
+
return this.runGuarded({
|
|
181
|
+
projectPath,
|
|
182
|
+
connId,
|
|
183
|
+
op: 'query',
|
|
184
|
+
statement,
|
|
185
|
+
challengeId,
|
|
186
|
+
run: (adapter) => adapter.query(statement, params),
|
|
187
|
+
rowsAffected: (r) => r.rowCount,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async execute(projectPath, connId, statement, params, challengeId) {
|
|
191
|
+
const stmt = assertNonEmpty(statement, 'statement');
|
|
192
|
+
return this.runGuarded({
|
|
193
|
+
projectPath,
|
|
194
|
+
connId,
|
|
195
|
+
op: 'execute',
|
|
196
|
+
statement: stmt,
|
|
197
|
+
challengeId,
|
|
198
|
+
run: (adapter) => adapter.execute(stmt, params),
|
|
199
|
+
rowsAffected: (r) => r.affectedRows,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* run_script:子进程隔离执行(lib/script/worker.cjs + permission model + 新 vm realm)。
|
|
204
|
+
* - 会话专用适配器(不走缓存,超时即 close 中断在途调用,不污染共享缓存);
|
|
205
|
+
* - db 句柄经 IPC 回父进程,走完整 guard/challenge/审计链路;
|
|
206
|
+
* - 脚本内 NEEDS_CONFIRMATION 以 DbToolError 形式抛出(message 为 JSON),
|
|
207
|
+
* 由入口层还原为 NEEDS_CONFIRMATION 响应;
|
|
208
|
+
* - 审计后置:仅在拿到真实执行结果(成功/失败)后落盘。
|
|
209
|
+
*/
|
|
210
|
+
async runScript(projectPath, connId, code, challengeId) {
|
|
211
|
+
const src = assertNonEmpty(code, 'code');
|
|
212
|
+
// authorize(needWrite=true) 已拒绝 ro 授权;脚本内写句柄再经 runGuarded 双重校验
|
|
213
|
+
const { key, mode } = await this.authorize(projectPath, connId, true);
|
|
214
|
+
// 会话专用适配器:独立于缓存实例,脚本超时可立即 close(中断在途查询)
|
|
215
|
+
const rc = this.requireConn(connId);
|
|
216
|
+
const factory = await this.resolver(rc.meta.kind);
|
|
217
|
+
const sessionAdapter = await factory(rc, { mode }).catch((e) => {
|
|
218
|
+
throw e instanceof DbToolError ? e : this.toDriverError(e);
|
|
219
|
+
});
|
|
220
|
+
const auditStmt = src.length > 200 ? src.slice(0, 200) + '…' : src;
|
|
221
|
+
try {
|
|
222
|
+
const result = await runScriptInChild({
|
|
223
|
+
code: src,
|
|
224
|
+
dbQuery: async (sql, params) => this.unwrapForScript(await this.query(key, connId, sql, params, challengeId)),
|
|
225
|
+
dbExecute: async (statement, params) => this.unwrapForScript(await this.execute(key, connId, statement, params, challengeId)),
|
|
226
|
+
onLog: (level, text) => level === 'error' ? console.error('[db-script]', text) : console.log('[db-script]', text),
|
|
227
|
+
onTimeout: () => {
|
|
228
|
+
void sessionAdapter.close().catch(() => { });
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
this.audit(key, connId, 'script', auditStmt, 'none', false, true);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
if (!(e instanceof DbToolError && e.code === 'NEEDS_CONFIRMATION')) {
|
|
236
|
+
this.audit(key, connId, 'script', auditStmt, 'none', false, false, this.errText(e));
|
|
237
|
+
}
|
|
238
|
+
if (e instanceof DbToolError)
|
|
239
|
+
throw e;
|
|
240
|
+
throw this.toDriverError(e);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await sessionAdapter.close().catch(() => { });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/* -- 审计与状态 -- */
|
|
247
|
+
auditTail(projectPath, limit) {
|
|
248
|
+
const n = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
|
|
249
|
+
if (!projectPath || projectPath.trim() === '')
|
|
250
|
+
return this.store.audit.tail(n);
|
|
251
|
+
const key = normalizeProjectKey(projectPath);
|
|
252
|
+
return this.store.audit.tail(n).filter((e) => e.projectPathKey === key);
|
|
253
|
+
}
|
|
254
|
+
state(projectPath) {
|
|
255
|
+
const grants = projectPath && projectPath.trim() !== '' ? this.grantsFor(projectPath) : [];
|
|
256
|
+
return {
|
|
257
|
+
connections: this.listConnections(),
|
|
258
|
+
grants,
|
|
259
|
+
auditTail: this.auditTail(projectPath, 50),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** 关闭缓存的适配器并停止 challenge 清理 */
|
|
263
|
+
async dispose() {
|
|
264
|
+
this.disposed = true;
|
|
265
|
+
this.challenges.dispose();
|
|
266
|
+
const pending = [...this.adapterCache.values()];
|
|
267
|
+
this.adapterCache.clear();
|
|
268
|
+
for (const p of pending) {
|
|
269
|
+
try {
|
|
270
|
+
const a = await p;
|
|
271
|
+
await a.close().catch(() => { });
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// 缓存里的加载失败项直接忽略
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/* ---------------- 内部 ---------------- */
|
|
279
|
+
requireConn(connId) {
|
|
280
|
+
try {
|
|
281
|
+
return this.store.connections.testTarget(connId);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new DbToolError('NOT_FOUND', `连接不存在: ${connId}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** 授权校验 + 取(缓存的)适配器 */
|
|
288
|
+
async authorize(projectPath, connId, needWrite) {
|
|
289
|
+
if (this.disposed)
|
|
290
|
+
throw new DbToolError('INVALID_ARGUMENT', '服务已关闭');
|
|
291
|
+
if (!projectPath || projectPath.trim() === '') {
|
|
292
|
+
throw new DbToolError('UNAUTHORIZED_PROJECT', '业务操作需要 projectPath(匿名项目仅可管理连接)');
|
|
293
|
+
}
|
|
294
|
+
const key = normalizeProjectKey(projectPath);
|
|
295
|
+
const mode = this.store.grants.check(key, connId);
|
|
296
|
+
if (!mode)
|
|
297
|
+
throw new DbToolError('UNAUTHORIZED_PROJECT', `项目未授权该连接: ${connId}`);
|
|
298
|
+
if (needWrite && mode === 'ro') {
|
|
299
|
+
throw new DbToolError('READ_ONLY', '该连接对本项目为只读授权(ro),拒绝写操作');
|
|
300
|
+
}
|
|
301
|
+
const adapter = await this.adapterFor(connId, mode);
|
|
302
|
+
return { key, mode, adapter };
|
|
303
|
+
}
|
|
304
|
+
/** 关闭指定连接的全部缓存适配器(mode 变更/凭证变更/删除时) */
|
|
305
|
+
async dropAdapters(connId) {
|
|
306
|
+
const keys = [...this.adapterCache.keys()].filter((k) => k.startsWith(`${connId}@mode=`));
|
|
307
|
+
for (const k of keys) {
|
|
308
|
+
const p = this.adapterCache.get(k);
|
|
309
|
+
this.adapterCache.delete(k);
|
|
310
|
+
if (!p)
|
|
311
|
+
continue;
|
|
312
|
+
try {
|
|
313
|
+
const a = await p;
|
|
314
|
+
await a.close().catch(() => { });
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// 加载失败或已断开的旧实例直接忽略
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
adapterFor(connId, mode) {
|
|
322
|
+
const cacheKey = `${connId}@mode=${mode}`;
|
|
323
|
+
let p = this.adapterCache.get(cacheKey);
|
|
324
|
+
if (!p) {
|
|
325
|
+
p = (async () => {
|
|
326
|
+
const rc = this.requireConn(connId);
|
|
327
|
+
const factory = await this.resolver(rc.meta.kind);
|
|
328
|
+
return factory(rc, { mode });
|
|
329
|
+
})().catch((e) => {
|
|
330
|
+
this.adapterCache.delete(cacheKey);
|
|
331
|
+
throw e instanceof DbToolError ? e : this.toDriverError(e);
|
|
332
|
+
});
|
|
333
|
+
this.adapterCache.set(cacheKey, p);
|
|
334
|
+
}
|
|
335
|
+
return p;
|
|
336
|
+
}
|
|
337
|
+
/** guard 主流程:分类 → challenge → 执行 → 审计 */
|
|
338
|
+
async runGuarded(args) {
|
|
339
|
+
const { projectPath, connId, op, statement, challengeId } = args;
|
|
340
|
+
if (args.params !== undefined && !Array.isArray(args.params)) {
|
|
341
|
+
throw new DbToolError('INVALID_ARGUMENT', 'params 必须是数组(绑定参数)');
|
|
342
|
+
}
|
|
343
|
+
const { key, adapter } = await this.authorize(projectPath, connId, op === 'execute');
|
|
344
|
+
const kind = adapter.kind;
|
|
345
|
+
const verdict = classifyStatement(kind, statement, op);
|
|
346
|
+
if (verdict.level === 'danger') {
|
|
347
|
+
const scope = { connId, projectKey: key };
|
|
348
|
+
if (!challengeId) {
|
|
349
|
+
const { id: cid } = this.challenges.create(statement, scope);
|
|
350
|
+
this.audit(key, connId, op, statement, 'danger', false, false, 'NEEDS_CONFIRMATION');
|
|
351
|
+
return { needConfirmation: true, challengeId: cid, statement, danger: 'danger', reason: verdict.reason ?? '危险操作,需要用户确认' };
|
|
352
|
+
}
|
|
353
|
+
if (!this.challenges.consume(challengeId, statement, scope)) {
|
|
354
|
+
this.audit(key, connId, op, statement, 'danger', false, false, 'INVALID_CHALLENGE');
|
|
355
|
+
throw new DbToolError('INVALID_CHALLENGE', '确认凭据无效(不存在、已使用、已过期或语句已变更),请重新发起');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const result = await args.run(adapter);
|
|
360
|
+
this.audit(key, connId, op, statement, verdict.level, verdict.level === 'danger', true, undefined, args.rowsAffected?.(result));
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
this.audit(key, connId, op, statement, verdict.level, verdict.level === 'danger', false, this.errText(e));
|
|
365
|
+
throw this.toDriverError(e);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** 脚本内句柄:NeedConfirm 无法在脚本中交互,转为可还原的 DbToolError */
|
|
369
|
+
unwrapForScript(r) {
|
|
370
|
+
if (r && typeof r === 'object' && r.needConfirmation === true) {
|
|
371
|
+
const nc = r;
|
|
372
|
+
throw new DbToolError('NEEDS_CONFIRMATION', JSON.stringify(nc));
|
|
373
|
+
}
|
|
374
|
+
return r;
|
|
375
|
+
}
|
|
376
|
+
audit(projectPathKey, connId, action, statement, danger, confirmed, ok, error, rowsAffected) {
|
|
377
|
+
try {
|
|
378
|
+
this.store.audit.append({
|
|
379
|
+
projectPathKey, connId, action, statement, danger, confirmed, ok,
|
|
380
|
+
...(error !== undefined ? { error } : {}),
|
|
381
|
+
...(rowsAffected !== undefined ? { rowsAffected } : {}),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// 审计失败不阻塞业务(best-effort)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
errText(e) {
|
|
389
|
+
return e instanceof Error ? e.message : String(e);
|
|
390
|
+
}
|
|
391
|
+
toDriverError(e) {
|
|
392
|
+
if (e instanceof DbToolError)
|
|
393
|
+
return e;
|
|
394
|
+
return new DbToolError('DRIVER_ERROR', this.errText(e));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function pick(args, snake) {
|
|
398
|
+
return args[snake] ?? (snake === 'conn_id' ? args.connId : args.challengeId);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 工具 action 分发。危险待确认时文本内含确认指引,
|
|
402
|
+
* 提示模型向用户 ask 确认后带 challenge_id 重试。
|
|
403
|
+
* 返回 { text, isError } 对象:harness 工具契约要求结构化结果,
|
|
404
|
+
* isError=true 仅用于被拒/失败;NEEDS_CONFIRMATION 不是失败。
|
|
405
|
+
*/
|
|
406
|
+
export async function handleToolAction(service, args, projectPath) {
|
|
407
|
+
const action = args.action;
|
|
408
|
+
const connId = pick(args, 'conn_id');
|
|
409
|
+
const challengeId = pick(args, 'challenge_id');
|
|
410
|
+
const j = (v) => JSON.stringify(v, null, 2);
|
|
411
|
+
const ok = (t) => ({ text: t, isError: false });
|
|
412
|
+
try {
|
|
413
|
+
switch (action) {
|
|
414
|
+
case 'list_connections':
|
|
415
|
+
return ok(j(service.listConnections()));
|
|
416
|
+
case 'query': {
|
|
417
|
+
const r = await service.query(projectPath, requireConn(connId), assertArg(args.sql, 'sql'), args.params, challengeId);
|
|
418
|
+
return ok(needConfirmText(r) ?? j(r));
|
|
419
|
+
}
|
|
420
|
+
case 'execute': {
|
|
421
|
+
const r = await service.execute(projectPath, requireConn(connId), assertArg(args.statement ?? args.sql, 'statement'), args.params, challengeId);
|
|
422
|
+
return ok(needConfirmText(r) ?? j(r));
|
|
423
|
+
}
|
|
424
|
+
case 'schema': {
|
|
425
|
+
const conn = requireConn(connId);
|
|
426
|
+
if (args.table)
|
|
427
|
+
return ok(j(await service.schema(projectPath, conn, args.table, args.database)));
|
|
428
|
+
if (args.database)
|
|
429
|
+
return ok(j(await service.tables(projectPath, conn, args.database)));
|
|
430
|
+
return ok(j(await service.databases(projectPath, conn)));
|
|
431
|
+
}
|
|
432
|
+
case 'preview': {
|
|
433
|
+
const r = await service.preview(projectPath, requireConn(connId), assertArg(args.table, 'table'), args.limit, args.database, args.offset);
|
|
434
|
+
return ok(j(r));
|
|
435
|
+
}
|
|
436
|
+
case 'run_script': {
|
|
437
|
+
const r = await service.runScript(projectPath, requireConn(connId), assertArg(args.code, 'code'), challengeId);
|
|
438
|
+
return ok(needConfirmText(r) ?? j(r));
|
|
439
|
+
}
|
|
440
|
+
default:
|
|
441
|
+
throw new DbToolError('INVALID_ARGUMENT', `未知 action: ${action}(可用: list_connections/query/execute/schema/preview/run_script)`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
const code = e instanceof DbToolError ? e.code : 'DRIVER_ERROR';
|
|
446
|
+
return { text: j({ ok: false, error: e instanceof Error ? e.message : String(e), code }), isError: true };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function requireConn(connId) {
|
|
450
|
+
if (!connId || connId.trim() === '')
|
|
451
|
+
throw new DbToolError('INVALID_ARGUMENT', '缺少必填参数: conn_id');
|
|
452
|
+
return connId;
|
|
453
|
+
}
|
|
454
|
+
function assertArg(v, label) {
|
|
455
|
+
if (!v || v.trim() === '')
|
|
456
|
+
throw new DbToolError('INVALID_ARGUMENT', `缺少必填参数: ${label}`);
|
|
457
|
+
return v;
|
|
458
|
+
}
|
|
459
|
+
function needConfirmText(r) {
|
|
460
|
+
if (!(r && typeof r === 'object' && r.needConfirmation === true))
|
|
461
|
+
return null;
|
|
462
|
+
const nc = r;
|
|
463
|
+
return JSON.stringify({
|
|
464
|
+
ok: false,
|
|
465
|
+
code: 'NEEDS_CONFIRMATION',
|
|
466
|
+
...nc,
|
|
467
|
+
hint: `该操作危险(${nc.reason})。请先向用户说明并征得确认,用户同意后携带 challenge_id=${nc.challengeId} 重试同一语句;challenge 一次性、5 分钟内有效、绑定本语句。`,
|
|
468
|
+
}, null, 2);
|
|
469
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_script 子进程执行器。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 node:vm 同进程沙箱:node:vm 官方明确不是安全边界,脚本可经
|
|
5
|
+
* Function constructor 等已知路径逃逸到宿主 realm 拿到 process/require,
|
|
6
|
+
* 读取 secrets.json 与任意文件。子进程 + Node permission model(禁 fs)+
|
|
7
|
+
* 新 vm realm 双层隔离:vm 逃逸后仍是无文件权限的 worker 进程。
|
|
8
|
+
* 超时用 SIGKILL 进程级强杀(vm timeout 管不到异步调用),并对会话专用
|
|
9
|
+
* 适配器立即 close,中断在途调用。
|
|
10
|
+
*/
|
|
11
|
+
import { fork } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { DbToolError } from '../manager.js';
|
|
15
|
+
const WORKER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.cjs');
|
|
16
|
+
/** permission model 的版本兼容 flag:Node ≥22 用稳定名,20/21 用 experimental 别名 */
|
|
17
|
+
function permissionFlags() {
|
|
18
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
19
|
+
return major >= 22 ? ['--permission'] : ['--experimental-permission'];
|
|
20
|
+
}
|
|
21
|
+
/** worker 启动/运行所需的环境变量白名单(Windows/Unix 各取所需),其余一律不透传 */
|
|
22
|
+
const ENV_ALLOWLIST = [
|
|
23
|
+
'PATH', 'SYSTEMROOT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'COMSPEC', 'PATHEXT',
|
|
24
|
+
'USERPROFILE', 'HOME', 'LANG', 'TZ',
|
|
25
|
+
];
|
|
26
|
+
/** 最小化环境变量:--permission 只禁 fs,脚本仍可读 process.env,故 fork 时按白名单裁剪 */
|
|
27
|
+
export function minimalEnv() {
|
|
28
|
+
const env = {};
|
|
29
|
+
for (const key of ENV_ALLOWLIST) {
|
|
30
|
+
const value = process.env[key];
|
|
31
|
+
if (value !== undefined)
|
|
32
|
+
env[key] = value;
|
|
33
|
+
}
|
|
34
|
+
return env;
|
|
35
|
+
}
|
|
36
|
+
export async function runScriptInChild(opts) {
|
|
37
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
38
|
+
const child = fork(WORKER_PATH, [], {
|
|
39
|
+
execArgv: permissionFlags(),
|
|
40
|
+
env: minimalEnv(),
|
|
41
|
+
stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
|
|
42
|
+
});
|
|
43
|
+
const pendingDb = new Map();
|
|
44
|
+
let dbSeq = 0;
|
|
45
|
+
let settled = false;
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
if (settled)
|
|
49
|
+
return;
|
|
50
|
+
settled = true;
|
|
51
|
+
child.kill('SIGKILL');
|
|
52
|
+
opts.onTimeout?.();
|
|
53
|
+
reject(new DbToolError('SCRIPT_TIMEOUT', `脚本执行超时(${Math.round(timeoutMs / 1000)}s),已强制终止并关闭本次会话连接`));
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
timer.unref?.();
|
|
56
|
+
child.on('message', (raw) => {
|
|
57
|
+
if (!raw || typeof raw !== 'object')
|
|
58
|
+
return;
|
|
59
|
+
const msg = raw;
|
|
60
|
+
if (msg.type === 'db') {
|
|
61
|
+
const reqId = Number(msg.reqId);
|
|
62
|
+
// 先注册回包通道再发起调用(同一处理器内,保证时序)
|
|
63
|
+
pendingDb.set(reqId, (r) => {
|
|
64
|
+
child.send({ type: 'dbResult', reqId, ...r }, () => {
|
|
65
|
+
/* 忽略回包失败(worker 可能已死) */
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
void handleDbCall(String(msg.method ?? ''), Array.isArray(msg.args) ? msg.args : [])
|
|
69
|
+
.then((result) => pendingDb.get(reqId)?.({ ok: true, result }))
|
|
70
|
+
.catch((e) => pendingDb.get(reqId)?.({ ok: false, error: errorOf(e) }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (msg.type === 'log') {
|
|
74
|
+
opts.onLog?.(msg.level === 'error' ? 'error' : 'log', String(msg.text ?? ''));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (msg.type === 'done' && !settled) {
|
|
78
|
+
settled = true;
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
child.kill();
|
|
81
|
+
if (msg.ok) {
|
|
82
|
+
resolve(msg.result);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const err = msg.error ?? {};
|
|
86
|
+
if (err.code === 'NEEDS_CONFIRMATION' && err.payload) {
|
|
87
|
+
reject(needConfirmFromPayload(err.payload));
|
|
88
|
+
}
|
|
89
|
+
else if (err.code === 'SCRIPT_TIMEOUT') {
|
|
90
|
+
reject(new DbToolError('SCRIPT_TIMEOUT', err.message ?? '脚本执行超时'));
|
|
91
|
+
}
|
|
92
|
+
else if (err.code === 'READ_ONLY') {
|
|
93
|
+
reject(new DbToolError('READ_ONLY', err.message ?? '该连接为只读授权,拒绝写操作'));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const e = new Error(err.message ?? '脚本执行失败');
|
|
97
|
+
reject(e);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
child.on('exit', (code, signal) => {
|
|
103
|
+
if (settled)
|
|
104
|
+
return;
|
|
105
|
+
settled = true;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(new Error(`脚本进程异常退出(code=${code ?? 'null'}${signal ? `, signal=${signal}` : ''})`));
|
|
108
|
+
});
|
|
109
|
+
child.on('error', (e) => {
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
settled = true;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
reject(new DbToolError('DRIVER_ERROR', `脚本进程启动失败: ${e.message}`));
|
|
115
|
+
});
|
|
116
|
+
child.send({ type: 'run', code: opts.code }, () => {
|
|
117
|
+
/* 入队即可 */
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
async function handleDbCall(method, args) {
|
|
121
|
+
const [statement, params] = args;
|
|
122
|
+
if (method === 'query')
|
|
123
|
+
return opts.dbQuery(statement, params);
|
|
124
|
+
if (method === 'execute')
|
|
125
|
+
return opts.dbExecute(statement, params);
|
|
126
|
+
throw new DbToolError('INVALID_ARGUMENT', `未知 db 方法: ${method}`);
|
|
127
|
+
}
|
|
128
|
+
function errorOf(e) {
|
|
129
|
+
if (e instanceof DbToolError) {
|
|
130
|
+
if (e.code === 'NEEDS_CONFIRMATION') {
|
|
131
|
+
let payload;
|
|
132
|
+
try {
|
|
133
|
+
payload = JSON.parse(e.message);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
payload = undefined;
|
|
137
|
+
}
|
|
138
|
+
return { code: e.code, message: e.message, payload };
|
|
139
|
+
}
|
|
140
|
+
return { code: e.code, message: e.message };
|
|
141
|
+
}
|
|
142
|
+
return { message: e instanceof Error ? e.message : String(e) };
|
|
143
|
+
}
|
|
144
|
+
function needConfirmFromPayload(payload) {
|
|
145
|
+
return new DbToolError('NEEDS_CONFIRMATION', JSON.stringify(payload));
|
|
146
|
+
}
|
|
147
|
+
}
|