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

@@ -15,7 +15,10 @@ import (
"github.com/openai/openai-go/shared"
)
const maxIterations = 15
// maxIterations bounds one chat turn's tool-calling loop. Provisioning a
// service is a long chain (research → plan → request_execution → status), so
// 15 was too tight and turns died with "max iterations reached" mid-deploy.
const maxIterations = 25
const maxLLMRetries = 1
var refusalDenylist = []string{
@@ -41,7 +44,10 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
apiKey := os.Getenv("OPENROUTER_API_KEY")
model := os.Getenv("NOMOS_MODEL")
if model == "" {
model = "deepseek/deepseek-v4-flash"
// v4-pro over v4-flash: the flash tier over-narrates, occasionally
// emits canned refusals, and is unreliable at multi-step tool use —
// exactly the agentic provisioning path the operator needs to work.
model = "deepseek/deepseek-v4-pro"
}
provider := openai.NewClient(

View File

@@ -19,7 +19,7 @@ COPY nomos/ /app/nomos/
ENV NOMOS_MCP_URL=http://api:8090/mcp
ENV NOMOS_AGENT_SLUG=agent:nomos
ENV NOMOS_LISTEN=:8092
ENV NOMOS_MODEL=deepseek/deepseek-v4-flash
ENV NOMOS_MODEL=deepseek/deepseek-v4-pro
EXPOSE 8092

View File

@@ -127,7 +127,7 @@ services:
NOMOS_MCP_URL: http://api:8090/mcp
NOMOS_AGENT_SLUG: agent:nomos
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-flash}
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
ports:
- "8092:8092"

View File

@@ -0,0 +1,83 @@
package httpapi
import (
"encoding/json"
"testing"
)
// TestFlexBoolUnmarshal covers the exact production failure: the LLM emitted
// `"privileged":0` / `"nesting":1` (numbers) and the strict bool field made the
// already-approved pct_create execution fail to parse, so the LXC was never
// created.
func TestFlexBoolUnmarshal(t *testing.T) {
type cfg struct {
Privileged flexBool `json:"privileged"`
Nesting flexBool `json:"nesting"`
}
cases := []struct {
in string
privileged bool
nesting bool
wantErr bool
}{
{`{"privileged":0,"nesting":1}`, false, true, false}, // the prod payload
{`{"privileged":false,"nesting":true}`, false, true, false}, // canonical
{`{"privileged":"1","nesting":"0"}`, true, false, false}, // stringified
{`{"privileged":"true","nesting":"no"}`, true, false, false},
{`{}`, false, false, false}, // absent → zero
{`{"privileged":"maybe"}`, false, false, true},
}
for _, c := range cases {
var out cfg
err := json.Unmarshal([]byte(c.in), &out)
if (err != nil) != c.wantErr {
t.Fatalf("%s: err=%v wantErr=%v", c.in, err, c.wantErr)
}
if c.wantErr {
continue
}
if bool(out.Privileged) != c.privileged || bool(out.Nesting) != c.nesting {
t.Errorf("%s: got priv=%v nest=%v want priv=%v nest=%v",
c.in, bool(out.Privileged), bool(out.Nesting), c.privileged, c.nesting)
}
}
}
func TestResolveTemplate(t *testing.T) {
avail := []string{
"debian-12-standard_12.7-1_amd64.tar.zst",
"debian-13-standard_13.0-1_amd64.tar.zst",
"ubuntu-24.04-standard_24.04-2_amd64.tar.zst",
}
cases := []struct {
requested string
want string
}{
{"debian-13-standard_13.0-1_amd64.tar.zst", "debian-13-standard_13.0-1_amd64.tar.zst"}, // exact
{"debian-13", "debian-13-standard_13.0-1_amd64.tar.zst"}, // prefix
{"", "debian-13-standard_13.0-1_amd64.tar.zst"}, // auto newest debian
{"debian-99", "debian-13-standard_13.0-1_amd64.tar.zst"}, // miss prefix → auto debian
}
for _, c := range cases {
if got := resolveTemplate(c.requested, avail); got != c.want {
t.Errorf("resolveTemplate(%q): got %q want %q", c.requested, got, c.want)
}
}
if got := resolveTemplate("debian-13", nil); got != "" {
t.Errorf("empty cache should yield empty, got %q", got)
}
}
func TestSanitizePkgs(t *testing.T) {
in := []string{"docker.io", "git", "rm -rf /", "curl;wget", "python3-pip", ""}
got := sanitizePkgs(in)
want := map[string]bool{"docker.io": true, "git": true, "python3-pip": true}
if len(got) != len(want) {
t.Fatalf("got %v want keys %v", got, want)
}
for _, g := range got {
if !want[g] {
t.Errorf("unexpected package survived sanitize: %q", g)
}
}
}

View File

@@ -3,6 +3,7 @@ package httpapi
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
@@ -27,6 +28,26 @@ var (
_sshKey []byte
)
// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes").
// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool`
// field made the approved pct_create execution fail to parse *after* the
// operator had already approved it — the container was never created and the
// operator saw "queued" with no result. This type tolerates the common shapes.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(strings.Trim(string(data), `"`))
switch strings.ToLower(s) {
case "true", "1", "yes", "on":
*b = true
case "false", "0", "no", "off", "", "null":
*b = false
default:
return fmt.Errorf("cannot parse %q as bool", s)
}
return nil
}
func initSSH() {
if _sshUser == "" {
_sshUser = os.Getenv("OIKOS_SSH_USER")
@@ -185,11 +206,13 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
GW string `json:"gw"`
Storage string `json:"storage"`
Template string `json:"template"`
Privileged bool `json:"privileged"`
Nesting bool `json:"nesting"`
Privileged flexBool `json:"privileged"`
Nesting flexBool `json:"nesting"`
Mounts []string `json:"mounts"`
Nameserver string `json:"nameserver"`
Searchdomain string `json:"searchdomain"`
Services []string `json:"services"` // apt packages to install after create
PostInstall string `json:"post_install"` // shell run inside the container after create
}
if err := json.Unmarshal([]byte(params), &cfg); err != nil {
slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
@@ -225,9 +248,32 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
if cfg.Searchdomain == "" {
cfg.Searchdomain = "hubris.network"
}
// Template pre-flight: resolve against what the host actually has
// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
// `pct` error when that exact file isn't present. List the cache, then
// either validate the requested template or auto-pick the newest
// debian one; on miss, fail early with the available list so the
// operator/agent can retry with a real name.
cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true")
available := []string{}
for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") {
if l = strings.TrimSpace(l); l != "" {
available = append(available, l)
}
}
if tplErr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, fmt.Sprintf(`{"error":"list templates on %s: %s"}`, targetSlug, strings.ReplaceAll(tplErr.Error(), `"`, `'`)))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()})
return
}
cfg.Template = resolveTemplate(cfg.Template, available)
if cfg.Template == "" {
// Try to find the latest debian template
cfg.Template = "debian-13-standard_13.0-1_amd64.tar.zst"
msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, fmt.Sprintf(`{"error":%q}`, msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
privFlag := "--unprivileged 1"
@@ -270,6 +316,27 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
output, err = sshExec(ctx, host, user, createCmd)
// Post-create provisioning: install apt packages and run a post_install
// script inside the fresh container, so a single approved pct_create
// yields a *working service*, not just an empty container. Best-effort
// with a boot settle; failures are appended to output and mark the
// execution failed so the operator sees exactly which step broke.
if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") {
// Give the container a moment to finish booting before exec.
steps := []string{fmt.Sprintf("sleep 5")}
if len(cfg.Services) > 0 {
pkgs := strings.Join(sanitizePkgs(cfg.Services), " ")
steps = append(steps, fmt.Sprintf("pct exec %d -- bash -lc 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s'", cfg.VMID, pkgs))
}
if cfg.PostInstall != "" {
b64 := base64.StdEncoding.EncodeToString([]byte(cfg.PostInstall))
steps = append(steps, fmt.Sprintf("pct exec %d -- bash -lc 'echo %s | base64 -d | bash'", cfg.VMID, b64))
}
var provOut string
provOut, err = sshExec(ctx, host, user, strings.Join(steps, " && "))
output = output + "\n--- post-install ---\n" + provOut
}
// On success, register the entity in the DB with proper relationships
if err == nil {
slug := "lxc:" + cfg.Hostname
@@ -336,6 +403,67 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
}
// resolveTemplate maps a requested template name to one actually present in
// the host's template cache. Exact match wins; a bare distro hint (e.g.
// "debian-13" or "debian") matches by prefix; empty picks the newest debian
// (falling back to any) template available. Returns "" when nothing fits.
func resolveTemplate(requested string, available []string) string {
if len(available) == 0 {
return ""
}
if requested != "" {
for _, a := range available {
if a == requested {
return a
}
}
for _, a := range available {
if strings.HasPrefix(a, requested) {
return a
}
}
}
// Auto-pick: prefer debian, then the lexically-greatest (newest version).
best := ""
for _, a := range available {
if strings.Contains(a, "debian") && a > best {
best = a
}
}
if best != "" {
return best
}
for _, a := range available {
if a > best {
best = a
}
}
return best
}
// sanitizePkgs drops anything that isn't a plausible apt package token, so a
// hallucinated package list can't inject shell into the install command.
func sanitizePkgs(pkgs []string) []string {
out := make([]string, 0, len(pkgs))
for _, p := range pkgs {
p = strings.TrimSpace(p)
if p == "" {
continue
}
ok := true
for _, r := range p {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '+') {
ok = false
break
}
}
if ok {
out = append(out, p)
}
}
return out
}
// ─── Checks ────────────────────────────────────────────────────────────
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {

View File

@@ -0,0 +1,56 @@
package mcp
import (
"context"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// sprintResult extracts the concatenated text content from a tool result.
func sprintResult(r *mcp.CallToolResult) string {
var b strings.Builder
for _, c := range r.Content {
if tc, ok := c.(*mcp.TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
func TestSanitizeBodyStripsHTML(t *testing.T) {
raw := `<html><head><style>.x{color:red}</style><script>alert(1)</script></head>` +
`<body><h1>Hello &amp; Welcome</h1><p>Deploy with docker compose up -d</p></body></html>`
out := sanitizeBody("text/html; charset=utf-8", raw)
if strings.Contains(out, "<script") || strings.Contains(out, "alert(1)") {
t.Errorf("script not stripped: %q", out)
}
if strings.Contains(out, ".x{color:red}") {
t.Errorf("style not stripped: %q", out)
}
if !strings.Contains(out, "Hello & Welcome") {
t.Errorf("expected unescaped heading text, got: %q", out)
}
if !strings.Contains(out, "docker compose up -d") {
t.Errorf("expected body text preserved, got: %q", out)
}
}
func TestHTTPGetBlocksPrivateAndBadScheme(t *testing.T) {
cases := []string{
"http://127.0.0.1:8080/",
"http://localhost/admin",
"http://192.168.8.77/",
"http://10.0.0.5/",
"file:///etc/passwd",
"ftp://example.com/x",
"",
}
for _, c := range cases {
out := sprintResult(httpGet(context.Background(), c))
if !strings.Contains(strings.ToLower(out), "error") && !strings.Contains(strings.ToLower(out), "refus") {
t.Errorf("%q: expected rejection, got %q", c, out)
}
}
}

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)

View File

@@ -35,6 +35,11 @@ the actuator (a separate container with restricted SSH key) picks up.
- `get_trend` — metric trends for a specific entity (single-entity only)
- `request_execution` — the ONLY mutation path. Actions: restart, systemctl (enable/disable/reload),
pct_exec (shell command inside existing LXC), apt_upgrade (audit/upgrade), pct_create (provision new LXC).
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
stack, ports, and install steps BEFORE proposing a plan. Never tell the operator you
cannot access the web — use this tool.
- `get_agent_activity` — your own behavior log
### Tool selection rules
@@ -52,10 +57,15 @@ the actuator (a separate container with restricted SSH key) picks up.
Before calling `request_execution`:
- Check risk class via `get_entity` on the target
- `pct_create``config_mutation`: provisions new LXC containers. Requires operator approval.
Once approved, the new LXC entity is created in the DB with `hosts` relationships and
`state: provisioning`. Accepts JSON params with vmid, hostname, cores, memory, disk_gb,
ip, gw, storage, template, privileged, nesting, mounts, nameserver, searchdomain.
- `pct_create``config_mutation`: provisions a new LXC AND installs its service in one
approved step. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new
container name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB),
disk_gb, ip (CIDR), gw, storage, template (omit to auto-pick newest debian on the host),
privileged, nesting, mounts, and — to actually deliver a working service —
`services` ([]apt packages) and `post_install` (shell run inside the container, e.g. a
`git clone && docker compose up -d`). Prefer one pct_create with services+post_install
over pct_create followed by many pct_exec approvals. Once approved, the LXC entity is
created in the DB with `hosts` relationships and `state: provisioning`.
- If `destructive` or `config_mutation`: escalate to operator
- If `reversible_low` with validated pattern: auto-act allowed