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>
348 lines
11 KiB
Go
348 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const maxToolResultSize = 4096
|
|
|
|
type store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func newStore(ctx context.Context, databaseURL string) (*store, error) {
|
|
if databaseURL == "" {
|
|
return nil, nil
|
|
}
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect db: %w", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("ping db: %w", err)
|
|
}
|
|
return &store{pool: pool}, nil
|
|
}
|
|
|
|
func (s *store) close() {
|
|
if s.pool != nil {
|
|
s.pool.Close()
|
|
}
|
|
}
|
|
|
|
type session struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Actor string `json:"actor"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastActiveAt time.Time `json:"last_active_at"`
|
|
}
|
|
|
|
type message struct {
|
|
ID string `json:"id"`
|
|
SessionID string `json:"session_id"`
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
|
|
if s == nil {
|
|
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil
|
|
}
|
|
var id string
|
|
err := s.pool.QueryRow(ctx,
|
|
`INSERT INTO agent_sessions (title, actor) VALUES ($1, 'agent:nomos') RETURNING id`,
|
|
title).Scan(&id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
|
|
}
|
|
|
|
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
_, err := s.pool.Exec(ctx,
|
|
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
|
|
sessionID, role, truncateToolResults(content))
|
|
return err
|
|
}
|
|
|
|
func truncateToolResults(content json.RawMessage) json.RawMessage {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(content, &m); err != nil {
|
|
return content
|
|
}
|
|
toolCalls, ok := m["tool_calls"].([]any)
|
|
if !ok || len(toolCalls) == 0 {
|
|
return content
|
|
}
|
|
changed := false
|
|
for i, raw := range toolCalls {
|
|
tc, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if result, ok := tc["result"]; ok {
|
|
resultJSON, _ := json.Marshal(result)
|
|
if len(resultJSON) > maxToolResultSize {
|
|
tc["result"] = string(resultJSON[:maxToolResultSize]) + fmt.Sprintf("...truncated (%d bytes total)", len(resultJSON))
|
|
toolCalls[i] = tc
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if !changed {
|
|
return content
|
|
}
|
|
m["tool_calls"] = toolCalls
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
return content
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *store) touchSession(ctx context.Context, id string) {
|
|
if s != nil {
|
|
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
|
|
}
|
|
}
|
|
|
|
func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []session
|
|
for rows.Next() {
|
|
var sess session
|
|
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, sess)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id=$1 ORDER BY created_at ASC`,
|
|
sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []message
|
|
for rows.Next() {
|
|
var m message
|
|
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *store) deleteSession(ctx context.Context, id string) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
_, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET title = $1 WHERE id = $2`, title, id)
|
|
return err
|
|
}
|
|
|
|
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
|
|
// Returns uuid.Nil if the store is absent or the slug is unknown.
|
|
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
|
|
if s == nil {
|
|
return uuid.Nil
|
|
}
|
|
var id uuid.UUID
|
|
if err := s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&id); err != nil {
|
|
return uuid.Nil
|
|
}
|
|
return id
|
|
}
|
|
|
|
// linkExecution records that a gated execution was initiated by a chat
|
|
// session, so the auto-continuation worker can feed its result back to that
|
|
// session when it finishes. Idempotent — the same execution may appear in
|
|
// several tool results across a turn.
|
|
func (s *store) linkExecution(ctx context.Context, execID uuid.UUID, sessionID string) {
|
|
if s == nil || execID == uuid.Nil || sessionID == "" || sessionID == "ephemeral" {
|
|
return
|
|
}
|
|
s.pool.Exec(ctx, `
|
|
INSERT INTO nomos_plan_executions (execution_id, session_id)
|
|
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, execID, sessionID)
|
|
}
|
|
|
|
// pendingContinuation is one finished execution whose result hasn't yet been
|
|
// fed back to its originating session.
|
|
type pendingContinuation struct {
|
|
ExecID uuid.UUID
|
|
SessionID string
|
|
Status string
|
|
Result string
|
|
Action string
|
|
}
|
|
|
|
// pendingContinuations returns executions that have reached a terminal state
|
|
// but haven't been continued yet — the worker's work list. Bounded so one
|
|
// tick can't fan out unboundedly.
|
|
func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingContinuation {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT l.execution_id, l.session_id, e.status,
|
|
COALESCE(e.result::text, ''), COALESCE(e.action, '')
|
|
FROM nomos_plan_executions l
|
|
JOIN executions e ON e.entity_id = l.execution_id
|
|
WHERE l.continued_at IS NULL
|
|
AND e.status IN ('completed', 'failed', 'cancelled', 'denied', 'revoked')
|
|
ORDER BY l.created_at
|
|
LIMIT $1`, limit)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
var out []pendingContinuation
|
|
for rows.Next() {
|
|
var p pendingContinuation
|
|
if err := rows.Scan(&p.ExecID, &p.SessionID, &p.Status, &p.Result, &p.Action); err == nil {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// markContinued stamps an execution as fed-back so the worker won't process it
|
|
// again (prevents an auto-continuation loop).
|
|
func (s *store) markContinued(ctx context.Context, execID uuid.UUID) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE execution_id = $1`, execID)
|
|
}
|
|
|
|
// assentWindowActive reports whether this agent currently has an open assent
|
|
// window — the scope gate for auto-continuation. We only auto-continue
|
|
// executions that are part of an approved plan, never stray one-off actions.
|
|
func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID) bool {
|
|
if s == nil || agentID == uuid.Nil {
|
|
return false
|
|
}
|
|
var expires time.Time
|
|
key := "assent_window.agent:" + agentID.String()
|
|
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil {
|
|
return false
|
|
}
|
|
return time.Now().Before(expires)
|
|
}
|
|
|
|
// destructiveWindowDuration is intentionally shorter than the general assent
|
|
// window (30 min): it's a narrow, scoped grant for a multi-step DESTRUCTIVE
|
|
// recovery (e.g. "stop then destroy this specific half-provisioned
|
|
// container"), not a standing license to destroy things.
|
|
const destructiveWindowDuration = 15 * time.Minute
|
|
|
|
// destructiveWindowKey scopes the grant to one agent AND one target entity —
|
|
// an explicit typed confirmation ("I confirm") for a destructive action on
|
|
// target X must never be read as authorizing a destructive action on target Y.
|
|
func destructiveWindowKey(agentID uuid.UUID, targetSlug string) string {
|
|
return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug
|
|
}
|
|
|
|
// openDestructiveWindow records a short, target-scoped grant after an
|
|
// operator's EXPLICIT typed confirmation (never loose assent) authorized a
|
|
// destructive action. Real case this exists for: recovering a failed destroy
|
|
// took "stop" (destructive) then "destroy" (destructive) — same container,
|
|
// two separate typed-confirmation round trips, because each was gated
|
|
// independently. One explicit confirmation on a target should cover the
|
|
// short follow-up sequence needed to finish what was just confirmed.
|
|
func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug string) {
|
|
if s == nil || agentID == uuid.Nil || targetSlug == "" {
|
|
return
|
|
}
|
|
expires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339)
|
|
s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
|
ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug), expires)
|
|
}
|
|
|
|
// destructiveWindowActive reports whether target has a live, explicitly-
|
|
// confirmed destructive grant for this agent.
|
|
func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug string) bool {
|
|
if s == nil || agentID == uuid.Nil || targetSlug == "" {
|
|
return false
|
|
}
|
|
var expires time.Time
|
|
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`,
|
|
destructiveWindowKey(agentID, targetSlug)).Scan(&expires); err != nil {
|
|
return false
|
|
}
|
|
return time.Now().Before(expires)
|
|
}
|
|
|
|
// executionTarget resolves the target entity slug for an execution — used to
|
|
// scope the destructive window to the right entity when a chat-assent typed
|
|
// confirmation grants a destructive execution.
|
|
func (s *store) executionTarget(ctx context.Context, execID uuid.UUID) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
var slug string
|
|
s.pool.QueryRow(ctx, `
|
|
SELECT e.slug FROM executions ex JOIN entities e ON e.id = ex.target_entity_id
|
|
WHERE ex.entity_id = $1`, execID).Scan(&slug)
|
|
return slug
|
|
}
|
|
|
|
// logActivity records a tool call. agent_id is the agent entity UUID and is
|
|
// NOT NULL in the schema, so we skip logging when it can't be resolved.
|
|
// The (nullable) session_id column carries the conversation id.
|
|
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
|
|
if s == nil || agentID == uuid.Nil {
|
|
return
|
|
}
|
|
s.pool.Exec(ctx, `
|
|
INSERT INTO agent_activity
|
|
(agent_id, session_id, activity_type, tool_name, input_summary, output_summary,
|
|
duration_ms, success, correlation_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
agentID, sessionID, "tool_call", toolName, inputSummary, outputSummary,
|
|
durationMs, success, correlationID)
|
|
}
|