fix: make Nomos actually provision LXCs from chat (pct_create + web fetch)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Root cause of "asks permission but never acts": the approved pct_create
execution failed to parse because the LLM emitted `"privileged":0` /
`"nesting":1` (numbers) into strict `bool` fields, so the container was
never created. Compounded by a hardcoded template name (debian-13.0-1)
that no longer exists on the host, and no way for the agent to read the web.

- flexBool: accept 0/1, "true", bool for privileged/nesting (the exact prod failure)
- pct_create template pre-flight: list host cache, validate/auto-pick newest debian
- pct_create services[] + post_install: one approval provisions a working service
- new http_get MCP tool (sanitized, size-capped, SSRF-guarded) — agent can read repos/sites
- request_execution description: target=host, full JSON schema + example
- SOUL.md: agent CAN fetch the web; prefer one-step provisioning
- default model deepseek-v4-flash -> v4-pro; maxIterations 15 -> 25
- unit tests for flexBool, template resolve, pkg sanitize, HTML sanitize + SSRF block

Verified live on host:strong with a throwaway VMID 999: template auto-resolved,
container created + booted, services installed, post_install ran, then destroyed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 00:28:56 +02:00
parent a567930466
commit b37f85ae08
8 changed files with 383 additions and 15 deletions

View File

@@ -6,9 +6,14 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
@@ -261,11 +266,11 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
nStr(args["status"])), nil
})
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create.",
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create. pct_create provisions a NEW LXC and installs its service in one approved step — you do NOT need follow-up pct_exec calls for package installs.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug (e.g. lxc:caddy, host:strong)"},
prop{"target", "string", "Target entity slug. For pct_create this MUST be the Proxmox HOST that will run the container (e.g. host:strong) — NOT the new LXC's name. For restart/systemctl/apt_upgrade/pct_exec use the target service/LXC slug (e.g. lxc:caddy)."},
prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade, pct_create"},
prop{"params", "string", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade', for pct_create use JSON config (see docs)"},
prop{"params", "string", "For systemctl: 'enable|disable|reload'. For pct_exec: the shell command. For apt_upgrade: 'audit|upgrade'. For pct_create: a JSON object string with keys: vmid (int, required, unused id), hostname (string, required), cores (int), memory (MB int), disk_gb (int), ip (CIDR e.g. 192.168.8.50/24), gw (gateway ip), storage (default local-lvm), template (optional — omit to auto-pick newest debian on the host), privileged (bool), nesting (bool), mounts ([]string of 'src,mp=/dst'), services ([]string of apt packages to install), post_install (string shell script run inside the container after create). Example: {\"vmid\":150,\"hostname\":\"typetype\",\"cores\":2,\"memory\":2048,\"disk_gb\":16,\"ip\":\"192.168.8.50/24\",\"gw\":\"192.168.8.2\",\"nesting\":true,\"services\":[\"docker.io\",\"git\"],\"post_install\":\"git clone https://github.com/x/y /opt/y && cd /opt/y && docker compose up -d\"}"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
@@ -402,6 +407,16 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}
})
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
InputSchema: objSchema(
prop{"url", "string", "Absolute http(s) URL to fetch"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
rawURL, _ := args["url"].(string)
return httpGet(ctx, rawURL), nil
})
register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
InputSchema: objSchema(
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
@@ -999,6 +1014,76 @@ func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
}
// htmlTagRe strips HTML tags for the naive text extraction in httpGet.
var htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?</(script|style)>|<[^>]+>`)
// httpGet fetches a public URL and returns sanitized, size-capped text so the
// agent can read a service's README/site before provisioning. Guards: scheme
// allow-list, request timeout, 16KB body cap, and blocking of RFC1918/loopback
// hosts to avoid using the tool as an SSRF pivot into the private mesh.
func httpGet(ctx context.Context, rawURL string) *mcp.CallToolResult {
if rawURL == "" {
return textResult("error: url required")
}
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return textResult("error: url must be an absolute http(s) URL")
}
if isPrivateHost(u.Hostname()) {
return textResult("error: refusing to fetch private/loopback address")
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
hreq, err := http.NewRequestWithContext(cctx, http.MethodGet, u.String(), nil)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err))
}
hreq.Header.Set("User-Agent", "oikos-nomos/1.0 (+homelab agent)")
hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(hreq)
if err != nil {
return textResult(fmt.Sprintf("error: fetch failed: %v", err))
}
defer resp.Body.Close()
const cap = 256 * 1024 // read a bit extra pre-strip; final output capped below
body, _ := io.ReadAll(io.LimitReader(resp.Body, cap))
ct := resp.Header.Get("Content-Type")
text := sanitizeBody(ct, string(body))
return textResult(fmt.Sprintf("GET %s → %d %s\n\n%s", u.String(), resp.StatusCode, ct, text))
}
// sanitizeBody strips scripts/styles/tags from HTML, unescapes entities,
// collapses whitespace, and caps the result to ~16KB of readable text.
func sanitizeBody(contentType, raw string) string {
text := raw
if strings.Contains(contentType, "html") {
text = htmlTagRe.ReplaceAllString(text, " ")
text = html.UnescapeString(text)
text = strings.Join(strings.Fields(text), " ")
}
if len(text) > 16*1024 {
text = text[:16*1024] + "\n…[truncated]"
}
return text
}
// isPrivateHost reports whether host is loopback, link-local, or RFC1918.
func isPrivateHost(host string) bool {
host = strings.ToLower(host)
if host == "localhost" || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false // hostname; DNS may still resolve private — acceptable for a homelab tool
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
p := map[string]any{"action": action, "params": params, "execution_id": execID.String()}
payload, _ := json.Marshal(p)