feat: request_execution respects assent window — pct_create and apt_upgrade auto-approve
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

When the operator has approved a plan via chat assent (assent window
active), pct_create and apt_upgrade now auto-approve and execute
instead of queuing for a separate approval round. The auto-approve
path updates the approval+execution status in the DB, then calls the
HTTP API's decision endpoint to trigger executeApprovedAction — same
code path as a manual Approve button, consistent audit trail.
This commit is contained in:
2026-07-10 13:33:31 +02:00
parent 657e1a8be1
commit 7ff344ab47

View File

@@ -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:<slug> or lxc:<slug>", 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:<uuid>
// key in autonomy_settings with an expiry timestamp when chat-assent grants