fix: transport escalation exempts log reads, get_entity accepts slug alias, add restart_service + push_file tools
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

P0: Transport escalation in classifyAndGate (server.go:745) now exempts
    log-inspection commands (tail/head/cat/journalctl on *.log or /logs/)
    from the /opt//etc//var/lib/ gating on LXC targets. Fixes the
    'tail -3 /opt/seanime/data/logs/seanime.log queued for approval' bug.

P1: queryEntity returns actionable error when slug_or_id param is empty
    ('slug_or_id is required') instead of silent 'entity not found: '.

P2: Added slugArg() helper (server.go) so get_entity, get_relations,
    get_blast_radius, and explain accept 'slug' as an alias for their
    declared param name. Solves the discoverability inconsistency where
    every tool used a different param name for the same concept.

P3: Two new MCP tools:
    - restart_service(target, service) — systemctl restart wrapper,
      correctly classified config_mutation (requires approval)
    - push_file(target, source_path, dest_path, backup=true) — pct push
      from Proxmox host into LXC, with optional backup. Classified
      config_mutation. LXC-only for now.

P4: Updated homelab-lxc-ops skill with MCP tools preference table.

Plus: Wails desktop build now uses build-tag approach for frontend embed
      (assets_embed.go + assets_stub.go), so go build ./... works on
      clean checkout without the frontend built first.

Version: 0.31.0 → 0.32.0
This commit is contained in:
2026-08-15 17:38:26 +02:00
parent 7160eee1e1
commit 8fbe39cf2a
11 changed files with 162 additions and 12 deletions

View File

@@ -595,5 +595,82 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
}
return textResult(fmt.Sprintf("secret %s stored", key)), nil
}},
{tool: &mcp.Tool{Name: "restart_service", Description: "Restart a systemd service on a host or LXC. Wraps `systemctl restart <service>` with correct risk classification (config_mutation — requires operator approval). Prefer this over raw `run` for restarts: it resolves the target, sets the risk class, and records a clean execution row.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug (e.g. lxc:seanime, host:hubris)"},
prop{"service", "string", "systemd unit name (e.g. seanime, caddy)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
targetSlug := slugArg(args, "target", "service_slug", "slug")
service := slugArg(args, "service", "unit")
if targetSlug == "" || service == "" {
return textResult("error: target and service are required"), nil
}
sessionID, _ := args["_session_id"].(string)
var targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
}
command := fmt.Sprintf("systemctl restart %s", service)
purpose := fmt.Sprintf("restart %s on %s", service, targetSlug)
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, "config_mutation", sessionID), nil
}},
{tool: &mcp.Tool{Name: "push_file", Description: "Copy a file from a Proxmox host into an LXC container via `pct push`. The source path must already exist on the Proxmox host that owns the LXC (stage it there first via `run` on the host, e.g. with scp/curl/wget). Classified config_mutation — requires operator approval. Prefer this over manual 3-hop SSH piping (`cat | ssh | pct exec tee`), which repeatedly drops into partial-write/text-file-busy states.",
InputSchema: objSchema(
prop{"target", "string", "LXC entity slug (e.g. lxc:seanime)"},
prop{"source_path", "string", "Path on the Proxmox host (e.g. /tmp/seanime)"},
prop{"dest_path", "string", "Destination path inside the container (e.g. /opt/seanime/bin/seanime)"},
prop{"backup", "boolean", "If true, copy the existing dest to dest.bak inside the container first (default true)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
targetSlug := slugArg(args, "target", "slug")
sourcePath := slugArg(args, "source_path")
destPath := slugArg(args, "dest_path")
backup := true
if b, ok := args["backup"].(bool); ok {
backup = b
}
if targetSlug == "" || sourcePath == "" || destPath == "" {
return textResult("error: target, source_path, and dest_path are required"), nil
}
if !strings.HasPrefix(targetSlug, "lxc:") {
return textResult(fmt.Sprintf("error: push_file only supports lxc: targets for now (got %s) — host/vm push needs scp, not yet wired", targetSlug)), nil
}
// Resolve pve_id and the owning Proxmox host (same chain as get_lxc_state).
var pveID string
if err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", targetSlug).Scan(&pveID); err != nil || pveID == "" {
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", targetSlug)), nil
}
var hostID uuid.UUID
err := pool.QueryRow(ctx, `
SELECT t.id FROM entities t
JOIN relationships r ON r.source_id = t.id
JOIN entities s ON s.id = r.target_id
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
LIMIT 1`, targetSlug).Scan(&hostID)
var hostSlug string
if err == nil {
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
} else {
pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", targetSlug).Scan(&hostSlug)
}
if hostSlug == "" {
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", targetSlug)), nil
}
cmd := fmt.Sprintf("pct push %s %s %s", pveID, sourcePath, destPath)
if backup {
cmd = fmt.Sprintf("pct exec %s -- cp -n %s %s.bak 2>/dev/null || true; %s", pveID, destPath, destPath, cmd)
}
sessionID, _ := args["_session_id"].(string)
var hostEntityID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", "host:"+hostSlug).Scan(&hostEntityID); err != nil {
// hostSlug may already carry the host: prefix
if err2 := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", hostSlug).Scan(&hostEntityID); err2 != nil {
return textResult(fmt.Sprintf("host entity not found: %s", hostSlug)), nil
}
}
purpose := fmt.Sprintf("push %s into %s at %s", sourcePath, targetSlug, destPath)
return classifyAndGate(ctx, pool, agentID, hostEntityID, "host:"+hostSlug, cmd, purpose, "config_mutation", sessionID), nil
}},
}
}