Files
oikos/cmd/desktop/main.go
dtoro fb39a48bef refactor: split phase3.go + extract MCP tool registry (R4)
internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into
15 per-resource files:
- actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget,
  executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate)
- checks.go, classifications.go, executions.go, approvals.go, patterns.go,
  skills.go, approval_rules.go, autonomy.go, risk_classes.go,
  relationships.go, entity_types.go, metrics.go, agent_activity.go,
  helpers.go — one file per resource domain, each with its own imports.

internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations)
refactored to a registry pattern:
- internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33
  tool definitions. Handler logic moved verbatim — no changes to tool names,
  descriptions, schemas, or behavior.
- server.go: newServer is now 9 lines (iterate registry, AddTool each).
  -699 lines.

No function logic, names, or signatures changed. go vet, build, and all
tests pass (httpapi, mcp, db, policy).
2026-07-17 22:41:40 +02:00

789 lines
19 KiB
Go

package main
import (
"crypto/rand"
"crypto/sha256"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/zalando/go-keyring"
)
//go:embed frontend/dist
var assets embed.FS
//go:embed icon.png
var iconPNG []byte
const (
keyringService = "com.hubris.oikos-desktop"
keyringUser = "oikos"
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
pollInterval = 30 * time.Second
updateInterval = 6 * time.Hour
oidcCallbackPort = 18901
)
// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)"
// (Makefile desktop target). The default keeps a non-empty fallback for
// `go build ./cmd/desktop` without ldflags.
var version = "0.1.0-dev"
type OikosConfig struct {
ApiUrl string `json:"apiUrl"`
Token string `json:"token,omitempty"`
IsDesktop bool `json:"isDesktop"`
}
// ---- ConfigService ----
type ConfigService struct{}
func (c *ConfigService) Name() string { return "config" }
func (c *ConfigService) SaveConfig(apiUrl, token string) error {
cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true}
data, _ := json.Marshal(cfg)
return keyring.Set(keyringService, keyringUser, string(data))
}
func (c *ConfigService) ClearConfig() error {
return keyring.Delete(keyringService, keyringUser)
}
func (c *ConfigService) GetStoredConfig() *OikosConfig {
return loadConfig()
}
func (c *ConfigService) EnableAutoStart() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
}
usr, _ := user.Current()
dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents")
os.MkdirAll(dir, 0755)
exe, _ := os.Executable()
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.hubris.oikos-desktop</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
</dict>
</plist>`, exe)
return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644)
}
func (c *ConfigService) DisableAutoStart() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
}
usr, _ := user.Current()
path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist")
return os.Remove(path)
}
// ---- Local OIDC server (runs alongside the webview) ----
type oidcSession struct {
apiUrl string
verifier string
state string
ch chan string
}
var (
oidcSessionsMu sync.Mutex
oidcSessions = make(map[string]*oidcSession)
)
func startOIDCServer() *http.Server {
mux := http.NewServeMux()
cors := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
}
}
h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
cors(w, r)
if r.Method == "OPTIONS" {
return
}
handler(w, r)
})
}
h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
returnURL := r.URL.Query().Get("ret")
if apiUrl == "" {
http.Error(w, "apiUrl required", http.StatusBadRequest)
return
}
if returnURL == "" {
returnURL = "/?desktop=1"
}
oidcCfg, err := fetchOIDCConfig(apiUrl)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
verifier, challenge, _ := pkceParams()
state := randomString(32)
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
ch := make(chan string, 1)
oidcSessionsMu.Lock()
sessionID := randomString(16)
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
oidcSessionsMu.Unlock()
authURL := fmt.Sprintf("%s?%s",
oidcCfg.AuthorizationEndpoint,
url.Values{
"response_type": {"code"},
"client_id": {oidcCfg.ClientID},
"redirect_uri": {redirectURI},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {state},
"scope": {"openid profile email"},
}.Encode(),
)
exec.Command("open", authURL).Start()
select {
case token := <-ch:
if token != "" {
c := &ConfigService{}
c.SaveConfig(apiUrl, token)
returnURL += "&token=" + url.QueryEscape(token)
}
case <-time.After(5 * time.Minute):
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
<meta http-equiv="refresh" content="0;url=%s">
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
})
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
gotState := r.URL.Query().Get("state")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
oidcSessionsMu.Lock()
var session *oidcSession
var sessionID string
for id, s := range oidcSessions {
if s.state == gotState {
session = s
sessionID = id
break
}
}
oidcSessionsMu.Unlock()
if session == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid state."))
return
}
token, err := exchangeCode(
session.apiUrl,
code, session.verifier,
fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
)
oidcSessionsMu.Lock()
delete(oidcSessions, sessionID)
oidcSessionsMu.Unlock()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Token exchange failed: %v", err)
session.ch <- ""
return
}
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
session.ch <- token
})
mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
if apiUrl == "" {
http.Error(w, "apiUrl required", http.StatusBadRequest)
return
}
cfg, err := fetchOIDCConfig(apiUrl)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
})
mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) {
latest := fetchLatestRelease()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
if latest == nil {
json.NewEncoder(w).Encode(map[string]string{"current": version})
return
}
hasAsset := false
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
hasAsset = true
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
break
}
}
json.NewEncoder(w).Encode(map[string]string{
"current": version,
"latest": latest.Version,
"has_asset": fmt.Sprintf("%t", hasAsset),
})
})
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
if err != nil {
log.Printf("OIDC server: %v", err)
return nil
}
log.Printf("OIDC server listening on %s", listener.Addr())
srv := &http.Server{Handler: mux}
go srv.Serve(listener)
return srv
}
// ---- Window persistence ----
type windowState struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
func windowStatePath() string {
usr, _ := user.Current()
return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
}
func loadWindowState() *windowState {
data, err := os.ReadFile(windowStatePath())
if err != nil {
return nil
}
var ws windowState
if err := json.Unmarshal(data, &ws); err != nil {
return nil
}
if ws.Width < 200 || ws.Height < 200 {
return nil
}
return &ws
}
func saveWindowState(w application.Window) {
x, y := w.Position()
width, height := w.Size()
ws := windowState{X: x, Y: y, Width: width, Height: height}
data, _ := json.Marshal(ws)
usr, _ := user.Current()
dir := filepath.Join(usr.HomeDir, ".config", "oikos")
os.MkdirAll(dir, 0755)
os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
}
func loadConfig() *OikosConfig {
data, err := keyring.Get(keyringService, keyringUser)
if err != nil {
return nil
}
var cfg OikosConfig
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
return nil
}
cfg.IsDesktop = true
return &cfg
}
type oidcConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
}
func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
var cfg oidcConfig
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func pkceParams() (verifier, challenge string, _ error) {
v := randomString(64)
h := sha256.Sum256([]byte(v))
return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
}
func randomString(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
body, _ := json.Marshal(map[string]string{
"grant_type": "authorization_code",
"code": code,
"code_verifier": verifier,
"redirect_uri": redirectURI,
})
resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
}
var tokens struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
return "", err
}
if tokens.AccessToken == "" {
return "", fmt.Errorf("no access_token in response")
}
return tokens.AccessToken, nil
}
// ---- Notifications ----
type dashboardSummary struct {
ApprovalsPending int `json:"approvals_pending"`
Signals struct {
Critical int `json:"critical"`
} `json:"signals_by_severity"`
}
func (d *dashboardSummary) alertCount() int {
return d.ApprovalsPending + d.Signals.Critical
}
func notify(title, subtitle string) {
if runtime.GOOS != "darwin" {
return
}
script := fmt.Sprintf(
`display notification "%s" with title "%s" sound name "default"`,
strings.ReplaceAll(subtitle, `"`, `\"`),
strings.ReplaceAll(title, `"`, `\"`),
)
exec.Command("osascript", "-e", script).Run()
}
func pollDashboard(cfg *OikosConfig) {
if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" {
return
}
var lastCount int
first := true
for {
req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil)
if err != nil {
time.Sleep(pollInterval)
continue
}
req.Header.Set("Authorization", "Bearer "+cfg.Token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
time.Sleep(pollInterval)
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var summary dashboardSummary
if err := json.Unmarshal(body, &summary); err != nil {
time.Sleep(pollInterval)
continue
}
if first {
lastCount = summary.alertCount()
first = false
} else {
current := summary.alertCount()
if current > lastCount {
notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical))
}
lastCount = current
}
time.Sleep(pollInterval)
}
}
// ---- Auto-update ----
type giteaRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
type updateState struct {
mu sync.Mutex
latestURL string
}
var updater = &updateState{}
// CheckForUpdates checks Gitea releases for a newer version. If found, stores
// the download URL and returns the latest version string (empty if current).
func (c *ConfigService) CheckForUpdates() string {
latest := fetchLatestRelease()
if latest == nil || latest.Version == version {
return ""
}
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
return latest.Version
}
}
return ""
}
// InstallUpdate downloads the stored update, replaces the app, and restarts.
func (c *ConfigService) InstallUpdate() error {
updater.mu.Lock()
url := updater.latestURL
updater.mu.Unlock()
if url == "" {
return fmt.Errorf("no update available")
}
return doUpdate(url)
}
type latestRelease struct {
Version string
Assets []struct {
Name string
BrowserDownloadURL string
}
}
func fetchLatestRelease() *latestRelease {
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
if err != nil {
return nil
}
defer resp.Body.Close()
var releases []giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
return nil
}
r := releases[0]
v := strings.TrimPrefix(r.TagName, "v")
if v == version {
return nil
}
lr := &latestRelease{Version: v}
for _, a := range r.Assets {
lr.Assets = append(lr.Assets, struct {
Name string
BrowserDownloadURL string
}{a.Name, a.BrowserDownloadURL})
}
return lr
}
func doUpdate(downloadURL string) error {
tmp, err := os.CreateTemp("", "oikos-update-*.zip")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
if _, err := io.Copy(tmp, resp.Body); err != nil {
return err
}
tmp.Close()
extractDir, err := os.MkdirTemp("", "oikos-extract")
if err != nil {
return err
}
defer os.RemoveAll(extractDir)
cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("unzip: %w: %s", err, out)
}
newApp := filepath.Join(extractDir, "Oikos.app")
if _, err := os.Stat(newApp); err != nil {
return fmt.Errorf("extracted app not found: %w", err)
}
currentApp := "/Applications/Oikos.app"
if _, err := os.Stat(currentApp); os.IsNotExist(err) {
if exe, err := os.Executable(); err == nil {
currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
}
}
script := fmt.Sprintf(`#!/bin/bash
sleep 2
rm -rf "%s"
mv "%s" "%s"
open "%s"
rm "$0"
`, currentApp, newApp, currentApp, currentApp)
scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
return err
}
app := application.Get()
exec.Command("open", scriptPath).Start()
if app != nil {
app.Quit()
}
return nil
}
func checkUpdates() {
for {
time.Sleep(updateInterval)
latest := fetchLatestRelease()
if latest == nil {
continue
}
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
app := application.Get()
if app == nil {
continue
}
msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
app.Dialog.Info().
SetTitle("Update Available").
SetMessage(msg).
Show()
break
}
}
}
}
// ---- Main ----
func main() {
oidcSrv := startOIDCServer()
defer oidcSrv.Close()
distFS, err := fs.Sub(assets, "frontend/dist")
if err != nil {
log.Fatalf("embedded assets: %v", err)
}
app := application.New(application.Options{
Name: "Oikos",
Description: "Homelab Control Room",
Services: []application.Service{
application.NewService(&ConfigService{}),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(distFS),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
})
systemTray := app.SystemTray.New()
systemTray.SetTooltip("Oikos")
systemTray.SetIcon(iconPNG)
trayMenu := application.NewMenu()
trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
for _, w := range app.Window.GetAll() {
w.Show()
w.Focus()
}
})
trayMenu.AddSeparator()
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
go func() {
latest := fetchLatestRelease()
if latest == nil {
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
return
}
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version)
d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg)
yes := d.AddButton("Install")
yes.OnClick(func() { doUpdate(updater.latestURL) })
no := d.AddButton("Later")
d.SetDefaultButton(yes)
d.SetCancelButton(no)
d.Show()
return
}
}
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
}()
})
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
app.Quit()
})
systemTray.SetMenu(trayMenu)
ws := loadWindowState()
width, height := 1400, 900
if ws != nil {
width = ws.Width
height = ws.Height
}
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Oikos",
Width: width,
Height: height,
MinWidth: 1024,
MinHeight: 700,
URL: "/?desktop=1",
})
if ws != nil {
window.SetPosition(ws.X, ws.Y)
} else {
window.Center()
}
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
window.Hide()
e.Cancel()
})
window.Show()
systemTray.AttachWindow(window)
systemTray.Run()
app.OnShutdown(func() {
saveWindowState(window)
})
go pollDashboard(loadConfig())
go checkUpdates()
err = app.Run()
if err != nil {
log.Fatal(err)
}
}