- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
285 lines
9.4 KiB
Go
285 lines
9.4 KiB
Go
// opsxdb-crud-accept:MySQL + Redis 增删改查冒烟(进程内 mode=read_write,不改 mcp.json)。
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"opsxdb/server/internal/aitools"
|
||
)
|
||
|
||
func dump(v any) string {
|
||
b, _ := json.MarshalIndent(v, "", " ")
|
||
return string(b)
|
||
}
|
||
|
||
func short(v any, n int) string {
|
||
s := dump(v)
|
||
if len(s) > n {
|
||
return s[:n] + "…"
|
||
}
|
||
return s
|
||
}
|
||
|
||
type step struct {
|
||
Name string
|
||
OK bool
|
||
Note string
|
||
}
|
||
|
||
func main() {
|
||
ctx := context.Background()
|
||
mysqlID := "accept-mysql-demo"
|
||
redisID := "accept-redis-demo"
|
||
marker := fmt.Sprintf("opsxdb_crud_%d", time.Now().Unix())
|
||
email := marker + "@smoke.local"
|
||
redisKey := "opsxdb:crud:smoke:" + marker
|
||
|
||
var steps []step
|
||
record := func(name string, ok bool, note string) {
|
||
steps = append(steps, step{Name: name, OK: ok, Note: note})
|
||
tag := "OK"
|
||
if !ok {
|
||
tag = "FAIL"
|
||
}
|
||
fmt.Printf("[%s] %s — %s\n", tag, name, note)
|
||
}
|
||
|
||
// --- A) 默认 mcp.json read_only:写必须拒 ---
|
||
ro, err := aitools.DefaultRuntime()
|
||
if err != nil {
|
||
fmt.Println("FAIL: DefaultRuntime", err)
|
||
os.Exit(1)
|
||
}
|
||
_, err = ro.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "INSERT INTO users (name, email, status) VALUES ('crud_ro', '" + email + "', 0)",
|
||
})
|
||
record("MySQL INSERT under read_only", err != nil, fmt.Sprintf("err=%v", err))
|
||
|
||
_, err = ro.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "SET " + redisKey + " v1",
|
||
})
|
||
record("Redis SET under read_only", err != nil, fmt.Sprintf("err=%v", err))
|
||
|
||
// --- B) 进程内 read_write Runtime ---
|
||
rw := aitools.NewRuntime(aitools.Policy{
|
||
AllowedConnectionIDs: []string{mysqlID, redisID},
|
||
Mode: aitools.ModeReadWrite,
|
||
AllowDangerous: true, // 验收 DROP 确认路径;生产默认 false
|
||
DefaultLimit: 100,
|
||
MaxLimit: 500,
|
||
})
|
||
rw.Source = "crud-accept"
|
||
// 真库不可达时尽早退出,避免把后续的 nil 结果误报成 CRUD 逻辑故障。
|
||
if _, err := rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo", "sql": "SELECT 1 AS ready",
|
||
}); err != nil {
|
||
fmt.Printf("BLOCKED: MySQL preflight: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if _, err := rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "PING",
|
||
}); err != nil {
|
||
fmt.Printf("BLOCKED: Redis preflight: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// B1 无确认 → needConfirm
|
||
out, err := rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "INSERT INTO users (name, email, status) VALUES ('crud_pending', '" + email + "', 0)",
|
||
})
|
||
need := false
|
||
bind := ""
|
||
if err == nil {
|
||
if m, ok := out.(map[string]any); ok {
|
||
need, _ = m["needConfirm"].(bool)
|
||
bind, _ = m["confirmBind"].(string)
|
||
}
|
||
}
|
||
record("MySQL INSERT no confirm → needConfirm", err == nil && need && bind != "", fmt.Sprintf("needConfirm=%v bind=%s… out=%s", need, trunc(bind, 12), short(out, 220)))
|
||
|
||
// B1b allow_dangerous=false 时 DROP 即便确认也拒
|
||
rwSafe := aitools.NewRuntime(aitools.Policy{
|
||
AllowedConnectionIDs: []string{mysqlID, redisID},
|
||
Mode: aitools.ModeReadWrite,
|
||
AllowDangerous: false,
|
||
})
|
||
rwSafe.AllowConfirmedWrite = true
|
||
_, err = rwSafe.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "DROP TABLE IF EXISTS __opsxdb_crud_deny_ddl__", "confirm_danger": true,
|
||
})
|
||
record("MySQL DROP denied without allow_dangerous", err != nil && strings.Contains(fmt.Sprint(err), "dangerous sql disabled"), fmt.Sprintf("err=%v", err))
|
||
|
||
// B2 DROP 无确认 → needConfirm(不真执行)
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "DROP TABLE IF EXISTS __opsxdb_crud_should_deny__",
|
||
})
|
||
need = false
|
||
if err == nil {
|
||
if m, ok := out.(map[string]any); ok {
|
||
need, _ = m["needConfirm"].(bool)
|
||
}
|
||
}
|
||
record("MySQL DROP no confirm → needConfirm", err == nil && need, short(out, 200))
|
||
|
||
// B3 SELECT 基线
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "SELECT id, name, email, status FROM users ORDER BY id DESC LIMIT 5", "limit": 5,
|
||
})
|
||
record("MySQL SELECT baseline", err == nil, short(out, 400))
|
||
|
||
// B4 describe users
|
||
out, err = rw.Call(ctx, aitools.ToolDescribeTable, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo", "table": "users",
|
||
})
|
||
record("MySQL describe users", err == nil, short(out, 350))
|
||
|
||
// B5 INSERT + confirm
|
||
insSQL := "INSERT INTO users (name, email, status) VALUES ('crud_smoke', '" + email + "', 1)"
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": insSQL, "confirm_danger": true,
|
||
})
|
||
record("MySQL INSERT confirm", err == nil, short(out, 300))
|
||
|
||
// B6 SELECT 确认插入
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "SELECT id, name, email, status FROM users WHERE email = '" + email + "'",
|
||
})
|
||
var insertedID any
|
||
rowCount := 0
|
||
if err == nil {
|
||
s := dump(out)
|
||
if strings.Contains(s, `"rowCount": 1`) || strings.Contains(s, `"rowCount":1`) {
|
||
rowCount = 1
|
||
}
|
||
if strings.Contains(s, "crud_smoke") && strings.Contains(s, email) {
|
||
insertedID = "found"
|
||
}
|
||
}
|
||
record("MySQL SELECT after INSERT", err == nil && rowCount >= 1 && insertedID != nil,
|
||
fmt.Sprintf("rowCount=%d id=%v %s", rowCount, insertedID, short(out, 350)))
|
||
|
||
// B7 UPDATE + confirm
|
||
updSQL := fmt.Sprintf("UPDATE users SET name = 'crud_updated', status = 0 WHERE email = '%s'", email)
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": updSQL, "confirm_danger": true,
|
||
})
|
||
record("MySQL UPDATE confirm", err == nil, short(out, 250))
|
||
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "SELECT id, name, email, status FROM users WHERE email = '" + email + "'",
|
||
})
|
||
nameOK := false
|
||
if err == nil {
|
||
s := dump(out)
|
||
nameOK = strings.Contains(s, "crud_updated")
|
||
}
|
||
record("MySQL SELECT after UPDATE", err == nil && nameOK, short(out, 350))
|
||
|
||
// B8 DELETE + confirm(清理)
|
||
delSQL := fmt.Sprintf("DELETE FROM users WHERE email = '%s'", email)
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": delSQL, "confirm_danger": true,
|
||
})
|
||
record("MySQL DELETE confirm (cleanup)", err == nil, short(out, 250))
|
||
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||
"connection_id": mysqlID, "database": "demo",
|
||
"sql": "SELECT id FROM users WHERE email = '" + email + "'",
|
||
})
|
||
gone := false
|
||
if err == nil {
|
||
if m, ok := out.(map[string]any); ok {
|
||
switch rc := m["rowCount"].(type) {
|
||
case int:
|
||
gone = rc == 0
|
||
case float64:
|
||
gone = int(rc) == 0
|
||
}
|
||
}
|
||
}
|
||
record("MySQL SELECT after DELETE (expect 0)", err == nil && gone, short(out, 200))
|
||
|
||
// --- C) Redis ---
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "SET " + redisKey + " hello-opsxdb",
|
||
})
|
||
need = false
|
||
if err == nil {
|
||
if m, ok := out.(map[string]any); ok {
|
||
need, _ = m["needConfirm"].(bool)
|
||
}
|
||
}
|
||
record("Redis SET no confirm → needConfirm", err == nil && need, short(out, 220))
|
||
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "SET " + redisKey + " hello-opsxdb", "confirm_danger": true,
|
||
})
|
||
record("Redis SET confirm", err == nil, short(out, 220))
|
||
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "GET " + redisKey,
|
||
})
|
||
got := strings.Contains(dump(out), "hello-opsxdb")
|
||
record("Redis GET", err == nil && got, short(out, 220))
|
||
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "DEL " + redisKey, "confirm_danger": true,
|
||
})
|
||
record("Redis DEL confirm (cleanup)", err == nil, short(out, 220))
|
||
|
||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "GET " + redisKey,
|
||
})
|
||
record("Redis GET after DEL", err == nil, short(out, 200))
|
||
|
||
_, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "FLUSHDB", "confirm_danger": true,
|
||
})
|
||
record("Redis FLUSHDB always deny (even confirmed)", err != nil, fmt.Sprintf("err=%v", err))
|
||
|
||
_, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||
"connection_id": redisID, "command": "KEYS *", "confirm_danger": true,
|
||
})
|
||
record("Redis KEYS always deny", err != nil, fmt.Sprintf("err=%v", err))
|
||
|
||
// --- D) incomplete / max_steps 若可(不依赖 LLM,仅看 harness 配置字段)---
|
||
// 用极小 MaxRounds 的 Agent 需要 LLM;这里只验证 ConfirmBindHash 与 outcome 字段在包内可用。
|
||
bind2 := aitools.ConfirmBindHash(mysqlID, insSQL)
|
||
record("ConfirmBindHash non-empty", bind2 != "", "bind="+trunc(bind2, 16))
|
||
|
||
failN := 0
|
||
for _, s := range steps {
|
||
if !s.OK {
|
||
failN++
|
||
}
|
||
}
|
||
fmt.Println("---")
|
||
fmt.Printf("TOTAL %d steps, FAIL=%d marker=%s\n", len(steps), failN, marker)
|
||
if failN > 0 {
|
||
os.Exit(1)
|
||
}
|
||
fmt.Println("ALL CRUD ACCEPTANCE PASSED")
|
||
}
|
||
|
||
func trunc(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n]
|
||
}
|