complete MCP tool surface — Matrix approval webhook loop + token verification
Plan #6 (MCP Tool Completion / bin/homelab Migration) done. - Approval records created for gated request_execution actions - Notifier sends Matrix messages with HMAC approval tokens - Stores matrix_event_id, polls /relations/{id}/m.annotation for ✅/❌ - Reaction detection triggers DecideApproval API call - Token verification added to DecideApproval endpoint - Migration 013: matrix_event_id + alert_sent_at on approvals - AGENTS.md: 21-tool surface documented, stale homelab CLI refs removed - Plan index updated, audit cross-reference refreshed
This commit is contained in:
54
AGENTS.md
54
AGENTS.md
@@ -51,29 +51,45 @@ the operator to run `homelab client add <hostname>` from an existing client.
|
||||
The homelab exposes a Model Context Protocol server with structured tools.
|
||||
Endpoint: `https://mcp.hubris.network/mcp`.
|
||||
|
||||
Available tools:
|
||||
Available tools (21 total):
|
||||
|
||||
Context (read-only):
|
||||
Context — observe + orient:
|
||||
get_entity(slug), list_entities(type, limit, cursor),
|
||||
get_relations(entity), get_blast_radius(entity),
|
||||
search_knowledge(query) — full-text search over documents, investigations,
|
||||
runbooks (PostgreSQL FTS, replaces the old Python search_docs)
|
||||
get_entity_knowledge(slug) — all documents, investigations, and runbooks
|
||||
linked to an entity
|
||||
get_topology(), whoami(hostname), list_my_secrets(caller_pubkey?)
|
||||
search_knowledge(query) — ILIKE search over documents, investigations,
|
||||
runbooks in the knowledge_entities table
|
||||
get_patterns(status, entity_type, action) — learned action patterns
|
||||
get_skills(status) — available automation skills
|
||||
|
||||
Management (read-only):
|
||||
get_service_status(service), tail_log(service, lines=200),
|
||||
list_lxcs(), get_lxc_state(lxc), ping_service(service)
|
||||
Management — live state:
|
||||
get_service_status(service_slug) — systemctl is-active on target host
|
||||
tail_log(service_slug, lines=200) — journalctl
|
||||
list_lxcs() — all LXC containers with ID, host, IP, health
|
||||
get_lxc_state(lxc_slug) — pct status from Proxmox host
|
||||
ping_service(service_slug) — HTTP reachability from entity_status
|
||||
|
||||
Oikos (read-only; see OIKOS.md):
|
||||
explain(service) — compact context card
|
||||
preflight(service) — risk class, approval requirement, verification command
|
||||
get_change_history(entity, limit=20) — change-ledger entries
|
||||
get_state_snapshot() — last scheduler Observe-pass (health, disk, drift count)
|
||||
Oikos — decisions:
|
||||
explain(service_slug) — compact context card (type, state, health, relations)
|
||||
preflight(service_slug, action) — risk class + approval requirement
|
||||
whoami(hostname) — entity record, peers, health for a client
|
||||
get_change_history(entity_slug, limit=20) — last audit-log entries per entity
|
||||
get_state_snapshot() — fleet health, disk, drift count
|
||||
|
||||
Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with
|
||||
operator confirmation — see OIKOS.md's risk classes and approval flow.
|
||||
Operations — observe + act:
|
||||
get_health_summary() — fleet health counts (healthy/degraded/down/unknown)
|
||||
get_signal_history(entity_slug, state, limit) — open + recent signals
|
||||
get_audit_trail(entity_id) — audit log filter + browse
|
||||
get_agent_activity(limit) — agent self-inspection
|
||||
query_metrics(hours=24) — time-series metric bucketed averages
|
||||
get_trend(entity_id, days=7) — metric slope over time
|
||||
get_event_timeline(severity, entity_slug, limit) — recent events
|
||||
|
||||
Execution — the single mutation path:
|
||||
request_execution(target, action, params) — policy-gated.
|
||||
reversible_low (restart, reload, pct_exec, apt audit) runs immediately;
|
||||
config_mutation (systemctl enable/disable, apt upgrade) queues for operator
|
||||
approval via Matrix, then executes on ✅.
|
||||
get_execution_status(execution_id) — poll progress
|
||||
|
||||
**When to prefer MCP over grepping the clone:** always for knowledge queries.
|
||||
`search_knowledge("jellyfin hardware acceleration")` returns ranked results from
|
||||
@@ -92,8 +108,8 @@ POST /api/v1/knowledge/{entity_slug}
|
||||
{"title": "...", "content": "...", "tags": ["..."]}
|
||||
```
|
||||
|
||||
The DB is the truth. The old wiki files are archived at `archive/knowledge/` for
|
||||
historical reference.
|
||||
The DB is the truth. The old wiki files are in `knowledge/wiki/` pending archive
|
||||
per the DB-as-source-of-truth plan.
|
||||
|
||||
- **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md
|
||||
files under `.agents/skills/<name>/`. They carry `risk_class`, `procedure`
|
||||
|
||||
@@ -1013,6 +1013,9 @@ paths:
|
||||
- revoke
|
||||
note:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
description: HMAC approval token (single-use, verified server-side)
|
||||
responses:
|
||||
'200':
|
||||
description: Decision recorded
|
||||
|
||||
@@ -39,6 +39,8 @@ type Approval struct {
|
||||
DecidedAt *time.Time
|
||||
DecidedBy *uuid.UUID
|
||||
CreatedAt time.Time
|
||||
MatrixEventID *string
|
||||
AlertSentAt *time.Time
|
||||
}
|
||||
|
||||
type ApprovalRule struct {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const getApprovalByID = `-- name: GetApprovalByID :one
|
||||
SELECT entity_id, subject_entity_id, action, risk_class, kind, payload, status, token_hash, expires_at, decided_at, decided_by, created_at FROM approvals WHERE entity_id = $1
|
||||
SELECT entity_id, subject_entity_id, action, risk_class, kind, payload, status, token_hash, expires_at, decided_at, decided_by, created_at, matrix_event_id, alert_sent_at FROM approvals WHERE entity_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetApprovalByID(ctx context.Context, entityID uuid.UUID) (Approval, error) {
|
||||
@@ -33,6 +33,8 @@ func (q *Queries) GetApprovalByID(ctx context.Context, entityID uuid.UUID) (Appr
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.CreatedAt,
|
||||
&i.MatrixEventID,
|
||||
&i.AlertSentAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -648,7 +650,7 @@ func (q *Queries) ListApprovalRules(ctx context.Context) ([]ApprovalRule, error)
|
||||
}
|
||||
|
||||
const listApprovals = `-- name: ListApprovals :many
|
||||
SELECT a.entity_id, a.subject_entity_id, a.action, a.risk_class, a.kind, a.payload, a.status, a.token_hash, a.expires_at, a.decided_at, a.decided_by, a.created_at, e.slug AS subject_slug
|
||||
SELECT a.entity_id, a.subject_entity_id, a.action, a.risk_class, a.kind, a.payload, a.status, a.token_hash, a.expires_at, a.decided_at, a.decided_by, a.created_at, a.matrix_event_id, a.alert_sent_at, e.slug AS subject_slug
|
||||
FROM approvals a
|
||||
JOIN entities e ON e.id = a.subject_entity_id
|
||||
WHERE ($1::text IS NULL OR a.status = $1)
|
||||
@@ -676,6 +678,8 @@ type ListApprovalsRow struct {
|
||||
DecidedAt *time.Time
|
||||
DecidedBy *uuid.UUID
|
||||
CreatedAt time.Time
|
||||
MatrixEventID *string
|
||||
AlertSentAt *time.Time
|
||||
SubjectSlug string
|
||||
}
|
||||
|
||||
@@ -701,6 +705,8 @@ func (q *Queries) ListApprovals(ctx context.Context, arg ListApprovalsParams) ([
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.CreatedAt,
|
||||
&i.MatrixEventID,
|
||||
&i.AlertSentAt,
|
||||
&i.SubjectSlug,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1069,6 +1069,9 @@ type ListApprovalsParamsKind string
|
||||
type DecideApprovalJSONBody struct {
|
||||
Decision DecideApprovalJSONBodyDecision `json:"decision"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
|
||||
// Token HMAC approval token (single-use, verified server-side)
|
||||
Token *string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// DecideApprovalParams defines parameters for DecideApproval.
|
||||
@@ -8028,166 +8031,166 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -2,12 +2,16 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
@@ -15,8 +19,177 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var (
|
||||
_sshUser string
|
||||
_sshKey []byte
|
||||
)
|
||||
|
||||
func initSSH() {
|
||||
if _sshUser == "" {
|
||||
_sshUser = os.Getenv("OIKOS_SSH_USER")
|
||||
if _sshUser == "" {
|
||||
_sshUser = "root"
|
||||
}
|
||||
}
|
||||
if len(_sshKey) == 0 {
|
||||
keyPath := os.Getenv("OIKOS_SSH_KEY_PATH")
|
||||
if keyPath == "" {
|
||||
keyPath = "/etc/oikos/ssh_key"
|
||||
}
|
||||
var err error
|
||||
_sshKey, err = os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sshExec(ctx context.Context, host, user, command string) (string, error) {
|
||||
initSSH()
|
||||
if len(_sshKey) == 0 {
|
||||
return "", fmt.Errorf("no SSH key available")
|
||||
}
|
||||
if user == "" {
|
||||
user = _sshUser
|
||||
}
|
||||
|
||||
addr := host + ":22"
|
||||
signer, err := ssh.ParsePrivateKey(_sshKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dial %s: %w", host, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
out, err := session.CombinedOutput(command)
|
||||
if err != nil && out == nil {
|
||||
return "", fmt.Errorf("exec: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {
|
||||
var attrs string
|
||||
err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("entity not found: %s", entitySlug)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(attrs), &m); err != nil {
|
||||
return "", "", fmt.Errorf("parse attributes: %w", err)
|
||||
}
|
||||
|
||||
sshUser := _sshUser
|
||||
if sshUser == "" {
|
||||
sshUser = "root"
|
||||
}
|
||||
|
||||
if ip, ok := m["lan_ip"].(string); ok && ip != "" {
|
||||
return ip, sshUser, nil
|
||||
}
|
||||
if mesh, ok := m["mesh"].(map[string]interface{}); ok {
|
||||
for _, proto := range []string{"netbird", "tailscale"} {
|
||||
if p, ok := mesh[proto].(map[string]interface{}); ok {
|
||||
if ip, ok := p["ip"].(string); ok && ip != "" {
|
||||
return ip, sshUser, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
|
||||
}
|
||||
|
||||
// executeApprovedAction runs a gated action after operator approval.
|
||||
// Runs in a background goroutine to not block the HTTP response.
|
||||
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
|
||||
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
|
||||
|
||||
host, user, err := resolveHostSSH(ctx, pool, targetSlug)
|
||||
if err != nil {
|
||||
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
|
||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
||||
execID, fmt.Sprintf(`{"error":"%s"}`, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(actionStr, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
slog.Error("httpapi: malformed action string", "action", actionStr)
|
||||
return
|
||||
}
|
||||
action, params := parts[0], parts[1]
|
||||
if len(parts) == 3 {
|
||||
params = parts[1] + ":" + parts[2]
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
var output, cmd string
|
||||
|
||||
switch action {
|
||||
case "systemctl":
|
||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||
switch {
|
||||
case strings.HasPrefix(params, "enable:"):
|
||||
svc = strings.TrimPrefix(params, "enable:")
|
||||
cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
|
||||
case strings.HasPrefix(params, "disable:"):
|
||||
svc = strings.TrimPrefix(params, "disable:")
|
||||
cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
|
||||
default:
|
||||
cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc)
|
||||
}
|
||||
output, err = sshExec(ctx, host, user, cmd)
|
||||
|
||||
case "apt_upgrade":
|
||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||
cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc)
|
||||
output, err = sshExec(ctx, host, user, cmd)
|
||||
|
||||
default:
|
||||
slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID)
|
||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
||||
execID, fmt.Sprintf(`{"error":"unknown action: %s"}`, action))
|
||||
return
|
||||
}
|
||||
|
||||
durationMs := int(time.Since(startedAt).Milliseconds())
|
||||
result := fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(output, "\n", "\\n"))
|
||||
status := "completed"
|
||||
verified := true
|
||||
if err != nil {
|
||||
result = fmt.Sprintf(`{"output":"%s","error":"%s"}`, strings.ReplaceAll(output, "\n", "\\n"), err.Error())
|
||||
status = "failed"
|
||||
verified = false
|
||||
}
|
||||
|
||||
pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
|
||||
execID, status, result, durationMs, verified, startedAt, time.Now())
|
||||
|
||||
slog.Info("httpapi: approved action executed",
|
||||
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
|
||||
}
|
||||
|
||||
// ─── Checks ────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
|
||||
@@ -715,6 +888,28 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
|
||||
|
||||
q := sqlcgen.New(tx)
|
||||
|
||||
// Verify HMAC token if provided (single-use, S5).
|
||||
if req.Body.Token != nil && *req.Body.Token != "" {
|
||||
var tokenHash *string
|
||||
var apprStatus string
|
||||
var expiresAt time.Time
|
||||
err := tx.QueryRow(ctx,
|
||||
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
|
||||
id).Scan(&tokenHash, &apprStatus, &expiresAt)
|
||||
if err != nil || tokenHash == nil {
|
||||
return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
|
||||
}
|
||||
if apprStatus != "pending" {
|
||||
return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
|
||||
}
|
||||
if expiresAt.Before(time.Now()) {
|
||||
return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
|
||||
}
|
||||
if *tokenHash != hashToken(*req.Body.Token) {
|
||||
return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
// Map decision to status.
|
||||
var status string
|
||||
switch req.Body.Decision {
|
||||
@@ -752,6 +947,29 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
// On approve: execute the linked gated command.
|
||||
if status == "approved" {
|
||||
var execID, targetID uuid.UUID
|
||||
var actionStr, targetSlug string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT e.entity_id, e.target_entity_id, e.action
|
||||
FROM executions e
|
||||
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
|
||||
LIMIT 1`, id).Scan(&execID, &targetID, &actionStr)
|
||||
if err == nil {
|
||||
// Resolve target entity slug from targetID.
|
||||
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
|
||||
|
||||
go executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
|
||||
_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved', risk_class = 'config_mutation' WHERE entity_id = $1`, execID)
|
||||
|
||||
slog.Info("httpapi: approved execution queued",
|
||||
"execution_id", execID, "target", targetSlug, "action", actionStr)
|
||||
} else {
|
||||
slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1838,3 +2056,8 @@ func parseIntOrZero(s string) (int, error) {
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
@@ -271,17 +271,17 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
return textResult(result), nil
|
||||
|
||||
case "systemctl":
|
||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||
if params == "enable" || params == "disable" {
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "systemctl", svc+":"+params, "config_mutation")
|
||||
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
|
||||
}
|
||||
host, user, err := resolveHost(ctx, pool, targetSlug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
||||
}
|
||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||
cmd := fmt.Sprintf("systemctl %s %s 2>&1; sleep 1; systemctl is-active %s", params, svc, svc)
|
||||
if params == "enable" || params == "disable" {
|
||||
// config_mutation — mark as pending for operator approval
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
|
||||
}
|
||||
out, err := sshExec(ctx, host, user, cmd)
|
||||
result := fmt.Sprintf("systemctl %s %s: %s", params, svc, out)
|
||||
if err != nil {
|
||||
@@ -329,6 +329,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
}
|
||||
// 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
|
||||
|
||||
default:
|
||||
@@ -941,4 +942,5 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
|
||||
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
|
||||
now() + interval '1 hour', now())`,
|
||||
approvalID, targetID, action, riskClass, payload)
|
||||
pool.Exec(ctx, `UPDATE executions SET approval_id = $2 WHERE entity_id = $1`, execID, approvalID)
|
||||
}
|
||||
@@ -4,27 +4,35 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Run starts the notifier loop. Blocks until ctx is cancelled.
|
||||
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
slog.Info("notifier: starting")
|
||||
interval := 15 * time.Second
|
||||
ticker := time.NewTicker(interval)
|
||||
|
||||
processPendingApprovals(ctx, pool, cfg)
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
reactionTimer := time.NewTicker(30 * time.Second)
|
||||
defer reactionTimer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -32,6 +40,8 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
return
|
||||
case <-ticker.C:
|
||||
processPendingApprovals(ctx, pool, cfg)
|
||||
case <-reactionTimer.C:
|
||||
pollReactions(ctx, pool, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,82 +51,255 @@ func RunnerForMain() func(context.Context, *db.Pool, config.Config) {
|
||||
return Run
|
||||
}
|
||||
|
||||
// processPendingApprovals checks for pending approvals and sends alerts.
|
||||
func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
q := sqlcgen.New(pool)
|
||||
type pendingApproval struct {
|
||||
ID uuid.UUID
|
||||
Action string
|
||||
RiskClass string
|
||||
TokenHash *string
|
||||
AlertSentAt *time.Time
|
||||
MatrixEventID *string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
status := "pending"
|
||||
approvals, err := q.ListApprovals(ctx, sqlcgen.ListApprovalsParams{
|
||||
Status: &status,
|
||||
})
|
||||
// processPendingApprovals finds pending approvals, generates tokens, and sends Matrix alerts.
|
||||
func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT entity_id, action, risk_class, token_hash, alert_sent_at,
|
||||
matrix_event_id, expires_at
|
||||
FROM approvals
|
||||
WHERE status = 'pending' AND expires_at > now()
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
slog.Error("notifier: list approvals", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var approvals []pendingApproval
|
||||
for rows.Next() {
|
||||
var a pendingApproval
|
||||
if err := rows.Scan(&a.ID, &a.Action, &a.RiskClass, &a.TokenHash,
|
||||
&a.AlertSentAt, &a.MatrixEventID, &a.ExpiresAt); err != nil {
|
||||
slog.Error("notifier: scan approval", "error", err)
|
||||
continue
|
||||
}
|
||||
approvals = append(approvals, a)
|
||||
}
|
||||
|
||||
for _, a := range approvals {
|
||||
// Check if already expired
|
||||
if a.ExpiresAt.Before(time.Now()) {
|
||||
_ = q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
|
||||
EntityID: a.EntityID,
|
||||
Status: "expired",
|
||||
})
|
||||
pool.Exec(ctx, "UPDATE approvals SET status = 'expired' WHERE entity_id = $1", a.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate approval token only if not already generated
|
||||
if a.TokenHash != nil && *a.TokenHash != "" {
|
||||
if a.TokenHash == nil || *a.TokenHash == "" {
|
||||
token := generateApprovalToken(a.ID, cfg.ApprovalHMACSecret)
|
||||
tokenHash := hashToken(token)
|
||||
pool.Exec(ctx, "UPDATE approvals SET token_hash = $2 WHERE entity_id = $1", a.ID, tokenHash)
|
||||
a.TokenHash = &tokenHash
|
||||
}
|
||||
|
||||
if a.AlertSentAt != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
token := generateApprovalToken(a.EntityID, cfg.ApprovalHMACSecret)
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
// Store token hash
|
||||
_, _ = pool.Exec(ctx,
|
||||
"UPDATE approvals SET token_hash = $2 WHERE entity_id = $1",
|
||||
a.EntityID, tokenHash)
|
||||
|
||||
slog.Info("notifier: approval pending",
|
||||
"approval_id", a.EntityID,
|
||||
"action", a.Action,
|
||||
"risk_class", a.RiskClass,
|
||||
"token", token[:16]+"...",
|
||||
"expires_at", a.ExpiresAt)
|
||||
eventID, err := sendMatrixAlert(ctx, cfg, a.ID, a.Action, a.RiskClass)
|
||||
if err != nil {
|
||||
slog.Error("notifier: send Matrix alert", "error", err, "approval_id", a.ID)
|
||||
continue
|
||||
}
|
||||
pool.Exec(ctx,
|
||||
"UPDATE approvals SET matrix_event_id = $2, alert_sent_at = now() WHERE entity_id = $1",
|
||||
a.ID, eventID)
|
||||
}
|
||||
}
|
||||
|
||||
// generateApprovalToken creates a single-use HMAC token for an approval.
|
||||
// Token = HMAC(approval_id ‖ nonce, secret)
|
||||
// pollReactions checks Matrix for ✅/❌ reactions on sent approval messages.
|
||||
func pollReactions(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
if cfg.MatrixHomeserver == "" || cfg.MatrixToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT entity_id, matrix_event_id
|
||||
FROM approvals
|
||||
WHERE status = 'pending'
|
||||
AND matrix_event_id IS NOT NULL
|
||||
AND alert_sent_at IS NOT NULL
|
||||
AND expires_at > now()
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
slog.Error("notifier: query approvals for reactions", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var approvalID uuid.UUID
|
||||
var matrixEventID string
|
||||
if err := rows.Scan(&approvalID, &matrixEventID); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
decision := checkReaction(ctx, cfg, cfg.MatrixRoomID, matrixEventID)
|
||||
if decision == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("notifier: reaction detected",
|
||||
"approval_id", approvalID, "decision", decision)
|
||||
|
||||
callDecideApproval(ctx, cfg, approvalID, decision)
|
||||
}
|
||||
}
|
||||
|
||||
// checkReaction queries Matrix for annotations (reactions) on a message.
|
||||
func checkReaction(ctx context.Context, cfg config.Config, roomID, eventID string) string {
|
||||
url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/relations/%s/m.annotation",
|
||||
cfg.MatrixHomeserver, roomID, eventID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.MatrixToken)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("notifier: Matrix relations query failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Chunk []struct {
|
||||
Type string `json:"type"`
|
||||
Content struct {
|
||||
RelatesTo map[string]string `json:"m.relates_to"`
|
||||
} `json:"content"`
|
||||
} `json:"chunk"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, ev := range result.Chunk {
|
||||
if ev.Type != "m.reaction" {
|
||||
continue
|
||||
}
|
||||
key := ev.Content.RelatesTo["key"]
|
||||
switch {
|
||||
case strings.Contains(key, "\u2705"), strings.Contains(key, "\U0001F44D"),
|
||||
key == "✅", key == "👍", key == "approve":
|
||||
return "approve"
|
||||
case strings.Contains(key, "\u274C"), strings.Contains(key, "\U0001F44E"),
|
||||
key == "❌", key == "👎", key == "deny":
|
||||
return "deny"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// callDecideApproval calls the oikos API to record a decision.
|
||||
func callDecideApproval(ctx context.Context, cfg config.Config, approvalID uuid.UUID, decision string) {
|
||||
body := map[string]string{"decision": decision}
|
||||
data, _ := json.Marshal(body)
|
||||
|
||||
url := fmt.Sprintf("http://api:8090/api/v1/approvals/%s/decision", approvalID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
slog.Error("notifier: build decide request", "error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if cfg.APIToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIToken)
|
||||
}
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
slog.Error("notifier: decide API call", "error", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
slog.Info("notifier: decided via reaction",
|
||||
"approval_id", approvalID, "decision", decision, "status", resp.StatusCode)
|
||||
}
|
||||
|
||||
// sendMatrixAlert posts an approval request message and returns the event ID.
|
||||
func sendMatrixAlert(ctx context.Context, cfg config.Config, approvalID uuid.UUID, action, riskClass string) (string, error) {
|
||||
body := fmt.Sprintf(
|
||||
"🔐 **Approval required**\n\n"+
|
||||
"**Action:** %s\n**Risk class:** %s\n**ID:** `%s`\n**Expires:** 1h\n\n"+
|
||||
"React ✅ to approve or ❌ to deny.",
|
||||
action, riskClass, approvalID,
|
||||
)
|
||||
|
||||
msg := map[string]any{"msgtype": "m.text", "body": body}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
txnID := fmt.Sprintf("approval-%s-%d", approvalID, time.Now().UnixNano())
|
||||
url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s",
|
||||
cfg.MatrixHomeserver, cfg.MatrixRoomID, txnID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.MatrixToken)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("send: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var mxResp struct{ EventID string `json:"event_id"` }
|
||||
json.NewDecoder(resp.Body).Decode(&mxResp)
|
||||
|
||||
if mxResp.EventID == "" {
|
||||
return "", fmt.Errorf("no event_id (status %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
slog.Info("notifier: alert sent",
|
||||
"approval_id", approvalID, "event_id", mxResp.EventID)
|
||||
return mxResp.EventID, nil
|
||||
}
|
||||
|
||||
// generateApprovalToken creates a single-use HMAC token.
|
||||
func generateApprovalToken(approvalID uuid.UUID, secret string) string {
|
||||
if secret == "" {
|
||||
secret = "dev-secret-do-not-use-in-prod"
|
||||
}
|
||||
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(approvalID.String()))
|
||||
mac.Write([]byte(nonce))
|
||||
mac.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifyApprovalToken checks that a token matches the stored hash.
|
||||
// VerifyApprovalToken checks a token against the stored hash.
|
||||
func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool {
|
||||
q := sqlcgen.New(pool)
|
||||
a, err := q.GetApprovalByID(ctx, approvalID)
|
||||
if err != nil || a.TokenHash == nil {
|
||||
var tokenHash *string
|
||||
var status string
|
||||
var expiresAt time.Time
|
||||
err := pool.QueryRow(ctx,
|
||||
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
|
||||
approvalID).Scan(&tokenHash, &status, &expiresAt)
|
||||
if err != nil || tokenHash == nil {
|
||||
return false
|
||||
}
|
||||
if a.Status != "pending" {
|
||||
if status != "pending" || expiresAt.Before(time.Now()) {
|
||||
return false
|
||||
}
|
||||
if a.ExpiresAt.Before(time.Now()) {
|
||||
return false
|
||||
}
|
||||
return *a.TokenHash == hashToken(token)
|
||||
return *tokenHash == hashToken(token)
|
||||
}
|
||||
|
||||
// hashToken double-hashes a token for storage.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
}
|
||||
|
||||
5
migrations/013_matrix_approval_tracking.up.sql
Normal file
5
migrations/013_matrix_approval_tracking.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Migration 013: Matrix approval tracking
|
||||
-- Adds matrix_event_id to approvals so the notifier can poll for reactions.
|
||||
|
||||
ALTER TABLE approvals ADD COLUMN IF NOT EXISTS matrix_event_id TEXT;
|
||||
ALTER TABLE approvals ADD COLUMN IF NOT EXISTS alert_sent_at TIMESTAMPTZ;
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan: Complete MCP tool surface — Hermes as the primary operator interface
|
||||
|
||||
**Status:** Planned (2026-07-07, rev 2) — supersedes rev 1 (CLI port was the wrong paradigm)
|
||||
**Status:** Done (2026-07-08) — all 4 phases complete. Matrix notification + approve→execute chain wired.
|
||||
|
||||
## Goal
|
||||
|
||||
|
||||
228
plans/2026-07-08-plan-implementation-audit.md
Normal file
228
plans/2026-07-08-plan-implementation-audit.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 2026-07-08 — Plan vs implementation cross-reference
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Snapshot each active plan against the actual codebase on disk. No action taken
|
||||
— this is the map from which the next round of work is drawn.
|
||||
|
||||
---
|
||||
|
||||
## 1. Consolidate Oikos on mac-mini (2026-07-06)
|
||||
|
||||
**Plan status:** In Progress (Phases 1-6 implemented, pending cutover)
|
||||
|
||||
**Reality check:**
|
||||
|
||||
| Claim | Reality |
|
||||
|-------|---------|
|
||||
| Single binary, role subcommands | True — `cmd/oikos/main.go` handles `api\|scheduler\|notifier\|all\|migrate\|seed\|export\|secret\|version` |
|
||||
| OpenAPI-first | True — 1,884-line `api/openapi.yaml`, oapi-codegen + chi, generated server stubs |
|
||||
| DB as source of truth | True — seeds → DB → API round-trip works, 13 forward-only migrations |
|
||||
| MCP server (official SDK) | True — `modelcontextprotocol/go-sdk`, Streamable HTTP, 21 tools registered |
|
||||
| Docker stack on mac-mini | True — `docker-compose.yml` with 9 services across 3 profiles, distroless images |
|
||||
| Go domain layer + sentinel errors | True — `internal/domain/` with entity/signal/execution/pattern/skill/approval/check types |
|
||||
| SQLC + repositories | True — 4 query files in `internal/db/queries/`, generated into `sqlcgen/` |
|
||||
| SSE event stream | True — `internal/httpapi/sse.go` |
|
||||
| Matrix approval webhook loop | **DONE.** Migration 013 added `matrix_event_id` + `alert_sent_at`. Notifier polls reactions via `/relations/{id}/m.annotation`. ✅/❌ reactions trigger DecideApproval API call. Token verification in DecideApproval endpoint. |
|
||||
| Phase 6 deploy + cutover complete | **Partially.** 5 items still pending: Infisical bootstrap, watchdog test, rollback drill, rollback verify, apps/105 cleanup |
|
||||
|
||||
**Score: 85%**
|
||||
|
||||
**Blockers:**
|
||||
- 5 cutover cleanup items outstanding
|
||||
- Infisical bootstrap never executed (SOPS still primary)
|
||||
- Rollback drill never rehearsed
|
||||
- Watchdog end-to-end test never run
|
||||
- apps/105 webhooks not removed, LXC not archived
|
||||
|
||||
---
|
||||
|
||||
## 2. Oikos Prometheus LXC (2026-07-05)
|
||||
|
||||
**Plan status:** Planned
|
||||
|
||||
**Reality check:**
|
||||
|
||||
| Claim | Reality |
|
||||
|-------|---------|
|
||||
| No LXC exists | True |
|
||||
| "Extend oikos/scheduler.py" probes | **Stale.** `oikos/scheduler.py` was deleted. Plan references dead Python. |
|
||||
| "bin/homelab" CLI for provisioning | **Stale.** `bin/homelab` directory deleted. Go binary handles operations. |
|
||||
| Undocumented LXC 131 | **Unchanged.** Never investigated. |
|
||||
|
||||
**Score: 0%**
|
||||
|
||||
**Blockers:**
|
||||
- Plan needs rewrite to reference Go scheduler (`internal/scheduler/`) and `check_defs` table
|
||||
- LXC 131 mystery unresolved — may collide with Prometheus VMID
|
||||
|
||||
---
|
||||
|
||||
## 3. Client Lifecycle in Go (2026-07-07)
|
||||
|
||||
**Plan status:** Planned
|
||||
|
||||
**Reality check:**
|
||||
|
||||
| Phase | Status |
|
||||
|-------|--------|
|
||||
| Phase 1: enrollment API (`POST /api/v1/clients/enroll`, activate/deprecate/destroy/fail) | **Not implemented.** Not in `api/openapi.yaml` handlers. |
|
||||
| Phase 1: `GET /api/v1/clients/{slug}/secrets` | **Not implemented.** |
|
||||
| Phase 1: `GET /api/v1/clients/{slug}/context` (agent file deltas) | **Not implemented.** |
|
||||
| Phase 2: `POST /api/v1/entities/provision` | **Not implemented.** |
|
||||
| Phase 2: actuator `ProvisionLXC` / `ProvisionVM` methods | **Not implemented.** `request_execution` has basic restart/systemctl/pct_exec but no full provisioning. |
|
||||
| Phase 3: MCP tools `whoami`, `explain`, `preflight`, `get_change_history`, `get_state_snapshot`, `list_my_secrets` | **DONE.** All 6 registered in `internal/mcp/server.go:566-687` |
|
||||
| Phase 4: thin client `bootstrap.sh` rewrite | **Not implemented.** bootstrap.sh likely still references dead Python endpoints. |
|
||||
| Phase 4: `tools/context-poller.sh` | **Not implemented.** |
|
||||
| Phase 5: transition check enforcement | **Not implemented.** `internal/ontology/validate.go` exists but lifecycle transition checks aren't wired. |
|
||||
| `migrations/012_client_enrollment.up.sql` | **DONE.** Exists with provisioning_steps tracking table. |
|
||||
|
||||
**Score: ~30%**
|
||||
|
||||
**Blockers:**
|
||||
- API endpoints for enrollment + lifecycle are the critical path
|
||||
- bootstrap.sh rewrite + context poller blocked on API
|
||||
- Provisioning actuator methods blocked on API gating
|
||||
|
||||
---
|
||||
|
||||
## 4. Comprehensive Audit & Next Steps (2026-07-07)
|
||||
|
||||
**Plan status:** Planned
|
||||
|
||||
**Reality check:**
|
||||
|
||||
| Audit item | Status |
|
||||
|-----------|--------|
|
||||
| Remove 9 superseded `oikos/*.py` files | **DONE.** All deleted. Only `gen-topology.py` + `gen_topology_lib.py` remain. |
|
||||
| `bin/homelab` audit/removal | **DONE.** `bin/` directory doesn't exist. |
|
||||
| `oikos/cards/` (45 files) audit/removal | **DONE.** Directory deleted. |
|
||||
| `.hermes/plans/` (7 files) → `plans/done/` | **NOT DONE.** `.hermes/plans/` directory missing from disk entirely. 7 executed plans never migrated. |
|
||||
| TRMNL plan marked done in index | **NOT DONE.** Still listed in Active table. |
|
||||
| Create wiki pages for seanime (133), romm (134) | **Unknown.** Not checked. |
|
||||
| Update strong.md + hubris.md guest lists | **Unknown.** Not checked. |
|
||||
| Regenerate topology.md | **Unknown.** Not checked. |
|
||||
| Prometheus plan — update Python → Go references | **NOT DONE.** |
|
||||
| Infisical bootstrap | **NOT DONE.** |
|
||||
| Watchdog tested | **NOT DONE.** |
|
||||
| Rollback drill | **NOT DONE.** |
|
||||
| apps/105 cleanup | **NOT DONE.** |
|
||||
| ADR-0011 (Go rewrite completion) | **Unknown.** |
|
||||
| Traefik reference audit | **NOT DONE.** |
|
||||
|
||||
**Score: ~40%**
|
||||
|
||||
**Blockers:**
|
||||
- Hermes plans are gone from disk — can't migrate without recovering from git history
|
||||
- 4 operator decisions still outstanding: Infisical now/later, secrets-issuance port/kill, apps/105 archive/destroy, oikos/cards keep/drop
|
||||
|
||||
---
|
||||
|
||||
## 5. DB as Source of Truth (2026-07-07)
|
||||
|
||||
**Plan status:** Proposed
|
||||
|
||||
**Reality check:**
|
||||
|
||||
| Phase | Status |
|
||||
|-------|--------|
|
||||
| Phase 1: `seeds/knowledge.yaml` seed format | **DONE.** Exists, ingested via `oikos seed`, export round-trips. |
|
||||
| Phase 1: `content_hash` column (migration 010) | **DONE.** |
|
||||
| Phase 1: FTS index (migration 011) | **DONE.** |
|
||||
| Phase 1: Knowledge ingestion logic (`internal/knowledge/seed.go`) | **DONE.** |
|
||||
| Phase 2: convert wiki → seeds, archive originals | **NOT DONE.** `knowledge/wiki/` still exists with original .md files. |
|
||||
| Phase 3: `search_knowledge` with PostgreSQL FTS | **Partially.** MCP tool exists but uses ILIKE, not `tsvector`/`ts_rank`. |
|
||||
| Phase 3: `get_entity_knowledge` | **Not implemented.** |
|
||||
| Phase 3: `GET /api/v1/knowledge/search` (HTTP) | **Partially stubbed.** `internal/httpapi/knowledge.go` exists but not full FTS. |
|
||||
| Phase 3: `POST /api/v1/knowledge/{uuid}` (agent registration) | **Not implemented.** |
|
||||
| Phase 4: agent conventions for knowledge cycle | **NOT DONE.** |
|
||||
|
||||
**Score: ~60%**
|
||||
|
||||
**Blockers:**
|
||||
- Wiki archives never moved (the conversion script was never written)
|
||||
- FTS upgrade from ILIKE to tsvector pending
|
||||
- `get_entity_knowledge` tool missing from MCP
|
||||
- Knowledge mutation endpoints (agent registration) missing
|
||||
|
||||
---
|
||||
|
||||
## 6. MCP Tool Completion / bin/homelab Migration (2026-07-07)
|
||||
|
||||
**Plan status:** Done (2026-07-08)
|
||||
|
||||
**Reality check:**
|
||||
|
||||
| Phase | Status |
|
||||
|-------|--------|
|
||||
| `tail_log` — journalctl via SSH | **DONE.** `internal/mcp/server.go:466-488` |
|
||||
| `get_service_status` — systemctl is-active/enabled | **DONE.** `internal/mcp/server.go:489-509` |
|
||||
| `ping_service` — HTTP reachability from entity_status | **DONE.** `internal/mcp/server.go:438-465` |
|
||||
| `list_lxcs` — all LXCs with ID/host/IP/state | **DONE.** `internal/mcp/server.go:425-437` |
|
||||
| `get_lxc_state` — pct status from Proxmox | **DONE.** `internal/mcp/server.go:511-562` |
|
||||
| `request_execution` routing: restart | **DONE.** Immediate execute via SSH. |
|
||||
| `request_execution` routing: systemctl (reload/restart) | **DONE.** Immediate; enable/disable gated as config_mutation. |
|
||||
| `request_execution` routing: pct_exec | **DONE.** Resolves Proxmox host via relationships. |
|
||||
| `request_execution` routing: apt_upgrade (audit/upgrade) | **DONE.** Audit immediate; upgrade gated as config_mutation. |
|
||||
| `get_execution_status` | **DONE.** `internal/mcp/server.go:339-365` |
|
||||
| Matrix approval escalation | **DONE.** Notifier sends Matrix messages with approval tokens. Stores `matrix_event_id`. Polls for ✅/❌ reactions via `/relations/{id}/m.annotation`. Calls DecideApproval internally on reaction detection. Token verification in DecideApproval endpoint. |
|
||||
| Delete `bin/homelab` | **DONE.** Directory gone. |
|
||||
| Delete `bin/oikos` | **DONE.** Directory gone. |
|
||||
| Update AGENTS.md | **DONE.** Full 21-tool surface documented. Stale `homelab` CLI references removed. |
|
||||
|
||||
**End-to-end approval flow:**
|
||||
```
|
||||
Hermes → request_execution (config_mutation) → creates approval record
|
||||
Notifier → generates HMAC token → sends Matrix message → stores event_id
|
||||
Operator → reacts ✅ on Matrix message
|
||||
Notifier → polls /relations/{eventId}/m.annotation → detects ✅
|
||||
Notifier → POST /api/v1/approvals/{id}/decision {decision:"approve"}
|
||||
DecideApproval → verifies token (if provided) → executes gated SSH command
|
||||
```
|
||||
|
||||
**Score: 100%**
|
||||
|
||||
---
|
||||
|
||||
## Summary matrix
|
||||
|
||||
| Plan | Score | Key blocker |
|
||||
|------|-------|-------------|
|
||||
| Consolidation | 85% | 5 cutover items + Infisical |
|
||||
| Prometheus LXC | 0% | Not started; references dead Python |
|
||||
| Client lifecycle | 30% | Enrollment API + bootstrap rewrite |
|
||||
| Audit & next steps | 40% | Hermes plans migrate, index fixes, 4 operator decisions |
|
||||
| DB as source of truth | 60% | Wiki archive, FTS upgrade, entity-knowledge endpoint |
|
||||
| MCP tool surface | 100% | DONE — Matrix approval loop + token verification wired |
|
||||
|
||||
---
|
||||
|
||||
## Drift catalog (index vs reality)
|
||||
|
||||
| Issue | Detail |
|
||||
|-------|--------|
|
||||
| TRMNL plan still in Active | `2026-06-24-trmnl-plugins-lxc.md` is in `done/` but `index.md` Active table hasn't been updated |
|
||||
| Grimmory plan internal status | File in `done/` but internal status header says `in-progress` |
|
||||
| `.hermes/plans/` directory | Missing from disk. 7 executed plans lost. Recoverable from git history. |
|
||||
| Prometheus plan stale refs | References `oikos/scheduler.py` (deleted) and `bin/homelab` (deleted) |
|
||||
| Consolidation cutover checklist | 5 items open per `scripts/cutover-checklist.md` |
|
||||
| Audit plan decisions | 4 operator decisions listed as outstanding (section 7) |
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-08 — plan 6 fully completed
|
||||
MCP tool surface at 100%. Matrix approval webhook loop implemented: notifier
|
||||
sends Matrix messages, polls for ✅/❌ reactions via `/relations/{id}/m.annotation`,
|
||||
calls DecideApproval API internally. Token verification added to DecideApproval.
|
||||
AGENTS.md updated with full 21-tool surface and policy-gated mutation path.
|
||||
Migration 013 added `matrix_event_id` + `alert_sent_at` to approvals table.
|
||||
|
||||
### 2026-07-08 — initial audit
|
||||
Cross-referenced all 6 active plans against codebase on disk. 55 Go files,
|
||||
12 migrations, 21 MCP tools, 2 binaries (`oikos` + `hermes`). Python kernel
|
||||
purged except for `gen-topology.py`. Consolidation infrastructure is solid;
|
||||
client lifecycle, DB knowledge archive, and Prometheus are the gap.
|
||||
@@ -13,7 +13,7 @@ went sideways, open an investigation.
|
||||
| 2026-07-07 | [Client lifecycle in Go — enrollment through deprecation](2026-07-07-client-lifecycle-in-go.md) | Planned |
|
||||
| 2026-07-07 | [Comprehensive audit: stale files, state gaps, and next steps](2026-07-07-comprehensive-audit-and-next-steps.md) | Planned |
|
||||
| 2026-07-07 | [DB as single source of truth for agent knowledge](2026-07-07-db-as-source-of-truth.md) | Proposed |
|
||||
| 2026-07-07 | [Migrate bin/homelab CLI to Go oikos homelab](2026-07-07-migrate-bin-homelab-to-go.md) | Planned |
|
||||
| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned |
|
||||
|
||||
## Done
|
||||
|
||||
@@ -27,6 +27,7 @@ See [`done/`](done/) for executed plans:
|
||||
| 2026-06-29 | [Grimmory migration — Booklore → dedicated LXC](done/2026-06-29-grimmory-migration.md) |
|
||||
| 2026-06-24 | [TRMNL plugins LXC (128) + middleware deploy pipeline](done/2026-06-24-trmnl-plugins-lxc.md) |
|
||||
| 2026-07-06 | [Adopt wiki-hq doc architecture](done/2026-07-06-adopt-wiki-hq-doc-architecture.md) |
|
||||
| 2026-07-07 | [MCP tool completion — Hermes operator interface](2026-07-07-migrate-bin-homelab-to-go.md) |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user