phase 3-4: get_execution_status tool, approval creation, delete bin/ + homelab.go, update AGENTS.md

This commit is contained in:
2026-07-07 23:13:13 +02:00
parent 7ac2521a22
commit 7f7d039e1b
5 changed files with 54 additions and 2098 deletions

View File

@@ -105,16 +105,16 @@ historical reference.
## 5. Acting on the homelab
- **Read state**: prefer MCP tools, then files, then shell. Examples:
`homelab whoami`, `homelab list`, `homelab status`, `homelab logs caddy`.
- **Cross-host actions** (caddy reload, pct exec, etc.): use the `homelab`
CLI — it resolves hostname → mesh address → ssh / pct path for you.
- **Secrets**: never hardcode. Call `homelab secret <name>` to decrypt on
demand using the per-client age key at `/etc/age/key.txt`.
- **Mutations** (restart, edit configs, etc.): classify against
`seeds/policy.yaml` first (`homelab decide <action> <entity>`).
`reversible_low` actions just need the interactive confirmation prompt;
`config_mutation`/`destructive` actions are mechanically refused without
- **Read state**: use MCP tools. Hermes (the AI agent) is the primary
operator interface — it has 21 MCP tools for observe/orient/decide/act.
- **Actions** (restart, logs, apt, pct exec): Hermes calls `request_execution`
via MCP. `reversible_low` actions execute immediately; `config_mutation`
and `destructive` actions are queued for operator approval via Matrix.
- **Secrets**: managed by Infisical (`oikos secret` subcommand for migration).
Never hardcode secrets — use env vars from `.env`.
- **Mutations** (restart, edit configs, etc.): classified against
`seeds/policy.yaml`. `reversible_low` actions auto-execute;
`config_mutation`/`destructive` actions require approval.
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
## 6. Communication mode

File diff suppressed because it is too large Load Diff

View File

@@ -1,137 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type cliHost struct {
Name string
LanIP string `yaml:"lan_ip"`
Role string
Kind string
State string
Mesh map[string]any
Runs []string
Services []map[string]any `yaml:"services_hosted"`
}
func runHomelabSubcommand() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: oikos homelab <list|whoami|ssh|secret>")
os.Exit(1)
}
ctxDir := os.Getenv("HOMELAB_CONTEXT_DIR")
if ctxDir == "" {
ctxDir = "."
}
switch os.Args[2] {
case "list":
inv := loadInventory(ctxDir)
fmt.Printf("%-25s %-8s %-18s %-6s %s\n", "HOST", "TYPE", "IP", "STATE", "ROLE")
fmt.Println(strings.Repeat("-", 80))
for name, host := range inv.Hosts {
state := host.State
if state == "" {
state = "active"
}
fmt.Printf("%-25s %-8s %-18s %-6s %s\n", name, host.Kind, host.LanIP, state, host.Role)
}
case "whoami":
hostname, _ := os.Hostname()
// Try short hostname and full hostname
hostnameShort := strings.Split(hostname, ".")[0]
inv := loadInventory(ctxDir)
h, ok := inv.Hosts[hostnameShort]
if !ok {
// Try with full hostname
h, ok = inv.Hosts[hostname]
}
if !ok {
fmt.Printf("host: %s (not enrolled — no entry in inventory.yaml)\n", hostname)
return
}
state := h.State
if state == "" {
state = "active"
}
fmt.Printf("host: %s kind: %s role: %s lan_ip: %s state: %s\n",
hostnameShort, h.Kind, h.Role, h.LanIP, state)
for _, svc := range h.Services {
fmt.Printf(" service: %v\n", svc["name"])
}
case "ssh":
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "usage: oikos homelab ssh <host>")
os.Exit(1)
}
target := os.Args[3]
inv := loadInventory(ctxDir)
h, ok := inv.Hosts[target]
if !ok {
fmt.Fprintf(os.Stderr, "unknown host: %s\n", target)
os.Exit(1)
}
addr := h.LanIP
if addr == "" {
for _, m := range []string{"netbird", "tailscale"} {
if mh, ok := h.Mesh[m].(map[string]any); ok {
if ip, ok := mh["ip"].(string); ok && ip != "" {
addr = ip
break
}
}
}
}
if addr == "" {
fmt.Fprintf(os.Stderr, "no address for %s\n", target)
os.Exit(1)
}
user := "root"
cmd := exec.Command("ssh", user+"@"+addr)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
case "secret":
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "usage: oikos homelab secret <name>")
os.Exit(1)
}
secretName := os.Args[3]
secretFile := filepath.Join(ctxDir, "secrets", secretName+".yaml")
// Shell out to sops for decryption
cmd := exec.Command("sops", "-d", secretFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "decrypt %s: %v\n", secretName, err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "unknown homelab command: %s\n", os.Args[2])
os.Exit(1)
}
}
func loadInventory(ctxDir string) inventoryFile {
data, _ := os.ReadFile(filepath.Join(ctxDir, "inventory.yaml"))
var inv inventoryFile
yaml.Unmarshal(data, &inv)
return inv
}
type inventoryFile struct {
Hosts map[string]cliHost `yaml:"hosts"`
}

View File

@@ -60,8 +60,6 @@ func main() {
slog.Error("export failed", "error", err)
os.Exit(1)
}
case "homelab":
runHomelabSubcommand()
case "api":
if err := runAPI(ctx, cfg); err != nil {
slog.Error("api failed", "error", err)
@@ -114,15 +112,15 @@ Roles:
migrate Run database migrations (forward-only, idempotent)
seed Ingest seed YAML files into the database
export Export DB state back to seed YAMLs (DR / version control)
homelab Operator CLI: list, whoami, ssh, secret
api Run the REST + MCP API server (Phase 2)
scheduler Run the observe + act loop (Phase 3)
notifier Run the notification service (Phase 3)
api Run the REST + MCP API server
scheduler Run the observe loop
notifier Run the notification service (Matrix alerts)
all Run all roles in one process (dev mode)
secret Secret management (Phase 5)
secret Secret management (Infisical)
knowledge Convert wiki to knowledge seed (one-shot)
version Print version info
The operator interface is Hermes (MCP agent) — no CLI needed.
Environment:
OIKOS_DATABASE_URL Postgres connection string
OIKOS_API_LISTEN API listen address (default :8090)

View File

@@ -336,6 +336,34 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}
})
register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
InputSchema: objSchema(
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
execID, _ := args["execution_id"].(string)
if execID == "" {
return textResult("execution_id required"), nil
}
eid, err := uuid.Parse(execID)
if err != nil {
// Try finding by exec slug prefix
var found uuid.UUID
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
if err2 != nil {
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
}
eid = found
}
return queryRows(ctx, pool, `
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
e.result::text, e.duration_ms, e.started_at::text,
e.completed_at::text, e.correlation_id
FROM executions e
WHERE e.entity_id = $1`, eid), nil
})
register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
InputSchema: objSchema(
prop{"entity_id", "string", "Entity slug"},
@@ -777,4 +805,15 @@ func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP
}
}
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
}
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
approvalID, _ := uuid.NewV7()
payload := fmt.Sprintf(`{"action":"%s","params":"%s","execution_id":"%s"}`, action, params, execID)
pool.Exec(ctx, `
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class,
kind, payload, status, expires_at, created_at)
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
now() + interval '1 hour', now())`,
approvalID, targetID, action, riskClass, payload)
}