diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index b032b8d..19ce82c 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -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( diff --git a/compose/nomos/Dockerfile b/compose/nomos/Dockerfile index 40a1c59..ae780d9 100644 --- a/compose/nomos/Dockerfile +++ b/compose/nomos/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 91f057e..2b1b27b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/internal/httpapi/pct_create_test.go b/internal/httpapi/pct_create_test.go new file mode 100644 index 0000000..3391a85 --- /dev/null +++ b/internal/httpapi/pct_create_test.go @@ -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) + } + } +} diff --git a/internal/httpapi/phase3.go b/internal/httpapi/phase3.go index 8e18680..fec290c 100644 --- a/internal/httpapi/phase3.go +++ b/internal/httpapi/phase3.go @@ -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) { diff --git a/internal/mcp/httpget_test.go b/internal/mcp/httpget_test.go new file mode 100644 index 0000000..20d92d7 --- /dev/null +++ b/internal/mcp/httpget_test.go @@ -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 := `` + + `

Hello & Welcome

Deploy with docker compose up -d

` + out := sanitizeBody("text/html; charset=utf-8", raw) + if strings.Contains(out, "]*>.*?|<[^>]+>`) + +// 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) diff --git a/nomos/SOUL.md b/nomos/SOUL.md index 26d4873..c1cff02 100644 --- a/nomos/SOUL.md +++ b/nomos/SOUL.md @@ -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