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 := `
` + + `Deploy with docker compose up -d
` + out := sanitizeBody("text/html; charset=utf-8", raw) + if strings.Contains(out, "