feat: decompose pct_create into atomic create + agent-driven install; add scoped destructive window
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Closes the two remaining open points from the auto-continuation work.

1. Atomic pct_create (observability, the bigger of the two):
   pct_create used to bundle create + apt install + post_install script into
   one black-box multi-minute SSH call — the agent got back a single opaque
   success/fail with no way to see (or fix) which step actually broke.
   Removed the whole post-create provisioning block (and the now-dead
   provisionScript/sanitizePkgs helpers + their tests). pct_create is now
   create + start + register ONLY — fast, and its result is fed back to the
   agent via auto-continuation almost immediately. The agent installs
   packages and runs setup as its OWN sequence of `run` calls against the new
   lxc:<hostname>, observing each command's real output and able to diagnose
   and retry exactly the step that failed — the same recovery loop already
   proven for the general case, now applied to installs too, instead of
   requiring a separate black-box mechanism.
   - services/post_install removed from the pct_create params struct and
     from the MCP tool schema/SOUL.md docs.
   - SOUL.md: explains the new flow, moves the Docker CLI gotcha and DNS
     troubleshooting guidance to be steps the agent runs itself.

2. Scoped destructive window (targeted autonomy for recovery):
   Verified live in the previous session that a destructive recovery (a
   failed destroy needing stop-then-destroy on the same container) required
   TWO separate typed confirmations for what was clearly one recovery
   action. Added a narrow, TARGET-scoped 15-minute grant
   (destructive_window.agent:<id>.target:<slug> in autonomy_settings,
   shared key format across cmd/nomos and internal/mcp) that opens only
   after an EXPLICIT typed confirmation (never loose assent) or an explicit
   button-approval of a destructive step, and only ever covers further
   destructive commands against that SAME target. A different target always
   needs its own fresh confirmation — this narrows risk instead of loosening
   it globally, unlike broadening the general assent window to cover
   destructive actions would have.
   - cmd/nomos/store.go: openDestructiveWindow/destructiveWindowActive/
     executionTarget.
   - cmd/nomos/agent.go: opens the window when a typed confirmation grants a
     destructive chat-assent execution.
   - internal/mcp/server.go: `run` tool checks the window before gating a
     destructive command; auto-runs if active.
   - internal/httpapi/phase3.go: DecideApproval opens the same window when a
     destructive execution is approved via the button/API, for parity with
     the chat-assent path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 14:45:09 +02:00
parent 6f9998fa29
commit 2e922f6421
6 changed files with 208 additions and 150 deletions

View File

@@ -2,7 +2,6 @@ package httpapi
import (
"encoding/json"
"strings"
"testing"
)
@@ -114,44 +113,7 @@ func TestGatewayPreflightPassed(t *testing.T) {
}
}
func TestProvisionScript(t *testing.T) {
s := provisionScript([]string{"docker.io", "git"}, "echo hi > /root/x")
// Network/DNS gate must come before apt.
gate := strings.Index(s, "getent hosts")
apt := strings.Index(s, "apt-get update")
post := strings.Index(s, "echo hi > /root/x")
if gate < 0 || apt < 0 || post < 0 {
t.Fatalf("missing sections: gate=%d apt=%d post=%d\n%s", gate, apt, post, s)
}
if !(gate < apt && apt < post) {
t.Errorf("wrong ordering: gate=%d apt=%d post=%d", gate, apt, post)
}
if !strings.Contains(s, "nameserver 1.1.1.1") {
t.Error("missing DNS self-heal fallback")
}
if !strings.Contains(s, "docker.io git") {
t.Error("packages not joined into install line")
}
// No packages: no apt lines, but post_install and gate still present.
s2 := provisionScript(nil, "systemctl status foo")
if strings.Contains(s2, "apt-get install") {
t.Error("apt install should be absent when no packages requested")
}
if !strings.Contains(s2, "systemctl status foo") || !strings.Contains(s2, "getent hosts") {
t.Error("post_install or gate missing in no-package case")
}
}
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)
}
}
}
// provisionScript and sanitizePkgs were removed when pct_create was made
// atomic (create + start + register only) — installing packages and running
// setup scripts is now the agent's own job via follow-up `run` calls, which
// already has its own classifier/sanitization tests in internal/policy.