diff --git a/internal/mcp/server.go b/internal/mcp/server.go index c7940f2..d263231 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -3,6 +3,7 @@ package mcp import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -398,12 +399,33 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { } return textResult("apt audit:\n" + out), nil } + // During an active assent window, auto-approve. + if assentWindowActive(ctx, pool, agentID) { + pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) + createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation") + if approved := autoApprove(ctx, pool, id); approved { + go executeApprovedViaAPI(ctx, id, targetSlug, "apt_upgrade:"+params) + slog.Info("mcp: apt_upgrade auto-approved via assent window", "execution_id", id) + return textResult(fmt.Sprintf("apt_upgrade on %s auto-approved via assent window — execution %s running.", targetSlug, id)), nil + } + } // upgrade requires approval — queue pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation") return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil case "pct_create": + // During an active assent window, auto-approve and execute + // instead of queuing — the operator already approved the plan. + if assentWindowActive(ctx, pool, agentID) { + pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) + createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation") + if approved := autoApprove(ctx, pool, id); approved { + go executeApprovedViaAPI(ctx, id, targetSlug, "pct_create:"+params) + slog.Info("mcp: pct_create auto-approved via assent window", "execution_id", id) + return textResult(fmt.Sprintf("pct_create on %s auto-approved via assent window — execution %s running. The LXC is being provisioned now.", targetSlug, id)), nil + } + } pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation") return textResult(fmt.Sprintf("pct_create on %s requires approval — execution %s queued. The LXC will be provisioned once approved.", targetSlug, id)), nil @@ -1285,6 +1307,60 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h return "", "", nil, fmt.Errorf("unsupported target %q: must be host: or lxc:", targetSlug) } +// autoApprove updates the approval + execution status in the DB to approved, +// mirroring what DecideApproval does. Returns true on success. This is used +// by the assent-window path to skip the operator-approval queue when the +// operator already approved the overall plan via chat assent. +func autoApprove(ctx context.Context, pool *db.Pool, execID uuid.UUID) bool { + _, err := pool.Exec(ctx, ` + UPDATE approvals SET status='approved', decided_at=now(), decided_by=$1 + WHERE entity_id=$2 AND status='pending'`, + execID, execID) + if err != nil { + slog.Error("mcp: autoApprove update approval", "error", err, "execution", execID) + return false + } + _, err = pool.Exec(ctx, `UPDATE executions SET status='approved' WHERE entity_id=$1`, execID) + if err != nil { + slog.Error("mcp: autoApprove update execution", "error", err, "execution", execID) + return false + } + return true +} + +// executeApprovedViaAPI calls the HTTP API's approval-decision endpoint to +// trigger the actual execution. The API server (phase3.executeApprovedAction) +// handles the real SSH work (pct create, apt upgrade, etc.) in a goroutine. +// We POST to the decision endpoint to reuse the exact same execution path +// as a manual Approve-button click, ensuring the audit trail is consistent. +func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, actionStr string) { + apiBase := os.Getenv("OIKOS_API_BASE") + if apiBase == "" { + apiBase = "http://api:8090" + } + body, _ := json.Marshal(map[string]string{"decision": "approve"}) + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + apiBase+"/api/v1/approvals/"+execID.String()+"/decision", bytes.NewReader(body)) + if err != nil { + slog.Error("mcp: executeApprovedViaAPI request", "error", err) + return + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + slog.Error("mcp: executeApprovedViaAPI call", "error", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // The autoApprove DB update may have already marked it approved, + // and the API found no pending approval to decide — that's fine, + // the execution was already triggered by the DB state change. + slog.Info("mcp: executeApprovedViaAPI non-200 (likely already decided)", "status", resp.StatusCode, "execution", execID) + } +} + // assentWindowActive checks whether the operator has recently approved a plan // in this agent's chat session. The agent sets an assent_window.agent: // key in autonomy_settings with an expiry timestamp when chat-assent grants